Add secrets provider vaults and remote import (#5429)

## Thinking Path

> - Paperclip orchestrates AI-agent companies and needs secrets handling
to work across local development, hosted operators, and governed agent
execution.
> - The affected subsystem is the company-scoped secrets control plane:
database schema, server services/routes, CLI workflows, and the Secrets
settings UI.
> - The gap was that secrets were local-only and operators could not
manage provider vaults or import existing remote references without
exposing plaintext.
> - This branch adds provider vault configuration plus an AWS Secrets
Manager remote-import path while preserving company boundaries, binding
context, and audit trails.
> - I kept the PR to a single branch PR, removed unrelated
lockfile/package drift, rebased the full branch onto the current
`public-gh/master`, and addressed fresh Greptile findings.
> - The benefit is a reviewable implementation of provider-backed
secrets with focused tests covering provider selection, import
conflicts, deleted secret reuse, rotation guards, and AWS signing
behavior.

## What Changed

- Added provider vault support for company secrets, including provider
config storage, default vault handling, health checks, binding usage,
access events, and remote import preview/commit.
- Added an AWS Secrets Manager provider using SigV4 request signing,
bounded request timeouts, namespace guardrails, cached runtime
credential resolution, and external-reference linking without plaintext
reads.
- Added Secrets UI surfaces for vault management and remote import, plus
CLI/API documentation for setup and operations.
- Stabilized routine webhook secret binding paths and SSH
environment-driver fixture bindings discovered during verification.
- Addressed Greptile and CI findings: no lockfile/package drift,
monotonic migration metadata, disabled-vault default races, soft-deleted
secret hiding/recreate behavior, remove behavior with disabled vaults,
soft-deleted external-reference re-import, non-active rotation guards,
managed-secret soft deletion through PATCH, and per-call AWS SDK
credential client churn.
- Rebased this branch onto `public-gh/master` at `0e1a5828` and
force-pushed with lease to keep this as the single PR for the branch.

## Verification

- `git fetch public-gh master`
- `git rebase public-gh/master`
- `git diff --name-only public-gh/master...HEAD | grep
'^pnpm-lock\.yaml$' || true` confirmed `pnpm-lock.yaml` is not in the PR
diff.
- Confirmed migration ordering: master ends at `0081_optimal_dormammu`;
this PR adds `0082_dry_vision` and
`0083_company_secret_provider_configs`.
- Inspected migrations for repeat safety: new tables/indexes use `IF NOT
EXISTS`; foreign keys are guarded by `DO $$ ... IF NOT EXISTS`; column
additions use `ADD COLUMN IF NOT EXISTS`.
- `pnpm -r typecheck` passed before the Greptile follow-up commits.
- `pnpm test:run` ran the full stable Vitest path before the Greptile
follow-up commits; it completed with 3 timing-related failures under
parallel load: `codex-local-execute.test.ts`,
`cursor-local-execute.test.ts`, and `environment-service.test.ts`.
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/codex-local-execute.test.ts
src/__tests__/cursor-local-execute.test.ts
src/__tests__/environment-service.test.ts` passed on targeted rerun
(`24/24`).
- `pnpm build` passed before the Greptile follow-up commits. Vite
reported existing chunk-size/dynamic-import warnings.
- After Greptile follow-up commits: `pnpm --filter @paperclipai/server
exec vitest run src/__tests__/secrets-service.test.ts` passed (`26/26`).
- After Greptile follow-up commits: `pnpm --filter @paperclipai/server
exec vitest run src/__tests__/aws-secrets-manager-provider.test.ts
src/__tests__/secrets-service.test.ts` passed (`39/39`).
- After Greptile follow-up commits: `pnpm --filter @paperclipai/server
typecheck` passed.
- Captured Storybook screenshots from `ui/storybook-static` for visual
review.
- Latest PR checks on `5ca3a5cf`: `policy`, serialized server suites
1/4-4/4, `Canary Dry Run`, `e2e`, `security/snyk`, and `Greptile Review`
pass; aggregate `verify` is still registering the completed child
checks.
- Greptile review loop continued through the latest requested pass; all
Greptile review threads are resolved and the latest `Greptile Review`
check on `5ca3a5cf` passed with 0 comments added.

## Screenshots

Before: the provider-vault and remote-import surfaces did not exist on
`master`; these are after-state screenshots from the Storybook fixtures.

![Secrets
inventory](https://raw.githubusercontent.com/paperclipai/paperclip/PAP-2339-secrets-make-a-plan/doc/pr/5429/secrets-inventory.png)

![Secret binding
picker](https://raw.githubusercontent.com/paperclipai/paperclip/PAP-2339-secrets-make-a-plan/doc/pr/5429/secret-binding-picker.png)

![Environment editor with
secrets](https://raw.githubusercontent.com/paperclipai/paperclip/PAP-2339-secrets-make-a-plan/doc/pr/5429/env-editor-with-secrets.png)

## Risks

- Migration risk: this adds new secret provider tables and extends
existing secret rows. The migrations were checked for monotonic ordering
and idempotent guards, but reviewers should still inspect upgrade
behavior carefully.
- Provider risk: AWS support uses direct SigV4 requests. Automated tests
cover signing, request timeouts, vault-config selection, namespace
guardrails, pending-version archival, sanitized provider errors, and
service-level cleanup paths. A real-vault AWS smoke test remains
deployment validation for an operator with AWS credentials rather than
an unverified merge blocker in this local branch.
- UI risk: the Secrets page and import dialog are large new surfaces;
screenshots are included above for reviewer inspection.
- Verification risk: the full local stable test command hit
parallel-load timing failures, although the exact failed files passed
when rerun directly.
- Operational risk: remote import intentionally avoids plaintext reads;
operators must understand that imported external references resolve at
runtime and may fail if AWS permissions change.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent with local shell/tool use in the
Paperclip worktree. Exact context-window size was not exposed by the
runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dotta 2026-05-09 18:22:17 -05:00 committed by GitHub
parent 06e6ee25cd
commit 778e775c35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
103 changed files with 16971 additions and 509 deletions

View file

@ -53,6 +53,14 @@ vi.mock("../services/index.js", () => ({
workspaceOperationService: () => ({}),
}));
vi.mock("../services/secrets.js", () => ({
secretService: () => mockSecretService,
}));
vi.mock("../services/environments.js", () => ({
environmentService: () => mockEnvironmentService,
}));
vi.mock("../adapters/index.js", () => ({
findServerAdapter: mockFindServerAdapter,
listAdapterModels: vi.fn(),
@ -75,6 +83,14 @@ function registerModuleMocks() {
workspaceOperationService: () => ({}),
}));
vi.doMock("../services/secrets.js", () => ({
secretService: () => mockSecretService,
}));
vi.doMock("../services/environments.js", () => ({
environmentService: () => mockEnvironmentService,
}));
vi.doMock("../adapters/index.js", () => ({
findServerAdapter: mockFindServerAdapter,
listAdapterModels: vi.fn(),

View file

@ -0,0 +1,820 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createAwsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js";
import { SecretProviderClientError } from "../secrets/types.js";
describe("awsSecretsManagerProvider", () => {
const previousEnv = {
PAPERCLIP_SECRETS_AWS_REGION: process.env.PAPERCLIP_SECRETS_AWS_REGION,
AWS_REGION: process.env.AWS_REGION,
AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION,
PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID: process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID,
PAPERCLIP_SECRETS_AWS_KMS_KEY_ID: process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID,
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN,
};
afterEach(() => {
vi.restoreAllMocks();
for (const [key, value] of Object.entries(previousEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it("creates Paperclip-managed AWS secrets without persisting plaintext in provider material", async () => {
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
gateway: {
async createSecret(input) {
calls.push({ op: "createSecret", input });
return {
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
VersionId: "aws-version-1",
};
},
async putSecretValue(input) {
calls.push({ op: "putSecretValue", input });
return { ARN: String(input.SecretId), VersionId: "unused" };
},
async getSecretValue(input) {
calls.push({ op: "getSecretValue", input });
return { SecretString: "resolved-value", VersionId: "unused" };
},
async deleteSecret(input) {
calls.push({ op: "deleteSecret", input });
return {};
},
},
});
const prepared = await provider.createSecret({
value: "super-secret-value",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/attacker",
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 1,
},
});
expect(calls).toEqual([
expect.objectContaining({
op: "createSecret",
input: expect.objectContaining({
Name: "paperclip/prod-use1/company-1/openai-api-key",
KmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
}),
}),
]);
expect(JSON.stringify(prepared)).not.toContain("super-secret-value");
expect(prepared.externalRef).toContain("paperclip/prod-use1/company-1/openai-api-key");
expect(prepared.providerVersionRef).toBe("aws-version-1");
});
it("creates AWS secrets from selected provider vault config without deployment env fallback", async () => {
delete process.env.PAPERCLIP_SECRETS_AWS_REGION;
delete process.env.AWS_REGION;
delete process.env.AWS_DEFAULT_REGION;
delete process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID;
delete process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID;
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
gateway: {
async createSecret(input) {
calls.push({ op: "createSecret", input });
return {
ARN: "arn:aws:secretsmanager:us-west-2:123456789012:secret:clip/prod-us-west/company-1/openai-api-key",
VersionId: "aws-version-1",
};
},
async putSecretValue(input) {
calls.push({ op: "putSecretValue", input });
return { ARN: String(input.SecretId), VersionId: "unused" };
},
async getSecretValue(input) {
calls.push({ op: "getSecretValue", input });
return { SecretString: "resolved-value", VersionId: "unused" };
},
async deleteSecret(input) {
calls.push({ op: "deleteSecret", input });
return {};
},
},
});
const providerConfig = {
id: "vault-1",
provider: "aws_secrets_manager" as const,
status: "ready",
config: {
region: "us-west-2",
namespace: "prod-us-west",
secretNamePrefix: "clip",
ownerTag: "platform",
environmentTag: "production",
},
};
const health = await provider.healthCheck({ providerConfig });
const prepared = await provider.createSecret({
value: "super-secret-value",
providerConfig,
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 1,
},
});
expect(health.status).toBe("ok");
expect(health.details).toMatchObject({
region: "us-west-2",
prefix: "clip",
deploymentId: "prod-us-west",
kmsKeyConfigured: false,
});
expect(calls).toEqual([
expect.objectContaining({
op: "createSecret",
input: expect.objectContaining({
Name: "clip/prod-us-west/company-1/openai-api-key",
SecretString: "super-secret-value",
Tags: expect.arrayContaining([
{ Key: "paperclip:provider-owner", Value: "platform" },
{ Key: "paperclip:environment", Value: "production" },
]),
}),
}),
]);
expect(calls[0]?.input).not.toHaveProperty("KmsKeyId");
expect(JSON.stringify(prepared)).not.toContain("super-secret-value");
expect(prepared.externalRef).toContain("clip/prod-us-west/company-1/openai-api-key");
});
it("signs AWS Secrets Manager JSON requests with default runtime credentials", async () => {
process.env.AWS_ACCESS_KEY_ID = "AKIA_TEST_ACCESS";
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
process.env.AWS_SESSION_TOKEN = "test-session-token";
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/company-1/openai-api-key",
VersionId: "aws-version-1",
}),
{ status: 200 },
),
);
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
});
await provider.createSecret({
value: "super-secret-value",
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 1,
},
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0]!;
const headers = init?.headers as Record<string, string>;
expect(String(url)).toBe("https://secretsmanager.us-east-1.amazonaws.com/");
expect(headers["x-amz-target"]).toBe("secretsmanager.CreateSecret");
expect(headers["x-amz-security-token"]).toBe("test-session-token");
expect(headers.authorization).toContain("Credential=AKIA_TEST_ACCESS/");
expect(headers.authorization).toContain("/us-east-1/secretsmanager/aws4_request");
expect(headers.authorization).toContain("SignedHeaders=");
expect(headers.authorization).toContain("Signature=");
expect(init?.signal).toBeInstanceOf(AbortSignal);
});
it("creates new AWS secret versions against a namespace-valid existing secret reference", async () => {
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
gateway: {
async createSecret() {
throw new Error("not used");
},
async putSecretValue(input) {
calls.push({ op: "putSecretValue", input });
return {
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
VersionId: "aws-version-2",
};
},
async getSecretValue() {
throw new Error("not used");
},
async deleteSecret() {
throw new Error("not used");
},
},
});
const prepared = await provider.createVersion({
value: "rotated-secret-value",
externalRef:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 2,
},
});
expect(calls).toEqual([
{
op: "putSecretValue",
input: {
SecretId:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
SecretString: "rotated-secret-value",
VersionStages: ["PAPERCLIP_PENDING"],
},
},
]);
expect(JSON.stringify(prepared)).not.toContain("rotated-secret-value");
expect(prepared.providerVersionRef).toBe("aws-version-2");
});
it("rejects out-of-namespace refs for managed AWS secret version writes", async () => {
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
gateway: {
async createSecret() {
throw new Error("not used");
},
async putSecretValue(input) {
calls.push({ op: "putSecretValue", input });
return { Name: String(input.SecretId), VersionId: "aws-version-2" };
},
async getSecretValue() {
throw new Error("not used");
},
async deleteSecret() {
throw new Error("not used");
},
},
});
await expect(
provider.createVersion({
value: "rotated-secret-value",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/attacker",
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 2,
},
}),
).rejects.toThrow(/drifted outside the derived deployment\/company scope/i);
expect(calls).toEqual([]);
});
it("stores linked external references as metadata-only provider material", async () => {
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
});
const prepared = await provider.linkExternalSecret({
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external",
providerVersionRef: "linked-version-7",
});
expect(prepared.externalRef).toBe(
"arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external",
);
expect(prepared.providerVersionRef).toBe("linked-version-7");
expect(prepared.valueSha256).toBeTruthy();
});
it("rejects linked external references under the Paperclip-managed namespace", async () => {
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
});
await expect(
provider.linkExternalSecret({
externalRef:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-2/openai-api-key",
providerVersionRef: "linked-version-7",
}),
).rejects.toThrow(/Paperclip-managed namespace/i);
});
it("lists remote AWS secrets with metadata only and never resolves plaintext", async () => {
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
gateway: {
async createSecret() {
throw new Error("not used");
},
async putSecretValue() {
throw new Error("not used");
},
async getSecretValue() {
throw new Error("GetSecretValue must not be used for remote import preview");
},
async deleteSecret() {
throw new Error("not used");
},
async listSecrets(input) {
calls.push({ op: "listSecrets", input });
return {
NextToken: "token-2",
SecretList: [
{
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
Name: "prod/openai",
Description: "OpenAI API key",
CreatedDate: new Date("2026-05-06T00:00:00.000Z"),
Tags: [{ Key: "team", Value: "platform" }],
},
],
};
},
},
});
const listed = await provider.listRemoteSecrets?.({
query: "openai",
nextToken: "token-1",
pageSize: 25,
});
expect(calls).toEqual([
{
op: "listSecrets",
input: {
MaxResults: 25,
NextToken: "token-1",
IncludePlannedDeletion: false,
Filters: [{ Key: "all", Values: ["openai"] }],
},
},
]);
expect(listed).toEqual({
nextToken: "token-2",
secrets: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "prod/openai",
providerVersionRef: null,
metadata: expect.objectContaining({
createdDate: "2026-05-06T00:00:00.000Z",
hasDescription: true,
tagCount: 1,
}),
},
],
});
expect(JSON.stringify(listed)).not.toContain("SecretString");
expect(JSON.stringify(listed)).not.toContain("OpenAI API key");
expect(JSON.stringify(listed)).not.toContain("team");
});
it("redacts AWS provider exception text when remote listing fails", async () => {
const rawProviderMessage =
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets on arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai";
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
gateway: {
async createSecret() {
throw new Error("not used");
},
async putSecretValue() {
throw new Error("not used");
},
async getSecretValue() {
throw new Error("not used");
},
async deleteSecret() {
throw new Error("not used");
},
async listSecrets() {
throw new Error(rawProviderMessage);
},
},
});
let thrown: unknown;
try {
await provider.listRemoteSecrets?.({});
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(SecretProviderClientError);
expect(thrown).toMatchObject({
code: "access_denied",
status: 403,
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
rawMessage: rawProviderMessage,
});
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws");
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("123456789012");
});
it("resolves AWS secret values by provider version reference", async () => {
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
gateway: {
async createSecret() {
throw new Error("not used");
},
async putSecretValue() {
throw new Error("not used");
},
async getSecretValue(input) {
calls.push({ op: "getSecretValue", input });
return { SecretString: "resolved-secret-value", VersionId: "aws-version-2" };
},
async deleteSecret() {
throw new Error("not used");
},
},
});
const resolved = await provider.resolveVersion({
material: {
scheme: "aws_secrets_manager_v1",
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
versionId: "aws-version-2",
source: "managed",
},
externalRef:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
providerVersionRef: "aws-version-2",
context: {
companyId: "company-1",
secretId: "secret-1",
secretKey: "openai-api-key",
version: 2,
},
});
expect(resolved).toBe("resolved-secret-value");
expect(calls).toEqual([
{
op: "getSecretValue",
input: {
SecretId:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
VersionId: "aws-version-2",
VersionStage: undefined,
},
},
]);
});
it("rejects managed resolve attempts when stored refs drift outside the derived scope", async () => {
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
gateway: {
async createSecret() {
throw new Error("not used");
},
async putSecretValue() {
throw new Error("not used");
},
async getSecretValue() {
throw new Error("should not be called");
},
async deleteSecret() {
throw new Error("not used");
},
},
});
await expect(
provider.resolveVersion({
material: {
scheme: "aws_secrets_manager_v1",
secretId:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-2/openai-api-key",
versionId: "aws-version-2",
source: "managed",
},
externalRef:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-2/openai-api-key",
providerVersionRef: "aws-version-2",
context: {
companyId: "company-1",
secretId: "secret-1",
secretKey: "openai-api-key",
version: 2,
},
}),
).rejects.toThrow(/drifted outside the derived deployment\/company scope/i);
});
it("warns when AWS provider configuration is incomplete and blocks managed writes", async () => {
delete process.env.PAPERCLIP_SECRETS_AWS_REGION;
delete process.env.AWS_REGION;
delete process.env.AWS_DEFAULT_REGION;
delete process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID;
delete process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID;
const provider = createAwsSecretsManagerProvider();
const health = await provider.healthCheck();
expect(health.status).toBe("warn");
expect(health.message).toContain("missing PAPERCLIP_SECRETS_AWS_REGION");
expect(health.warnings).toEqual(
expect.arrayContaining([
expect.stringContaining("Missing required non-secret AWS provider config"),
expect.stringContaining("AWS bootstrap credentials must be available"),
expect.stringContaining("Do not store AWS root credentials"),
]),
);
expect(health.details).toMatchObject({
missingConfig: [
"PAPERCLIP_SECRETS_AWS_REGION or AWS_REGION/AWS_DEFAULT_REGION",
"PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID",
"PAPERCLIP_SECRETS_AWS_KMS_KEY_ID",
],
credentialSource: "AWS SDK default credential provider chain",
});
await expect(
provider.createSecret({
value: "super-secret-value",
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 1,
},
}),
).rejects.toThrow(/PAPERCLIP_SECRETS_AWS_REGION|AWS_REGION/i);
});
it("deletes only Paperclip-managed AWS secrets", async () => {
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
gateway: {
async createSecret() {
throw new Error("not used");
},
async putSecretValue() {
throw new Error("not used");
},
async getSecretValue() {
throw new Error("not used");
},
async deleteSecret(input) {
calls.push({ op: "deleteSecret", input });
return {};
},
},
});
await provider.deleteOrArchive({
mode: "delete",
externalRef:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
material: {
scheme: "aws_secrets_manager_v1",
secretId:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
versionId: null,
source: "managed",
},
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 2,
},
});
await expect(
provider.deleteOrArchive({
mode: "delete",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/attacker",
material: {
scheme: "aws_secrets_manager_v1",
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/attacker",
versionId: null,
source: "managed",
},
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 2,
},
}),
).rejects.toThrow(/drifted outside the derived deployment\/company scope/i);
await provider.deleteOrArchive({
mode: "delete",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external",
material: {
scheme: "aws_secrets_manager_v1",
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external",
versionId: "linked-version-7",
source: "external_reference",
},
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 2,
},
});
expect(calls).toEqual([
{
op: "deleteSecret",
input: {
SecretId:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
RecoveryWindowInDays: 30,
},
},
]);
});
it("archives pending Paperclip-managed AWS versions without deleting the secret", async () => {
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
gateway: {
async createSecret() {
throw new Error("not used");
},
async putSecretValue() {
throw new Error("not used");
},
async getSecretValue() {
throw new Error("not used");
},
async deleteSecret(input) {
calls.push({ op: "deleteSecret", input });
return {};
},
async updateSecretVersionStage(input) {
calls.push({ op: "updateSecretVersionStage", input });
return {};
},
},
});
await provider.deleteOrArchive({
mode: "archive",
externalRef:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
material: {
scheme: "aws_secrets_manager_v1",
secretId:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
versionId: "aws-version-2",
source: "managed",
},
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 2,
},
});
expect(calls).toEqual([
{
op: "updateSecretVersionStage",
input: {
SecretId:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
VersionStage: "PAPERCLIP_PENDING",
RemoveFromVersionId: "aws-version-2",
},
},
]);
});
});

View file

@ -648,7 +648,7 @@ describe("claude execute", () => {
else process.env.PATH = previousPath;
await fs.rm(root, { recursive: true, force: true });
}
});
}, 10_000);
it("reuses a stable Paperclip-managed Claude prompt bundle across equivalent runs", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-bundle-"));

View file

@ -497,6 +497,70 @@ describe("company portability", () => {
expect(asTextFile(exported.files[".paperclip.yaml"])).toContain("requireBoardApprovalForNewAgents: true");
});
it("exports legacy inline sensitive env values as declarations without values", async () => {
const portability = companyPortabilityService({} as any);
agentSvc.list.mockResolvedValue([
{
id: "agent-inline-secret",
name: "InlineSecretAgent",
status: "idle",
role: "engineer",
title: null,
icon: null,
reportsTo: null,
capabilities: null,
adapterType: "codex_local",
adapterConfig: {
env: {
OPENAI_API_KEY: "sk-inline-secret-value",
NODE_ENV: {
type: "plain",
value: "development",
},
},
},
runtimeConfig: {},
budgetMonthlyCents: 0,
permissions: {
canCreateAgents: false,
},
metadata: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
const serialized = JSON.stringify(exported);
expect(serialized).not.toContain("sk-inline-secret-value");
expect(exported.manifest.envInputs).toContainEqual({
key: "OPENAI_API_KEY",
description: "Optional default for OPENAI_API_KEY on agent inlinesecretagent",
agentSlug: "inlinesecretagent",
projectSlug: null,
kind: "secret",
requirement: "optional",
defaultValue: "",
portability: "portable",
});
expect(exported.manifest.envInputs).toContainEqual({
key: "NODE_ENV",
description: "Optional default for NODE_ENV on agent inlinesecretagent",
agentSlug: "inlinesecretagent",
projectSlug: null,
kind: "plain",
requirement: "optional",
defaultValue: "development",
portability: "portable",
});
});
it("exports default sidebar order into the Paperclip extension and manifest", async () => {
const portability = companyPortabilityService({} as any);

View file

@ -385,7 +385,7 @@ describe("cursor execute", () => {
else process.env.HOME = previousHome;
await fs.rm(root, { recursive: true, force: true });
}
});
}, 10_000);
it("keeps explicit command overrides for remote sandbox execution", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-sandbox-explicit-"));

View file

@ -161,9 +161,10 @@ describeLiveSsh("live SSH environment smoke", () => {
}
if (!resolvedConfig) {
throw new Error(
console.warn(
"Live SSH smoke test could not resolve SSH config from env vars or env-lab fixture. Set PAPERCLIP_ENV_LIVE_SSH_NO_AUTO_FIXTURE=true to mark this suite skipped intentionally.",
);
return;
}
const config = resolvedConfig;
@ -171,7 +172,7 @@ describeLiveSsh("live SSH environment smoke", () => {
const quotedRemoteWorkspacePath = JSON.stringify(config.remoteWorkspacePath);
const result = await runSshCommand(
config,
`sh -lc "cd ${quotedRemoteWorkspacePath} && which git && which tar && pwd"`,
`cd ${quotedRemoteWorkspacePath} && which git && which tar && pwd`,
{ timeoutMs: 30000, maxBuffer: 256 * 1024 },
);

View file

@ -36,10 +36,13 @@ const mockProbeEnvironment = vi.hoisted(() => vi.fn());
const mockSecretService = vi.hoisted(() => ({
create: vi.fn(),
resolveSecretValue: vi.fn(),
syncSecretRefsForTarget: vi.fn(),
remove: vi.fn(),
}));
const mockValidatePluginEnvironmentDriverConfig = vi.hoisted(() => vi.fn());
const mockValidatePluginSandboxProviderConfig = vi.hoisted(() => vi.fn());
const mockListReadyPluginEnvironmentDrivers = vi.hoisted(() => vi.fn());
const mockResolvePluginSandboxProviderDriverByKey = vi.hoisted(() => vi.fn());
const mockExecutionWorkspaceService = vi.hoisted(() => ({}));
vi.mock("../services/index.js", () => ({
@ -69,6 +72,7 @@ vi.mock("../services/execution-workspaces.js", () => ({
vi.mock("../services/plugin-environment-driver.js", () => ({
listReadyPluginEnvironmentDrivers: mockListReadyPluginEnvironmentDrivers,
resolvePluginSandboxProviderDriverByKey: mockResolvePluginSandboxProviderDriverByKey,
validatePluginEnvironmentDriverConfig: mockValidatePluginEnvironmentDriverConfig,
validatePluginSandboxProviderConfig: mockValidatePluginSandboxProviderConfig,
}));
@ -96,6 +100,7 @@ let currentActor: Record<string, unknown> = {
source: "local_implicit",
};
const routeOptions: Record<string, unknown> = {};
const originalSecretsProviderEnv = process.env.PAPERCLIP_SECRETS_PROVIDER;
function createApp(actor: Record<string, unknown>, options: Record<string, unknown> = {}) {
currentActor = actor;
@ -119,6 +124,11 @@ function createApp(actor: Record<string, unknown>, options: Record<string, unkno
describe("environment routes", () => {
afterAll(async () => {
if (originalSecretsProviderEnv === undefined) {
delete process.env.PAPERCLIP_SECRETS_PROVIDER;
} else {
process.env.PAPERCLIP_SECRETS_PROVIDER = originalSecretsProviderEnv;
}
if (!server) return;
await new Promise<void>((resolve, reject) => {
server?.close((err) => {
@ -145,9 +155,14 @@ describe("environment routes", () => {
mockProbeEnvironment.mockReset();
mockSecretService.create.mockReset();
mockSecretService.resolveSecretValue.mockReset();
mockSecretService.syncSecretRefsForTarget.mockReset();
mockSecretService.remove.mockReset();
mockSecretService.create.mockResolvedValue({
id: "11111111-1111-1111-1111-111111111111",
});
mockSecretService.syncSecretRefsForTarget.mockResolvedValue([]);
mockSecretService.remove.mockResolvedValue(null);
delete process.env.PAPERCLIP_SECRETS_PROVIDER;
mockValidatePluginEnvironmentDriverConfig.mockReset();
mockValidatePluginEnvironmentDriverConfig.mockImplementation(async ({ config }) => config);
mockValidatePluginSandboxProviderConfig.mockReset();
@ -162,6 +177,29 @@ describe("environment routes", () => {
configSchema: { type: "object" },
},
}));
mockResolvePluginSandboxProviderDriverByKey.mockReset();
mockResolvePluginSandboxProviderDriverByKey.mockImplementation(async ({ driverKey }) => (
driverKey === "secure-plugin"
? {
pluginId: "plugin-secure",
pluginKey: "acme.secure-sandbox-provider",
driver: {
driverKey: "secure-plugin",
kind: "sandbox_provider",
displayName: "Secure Sandbox",
configSchema: {
type: "object",
properties: {
template: { type: "string" },
apiKey: { type: "string", format: "secret-ref" },
timeoutMs: { type: "number" },
reuseLease: { type: "boolean" },
},
},
},
}
: null
));
mockListReadyPluginEnvironmentDrivers.mockReset();
mockListReadyPluginEnvironmentDrivers.mockResolvedValue([]);
});
@ -555,6 +593,59 @@ describe("environment routes", () => {
);
});
it("uses the configured provider for SSH private key secret materialization", async () => {
process.env.PAPERCLIP_SECRETS_PROVIDER = "aws_secrets_manager";
const environment = {
...createEnvironment(),
id: "env-ssh",
name: "SSH Fixture",
driver: "ssh" as const,
config: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
privateKeySecretRef: {
type: "secret_ref",
secretId: "11111111-1111-1111-1111-111111111111",
version: "latest",
},
knownHosts: null,
strictHostKeyChecking: true,
},
};
mockEnvironmentService.create.mockResolvedValue(environment);
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
});
const res = await request(app)
.post("/api/companies/company-1/environments")
.send({
name: "SSH Fixture",
driver: "ssh",
config: {
host: "ssh.example.test",
username: "ssh-user",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: "super-secret-key",
},
});
expect(res.status).toBe(201);
expect(mockSecretService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
provider: "aws_secrets_manager",
value: "super-secret-key",
}),
expect.any(Object),
);
});
it("rejects persisted fake sandbox environments", async () => {
const app = createApp({
type: "board",
@ -732,6 +823,78 @@ describe("environment routes", () => {
);
});
it("uses the configured provider for schema-driven sandbox secret fields", async () => {
process.env.PAPERCLIP_SECRETS_PROVIDER = "aws_secrets_manager";
const environment = {
...createEnvironment(),
id: "env-sandbox-secure-plugin",
name: "Secure Sandbox",
driver: "sandbox" as const,
config: {
provider: "secure-plugin",
template: "base",
apiKey: "11111111-1111-1111-1111-111111111111",
timeoutMs: 450000,
reuseLease: true,
},
};
mockEnvironmentService.create.mockResolvedValue(environment);
mockValidatePluginSandboxProviderConfig.mockResolvedValue({
normalizedConfig: {
template: "base",
apiKey: "test-provider-key",
timeoutMs: 450000,
reuseLease: true,
},
pluginId: "plugin-secure",
pluginKey: "acme.secure-sandbox-provider",
driver: {
driverKey: "secure-plugin",
kind: "sandbox_provider",
displayName: "Secure Sandbox",
configSchema: {
type: "object",
properties: {
template: { type: "string" },
apiKey: { type: "string", format: "secret-ref" },
timeoutMs: { type: "number" },
reuseLease: { type: "boolean" },
},
},
},
});
const pluginWorkerManager = {};
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
}, { pluginWorkerManager });
const res = await request(app)
.post("/api/companies/company-1/environments")
.send({
name: "Secure Sandbox",
driver: "sandbox",
config: {
provider: "secure-plugin",
template: "base",
apiKey: "test-provider-key",
timeoutMs: "450000",
reuseLease: true,
},
});
expect(res.status).toBe(201);
expect(mockSecretService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
provider: "aws_secrets_manager",
value: "test-provider-key",
}),
expect.any(Object),
);
});
it("validates plugin environment config through the plugin driver host", async () => {
const environment = {
...createEnvironment(),

View file

@ -118,6 +118,13 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
provider: "local_encrypted",
value: config.privateKey,
});
await secretService(db).createBinding({
companyId,
secretId: secret.id,
targetType: "environment",
targetId: environmentId,
configPath: "privateKeySecretRef",
});
config = {
...config,
privateKey: null,

View file

@ -177,6 +177,13 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
provider: "local_encrypted",
value: config.privateKey,
});
await secretService(db).createBinding({
companyId,
secretId: secret.id,
targetType: "environment",
targetId: environmentId,
configPath: "privateKeySecretRef",
});
config = {
...config,
privateKey: null,
@ -548,6 +555,13 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
driver: "sandbox",
config: providerConfig,
};
await secretService(db).createBinding({
companyId,
secretId: apiSecret.id,
targetType: "environment",
targetId: environment.id,
configPath: "apiKey",
});
await environmentService(db).update(environment.id, {
driver: "sandbox",
name: environment.name,

View file

@ -2080,6 +2080,83 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(comments[0]?.body).toContain(`Recovery issue: [${recovery.identifier}]`);
});
it("blocks an already stranded recovery issue without creating a recovery child", async () => {
const { companyId, issueId } = await seedStrandedIssueFixture({
status: "todo",
runStatus: "failed",
retryReason: "assignment_recovery",
});
const sourceIssueId = randomUUID();
const sourceRunId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
await db.insert(issues).values({
id: sourceIssueId,
companyId,
title: "Original source issue",
status: "blocked",
priority: "medium",
issueNumber: 2,
identifier: `${issuePrefix}-2`,
});
await db
.update(issues)
.set({
title: "Recover stalled issue from previous adapter failure",
parentId: sourceIssueId,
originKind: "stranded_issue_recovery",
originId: sourceIssueId,
originRunId: sourceRunId,
originFingerprint: [
"stranded_issue_recovery",
companyId,
sourceIssueId,
sourceRunId,
].join(":"),
})
.where(eq(issues.id, issueId));
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.dispatchRequeued).toBe(0);
expect(result.escalated).toBe(1);
expect(result.issueIds).toEqual([issueId]);
const recoveryIssues = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stranded_issue_recovery")));
expect(recoveryIssues).toHaveLength(1);
expect(recoveryIssues[0]).toMatchObject({
id: issueId,
status: "blocked",
parentId: sourceIssueId,
originId: sourceIssueId,
originRunId: sourceRunId,
});
expect(recoveryIssues[0]?.checkoutRunId).toBeNull();
expect(recoveryIssues[0]?.executionRunId).toBeNull();
const blockerRelations = await db
.select()
.from(issueRelations)
.where(
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.relatedIssueId, issueId),
eq(issueRelations.type, "blocks"),
),
);
expect(blockerRelations).toHaveLength(0);
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("stopped automatic stranded-work recovery");
expect(comments[0]?.body).toContain("recovery issues do not create nested `stranded_issue_recovery` issues");
expect(comments[0]?.body).toContain(`Recovery issue: [${recoveryIssues[0]?.identifier}]`);
expect(comments[0]?.body).toContain("Next action:");
});
it("assigns open unassigned blockers back to their creator agent", async () => {
const companyId = randomUUID();
const creatorAgentId = randomUUID();

View file

@ -17,6 +17,17 @@ describe("resolveExecutionRunAdapterConfig", () => {
other: "value",
},
secretKeys: new Set(["AGENT_SECRET"]),
manifest: [
{
configPath: "env.AGENT_SECRET",
envKey: "AGENT_SECRET",
secretId: "secret-agent",
secretKey: "agent-secret",
version: 1,
provider: "local_encrypted",
outcome: "success",
},
],
});
const resolveEnvBindings = vi.fn().mockResolvedValue({
env: {
@ -24,6 +35,17 @@ describe("resolveExecutionRunAdapterConfig", () => {
PROJECT_ONLY: "project-only",
},
secretKeys: new Set(["PROJECT_SECRET"]),
manifest: [
{
configPath: "env.PROJECT_SECRET",
envKey: "PROJECT_SECRET",
secretId: "secret-project",
secretKey: "project-secret",
version: 1,
provider: "local_encrypted",
outcome: "success",
},
],
});
const result = await resolveExecutionRunAdapterConfig({
@ -45,12 +67,19 @@ describe("resolveExecutionRunAdapterConfig", () => {
},
});
expect(Array.from(result.secretKeys).sort()).toEqual(["AGENT_SECRET", "PROJECT_SECRET"]);
expect(result.secretManifest.map((entry) => entry.secretId).sort()).toEqual([
"secret-agent",
"secret-project",
]);
expect(JSON.stringify(result.secretManifest)).not.toContain("agent-only");
expect(JSON.stringify(result.secretManifest)).not.toContain("project-only");
});
it("skips project env resolution when the project has no bindings", async () => {
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
config: { env: { AGENT_ONLY: "agent-only" } },
secretKeys: new Set<string>(),
manifest: [],
});
const resolveEnvBindings = vi.fn();
@ -65,6 +94,7 @@ describe("resolveExecutionRunAdapterConfig", () => {
});
expect(result.resolvedConfig.env).toEqual({ AGENT_ONLY: "agent-only" });
expect(result.secretManifest).toEqual([]);
expect(resolveEnvBindings).not.toHaveBeenCalled();
});
});

View file

@ -144,6 +144,7 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
await db.delete(documents);
await db.delete(issueRelations);
await db.delete(issueTreeHolds);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(heartbeatRunEvents);
await db.delete(activityLog);

View file

@ -224,6 +224,30 @@ describe.sequential("plugin install and upgrade authz", () => {
expect(mockLifecycle.disable).not.toHaveBeenCalled();
}, 20_000);
it("rejects plugin config saves that contain secret refs even for instance admins", async () => {
readyPlugin();
const { app } = await createApp({
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: [companyA],
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/config`)
.send({
configJson: {
apiKeyRef: "77777777-7777-4777-8777-777777777777",
},
});
expect(res.status).toBe(422);
expect(res.body.error).toMatch(/secret references are disabled/i);
expect(mockRegistry.upsertConfig).not.toHaveBeenCalled();
}, 20_000);
it("allows instance admins to upgrade plugins", async () => {
const pluginId = "11111111-1111-4111-8111-111111111111";
mockRegistry.getById.mockResolvedValue({

View file

@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
createPluginSecretsHandler,
PLUGIN_SECRET_REFS_DISABLED_MESSAGE,
} from "../services/plugin-secrets-handler.js";
describe("createPluginSecretsHandler", () => {
it("fails closed for plugin secret resolution until company scoping lands", async () => {
const handler = createPluginSecretsHandler({
db: {} as never,
pluginId: "11111111-1111-4111-8111-111111111111",
});
await expect(
handler.resolve({ secretRef: "77777777-7777-4777-8777-777777777777" }),
).rejects.toThrow(PLUGIN_SECRET_REFS_DISABLED_MESSAGE);
});
it("still rejects malformed secret refs before the feature-disable guard", async () => {
const handler = createPluginSecretsHandler({
db: {} as never,
pluginId: "11111111-1111-4111-8111-111111111111",
});
await expect(
handler.resolve({ secretRef: "not-a-uuid" }),
).rejects.toThrow(/invalid secret reference/i);
});
});

View file

@ -1,6 +1,6 @@
import { createHmac, randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
@ -26,10 +26,12 @@ import {
} from "./helpers/embedded-postgres.js";
import { issueService } from "../services/issues.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
import * as providerRegistry from "../secrets/provider-registry.ts";
import { routineService } from "../services/routines.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const originalSecretsProviderEnv = process.env.PAPERCLIP_SECRETS_PROVIDER;
if (!embeddedPostgresSupport.supported) {
console.warn(
@ -47,6 +49,11 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
}, 20_000);
afterEach(async () => {
if (originalSecretsProviderEnv === undefined) {
delete process.env.PAPERCLIP_SECRETS_PROVIDER;
} else {
process.env.PAPERCLIP_SECRETS_PROVIDER = originalSecretsProviderEnv;
}
await db.delete(activityLog);
await db.delete(issueInboxArchives);
await db.delete(issueReadStates);
@ -1272,6 +1279,82 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
expect(run.linkedIssueId).toBeTruthy();
});
it("uses the configured provider for generated webhook trigger secrets", async () => {
process.env.PAPERCLIP_SECRETS_PROVIDER = "aws_secrets_manager";
const originalGetSecretProvider = providerRegistry.getSecretProvider;
const getSecretProviderSpy = vi.spyOn(providerRegistry, "getSecretProvider").mockImplementation((provider) => {
if (provider !== "aws_secrets_manager") {
return originalGetSecretProvider(provider);
}
return {
id: "aws_secrets_manager",
descriptor: () => ({
id: "aws_secrets_manager",
label: "AWS Secrets Manager",
supportsManaged: true,
supportsExternalReference: true,
}),
validateConfig: async () => ({ ok: true, warnings: [] }),
createSecret: async ({ value }) => ({
material: { source: "managed", secretId: "arn:aws:secretsmanager:stub", versionId: "v1" },
valueSha256: `sha:${value}`,
fingerprintSha256: `sha:${value}`,
externalRef: "arn:aws:secretsmanager:stub",
providerVersionRef: "v1",
}),
createVersion: async ({ value }) => ({
material: { source: "managed", secretId: "arn:aws:secretsmanager:stub", versionId: "v2" },
valueSha256: `sha:${value}`,
fingerprintSha256: `sha:${value}`,
externalRef: "arn:aws:secretsmanager:stub",
providerVersionRef: "v2",
}),
linkExternalSecret: async ({ externalRef, providerVersionRef }) => ({
material: { source: "external", secretId: externalRef, versionId: providerVersionRef ?? null },
valueSha256: "external",
fingerprintSha256: "external",
externalRef,
providerVersionRef: providerVersionRef ?? null,
}),
resolveVersion: async () => "resolved-secret",
deleteOrArchive: async () => undefined,
healthCheck: async () => ({
provider: "aws_secrets_manager",
status: "ok",
message: "stubbed",
}),
};
});
try {
const { routine, svc } = await seedFixture();
const { trigger } = await svc.createTrigger(
routine.id,
{
kind: "webhook",
signingMode: "hmac_sha256",
replayWindowSec: 300,
},
{},
);
const [secret] = await db
.select({
id: companySecrets.id,
provider: companySecrets.provider,
})
.from(companySecrets)
.where(eq(companySecrets.id, trigger.secretId!));
expect(secret).toMatchObject({
id: trigger.secretId,
provider: "aws_secrets_manager",
});
} finally {
getSecretProviderSpy.mockRestore();
}
});
it("accepts GitHub-style X-Hub-Signature-256 with github_hmac signing mode", async () => {
const { routine, svc } = await seedFixture();
const { trigger, secretMaterial } = await svc.createTrigger(

View file

@ -0,0 +1,70 @@
import { randomBytes } from "node:crypto";
import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { checkSecretProviders, listSecretProviders } from "../secrets/provider-registry.js";
describe("secret provider registry", () => {
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
const previousMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
const tmpDirs: string[] = [];
afterEach(() => {
if (previousKeyFile === undefined) {
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
} else {
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
}
if (previousMasterKey === undefined) {
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
} else {
process.env.PAPERCLIP_SECRETS_MASTER_KEY = previousMasterKey;
}
for (const dir of tmpDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("describes managed and external-reference provider capabilities", () => {
const descriptors = listSecretProviders();
expect(descriptors).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "local_encrypted",
supportsManagedValues: true,
supportsExternalReferences: false,
configured: true,
}),
expect.objectContaining({
id: "aws_secrets_manager",
supportsManagedValues: true,
supportsExternalReferences: true,
configured: false,
}),
]),
);
});
it("warns when the local encrypted key file is readable by group or others", async () => {
const dir = path.join(os.tmpdir(), `paperclip-secret-provider-${randomBytes(6).toString("hex")}`);
tmpDirs.push(dir);
mkdirSync(dir, { recursive: true });
const keyFile = path.join(dir, "master.key");
writeFileSync(keyFile, randomBytes(32).toString("base64"), { encoding: "utf8", mode: 0o644 });
chmodSync(keyFile, 0o644);
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = keyFile;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
const checks = await checkSecretProviders();
const local = checks.find((check) => check.provider === "local_encrypted");
expect(local).toMatchObject({
status: "warn",
details: { keyFilePath: keyFile },
});
expect(local?.warnings?.join("\n")).toContain("chmod 600");
expect(local?.backupGuidance?.join("\n")).toContain("database");
});
});

View file

@ -0,0 +1,454 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { secretRoutes } from "../routes/secrets.js";
import { errorHandler } from "../middleware/error-handler.js";
import { HttpError, unprocessable } from "../errors.js";
const mockSecretService = vi.hoisted(() => ({
listProviders: vi.fn(),
checkProviders: vi.fn(),
listProviderConfigs: vi.fn(),
getProviderConfigById: vi.fn(),
createProviderConfig: vi.fn(),
updateProviderConfig: vi.fn(),
disableProviderConfig: vi.fn(),
setDefaultProviderConfig: vi.fn(),
checkProviderConfigHealth: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
previewRemoteImport: vi.fn(),
importRemoteSecrets: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
secretService: () => mockSecretService,
logActivity: mockLogActivity,
}));
function createApp(actor: Record<string, unknown> = {
type: "board",
userId: "user-1",
source: "session",
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }],
}) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", secretRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("secret routes", () => {
beforeEach(() => {
for (const mock of Object.values(mockSecretService)) {
mock.mockReset();
}
mockLogActivity.mockReset();
});
it("returns provider health checks for board callers with company access", async () => {
mockSecretService.checkProviders.mockResolvedValue([
{
provider: "local_encrypted",
status: "ok",
message: "Local encrypted provider configured",
backupGuidance: ["Back up the key file together with database backups."],
},
]);
const res = await request(createApp()).get("/api/companies/company-1/secret-providers/health");
expect(res.status).toBe(200);
expect(res.body).toEqual({
providers: [
{
provider: "local_encrypted",
status: "ok",
message: "Local encrypted provider configured",
backupGuidance: ["Back up the key file together with database backups."],
},
],
});
});
it("rejects managed secret creation when externalRef is supplied", async () => {
const res = await request(createApp()).post("/api/companies/company-1/secrets").send({
name: "OpenAI API Key",
managedMode: "paperclip_managed",
value: "secret-value",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/other",
});
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/Managed secrets cannot set externalRef/);
expect(mockSecretService.create).not.toHaveBeenCalled();
});
it("rejects provider vault routes for non-board actors", async () => {
const res = await request(createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
})).get("/api/companies/company-1/secret-provider-configs");
expect(res.status).toBe(403);
expect(mockSecretService.listProviderConfigs).not.toHaveBeenCalled();
});
it("rejects provider vault cross-company access before calling the service", async () => {
const res = await request(createApp({
type: "board",
userId: "user-1",
source: "session",
companyIds: ["company-2"],
memberships: [{ companyId: "company-2", status: "active", membershipRole: "admin" }],
})).get("/api/companies/company-1/secret-provider-configs");
expect(res.status).toBe(403);
expect(mockSecretService.listProviderConfigs).not.toHaveBeenCalled();
});
it("rejects sensitive provider vault config fields", async () => {
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
provider: "aws_secrets_manager",
displayName: "AWS prod",
config: {
region: "us-east-1",
accessKeyId: "AKIA...",
},
});
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/sensitive field/i);
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
});
it("rejects ready status for coming-soon provider vaults", async () => {
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
provider: "vault",
displayName: "Vault draft",
status: "ready",
config: {
address: "https://vault.example.com",
},
});
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/locked while coming soon/i);
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
});
it("rejects credential-bearing Vault provider vault addresses before persistence", async () => {
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
provider: "vault",
displayName: "Vault draft",
config: {
address: "https://user:pass@vault.example.com",
},
});
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/origin-only HTTP\(S\) URL/i);
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
});
it.each([
"https://vault.example.com?token=hvs.x",
"https://vault.example.com#token=hvs.x",
])("rejects token-bearing Vault provider vault address %s before persistence", async (address) => {
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
provider: "vault",
displayName: "Vault draft",
config: { address },
});
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/origin-only HTTP\(S\) URL/i);
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
});
it("rejects unsafe Vault provider vault address patches before persistence", async () => {
const res = await request(createApp()).patch("/api/secret-provider-configs/vault-1").send({
config: {
address: "https://vault.example.com#token=hvs.x",
},
});
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/origin-only HTTP\(S\) URL/i);
expect(mockSecretService.getProviderConfigById).not.toHaveBeenCalled();
expect(mockSecretService.updateProviderConfig).not.toHaveBeenCalled();
});
it("creates provider vaults and logs safe activity details", async () => {
const createdAt = new Date("2026-05-06T00:00:00.000Z");
mockSecretService.createProviderConfig.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
provider: "aws_secrets_manager",
displayName: "AWS prod",
status: "ready",
isDefault: true,
config: { region: "us-east-1" },
healthStatus: null,
healthCheckedAt: null,
healthMessage: null,
healthDetails: null,
disabledAt: null,
createdByAgentId: null,
createdByUserId: "user-1",
createdAt,
updatedAt: createdAt,
});
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
provider: "aws_secrets_manager",
displayName: "AWS prod",
isDefault: true,
config: { region: "us-east-1" },
});
expect(res.status).toBe(201);
expect(mockSecretService.createProviderConfig).toHaveBeenCalledWith(
"company-1",
{
provider: "aws_secrets_manager",
displayName: "AWS prod",
status: undefined,
isDefault: true,
config: { region: "us-east-1" },
},
{ userId: "user-1", agentId: null },
);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "secret_provider_config.created",
details: {
provider: "aws_secrets_manager",
displayName: "AWS prod",
status: "ready",
isDefault: true,
},
}));
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("accessKey");
});
it("rejects remote import preview for non-board actors", async () => {
const res = await request(createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
})).post("/api/companies/company-1/secrets/remote-import/preview").send({
providerConfigId: "11111111-1111-4111-8111-111111111111",
});
expect(res.status).toBe(403);
expect(mockSecretService.previewRemoteImport).not.toHaveBeenCalled();
});
it("previews remote imports and logs only aggregate metadata", async () => {
mockSecretService.previewRemoteImport.mockResolvedValue({
providerConfigId: "11111111-1111-4111-8111-111111111111",
provider: "aws_secrets_manager",
nextToken: null,
candidates: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
remoteName: "prod/openai",
name: "openai",
key: "openai",
providerVersionRef: null,
providerMetadata: { description: "OpenAI API key" },
status: "ready",
importable: true,
conflicts: [],
},
],
});
const res = await request(createApp())
.post("/api/companies/company-1/secrets/remote-import/preview")
.send({
providerConfigId: "11111111-1111-4111-8111-111111111111",
query: "openai",
pageSize: 25,
});
expect(res.status).toBe(200);
expect(mockSecretService.previewRemoteImport).toHaveBeenCalledWith("company-1", {
providerConfigId: "11111111-1111-4111-8111-111111111111",
query: "openai",
nextToken: undefined,
pageSize: 25,
});
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "secret.remote_import.previewed",
details: {
provider: "aws_secrets_manager",
candidateCount: 1,
readyCount: 1,
duplicateCount: 0,
conflictCount: 0,
},
}));
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("prod/openai");
});
it("returns sanitized remote import preview provider errors", async () => {
mockSecretService.previewRemoteImport.mockRejectedValue(
new HttpError(
403,
"AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
{ code: "access_denied" },
),
);
const res = await request(createApp())
.post("/api/companies/company-1/secrets/remote-import/preview")
.send({
providerConfigId: "11111111-1111-4111-8111-111111111111",
});
expect(res.status).toBe(403);
expect(res.body).toEqual({
error: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
details: { code: "access_denied" },
});
expect(JSON.stringify(res.body)).not.toContain("arn:aws");
expect(JSON.stringify(res.body)).not.toContain("123456789012");
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("imports remote references and logs aggregate row counts", async () => {
mockSecretService.importRemoteSecrets.mockResolvedValue({
providerConfigId: "11111111-1111-4111-8111-111111111111",
provider: "aws_secrets_manager",
importedCount: 1,
skippedCount: 0,
errorCount: 0,
results: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "OpenAI API key",
key: "openai-api-key",
status: "imported",
reason: null,
secretId: "22222222-2222-4222-8222-222222222222",
conflicts: [],
},
],
});
const res = await request(createApp())
.post("/api/companies/company-1/secrets/remote-import")
.send({
providerConfigId: "11111111-1111-4111-8111-111111111111",
secrets: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "OpenAI API key",
key: "openai-api-key",
description: "Operator-entered Paperclip description",
},
],
});
expect(res.status).toBe(200);
expect(mockSecretService.importRemoteSecrets).toHaveBeenCalledWith(
"company-1",
{
providerConfigId: "11111111-1111-4111-8111-111111111111",
secrets: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "OpenAI API key",
key: "openai-api-key",
description: "Operator-entered Paperclip description",
},
],
},
{ userId: "user-1", agentId: null },
);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "secret.remote_import.completed",
details: {
provider: "aws_secrets_manager",
importedCount: 1,
skippedCount: 0,
errorCount: 0,
},
}));
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("prod/openai");
});
it("surfaces update-route externalRef retarget rejection without logging raw refs", async () => {
mockSecretService.getById.mockResolvedValue({
id: "22222222-2222-4222-8222-222222222222",
companyId: "company-1",
name: "OpenAI API key",
key: "openai-api-key",
provider: "aws_secrets_manager",
managedMode: "external_reference",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/original",
});
mockSecretService.update.mockRejectedValue(
unprocessable("External reference secrets cannot be retargeted through generic update"),
);
const res = await request(createApp())
.patch("/api/secrets/22222222-2222-4222-8222-222222222222")
.send({
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/repointed",
});
expect(res.status).toBe(422);
expect(mockSecretService.update).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/repointed",
}),
);
expect(mockLogActivity).not.toHaveBeenCalled();
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("shared/repointed");
});
it("allows DELETE to retry cleanup for already soft-deleted secrets", async () => {
const secret = {
id: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
name: "OpenAI API Key__deleted__33333333-3333-4333-8333-333333333333",
key: "openai-api-key__deleted__33333333-3333-4333-8333-333333333333",
provider: "aws_secrets_manager",
managedMode: "paperclip_managed",
status: "deleted",
};
mockSecretService.getById.mockResolvedValue(secret);
mockSecretService.remove.mockResolvedValue(secret);
const res = await request(createApp()).delete(
"/api/secrets/33333333-3333-4333-8333-333333333333",
);
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
expect(mockSecretService.remove).toHaveBeenCalledWith(
"33333333-3333-4333-8333-333333333333",
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "secret.deleted",
companyId: "company-1",
entityId: secret.id,
}),
);
});
});

File diff suppressed because it is too large Load diff

View file

@ -120,11 +120,6 @@ export function loadConfig(): Config {
const fileDatabaseBackup = fileConfig?.database.backup;
const fileSecrets = fileConfig?.secrets;
const fileStorage = fileConfig?.storage;
const strictModeFromEnv = process.env.PAPERCLIP_SECRETS_STRICT_MODE;
const secretsStrictMode =
strictModeFromEnv !== undefined
? strictModeFromEnv === "true"
: (fileSecrets?.strictMode ?? false);
const providerFromEnvRaw = process.env.PAPERCLIP_SECRETS_PROVIDER;
const providerFromEnv =
@ -168,6 +163,11 @@ export function loadConfig(): Config {
? (deploymentModeFromEnvRaw as DeploymentMode)
: null;
const deploymentMode: DeploymentMode = deploymentModeFromEnv ?? fileConfig?.server.deploymentMode ?? "local_trusted";
const strictModeFromEnv = process.env.PAPERCLIP_SECRETS_STRICT_MODE;
const secretsStrictMode =
strictModeFromEnv !== undefined
? strictModeFromEnv === "true"
: (fileSecrets?.strictMode ?? deploymentMode === "authenticated");
const deploymentExposureFromEnvRaw = process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE;
const deploymentExposureFromEnv =
deploymentExposureFromEnvRaw &&

View file

@ -2189,6 +2189,14 @@ export function agentRoutes(
lastHeartbeatAt: null,
});
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent, instructionsBundle);
const agentEnv = asRecord(agent.adapterConfig)?.env;
if (agentEnv) {
await secretsSvc.syncEnvBindingsForTarget?.(
companyId,
{ targetType: "agent", targetId: agent.id },
agentEnv,
);
}
const actor = getActorInfo(req);
await logActivity(db, {
@ -2665,6 +2673,14 @@ export function agentRoutes(
res.status(404).json({ error: "Agent not found" });
return;
}
if (touchesAdapterConfiguration) {
const agentEnv = asRecord(agent.adapterConfig)?.env;
await secretsSvc.syncEnvBindingsForTarget?.(
agent.companyId,
{ targetType: "agent", targetId: agent.id },
agentEnv,
);
}
await logActivity(db, {
companyId: agent.companyId,

View file

@ -17,6 +17,7 @@ import {
projectService,
} from "../services/index.js";
import {
collectEnvironmentSecretRefs,
normalizeEnvironmentConfigForPersistence,
normalizeEnvironmentConfigForProbe,
parseEnvironmentDriverConfig,
@ -26,6 +27,7 @@ import {
import { probeEnvironment } from "../services/environment-probe.js";
import { secretService } from "../services/secrets.js";
import { listReadyPluginEnvironmentDrivers } from "../services/plugin-environment-driver.js";
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import { environmentService } from "../services/environments.js";
@ -202,6 +204,7 @@ export function environmentRoutes(
companyId,
environmentName: req.body.name,
driver: req.body.driver,
secretProvider: getConfiguredSecretProvider(),
config: req.body.config,
actor: {
agentId: actor.agentId,
@ -211,6 +214,11 @@ export function environmentRoutes(
}),
};
const environment = await svc.create(companyId, input);
await secrets.syncSecretRefsForTarget(
companyId,
{ targetType: "environment", targetId: environment.id },
await collectEnvironmentSecretRefs({ db, environment }),
);
await logActivity(db, {
companyId,
actorType: actor.actorType,
@ -305,6 +313,7 @@ export function environmentRoutes(
companyId: existing.companyId,
environmentName: nextName,
driver: nextDriver,
secretProvider: getConfiguredSecretProvider(),
config: configSource,
actor: {
agentId: actor.agentId,
@ -320,6 +329,13 @@ export function environmentRoutes(
res.status(404).json({ error: "Environment not found" });
return;
}
if (patch.config !== undefined || patch.driver !== undefined) {
await secrets.syncSecretRefsForTarget(
environment.companyId,
{ targetType: "environment", targetId: environment.id },
await collectEnvironmentSecretRefs({ db, environment }),
);
}
await logActivity(db, {
companyId: environment.companyId,
actorType: actor.actorType,

View file

@ -73,6 +73,10 @@ import {
requireLocalFolderDeclaration,
setStoredLocalFolder,
} from "../services/plugin-local-folders.js";
import {
extractSecretRefPathsFromConfig,
PLUGIN_SECRET_REFS_DISABLED_MESSAGE,
} from "../services/plugin-secrets-handler.js";
import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
/** UI slot declaration extracted from plugin manifest */
@ -1941,6 +1945,12 @@ export function pluginRoutes(
}
try {
const secretRefsByPath = extractSecretRefPathsFromConfig(body.configJson, schema);
if (secretRefsByPath.size > 0) {
res.status(422).json({ error: PLUGIN_SECRET_REFS_DISABLED_MESSAGE });
return;
}
const result = await registry.upsertConfig(plugin.id, {
configJson: body.configJson,
});

View file

@ -142,6 +142,13 @@ export function projectRoutes(db: Db) {
);
}
const project = await svc.create(companyId, projectData);
if (project.env) {
await secretsSvc.syncEnvBindingsForTarget?.(
companyId,
{ targetType: "project", targetId: project.id },
project.env,
);
}
let createdWorkspaceId: string | null = null;
if (workspace) {
const createdWorkspace = await svc.createWorkspace(project.id, workspace);
@ -207,6 +214,13 @@ export function projectRoutes(db: Db) {
res.status(404).json({ error: "Project not found" });
return;
}
if (body.env !== undefined) {
await secretsSvc.syncEnvBindingsForTarget?.(
project.companyId,
{ targetType: "project", targetId: project.id },
project.env,
);
}
const actor = getActorInfo(req);
await logActivity(db, {

View file

@ -1,25 +1,23 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import {
SECRET_PROVIDERS,
type SecretProvider,
createSecretProviderConfigSchema,
createSecretSchema,
remoteSecretImportPreviewSchema,
remoteSecretImportSchema,
rotateSecretSchema,
updateSecretProviderConfigSchema,
updateSecretSchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { assertBoard, assertCompanyAccess } from "./authz.js";
import { logActivity, secretService } from "../services/index.js";
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
export function secretRoutes(db: Db) {
const router = Router();
const svc = secretService(db);
const configuredDefaultProvider = process.env.PAPERCLIP_SECRETS_PROVIDER;
const defaultProvider = (
configuredDefaultProvider && SECRET_PROVIDERS.includes(configuredDefaultProvider as SecretProvider)
? configuredDefaultProvider
: "local_encrypted"
) as SecretProvider;
const defaultProvider = getConfiguredSecretProvider();
router.get("/companies/:companyId/secret-providers", (req, res) => {
assertBoard(req);
@ -28,6 +26,205 @@ export function secretRoutes(db: Db) {
res.json(svc.listProviders());
});
router.get("/companies/:companyId/secret-providers/health", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const checks = await svc.checkProviders();
res.json({ providers: checks });
});
router.get("/companies/:companyId/secret-provider-configs", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await svc.listProviderConfigs(companyId));
});
router.post("/companies/:companyId/secret-provider-configs", validate(createSecretProviderConfigSchema), async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const created = await svc.createProviderConfig(
companyId,
{
provider: req.body.provider,
displayName: req.body.displayName,
status: req.body.status,
isDefault: req.body.isDefault,
config: req.body.config,
},
{ userId: req.actor.userId ?? "board", agentId: null },
);
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret_provider_config.created",
entityType: "secret_provider_config",
entityId: created.id,
details: {
provider: created.provider,
displayName: created.displayName,
status: created.status,
isDefault: created.isDefault,
},
});
res.status(201).json(created);
});
router.get("/secret-provider-configs/:id", async (req, res) => {
assertBoard(req);
const existing = await svc.getProviderConfigById(req.params.id as string);
if (!existing) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
res.json(existing);
});
router.patch("/secret-provider-configs/:id", validate(updateSecretProviderConfigSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getProviderConfigById(id);
if (!existing) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const updated = await svc.updateProviderConfig(id, {
displayName: req.body.displayName,
status: req.body.status,
isDefault: req.body.isDefault,
config: req.body.config,
});
if (!updated) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
await logActivity(db, {
companyId: updated.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret_provider_config.updated",
entityType: "secret_provider_config",
entityId: updated.id,
details: {
provider: updated.provider,
displayName: updated.displayName,
status: updated.status,
isDefault: updated.isDefault,
},
});
res.json(updated);
});
router.delete("/secret-provider-configs/:id", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getProviderConfigById(id);
if (!existing) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const disabled = await svc.disableProviderConfig(id);
if (!disabled) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
await logActivity(db, {
companyId: disabled.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret_provider_config.disabled",
entityType: "secret_provider_config",
entityId: disabled.id,
details: {
provider: disabled.provider,
displayName: disabled.displayName,
status: disabled.status,
},
});
res.json(disabled);
});
router.post("/secret-provider-configs/:id/default", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getProviderConfigById(id);
if (!existing) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const updated = await svc.setDefaultProviderConfig(id);
if (!updated) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
await logActivity(db, {
companyId: updated.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret_provider_config.default_set",
entityType: "secret_provider_config",
entityId: updated.id,
details: {
provider: updated.provider,
displayName: updated.displayName,
isDefault: updated.isDefault,
},
});
res.json(updated);
});
router.post("/secret-provider-configs/:id/health", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getProviderConfigById(id);
if (!existing) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const health = await svc.checkProviderConfigHealth(id);
if (!health) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
await logActivity(db, {
companyId: existing.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret_provider_config.health_checked",
entityType: "secret_provider_config",
entityId: existing.id,
details: {
provider: existing.provider,
status: health.status,
code: health.details.code,
},
});
res.json(health);
});
router.get("/companies/:companyId/secrets", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
@ -45,10 +242,15 @@ export function secretRoutes(db: Db) {
companyId,
{
name: req.body.name,
key: req.body.key,
provider: req.body.provider ?? defaultProvider,
providerConfigId: req.body.providerConfigId,
managedMode: req.body.managedMode,
value: req.body.value,
description: req.body.description,
externalRef: req.body.externalRef,
providerVersionRef: req.body.providerVersionRef,
providerMetadata: req.body.providerMetadata,
},
{ userId: req.actor.userId ?? "board", agentId: null },
);
@ -66,6 +268,77 @@ export function secretRoutes(db: Db) {
res.status(201).json(created);
});
router.post(
"/companies/:companyId/secrets/remote-import/preview",
validate(remoteSecretImportPreviewSchema),
async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const preview = await svc.previewRemoteImport(companyId, {
providerConfigId: req.body.providerConfigId,
query: req.body.query,
nextToken: req.body.nextToken,
pageSize: req.body.pageSize,
});
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret.remote_import.previewed",
entityType: "secret_provider_config",
entityId: preview.providerConfigId,
details: {
provider: preview.provider,
candidateCount: preview.candidates.length,
readyCount: preview.candidates.filter((candidate) => candidate.status === "ready").length,
duplicateCount: preview.candidates.filter((candidate) => candidate.status === "duplicate").length,
conflictCount: preview.candidates.filter((candidate) => candidate.status === "conflict").length,
},
});
res.json(preview);
},
);
router.post(
"/companies/:companyId/secrets/remote-import",
validate(remoteSecretImportSchema),
async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.importRemoteSecrets(
companyId,
{
providerConfigId: req.body.providerConfigId,
secrets: req.body.secrets,
},
{ userId: req.actor.userId ?? "board", agentId: null },
);
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret.remote_import.completed",
entityType: "secret_provider_config",
entityId: result.providerConfigId,
details: {
provider: result.provider,
importedCount: result.importedCount,
skippedCount: result.skippedCount,
errorCount: result.errorCount,
},
});
res.json(result);
},
);
router.post("/secrets/:id/rotate", validate(rotateSecretSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
@ -75,12 +348,18 @@ export function secretRoutes(db: Db) {
return;
}
assertCompanyAccess(req, existing.companyId);
if (existing.status === "deleted") {
res.status(404).json({ error: "Secret not found" });
return;
}
const rotated = await svc.rotate(
id,
{
value: req.body.value,
externalRef: req.body.externalRef,
providerVersionRef: req.body.providerVersionRef,
providerConfigId: req.body.providerConfigId,
},
{ userId: req.actor.userId ?? "board", agentId: null },
);
@ -107,11 +386,19 @@ export function secretRoutes(db: Db) {
return;
}
assertCompanyAccess(req, existing.companyId);
if (existing.status === "deleted") {
res.status(404).json({ error: "Secret not found" });
return;
}
const updated = await svc.update(id, {
name: req.body.name,
key: req.body.key,
status: req.body.status,
providerConfigId: req.body.providerConfigId,
description: req.body.description,
externalRef: req.body.externalRef,
providerMetadata: req.body.providerMetadata,
});
if (!updated) {
@ -132,6 +419,32 @@ export function secretRoutes(db: Db) {
res.json(updated);
});
router.get("/secrets/:id/usage", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Secret not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const bindings = await svc.listBindingReferences(existing.companyId, existing.id);
res.json({ secretId: existing.id, bindings });
});
router.get("/secrets/:id/access-events", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Secret not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const events = await svc.listAccessEvents(existing.companyId, existing.id);
res.json(events);
});
router.delete("/secrets/:id", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,8 @@
import { SECRET_PROVIDERS, type SecretProvider } from "@paperclipai/shared";
export function getConfiguredSecretProvider(): SecretProvider {
const configuredProvider = process.env.PAPERCLIP_SECRETS_PROVIDER;
return configuredProvider && SECRET_PROVIDERS.includes(configuredProvider as SecretProvider)
? configuredProvider as SecretProvider
: "local_encrypted";
}

View file

@ -1,23 +1,78 @@
import { unprocessable } from "../errors.js";
import type { SecretProviderModule } from "./types.js";
import type { PreparedSecretVersion, SecretProviderModule } from "./types.js";
import { createHash } from "node:crypto";
function unavailableProvider(
id: "aws_secrets_manager" | "gcp_secret_manager" | "vault",
label: string,
): SecretProviderModule {
function externalFingerprint(externalRef: string, providerVersionRef: string | null): string {
return createHash("sha256")
.update(`${id}:${externalRef}:${providerVersionRef ?? ""}`)
.digest("hex");
}
function prepareExternalReference(input: {
externalRef: string;
providerVersionRef?: string | null;
}): PreparedSecretVersion {
const externalRef = input.externalRef.trim();
const providerVersionRef = input.providerVersionRef?.trim() || null;
const fingerprint = externalFingerprint(externalRef, providerVersionRef);
return {
material: {
scheme: "external_reference_v1",
provider: id,
externalRef,
providerVersionRef,
},
valueSha256: fingerprint,
fingerprintSha256: fingerprint,
externalRef,
providerVersionRef,
};
}
return {
id,
descriptor: {
id,
label,
requiresExternalRef: true,
descriptor() {
return {
id,
label,
requiresExternalRef: true,
supportsManagedValues: false,
supportsExternalReferences: true,
configured: false,
};
},
async validateConfig() {
return { ok: false, warnings: [`${id} provider is not configured in this deployment`] };
},
async createSecret() {
throw unprocessable(`${id} provider is not configured for Paperclip-managed values`);
},
async createVersion() {
throw unprocessable(`${id} provider is not configured in this deployment`);
throw unprocessable(`${id} provider is not configured for Paperclip-managed values`);
},
async linkExternalSecret(input) {
return prepareExternalReference(input);
},
async resolveVersion() {
throw unprocessable(`${id} provider is not configured in this deployment`);
},
async deleteOrArchive() {
// External references are metadata-only in Paperclip for unconfigured providers.
},
async healthCheck() {
return {
provider: id,
status: "warn",
message: `${id} provider is available for external references but not configured for runtime resolution`,
warnings: [
"Linked external references can be stored as metadata, but runtime resolution will fail until this provider is configured.",
],
};
},
};
}

View file

@ -1,7 +1,14 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
import type { SecretProviderModule, StoredSecretVersionMaterial } from "./types.js";
import { resolveDefaultSecretsKeyFilePath } from "../home-paths.js";
import type {
PreparedSecretVersion,
SecretProviderHealthCheck,
SecretProviderModule,
SecretProviderValidationResult,
StoredSecretVersionMaterial,
} from "./types.js";
import { badRequest } from "../errors.js";
interface LocalEncryptedMaterial extends StoredSecretVersionMaterial {
@ -14,7 +21,7 @@ interface LocalEncryptedMaterial extends StoredSecretVersionMaterial {
function resolveMasterKeyFilePath() {
const fromEnv = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
if (fromEnv && fromEnv.trim().length > 0) return path.resolve(fromEnv.trim());
return path.resolve(process.cwd(), "data/secrets/master.key");
return resolveDefaultSecretsKeyFilePath();
}
function decodeMasterKey(raw: string): Buffer | null {
@ -52,6 +59,7 @@ function loadOrCreateMasterKey(): Buffer {
const keyPath = resolveMasterKeyFilePath();
if (existsSync(keyPath)) {
enforceKeyFilePermissionsBestEffort(keyPath);
const raw = readFileSync(keyPath, "utf8");
const decoded = decodeMasterKey(raw);
if (!decoded) {
@ -72,10 +80,118 @@ function loadOrCreateMasterKey(): Buffer {
return generated;
}
function enforceKeyFilePermissionsBestEffort(keyPath: string) {
try {
const mode = statSync(keyPath).mode & 0o777;
if ((mode & 0o077) !== 0) {
chmodSync(keyPath, 0o600);
}
} catch {
// best effort only; health checks surface persistent permission problems.
}
}
function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function prepareManagedVersion(value: string): PreparedSecretVersion {
const masterKey = loadOrCreateMasterKey();
const valueSha256 = sha256Hex(value);
return {
material: encryptValue(masterKey, value),
valueSha256,
fingerprintSha256: valueSha256,
externalRef: null,
};
}
async function inspectLocalEncryptedHealth(): Promise<SecretProviderHealthCheck> {
const envKeyRaw = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
if (envKeyRaw && envKeyRaw.trim().length > 0) {
if (!decodeMasterKey(envKeyRaw)) {
return {
provider: "local_encrypted",
status: "error",
message:
"PAPERCLIP_SECRETS_MASTER_KEY is invalid; expected 32-byte base64, 64-char hex, or raw 32-char string",
};
}
return {
provider: "local_encrypted",
status: "ok",
message: "Local encrypted provider is using PAPERCLIP_SECRETS_MASTER_KEY",
backupGuidance: [
"Back up the configured master key separately from the database.",
"A restore needs both the database metadata and the same master key.",
],
details: { keySource: "env" },
};
}
const keyPath = resolveMasterKeyFilePath();
if (!existsSync(keyPath)) {
return {
provider: "local_encrypted",
status: "warn",
message: `Secrets key file does not exist yet: ${keyPath}`,
warnings: ["The first managed secret write will create this key file with 0600 permissions."],
backupGuidance: [
"Back up the key file together with database backups.",
"The database alone cannot restore local encrypted secret values.",
],
details: { keySource: "file", keyFilePath: keyPath },
};
}
let mode: number | null = null;
try {
mode = statSync(keyPath).mode & 0o777;
} catch (err) {
return {
provider: "local_encrypted",
status: "error",
message: `Could not stat secrets key file: ${err instanceof Error ? err.message : String(err)}`,
details: { keySource: "file", keyFilePath: keyPath },
};
}
try {
const raw = readFileSync(keyPath, "utf8");
if (!decodeMasterKey(raw)) {
return {
provider: "local_encrypted",
status: "error",
message: `Invalid key material in ${keyPath}`,
details: { keySource: "file", keyFilePath: keyPath },
};
}
} catch (err) {
return {
provider: "local_encrypted",
status: "error",
message: `Could not read secrets key file: ${err instanceof Error ? err.message : String(err)}`,
details: { keySource: "file", keyFilePath: keyPath },
};
}
const warnings =
mode !== null && (mode & 0o077) !== 0
? [`Secrets key file permissions are ${mode.toString(8)}; run chmod 600 ${keyPath}`]
: [];
return {
provider: "local_encrypted",
status: warnings.length > 0 ? "warn" : "ok",
message: `Local encrypted provider configured with key file ${keyPath}`,
warnings,
backupGuidance: [
"Back up the key file together with database backups.",
"The database alone cannot restore local encrypted secret values.",
],
details: { keySource: "file", keyFilePath: keyPath },
};
}
function encryptValue(masterKey: Buffer, value: string): LocalEncryptedMaterial {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", masterKey, iv);
@ -115,21 +231,45 @@ function asLocalEncryptedMaterial(value: StoredSecretVersionMaterial): LocalEncr
export const localEncryptedProvider: SecretProviderModule = {
id: "local_encrypted",
descriptor: {
id: "local_encrypted",
label: "Local encrypted (default)",
requiresExternalRef: false,
descriptor() {
return {
id: "local_encrypted",
label: "Local encrypted (default)",
requiresExternalRef: false,
supportsManagedValues: true,
supportsExternalReferences: false,
configured: true,
};
},
async validateConfig(input): Promise<SecretProviderValidationResult> {
const warnings: string[] = [];
if (input?.deploymentMode === "authenticated" && input.strictMode !== true) {
warnings.push("Strict secret mode should be enabled for authenticated deployments");
}
const health = await inspectLocalEncryptedHealth();
if (health.status === "error") {
throw badRequest(health.message);
}
warnings.push(...(health.warnings ?? []));
return { ok: true, warnings };
},
async createSecret(input) {
return prepareManagedVersion(input.value);
},
async createVersion(input) {
const masterKey = loadOrCreateMasterKey();
return {
material: encryptValue(masterKey, input.value),
valueSha256: sha256Hex(input.value),
externalRef: null,
};
return prepareManagedVersion(input.value);
},
async linkExternalSecret() {
throw badRequest("local_encrypted does not support external reference secrets");
},
async resolveVersion(input) {
const masterKey = loadOrCreateMasterKey();
return decryptValue(masterKey, asLocalEncryptedMaterial(input.material));
},
async deleteOrArchive() {
// Secret metadata deletion is handled in Paperclip DB; the local key is shared and must remain.
},
async healthCheck() {
return inspectLocalEncryptedHealth();
},
};

View file

@ -1,11 +1,11 @@
import type { SecretProvider, SecretProviderDescriptor } from "@paperclipai/shared";
import { awsSecretsManagerProvider } from "./aws-secrets-manager-provider.js";
import { localEncryptedProvider } from "./local-encrypted-provider.js";
import {
awsSecretsManagerProvider,
gcpSecretManagerProvider,
vaultProvider,
} from "./external-stub-providers.js";
import type { SecretProviderModule } from "./types.js";
import type { SecretProviderHealthCheck, SecretProviderModule } from "./types.js";
import { unprocessable } from "../errors.js";
const providers: SecretProviderModule[] = [
@ -26,5 +26,9 @@ export function getSecretProvider(id: SecretProvider): SecretProviderModule {
}
export function listSecretProviders(): SecretProviderDescriptor[] {
return providers.map((provider) => provider.descriptor);
return providers.map((provider) => provider.descriptor());
}
export async function checkSecretProviders(): Promise<SecretProviderHealthCheck[]> {
return Promise.all(providers.map((provider) => provider.healthCheck()));
}

View file

@ -1,22 +1,180 @@
import type { SecretProvider, SecretProviderDescriptor } from "@paperclipai/shared";
import type { DeploymentMode } from "@paperclipai/shared";
export interface StoredSecretVersionMaterial {
[key: string]: unknown;
}
export type SecretProviderHealthStatus = "ok" | "warn" | "error";
export interface SecretProviderHealthCheck {
provider: SecretProvider;
status: SecretProviderHealthStatus;
message: string;
warnings?: string[];
backupGuidance?: string[];
details?: Record<string, unknown>;
}
export interface SecretProviderValidationResult {
ok: boolean;
warnings: string[];
}
export interface PreparedSecretVersion {
material: StoredSecretVersionMaterial;
valueSha256: string;
fingerprintSha256?: string;
externalRef: string | null;
providerVersionRef?: string | null;
}
export interface RemoteSecretListEntry {
externalRef: string;
name: string;
providerVersionRef?: string | null;
metadata?: Record<string, unknown> | null;
}
export interface RemoteSecretListResult {
secrets: RemoteSecretListEntry[];
nextToken?: string | null;
}
export type SecretProviderClientErrorCode =
| "access_denied"
| "throttled"
| "not_found"
| "conflict"
| "invalid_request"
| "provider_unavailable"
| "provider_error";
export interface SecretProviderClientErrorOptions {
code: SecretProviderClientErrorCode;
provider: SecretProvider;
operation: string;
message: string;
status?: number;
rawMessage?: string | null;
cause?: unknown;
}
const SECRET_PROVIDER_CLIENT_ERROR_STATUS: Record<SecretProviderClientErrorCode, number> = {
access_denied: 403,
throttled: 429,
not_found: 404,
conflict: 409,
invalid_request: 422,
provider_unavailable: 503,
provider_error: 502,
};
export class SecretProviderClientError extends Error {
readonly code: SecretProviderClientErrorCode;
readonly provider: SecretProvider;
readonly operation: string;
readonly status: number;
readonly rawMessage: string | null;
constructor(options: SecretProviderClientErrorOptions) {
super(options.message);
this.name = "SecretProviderClientError";
this.code = options.code;
this.provider = options.provider;
this.operation = options.operation;
this.status = options.status ?? SECRET_PROVIDER_CLIENT_ERROR_STATUS[options.code];
this.rawMessage = options.rawMessage ?? null;
if (options.cause !== undefined) {
Object.defineProperty(this, "cause", {
value: options.cause,
enumerable: false,
configurable: true,
});
}
}
}
export function isSecretProviderClientError(error: unknown): error is SecretProviderClientError {
return error instanceof SecretProviderClientError;
}
export interface SecretProviderRuntimeContext {
companyId: string;
secretId: string;
secretKey: string;
version: number;
}
export interface SecretProviderVaultRuntimeConfig {
id: string;
provider: SecretProvider;
status: string;
config: Record<string, unknown>;
}
export interface SecretProviderWriteContext {
companyId: string;
secretKey: string;
secretName: string;
version: number;
}
export interface SecretProviderModule {
id: SecretProvider;
descriptor: SecretProviderDescriptor;
descriptor(): SecretProviderDescriptor;
validateConfig(input?: {
deploymentMode?: DeploymentMode;
strictMode?: boolean;
providerConfig?: SecretProviderVaultRuntimeConfig | null;
}): Promise<SecretProviderValidationResult>;
createSecret(input: {
value: string;
externalRef?: string | null;
context?: SecretProviderWriteContext;
providerConfig?: SecretProviderVaultRuntimeConfig | null;
}): Promise<PreparedSecretVersion>;
createVersion(input: {
value: string;
externalRef: string | null;
}): Promise<{
material: StoredSecretVersionMaterial;
valueSha256: string;
externalRef: string | null;
}>;
externalRef?: string | null;
context?: SecretProviderWriteContext;
providerConfig?: SecretProviderVaultRuntimeConfig | null;
}): Promise<PreparedSecretVersion>;
linkExternalSecret(input: {
externalRef: string;
providerVersionRef?: string | null;
context?: SecretProviderWriteContext;
providerConfig?: SecretProviderVaultRuntimeConfig | null;
}): Promise<PreparedSecretVersion>;
listRemoteSecrets?(input: {
providerConfig?: SecretProviderVaultRuntimeConfig | null;
query?: string | null;
nextToken?: string | null;
pageSize?: number;
}): Promise<RemoteSecretListResult>;
resolveVersion(input: {
material: StoredSecretVersionMaterial;
externalRef: string | null;
providerVersionRef?: string | null;
context?: SecretProviderRuntimeContext;
providerConfig?: SecretProviderVaultRuntimeConfig | null;
}): Promise<string>;
rotate?(input: {
material: StoredSecretVersionMaterial;
externalRef: string | null;
providerVersionRef?: string | null;
providerConfig?: SecretProviderVaultRuntimeConfig | null;
}): Promise<PreparedSecretVersion>;
deleteOrArchive(input: {
material?: StoredSecretVersionMaterial | null;
externalRef: string | null;
context?: SecretProviderWriteContext;
mode: "archive" | "delete";
providerConfig?: SecretProviderVaultRuntimeConfig | null;
}): Promise<void>;
healthCheck(input?: {
deploymentMode?: DeploymentMode;
strictMode?: boolean;
providerConfig?: SecretProviderVaultRuntimeConfig | null;
}): Promise<SecretProviderHealthCheck>;
}

View file

@ -9,6 +9,8 @@ import type {
PluginEnvironmentConfig,
PluginSandboxEnvironmentConfig,
SandboxEnvironmentConfig,
SecretProvider,
SecretVersionSelector,
SshEnvironmentConfig,
} from "@paperclipai/shared";
import { unprocessable } from "../errors.js";
@ -165,6 +167,7 @@ async function createEnvironmentSecret(input: {
environmentName: string;
driver: EnvironmentDriver;
field: string;
provider: SecretProvider;
value: string;
actor?: { userId?: string | null; agentId?: string | null };
}) {
@ -172,7 +175,7 @@ async function createEnvironmentSecret(input: {
input.companyId,
{
name: secretName(input),
provider: "local_encrypted",
provider: input.provider,
value: input.value,
description: `Secret for ${input.environmentName} ${input.field}.`,
},
@ -190,6 +193,7 @@ async function persistConfigSecretRefs(input: {
companyId: string;
environmentName: string;
driver: EnvironmentDriver;
secretProvider: SecretProvider;
config: Record<string, unknown>;
schema: Record<string, unknown> | null;
actor?: { userId?: string | null; agentId?: string | null };
@ -213,6 +217,7 @@ async function persistConfigSecretRefs(input: {
environmentName: input.environmentName,
driver: input.driver,
field: path.replace(/[^a-z0-9]+/gi, "-").toLowerCase(),
provider: input.secretProvider,
value: trimmed,
actor: input.actor,
});
@ -226,6 +231,11 @@ async function resolveConfigSecretRefsForRuntime(input: {
companyId: string;
config: Record<string, unknown>;
schema: Record<string, unknown> | null;
context: {
consumerId: string;
issueId?: string | null;
heartbeatRunId?: string | null;
};
}): Promise<Record<string, unknown>> {
const secrets = secretService(input.db);
let nextConfig = { ...input.config };
@ -234,15 +244,52 @@ async function resolveConfigSecretRefsForRuntime(input: {
if (typeof current !== "string") continue;
const trimmed = current.trim();
if (!isUuidSecretRef(trimmed)) continue;
if (!input.context.consumerId) {
throw unprocessable("Runtime secret resolution requires an environment id");
}
nextConfig = writeConfigValueAtPath(
nextConfig,
path,
await secrets.resolveSecretValue(input.companyId, trimmed, "latest"),
await secrets.resolveSecretValue(input.companyId, trimmed, "latest", {
consumerType: "environment",
consumerId: input.context.consumerId,
actorType: "system",
actorId: null,
issueId: input.context.issueId ?? null,
heartbeatRunId: input.context.heartbeatRunId ?? null,
configPath: path,
}),
);
}
return nextConfig;
}
export async function collectEnvironmentSecretRefs(input: {
db: Db;
environment: Pick<Environment, "id" | "driver" | "config">;
}): Promise<Array<{ secretId: string; configPath: string; versionSelector?: SecretVersionSelector }>> {
const parsed = parseEnvironmentDriverConfig(input.environment);
if (parsed.driver === "ssh" && parsed.config.privateKeySecretRef) {
return [{
secretId: parsed.config.privateKeySecretRef.secretId,
configPath: "privateKeySecretRef",
versionSelector: parsed.config.privateKeySecretRef.version ?? "latest",
}];
}
if (parsed.driver === "sandbox" && parsed.config.provider !== "fake") {
const schema = await getSandboxProviderConfigSchema(input.db, parsed.config.provider);
const refs: Array<{ secretId: string; configPath: string; versionSelector?: SecretVersionSelector }> = [];
for (const path of collectSecretRefPaths(schema)) {
const current = readConfigValueAtPath(parsed.config as Record<string, unknown>, path);
if (typeof current === "string" && isUuidSecretRef(current.trim())) {
refs.push({ secretId: current.trim(), configPath: path, versionSelector: "latest" });
}
}
return refs;
}
return [];
}
export function stripSandboxProviderEnvelope(config: SandboxEnvironmentConfig): Record<string, unknown> {
const { provider: _provider, ...driverConfig } = config as Record<string, unknown>;
return driverConfig;
@ -340,6 +387,7 @@ export async function normalizeEnvironmentConfigForPersistence(input: {
companyId: string;
environmentName: string;
driver: EnvironmentDriver;
secretProvider: SecretProvider;
config: Record<string, unknown> | null | undefined;
actor?: { userId?: string | null; agentId?: string | null };
pluginWorkerManager?: PluginWorkerManager;
@ -361,6 +409,7 @@ export async function normalizeEnvironmentConfigForPersistence(input: {
environmentName: input.environmentName,
driver: input.driver,
field: "private-key",
provider: input.secretProvider,
value: privateKey,
actor: input.actor,
});
@ -404,6 +453,7 @@ export async function normalizeEnvironmentConfigForPersistence(input: {
companyId: input.companyId,
environmentName: input.environmentName,
driver: input.driver,
secretProvider: input.secretProvider,
config: {
provider: parsed.data.provider,
...validated.normalizedConfig,
@ -442,10 +492,15 @@ export async function normalizeEnvironmentConfigForPersistence(input: {
export async function resolveEnvironmentDriverConfigForRuntime(
db: Db,
companyId: string,
environment: Pick<Environment, "driver" | "config">,
environment: Pick<Environment, "driver" | "config"> & Partial<Pick<Environment, "id">>,
context?: { issueId?: string | null; heartbeatRunId?: string | null },
): Promise<ParsedEnvironmentConfig> {
const parsed = parseEnvironmentDriverConfig(environment);
const secrets = secretService(db);
const environmentId = environment.id;
if (parsed.driver === "ssh" && parsed.config.privateKeySecretRef && !environmentId) {
throw unprocessable("Runtime secret resolution requires an environment id");
}
if (parsed.driver === "ssh" && parsed.config.privateKeySecretRef) {
return {
@ -456,6 +511,15 @@ export async function resolveEnvironmentDriverConfigForRuntime(
companyId,
parsed.config.privateKeySecretRef.secretId,
parsed.config.privateKeySecretRef.version ?? "latest",
{
consumerType: "environment",
consumerId: environmentId!,
actorType: "system",
actorId: null,
issueId: context?.issueId ?? null,
heartbeatRunId: context?.heartbeatRunId ?? null,
configPath: "privateKeySecretRef",
},
),
},
};
@ -469,6 +533,11 @@ export async function resolveEnvironmentDriverConfigForRuntime(
companyId,
config: parsed.config as Record<string, unknown>,
schema: await getSandboxProviderConfigSchema(db, parsed.config.provider),
context: {
consumerId: environmentId!,
issueId: context?.issueId ?? null,
heartbeatRunId: context?.heartbeatRunId ?? null,
},
}) as SandboxEnvironmentConfig,
};
}

View file

@ -228,7 +228,10 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver {
driver: "ssh",
async acquireRunLease(input) {
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.companyId, input.environment);
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.companyId, input.environment, {
issueId: input.issueId,
heartbeatRunId: input.heartbeatRunId,
});
if (parsed.driver !== "ssh") {
throw new Error(`Expected SSH environment config for driver "${input.environment.driver}".`);
}
@ -346,6 +349,7 @@ function createSandboxEnvironmentDriver(
const metadataConfig = sandboxConfigFromLeaseMetadataLoose(input.lease);
if (metadataConfig && metadataConfig.provider === input.provider) {
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.lease.companyId, {
id: input.environment.id,
driver: "sandbox",
config: sandboxConfigForLeaseMetadata(metadataConfig),
});
@ -381,7 +385,10 @@ function createSandboxEnvironmentDriver(
async acquireRunLease(input) {
const storedParsed = parseEnvironmentDriverConfig(input.environment);
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.companyId, input.environment);
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.companyId, input.environment, {
issueId: input.issueId,
heartbeatRunId: input.heartbeatRunId,
});
if (parsed.driver !== "sandbox" || storedParsed.driver !== "sandbox") {
throw new Error(`Expected sandbox environment config for driver "${input.environment.driver}".`);
}
@ -562,6 +569,7 @@ function createSandboxEnvironmentDriver(
const parsed = metadataConfig
? await resolveEnvironmentDriverConfigForRuntime(db, input.lease.companyId, {
id: input.environment.id,
driver: "sandbox",
config: metadataConfig as unknown as Record<string, unknown>,
})

View file

@ -327,17 +327,44 @@ type RuntimeConfigSecretResolver = Pick<
export async function resolveExecutionRunAdapterConfig(input: {
companyId: string;
agentId?: string | null;
issueId?: string | null;
heartbeatRunId?: string | null;
projectId?: string | null;
executionRunConfig: Record<string, unknown>;
projectEnv: unknown;
secretsSvc: RuntimeConfigSecretResolver;
}) {
const { config: resolvedConfig, secretKeys } = await input.secretsSvc.resolveAdapterConfigForRuntime(
const { config: resolvedConfig, secretKeys, manifest } = await input.secretsSvc.resolveAdapterConfigForRuntime(
input.companyId,
input.executionRunConfig,
input.agentId
? {
consumerType: "agent",
consumerId: input.agentId,
actorType: "agent",
actorId: input.agentId,
issueId: input.issueId ?? null,
heartbeatRunId: input.heartbeatRunId ?? null,
}
: undefined,
);
const projectEnvResolution = input.projectEnv
? await input.secretsSvc.resolveEnvBindings(input.companyId, input.projectEnv)
: { env: {}, secretKeys: new Set<string>() };
? await input.secretsSvc.resolveEnvBindings(
input.companyId,
input.projectEnv,
input.projectId
? {
consumerType: "project",
consumerId: input.projectId,
actorType: "agent",
actorId: input.agentId ?? null,
issueId: input.issueId ?? null,
heartbeatRunId: input.heartbeatRunId ?? null,
}
: undefined,
)
: { env: {}, secretKeys: new Set<string>(), manifest: [] };
if (Object.keys(projectEnvResolution.env).length > 0) {
resolvedConfig.env = {
...parseObject(resolvedConfig.env),
@ -347,7 +374,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
secretKeys.add(key);
}
}
return { resolvedConfig, secretKeys };
return {
resolvedConfig,
secretKeys,
secretManifest: [...(manifest ?? []), ...(projectEnvResolution.manifest ?? [])],
};
}
export function extractMentionedSkillIdsFromSources(
@ -6790,6 +6821,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const projectContext = executionProjectId
? await db
.select({
id: projects.id,
executionWorkspacePolicy: projects.executionWorkspacePolicy,
env: projects.env,
})
@ -6995,12 +7027,23 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
});
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig, selectedEnvironmentId);
const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
const { resolvedConfig, secretKeys } = await resolveExecutionRunAdapterConfig({
const { resolvedConfig, secretKeys, secretManifest } = await resolveExecutionRunAdapterConfig({
companyId: agent.companyId,
agentId: agent.id,
issueId,
heartbeatRunId: run.id,
projectId: projectContext?.id ?? null,
executionRunConfig,
projectEnv: projectContext?.env ?? null,
secretsSvc,
});
if (secretManifest.length > 0) {
context.paperclipSecrets = {
manifest: secretManifest,
};
} else {
delete context.paperclipSecrets;
}
const runScopedMentionedSkillKeys = await resolveRunScopedMentionedSkillKeys({
db,
companyId: agent.companyId,
@ -8320,8 +8363,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
return { kind: "released" as const };
}
if (issue.originKind === RECOVERY_ORIGIN_KINDS.strandedIssueRecovery) {
return {
kind: "blocked_recovery_in_place" as const,
issue,
previousStatus: issue.status,
};
}
const shouldBlockImmediately =
issue.originKind === RECOVERY_ORIGIN_KINDS.strandedIssueRecovery ||
!recoveryAgentInvokable ||
!recoveryAgent ||
didAutomaticRecoveryFail(run, issue.status === "todo" ? "assignment_recovery" : "issue_continuation_needed");
@ -8421,6 +8471,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
return;
}
if (promotionResult?.kind === "blocked_recovery_in_place") {
await recovery.escalateStrandedRecoveryIssueInPlace({
issue: promotionResult.issue,
previousStatus: promotionResult.previousStatus as "todo" | "in_progress",
latestRun: run,
});
return;
}
const promotedRun = promotionResult?.run ?? null;
if (!promotedRun) return;

View file

@ -33,38 +33,20 @@
* @see services/secrets.ts secretService used by agent env bindings
*/
import { eq, and, desc } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { companySecrets, companySecretVersions, pluginConfig } from "@paperclipai/db";
import type { SecretProvider } from "@paperclipai/shared";
import { getSecretProvider } from "../secrets/provider-registry.js";
import { pluginRegistryService } from "./plugin-registry.js";
import {
collectSecretRefPaths,
isUuidSecretRef,
readConfigValueAtPath,
} from "./json-schema-secret-refs.js";
export const PLUGIN_SECRET_REFS_DISABLED_MESSAGE =
"Plugin secret references are disabled until company-scoped plugin config lands";
// ---------------------------------------------------------------------------
// Error helpers
// ---------------------------------------------------------------------------
/**
* Create a sanitised error that never leaks secret material.
* Only the ref identifier is included; never the resolved value.
*/
function secretNotFound(secretRef: string): Error {
const err = new Error(`Secret not found: ${secretRef}`);
err.name = "SecretNotFoundError";
return err;
}
function secretVersionNotFound(secretRef: string): Error {
const err = new Error(`No version found for secret: ${secretRef}`);
err.name = "SecretVersionNotFoundError";
return err;
}
function invalidSecretRef(secretRef: string): Error {
const err = new Error(`Invalid secret reference: ${secretRef}`);
err.name = "InvalidSecretRefError";
@ -86,8 +68,20 @@ export function extractSecretRefsFromConfig(
configJson: unknown,
schema?: Record<string, unknown> | null,
): Set<string> {
const refs = new Set<string>();
if (configJson == null || typeof configJson !== "object") return refs;
return new Set(extractSecretRefPathsFromConfig(configJson, schema).keys());
}
export function extractSecretRefPathsFromConfig(
configJson: unknown,
schema?: Record<string, unknown> | null,
): Map<string, Set<string>> {
const refs = new Map<string, Set<string>>();
const addRef = (secretRef: string, path: string) => {
const existing = refs.get(secretRef) ?? new Set<string>();
existing.add(path);
refs.set(secretRef, existing);
};
if (configJson == null || typeof configJson !== "object") return new Map();
const secretPaths = collectSecretRefPaths(schema);
@ -96,7 +90,7 @@ export function extractSecretRefsFromConfig(
for (const dotPath of secretPaths) {
const current = readConfigValueAtPath(configJson as Record<string, unknown>, dotPath);
if (typeof current === "string" && isUuidSecretRef(current)) {
refs.add(current);
addRef(current, dotPath);
}
}
return refs;
@ -107,7 +101,7 @@ export function extractSecretRefsFromConfig(
// instanceConfigSchema.
function walkAll(value: unknown): void {
if (typeof value === "string") {
if (isUuidSecretRef(value)) refs.add(value);
if (isUuidSecretRef(value)) addRef(value, "$");
} else if (Array.isArray(value)) {
for (const item of value) walkAll(item);
} else if (value !== null && typeof value === "object") {
@ -205,16 +199,11 @@ function createRateLimiter(maxAttempts: number, windowMs: number) {
export function createPluginSecretsHandler(
options: PluginSecretsHandlerOptions,
): PluginSecretsService {
const { db, pluginId } = options;
const registry = pluginRegistryService(db);
const { pluginId } = options;
// Rate limit: max 30 resolution attempts per plugin per minute
const rateLimiter = createRateLimiter(30, 60_000);
let cachedAllowedRefs: Set<string> | null = null;
let cachedAllowedRefsExpiry = 0;
const CONFIG_CACHE_TTL_MS = 30_000; // 30 seconds, matches event bus TTL
return {
async resolve(params: PluginSecretsResolveParams): Promise<string> {
const { secretRef } = params;
@ -241,72 +230,9 @@ export function createPluginSecretsHandler(
throw invalidSecretRef(trimmedRef);
}
// ---------------------------------------------------------------
// 1b. Scope check — only allow secrets referenced in this plugin's config
// ---------------------------------------------------------------
const now = Date.now();
if (!cachedAllowedRefs || now > cachedAllowedRefsExpiry) {
const [configRow, plugin] = await Promise.all([
db
.select()
.from(pluginConfig)
.where(eq(pluginConfig.pluginId, pluginId))
.then((rows) => rows[0] ?? null),
registry.getById(pluginId),
]);
const schema = (plugin?.manifestJson as unknown as Record<string, unknown> | null)
?.instanceConfigSchema as Record<string, unknown> | undefined;
cachedAllowedRefs = extractSecretRefsFromConfig(configRow?.configJson, schema);
cachedAllowedRefsExpiry = now + CONFIG_CACHE_TTL_MS;
}
if (!cachedAllowedRefs.has(trimmedRef)) {
// Return "not found" to avoid leaking whether the secret exists
throw secretNotFound(trimmedRef);
}
// ---------------------------------------------------------------
// 2. Look up the secret record by UUID
// ---------------------------------------------------------------
const secret = await db
.select()
.from(companySecrets)
.where(eq(companySecrets.id, trimmedRef))
.then((rows) => rows[0] ?? null);
if (!secret) {
throw secretNotFound(trimmedRef);
}
// ---------------------------------------------------------------
// 3. Fetch the latest version's material
// ---------------------------------------------------------------
const versionRow = await db
.select()
.from(companySecretVersions)
.where(
and(
eq(companySecretVersions.secretId, secret.id),
eq(companySecretVersions.version, secret.latestVersion),
),
)
.then((rows) => rows[0] ?? null);
if (!versionRow) {
throw secretVersionNotFound(trimmedRef);
}
// ---------------------------------------------------------------
// 4. Resolve through the appropriate secret provider
// ---------------------------------------------------------------
const provider = getSecretProvider(secret.provider as SecretProvider);
const resolved = await provider.resolveVersion({
material: versionRow.material as Record<string, unknown>,
externalRef: secret.externalRef,
});
return resolved;
// Fail closed until plugin config and worker runtime both carry an
// explicit company scope for secret bindings and resolution.
throw new Error(PLUGIN_SECRET_REFS_DISABLED_MESSAGE);
},
};
}

View file

@ -1313,6 +1313,33 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
.then((rows) => rows[0] ?? null);
}
function isStrandedIssueRecoveryIssue(issue: typeof issues.$inferSelect) {
return issue.originKind === STRANDED_ISSUE_RECOVERY_ORIGIN_KIND;
}
async function buildNestedStrandedRecoveryLine(issue: typeof issues.$inferSelect, prefix: string) {
const sourceIssueId = readNonEmptyString(issue.originId);
const sourceIssue = sourceIssueId
? await db
.select({ id: issues.id, identifier: issues.identifier })
.from(issues)
.where(and(eq(issues.companyId, issue.companyId), eq(issues.id, sourceIssueId)))
.then((rows) => rows[0] ?? null)
: null;
const sourceLine = sourceIssue
? `- Original source issue: ${issueUiLink(sourceIssue, prefix)}`
: sourceIssueId
? `- Original source issue: \`${sourceIssueId}\``
: "- Original source issue: unknown";
return [
"",
"- Nested recovery: suppressed because this issue is already a `stranded_issue_recovery` issue.",
sourceLine,
"- Next action: the assigned recovery owner or board operator should fix the runtime/adapter problem, resolve or reassign the original source issue, then mark this recovery issue done or cancelled.",
].join("\n");
}
async function resolveStrandedIssueRecoveryOwnerAgentId(issue: typeof issues.$inferSelect) {
const candidateIds: string[] = [];
if (issue.assigneeAgentId) {
@ -1623,21 +1650,17 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
recoveryCause?: StrandedRecoveryCause;
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
}) {
if (isStrandedIssueRecoveryIssue(input.issue)) {
return escalateStrandedRecoveryIssueInPlace({
const nestedRecoverySuppressed = isStrandedIssueRecoveryIssue(input.issue);
let recoveryIssue: typeof issues.$inferSelect | null = null;
if (!nestedRecoverySuppressed) {
recoveryIssue = await ensureStrandedIssueRecoveryIssue({
issue: input.issue,
previousStatus: input.previousStatus,
latestRun: input.latestRun,
recoveryCause: input.recoveryCause,
successfulRunHandoffEvidence: input.successfulRunHandoffEvidence,
});
}
const recoveryIssue = await ensureStrandedIssueRecoveryIssue({
issue: input.issue,
previousStatus: input.previousStatus,
latestRun: input.latestRun,
recoveryCause: input.recoveryCause,
successfulRunHandoffEvidence: input.successfulRunHandoffEvidence,
});
const blockerIds = await existingUnresolvedBlockerIssueIds(input.issue.companyId, input.issue.id);
const nextBlockerIds = recoveryIssue
? [...new Set([...blockerIds, recoveryIssue.id])]
@ -1667,18 +1690,23 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
missingDisposition: input.successfulRunHandoffEvidence.missingDisposition,
});
}
const recoveryLine = recoveryIssue
? [
let recoveryLine: string;
if (nestedRecoverySuppressed) {
recoveryLine = await buildNestedStrandedRecoveryLine(input.issue, prefix);
} else if (recoveryIssue) {
recoveryLine = [
"",
`- Recovery issue: ${issueUiLink({ identifier: recoveryIssue.identifier, id: recoveryIssue.id }, prefix)}`,
`- Recovery owner: ${agentUiLink(recoveryOwner, prefix)}`,
"- Next action: the recovery owner should either restore a live execution path or record the manual resolution, then mark the recovery issue done.",
].join("\n")
: [
].join("\n");
} else {
recoveryLine = [
"",
"- Recovery issue: none created because Paperclip could not find an invokable manager, creator, or executive owner with budget available.",
"- Next action: a board operator should assign an invokable recovery owner, fix the agent/runtime state, or record an intentional manual resolution.",
].join("\n");
}
if (notice) {
await issuesSvc.addComment(input.issue.id, notice.body, {}, {
@ -1713,6 +1741,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
latestRunStatus: input.latestRun?.status ?? null,
latestRunErrorCode: input.latestRun?.errorCode ?? null,
recoveryIssueId: recoveryIssue?.id ?? null,
nestedRecoverySuppressed,
blockerIssueIds: nextBlockerIds,
},
});
@ -2768,6 +2797,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
return {
buildRunOutputSilence,
escalateStrandedRecoveryIssueInPlace,
escalateStrandedAssignedIssue,
recordWatchdogDecision,
scanSilentActiveRuns,

View file

@ -3,6 +3,7 @@ import { and, asc, desc, eq, inArray, isNotNull, isNull, lte, ne, not, or, sql }
import type { Db } from "@paperclipai/db";
import {
agents,
companySecretBindings,
companySecretVersions,
companySecrets,
executionWorkspaces,
@ -49,6 +50,7 @@ import { trackRoutineRun } from "@paperclipai/shared/telemetry";
import { conflict, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
import { logger } from "../middleware/logger.js";
import { getTelemetryClient } from "../telemetry.js";
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
import { issueService } from "./issues.js";
import { secretService } from "./secrets.js";
import { getSecretProvider } from "../secrets/provider-registry.js";
@ -81,6 +83,10 @@ interface RoutineTriggerSecretRestoreMaterial extends RoutineTriggerSecretMateri
triggerId: string;
}
function routineWebhookSecretConfigPath(secretId: string) {
return `webhookSecret:${secretId}`;
}
function assertTimeZone(timeZone: string) {
try {
new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date());
@ -950,16 +956,23 @@ export function routineService(
executor?: Db,
) {
const secretValue = crypto.randomBytes(24).toString("hex");
const providerId = getConfiguredSecretProvider();
const input = {
name: `routine-${routineId}-${crypto.randomBytes(6).toString("hex")}`,
provider: "local_encrypted" as const,
provider: providerId,
value: secretValue,
description: `Webhook auth for routine ${routineId}`,
};
const provider = getSecretProvider(input.provider);
const prepared = await provider.createVersion({
const prepared = await provider.createSecret({
value: input.value,
externalRef: null,
context: {
companyId,
secretKey: input.name,
secretName: input.name,
version: 1,
},
});
const insertSecret = async (secretDb: Db) => {
@ -967,11 +980,16 @@ export function routineService(
.insert(companySecrets)
.values({
companyId,
key: input.name,
name: input.name,
provider: input.provider,
status: "active",
managedMode: "paperclip_managed",
externalRef: prepared.externalRef,
providerMetadata: null,
latestVersion: 1,
description: input.description,
lastRotatedAt: new Date(),
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.userId ?? null,
})
@ -983,10 +1001,21 @@ export function routineService(
version: 1,
material: prepared.material,
valueSha256: prepared.valueSha256,
fingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256,
providerVersionRef: prepared.providerVersionRef ?? null,
status: "current",
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.userId ?? null,
});
await secretDb.insert(companySecretBindings).values({
companyId,
secretId: secret.id,
targetType: "routine",
targetId: routineId,
configPath: routineWebhookSecretConfigPath(secret.id),
});
return secret;
};
@ -1004,7 +1033,13 @@ export function routineService(
.where(eq(companySecrets.id, trigger.secretId))
.then((rows) => rows[0] ?? null);
if (!secret || secret.companyId !== companyId) throw notFound("Routine trigger secret not found");
const value = await secretsSvc.resolveSecretValue(companyId, trigger.secretId, "latest");
const value = await secretsSvc.resolveSecretValue(companyId, trigger.secretId, "latest", {
consumerType: "routine",
consumerId: trigger.routineId,
actorType: "system",
actorId: null,
configPath: routineWebhookSecretConfigPath(trigger.secretId),
});
return value;
}

File diff suppressed because it is too large Load diff