mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
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.



## 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:
parent
06e6ee25cd
commit
778e775c35
103 changed files with 16971 additions and 509 deletions
257
cli/src/__tests__/secrets.test.ts
Normal file
257
cli/src/__tests__/secrets.test.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import type { Agent, CompanySecret } from "@paperclipai/shared";
|
||||||
|
import type { PaperclipConfig } from "../config/schema.js";
|
||||||
|
import { secretsCheck } from "../checks/secrets-check.js";
|
||||||
|
import {
|
||||||
|
buildInlineMigrationSecretName,
|
||||||
|
buildMigratedAgentEnv,
|
||||||
|
collectInlineSecretMigrationCandidates,
|
||||||
|
parseSecretsInclude,
|
||||||
|
toPlainEnvValue,
|
||||||
|
} from "../commands/client/secrets.js";
|
||||||
|
|
||||||
|
function agent(partial: Partial<Agent>): Agent {
|
||||||
|
return {
|
||||||
|
id: "agent-12345678",
|
||||||
|
companyId: "company-1",
|
||||||
|
name: "Coder",
|
||||||
|
urlKey: "coder",
|
||||||
|
role: "engineer",
|
||||||
|
title: null,
|
||||||
|
icon: null,
|
||||||
|
status: "idle",
|
||||||
|
reportsTo: null,
|
||||||
|
capabilities: null,
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
budgetMonthlyCents: 0,
|
||||||
|
spentMonthlyCents: 0,
|
||||||
|
pauseReason: null,
|
||||||
|
pausedAt: null,
|
||||||
|
permissions: {
|
||||||
|
canCreateAgents: false,
|
||||||
|
},
|
||||||
|
lastHeartbeatAt: null,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: new Date("2026-04-26T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-26T00:00:00.000Z"),
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function secret(partial: Partial<CompanySecret>): CompanySecret {
|
||||||
|
return {
|
||||||
|
id: "secret-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
key: "agent_agent-12_anthropic_api_key",
|
||||||
|
name: "agent_agent-12_anthropic_api_key",
|
||||||
|
provider: "local_encrypted",
|
||||||
|
status: "active",
|
||||||
|
managedMode: "paperclip_managed",
|
||||||
|
externalRef: null,
|
||||||
|
providerConfigId: null,
|
||||||
|
providerMetadata: null,
|
||||||
|
latestVersion: 1,
|
||||||
|
description: null,
|
||||||
|
lastResolvedAt: null,
|
||||||
|
lastRotatedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
createdAt: new Date("2026-04-26T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-26T00:00:00.000Z"),
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function configWithSecretsProvider(provider: PaperclipConfig["secrets"]["provider"]): PaperclipConfig {
|
||||||
|
return {
|
||||||
|
$meta: {
|
||||||
|
version: 1,
|
||||||
|
updatedAt: "2026-05-02T00:00:00.000Z",
|
||||||
|
source: "configure",
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
mode: "embedded-postgres",
|
||||||
|
embeddedPostgresDataDir: "/tmp/paperclip/db",
|
||||||
|
embeddedPostgresPort: 55432,
|
||||||
|
backup: {
|
||||||
|
enabled: true,
|
||||||
|
intervalMinutes: 60,
|
||||||
|
retentionDays: 30,
|
||||||
|
dir: "/tmp/paperclip/backups",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
mode: "file",
|
||||||
|
logDir: "/tmp/paperclip/logs",
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
deploymentMode: "local_trusted",
|
||||||
|
exposure: "private",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 3100,
|
||||||
|
allowedHostnames: [],
|
||||||
|
serveUi: true,
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
baseUrlMode: "auto",
|
||||||
|
disableSignUp: false,
|
||||||
|
},
|
||||||
|
telemetry: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
provider: "local_disk",
|
||||||
|
localDisk: {
|
||||||
|
baseDir: "/tmp/paperclip/storage",
|
||||||
|
},
|
||||||
|
s3: {
|
||||||
|
bucket: "paperclip",
|
||||||
|
region: "us-east-1",
|
||||||
|
prefix: "",
|
||||||
|
forcePathStyle: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
secrets: {
|
||||||
|
provider,
|
||||||
|
strictMode: true,
|
||||||
|
localEncrypted: {
|
||||||
|
keyFilePath: "/tmp/paperclip/secrets/master.key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("secrets CLI helpers", () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses declaration include filters", () => {
|
||||||
|
expect(parseSecretsInclude("agents,projects,tasks")).toEqual({
|
||||||
|
company: false,
|
||||||
|
agents: true,
|
||||||
|
projects: true,
|
||||||
|
issues: true,
|
||||||
|
skills: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects inline sensitive env values that need migration", () => {
|
||||||
|
const rows = collectInlineSecretMigrationCandidates(
|
||||||
|
[
|
||||||
|
agent({
|
||||||
|
id: "agent-12345678",
|
||||||
|
adapterConfig: {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_API_KEY: "sk-ant-test",
|
||||||
|
GH_TOKEN: {
|
||||||
|
type: "plain",
|
||||||
|
value: "ghp-test",
|
||||||
|
},
|
||||||
|
PATH: {
|
||||||
|
type: "plain",
|
||||||
|
value: "/usr/bin",
|
||||||
|
},
|
||||||
|
OPENAI_API_KEY: {
|
||||||
|
type: "secret_ref",
|
||||||
|
secretId: "secret-existing",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
secret({
|
||||||
|
id: "secret-gh-token",
|
||||||
|
name: buildInlineMigrationSecretName("agent-12345678", "GH_TOKEN"),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows).toEqual([
|
||||||
|
{
|
||||||
|
agentId: "agent-12345678",
|
||||||
|
agentName: "Coder",
|
||||||
|
envKey: "ANTHROPIC_API_KEY",
|
||||||
|
secretName: "agent_agent-12_anthropic_api_key",
|
||||||
|
existingSecretId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agentId: "agent-12345678",
|
||||||
|
agentName: "Coder",
|
||||||
|
envKey: "GH_TOKEN",
|
||||||
|
secretName: "agent_agent-12_gh_token",
|
||||||
|
existingSecretId: "secret-gh-token",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds migrated env bindings without preserving secret values", () => {
|
||||||
|
const next = buildMigratedAgentEnv(
|
||||||
|
{
|
||||||
|
ANTHROPIC_API_KEY: "sk-ant-test",
|
||||||
|
NODE_ENV: {
|
||||||
|
type: "plain",
|
||||||
|
value: "development",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new Map([["ANTHROPIC_API_KEY", "secret-1"]]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(next).toEqual({
|
||||||
|
ANTHROPIC_API_KEY: {
|
||||||
|
type: "secret_ref",
|
||||||
|
secretId: "secret-1",
|
||||||
|
version: "latest",
|
||||||
|
},
|
||||||
|
NODE_ENV: {
|
||||||
|
type: "plain",
|
||||||
|
value: "development",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(next)).not.toContain("sk-ant-test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads only explicit plain env values", () => {
|
||||||
|
expect(toPlainEnvValue("plain-value")).toBe("plain-value");
|
||||||
|
expect(toPlainEnvValue({ type: "plain", value: "wrapped" })).toBe("wrapped");
|
||||||
|
expect(toPlainEnvValue({ type: "secret_ref", secretId: "secret-1" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the AWS bootstrap config required by doctor", () => {
|
||||||
|
const result = secretsCheck(configWithSecretsProvider("aws_secrets_manager"));
|
||||||
|
|
||||||
|
expect(result.status).toBe("fail");
|
||||||
|
expect(result.message).toContain("PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID");
|
||||||
|
expect(result.repairHint).toContain("AWS SDK default credential chain");
|
||||||
|
expect(result.repairHint).toContain("Do not store AWS root credentials");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes AWS doctor checks when non-secret provider config is present", () => {
|
||||||
|
process.env.PAPERCLIP_SECRETS_AWS_REGION = "us-east-1";
|
||||||
|
process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID = "prod-us-1";
|
||||||
|
process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID =
|
||||||
|
"arn:aws:kms:us-east-1:123456789012:key/test";
|
||||||
|
process.env.AWS_PROFILE = "paperclip-prod";
|
||||||
|
|
||||||
|
const result = secretsCheck(configWithSecretsProvider("aws_secrets_manager"));
|
||||||
|
|
||||||
|
expect(result.status).toBe("pass");
|
||||||
|
expect(result.message).toContain("prod-us-1");
|
||||||
|
expect(result.message).toContain("AWS_PROFILE/shared config");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,6 +5,9 @@ import type { PaperclipConfig } from "../config/schema.js";
|
||||||
import type { CheckResult } from "./index.js";
|
import type { CheckResult } from "./index.js";
|
||||||
import { resolveRuntimeLikePath } from "./path-resolver.js";
|
import { resolveRuntimeLikePath } from "./path-resolver.js";
|
||||||
|
|
||||||
|
const AWS_CREDENTIAL_SOURCE_HINT =
|
||||||
|
"Provide AWS runtime credentials through the AWS SDK default credential chain: IAM role/workload identity, AWS_PROFILE/SSO/shared credentials, web identity, container/instance metadata, or short-lived shell credentials";
|
||||||
|
|
||||||
function decodeMasterKey(raw: string): Buffer | null {
|
function decodeMasterKey(raw: string): Buffer | null {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
|
|
@ -47,13 +50,16 @@ function withStrictModeNote(
|
||||||
|
|
||||||
export function secretsCheck(config: PaperclipConfig, configPath?: string): CheckResult {
|
export function secretsCheck(config: PaperclipConfig, configPath?: string): CheckResult {
|
||||||
const provider = config.secrets.provider;
|
const provider = config.secrets.provider;
|
||||||
|
if (provider === "aws_secrets_manager") {
|
||||||
|
return withStrictModeNote(awsSecretsManagerCheck(), config);
|
||||||
|
}
|
||||||
if (provider !== "local_encrypted") {
|
if (provider !== "local_encrypted") {
|
||||||
return {
|
return {
|
||||||
name: "Secrets adapter",
|
name: "Secrets adapter",
|
||||||
status: "fail",
|
status: "fail",
|
||||||
message: `${provider} is configured, but this build only supports local_encrypted`,
|
message: `${provider} is configured, but this build only supports local_encrypted and aws_secrets_manager`,
|
||||||
canRepair: false,
|
canRepair: false,
|
||||||
repairHint: "Run `paperclipai configure --section secrets` and set provider to local_encrypted",
|
repairHint: "Run `paperclipai configure --section secrets` and choose local_encrypted or aws_secrets_manager",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,12 +141,100 @@ export function secretsCheck(config: PaperclipConfig, configPath?: string): Chec
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const keyMode = fs.statSync(keyFilePath).mode & 0o777;
|
||||||
|
const permissionWarning =
|
||||||
|
(keyMode & 0o077) !== 0
|
||||||
|
? `; key file permissions are ${keyMode.toString(8)} (run chmod 600 ${keyFilePath})`
|
||||||
|
: "";
|
||||||
|
|
||||||
return withStrictModeNote(
|
return withStrictModeNote(
|
||||||
{
|
{
|
||||||
name: "Secrets adapter",
|
name: "Secrets adapter",
|
||||||
status: "pass",
|
status: permissionWarning ? "warn" : "pass",
|
||||||
message: `Local encrypted provider configured with key file ${keyFilePath}`,
|
message: `Local encrypted provider configured with key file ${keyFilePath}${permissionWarning}`,
|
||||||
|
repairHint: permissionWarning
|
||||||
|
? "Restrict the local encrypted secrets key file to owner read/write permissions"
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
config,
|
config,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function awsSecretsManagerCheck(): CheckResult {
|
||||||
|
const missingConfig = missingAwsSecretsManagerConfig();
|
||||||
|
if (missingConfig.length > 0) {
|
||||||
|
return {
|
||||||
|
name: "Secrets adapter",
|
||||||
|
status: "fail",
|
||||||
|
message: `AWS Secrets Manager provider is missing non-secret config: ${missingConfig.join(", ")}`,
|
||||||
|
canRepair: false,
|
||||||
|
repairHint:
|
||||||
|
`Set ${missingConfig.join(", ")} in the Paperclip server runtime. ${AWS_CREDENTIAL_SOURCE_HINT}. Do not store AWS root credentials or long-lived IAM user keys in Paperclip secrets.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticEnvCredentials =
|
||||||
|
process.env.AWS_ACCESS_KEY_ID?.trim() && process.env.AWS_SECRET_ACCESS_KEY?.trim();
|
||||||
|
const credentialSource = detectedAwsCredentialSources().join(", ");
|
||||||
|
const message =
|
||||||
|
`AWS Secrets Manager provider configured for deployment ${process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID}; ` +
|
||||||
|
`runtime credentials source: ${credentialSource || "AWS SDK default credential chain"}`;
|
||||||
|
|
||||||
|
if (staticEnvCredentials) {
|
||||||
|
return {
|
||||||
|
name: "Secrets adapter",
|
||||||
|
status: "warn",
|
||||||
|
message,
|
||||||
|
canRepair: false,
|
||||||
|
repairHint:
|
||||||
|
"AWS static environment credentials are visible. Use only short-lived shell credentials locally; prefer IAM role/workload identity for hosted deployments and never store AWS access keys in Paperclip company secrets.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "Secrets adapter",
|
||||||
|
status: "pass",
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function missingAwsSecretsManagerConfig(): string[] {
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
process.env.PAPERCLIP_SECRETS_AWS_REGION?.trim() ||
|
||||||
|
process.env.AWS_REGION?.trim() ||
|
||||||
|
process.env.AWS_DEFAULT_REGION?.trim()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
missing.push("PAPERCLIP_SECRETS_AWS_REGION or AWS_REGION/AWS_DEFAULT_REGION");
|
||||||
|
}
|
||||||
|
if (!process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID?.trim()) {
|
||||||
|
missing.push("PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID");
|
||||||
|
}
|
||||||
|
if (!process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID?.trim()) {
|
||||||
|
missing.push("PAPERCLIP_SECRETS_AWS_KMS_KEY_ID");
|
||||||
|
}
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectedAwsCredentialSources(): string[] {
|
||||||
|
const sources: string[] = [];
|
||||||
|
if (process.env.AWS_PROFILE?.trim()) sources.push("AWS_PROFILE/shared config");
|
||||||
|
if (process.env.AWS_ACCESS_KEY_ID?.trim() && process.env.AWS_SECRET_ACCESS_KEY?.trim()) {
|
||||||
|
sources.push("temporary AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY environment credentials");
|
||||||
|
}
|
||||||
|
if (process.env.AWS_WEB_IDENTITY_TOKEN_FILE?.trim() && process.env.AWS_ROLE_ARN?.trim()) {
|
||||||
|
sources.push("AWS web identity token");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI?.trim() ||
|
||||||
|
process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI?.trim()
|
||||||
|
) {
|
||||||
|
sources.push("AWS container credentials endpoint");
|
||||||
|
}
|
||||||
|
if (process.env.AWS_SHARED_CREDENTIALS_FILE?.trim() || process.env.AWS_CONFIG_FILE?.trim()) {
|
||||||
|
sources.push("custom AWS shared credentials/config file");
|
||||||
|
}
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
|
||||||
501
cli/src/commands/client/secrets.ts
Normal file
501
cli/src/commands/client/secrets.ts
Normal file
|
|
@ -0,0 +1,501 @@
|
||||||
|
import { Command } from "commander";
|
||||||
|
import pc from "picocolors";
|
||||||
|
import type {
|
||||||
|
Agent,
|
||||||
|
AgentEnvConfig,
|
||||||
|
CompanyPortabilityEnvInput,
|
||||||
|
CompanyPortabilityExportPreviewResult,
|
||||||
|
CompanyPortabilityInclude,
|
||||||
|
CompanySecret,
|
||||||
|
EnvBinding,
|
||||||
|
SecretProvider,
|
||||||
|
SecretProviderDescriptor,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import {
|
||||||
|
addCommonClientOptions,
|
||||||
|
formatInlineRecord,
|
||||||
|
handleCommandError,
|
||||||
|
printOutput,
|
||||||
|
resolveCommandContext,
|
||||||
|
type BaseClientOptions,
|
||||||
|
} from "./common.js";
|
||||||
|
|
||||||
|
interface SecretListOptions extends BaseClientOptions {
|
||||||
|
companyId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecretDeclarationsOptions extends BaseClientOptions {
|
||||||
|
companyId?: string;
|
||||||
|
include?: string;
|
||||||
|
kind?: "all" | "secret" | "plain";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecretCreateOptions extends BaseClientOptions {
|
||||||
|
companyId?: string;
|
||||||
|
name?: string;
|
||||||
|
key?: string;
|
||||||
|
provider?: SecretProvider;
|
||||||
|
value?: string;
|
||||||
|
valueEnv?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecretLinkOptions extends BaseClientOptions {
|
||||||
|
companyId?: string;
|
||||||
|
name?: string;
|
||||||
|
key?: string;
|
||||||
|
provider?: SecretProvider;
|
||||||
|
externalRef?: string;
|
||||||
|
providerVersionRef?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecretDoctorOptions extends BaseClientOptions {
|
||||||
|
companyId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecretMigrateInlineEnvOptions extends BaseClientOptions {
|
||||||
|
companyId?: string;
|
||||||
|
apply?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecretProviderHealth {
|
||||||
|
provider: SecretProvider;
|
||||||
|
status: "ok" | "warn" | "error";
|
||||||
|
message: string;
|
||||||
|
warnings?: string[];
|
||||||
|
backupGuidance?: string[];
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecretProviderHealthResponse {
|
||||||
|
providers: SecretProviderHealth[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InlineSecretMigrationCandidate {
|
||||||
|
agentId: string;
|
||||||
|
agentName: string;
|
||||||
|
envKey: string;
|
||||||
|
secretName: string;
|
||||||
|
existingSecretId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SENSITIVE_ENV_KEY_RE =
|
||||||
|
/(^token$|[-_]?token$|api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
||||||
|
|
||||||
|
const DEFAULT_DECLARATION_INCLUDE: CompanyPortabilityInclude = {
|
||||||
|
company: true,
|
||||||
|
agents: true,
|
||||||
|
projects: true,
|
||||||
|
issues: false,
|
||||||
|
skills: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseSecretsInclude(input: string | undefined): CompanyPortabilityInclude {
|
||||||
|
if (!input?.trim()) return { ...DEFAULT_DECLARATION_INCLUDE };
|
||||||
|
const values = input.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
|
||||||
|
const include = {
|
||||||
|
company: values.includes("company"),
|
||||||
|
agents: values.includes("agents"),
|
||||||
|
projects: values.includes("projects"),
|
||||||
|
issues: values.includes("issues") || values.includes("tasks"),
|
||||||
|
skills: values.includes("skills"),
|
||||||
|
};
|
||||||
|
if (!Object.values(include).some(Boolean)) {
|
||||||
|
throw new Error("Invalid --include value. Use one or more of: company,agents,projects,issues,tasks,skills");
|
||||||
|
}
|
||||||
|
return include;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSensitiveEnvKey(key: string): boolean {
|
||||||
|
return SENSITIVE_ENV_KEY_RE.test(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPlainEnvValue(binding: unknown): string | null {
|
||||||
|
if (typeof binding === "string") return binding;
|
||||||
|
if (typeof binding !== "object" || binding === null || Array.isArray(binding)) return null;
|
||||||
|
const record = binding as Record<string, unknown>;
|
||||||
|
if (record.type === "plain" && typeof record.value === "string") return record.value;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInlineMigrationSecretName(agentId: string, key: string): string {
|
||||||
|
return `agent_${agentId.slice(0, 8)}_${key.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectInlineSecretMigrationCandidates(
|
||||||
|
agents: Agent[],
|
||||||
|
existingSecrets: CompanySecret[],
|
||||||
|
): InlineSecretMigrationCandidate[] {
|
||||||
|
const secretByName = new Map(existingSecrets.map((secret) => [secret.name, secret]));
|
||||||
|
const candidates: InlineSecretMigrationCandidate[] = [];
|
||||||
|
|
||||||
|
for (const agent of agents) {
|
||||||
|
const env = asRecord(agent.adapterConfig.env);
|
||||||
|
if (!env) continue;
|
||||||
|
for (const [envKey, binding] of Object.entries(env)) {
|
||||||
|
if (!isSensitiveEnvKey(envKey)) continue;
|
||||||
|
const plain = toPlainEnvValue(binding);
|
||||||
|
if (plain === null || plain.trim().length === 0) continue;
|
||||||
|
const secretName = buildInlineMigrationSecretName(agent.id, envKey);
|
||||||
|
candidates.push({
|
||||||
|
agentId: agent.id,
|
||||||
|
agentName: agent.name,
|
||||||
|
envKey,
|
||||||
|
secretName,
|
||||||
|
existingSecretId: secretByName.get(secretName)?.id ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMigratedAgentEnv(
|
||||||
|
env: Record<string, unknown>,
|
||||||
|
secretIdByEnvKey: Map<string, string>,
|
||||||
|
): AgentEnvConfig {
|
||||||
|
const next: AgentEnvConfig = { ...(env as Record<string, EnvBinding>) };
|
||||||
|
for (const [envKey, secretId] of secretIdByEnvKey) {
|
||||||
|
next[envKey] = {
|
||||||
|
type: "secret_ref",
|
||||||
|
secretId,
|
||||||
|
version: "latest",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readValueFromOptions(opts: SecretCreateOptions): string {
|
||||||
|
if (opts.value !== undefined && opts.valueEnv !== undefined) {
|
||||||
|
throw new Error("Use only one of --value or --value-env.");
|
||||||
|
}
|
||||||
|
if (opts.valueEnv !== undefined) {
|
||||||
|
const value = process.env[opts.valueEnv];
|
||||||
|
if (!value) throw new Error(`Environment variable ${opts.valueEnv} is empty or unset.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (opts.value !== undefined) return opts.value;
|
||||||
|
throw new Error("Secret value is required. Pass --value or --value-env.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDeclaration(input: CompanyPortabilityEnvInput): Record<string, unknown> {
|
||||||
|
const scope = input.agentSlug
|
||||||
|
? `agent:${input.agentSlug}`
|
||||||
|
: input.projectSlug
|
||||||
|
? `project:${input.projectSlug}`
|
||||||
|
: "company";
|
||||||
|
return {
|
||||||
|
key: input.key,
|
||||||
|
scope,
|
||||||
|
kind: input.kind,
|
||||||
|
requirement: input.requirement,
|
||||||
|
portability: input.portability,
|
||||||
|
hasDefault: input.defaultValue !== null && input.defaultValue.length > 0,
|
||||||
|
description: input.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSecret(secret: CompanySecret): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: secret.id,
|
||||||
|
name: secret.name,
|
||||||
|
key: secret.key,
|
||||||
|
provider: secret.provider,
|
||||||
|
status: secret.status,
|
||||||
|
managedMode: secret.managedMode,
|
||||||
|
latestVersion: secret.latestVersion,
|
||||||
|
externalRef: secret.externalRef ? "yes" : "no",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function printProviderHealth(rows: SecretProviderHealth[], json: boolean): void {
|
||||||
|
if (json) {
|
||||||
|
printOutput(rows, { json: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rows.length === 0) {
|
||||||
|
printOutput([], { json: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const row of rows) {
|
||||||
|
console.log(
|
||||||
|
formatInlineRecord({
|
||||||
|
id: row.provider,
|
||||||
|
status: row.status,
|
||||||
|
message: row.message,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const warning of row.warnings ?? []) {
|
||||||
|
console.log(pc.yellow(`warning=${warning}`));
|
||||||
|
}
|
||||||
|
const missingConfig = asStringArray(row.details?.missingConfig);
|
||||||
|
if (missingConfig.length > 0) {
|
||||||
|
console.log(pc.dim(`missingConfig=${missingConfig.join(",")}`));
|
||||||
|
}
|
||||||
|
const credentialSource = typeof row.details?.credentialSource === "string"
|
||||||
|
? row.details.credentialSource
|
||||||
|
: null;
|
||||||
|
if (credentialSource) {
|
||||||
|
console.log(pc.dim(`credentialSource=${credentialSource}`));
|
||||||
|
}
|
||||||
|
const detectedCredentialSources = asStringArray(row.details?.detectedCredentialSources);
|
||||||
|
if (detectedCredentialSources.length > 0) {
|
||||||
|
console.log(pc.dim(`detectedCredentialSources=${detectedCredentialSources.join(",")}`));
|
||||||
|
}
|
||||||
|
for (const guidance of row.backupGuidance ?? []) {
|
||||||
|
console.log(pc.dim(`backup=${guidance}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function asStringArray(value: unknown): string[] {
|
||||||
|
return Array.isArray(value)
|
||||||
|
? value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateInlineEnv(opts: SecretMigrateInlineEnvOptions): Promise<void> {
|
||||||
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||||
|
const companyId = ctx.companyId!;
|
||||||
|
const agents = (await ctx.api.get<Agent[]>(`/api/companies/${companyId}/agents`)) ?? [];
|
||||||
|
const secrets = (await ctx.api.get<CompanySecret[]>(`/api/companies/${companyId}/secrets`)) ?? [];
|
||||||
|
const candidates = collectInlineSecretMigrationCandidates(agents, secrets);
|
||||||
|
|
||||||
|
if (!opts.apply) {
|
||||||
|
printOutput(
|
||||||
|
{
|
||||||
|
apply: false,
|
||||||
|
agentsToUpdate: new Set(candidates.map((candidate) => candidate.agentId)).size,
|
||||||
|
secretsToCreate: candidates.filter((candidate) => !candidate.existingSecretId).length,
|
||||||
|
secretsToRotate: candidates.filter((candidate) => candidate.existingSecretId).length,
|
||||||
|
candidates,
|
||||||
|
},
|
||||||
|
{ json: ctx.json },
|
||||||
|
);
|
||||||
|
if (!ctx.json) {
|
||||||
|
console.log(pc.dim("Re-run with --apply to create/rotate secrets and update agent env bindings."));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdOrRotated = new Map<string, string>();
|
||||||
|
let createdSecrets = 0;
|
||||||
|
let rotatedSecrets = 0;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const agent = agents.find((row) => row.id === candidate.agentId);
|
||||||
|
const env = asRecord(agent?.adapterConfig.env);
|
||||||
|
const value = env ? toPlainEnvValue(env[candidate.envKey]) : null;
|
||||||
|
if (!value) continue;
|
||||||
|
|
||||||
|
if (candidate.existingSecretId) {
|
||||||
|
await ctx.api.post(`/api/secrets/${candidate.existingSecretId}/rotate`, { value });
|
||||||
|
createdOrRotated.set(`${candidate.agentId}:${candidate.envKey}`, candidate.existingSecretId);
|
||||||
|
rotatedSecrets += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await ctx.api.post<CompanySecret>(`/api/companies/${companyId}/secrets`, {
|
||||||
|
name: candidate.secretName,
|
||||||
|
provider: "local_encrypted",
|
||||||
|
value,
|
||||||
|
description: `Migrated from agent ${candidate.agentId} env ${candidate.envKey}`,
|
||||||
|
});
|
||||||
|
if (!created) throw new Error(`Secret create returned no data for ${candidate.secretName}`);
|
||||||
|
createdOrRotated.set(`${candidate.agentId}:${candidate.envKey}`, created.id);
|
||||||
|
createdSecrets += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let updatedAgents = 0;
|
||||||
|
for (const agent of agents) {
|
||||||
|
const env = asRecord(agent.adapterConfig.env);
|
||||||
|
if (!env) continue;
|
||||||
|
const secretIdByEnvKey = new Map<string, string>();
|
||||||
|
for (const [key] of Object.entries(env)) {
|
||||||
|
const secretId = createdOrRotated.get(`${agent.id}:${key}`);
|
||||||
|
if (secretId) secretIdByEnvKey.set(key, secretId);
|
||||||
|
}
|
||||||
|
if (secretIdByEnvKey.size === 0) continue;
|
||||||
|
const adapterConfig = {
|
||||||
|
...agent.adapterConfig,
|
||||||
|
env: buildMigratedAgentEnv(env, secretIdByEnvKey),
|
||||||
|
};
|
||||||
|
await ctx.api.patch(`/api/agents/${agent.id}`, {
|
||||||
|
adapterConfig,
|
||||||
|
replaceAdapterConfig: true,
|
||||||
|
});
|
||||||
|
updatedAgents += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
printOutput(
|
||||||
|
{
|
||||||
|
apply: true,
|
||||||
|
updatedAgents,
|
||||||
|
createdSecrets,
|
||||||
|
rotatedSecrets,
|
||||||
|
},
|
||||||
|
{ json: ctx.json },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSecretCommands(program: Command): void {
|
||||||
|
const secrets = program.command("secrets").description("Secret declaration and provider operations");
|
||||||
|
|
||||||
|
addCommonClientOptions(
|
||||||
|
secrets
|
||||||
|
.command("list")
|
||||||
|
.description("List secret metadata for a company")
|
||||||
|
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||||
|
.action(async (opts: SecretListOptions) => {
|
||||||
|
try {
|
||||||
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||||
|
const rows = (await ctx.api.get<CompanySecret[]>(`/api/companies/${ctx.companyId}/secrets`)) ?? [];
|
||||||
|
printOutput(ctx.json ? rows : rows.map(renderSecret), { json: ctx.json });
|
||||||
|
} catch (err) {
|
||||||
|
handleCommandError(err);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
addCommonClientOptions(
|
||||||
|
secrets
|
||||||
|
.command("declarations")
|
||||||
|
.description("List portable env declarations emitted by company export")
|
||||||
|
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||||
|
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents,projects")
|
||||||
|
.option("--kind <kind>", "Filter declarations: all | secret | plain", "all")
|
||||||
|
.action(async (opts: SecretDeclarationsOptions) => {
|
||||||
|
try {
|
||||||
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||||
|
const kind = opts.kind ?? "all";
|
||||||
|
if (!["all", "secret", "plain"].includes(kind)) {
|
||||||
|
throw new Error("Invalid --kind value. Use: all, secret, plain");
|
||||||
|
}
|
||||||
|
const preview = await ctx.api.post<CompanyPortabilityExportPreviewResult>(
|
||||||
|
`/api/companies/${ctx.companyId}/exports/preview`,
|
||||||
|
{ include: parseSecretsInclude(opts.include) },
|
||||||
|
);
|
||||||
|
const declarations = (preview?.manifest.envInputs ?? [])
|
||||||
|
.filter((entry) => kind === "all" || entry.kind === kind);
|
||||||
|
printOutput(ctx.json ? declarations : declarations.map(renderDeclaration), { json: ctx.json });
|
||||||
|
} catch (err) {
|
||||||
|
handleCommandError(err);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
addCommonClientOptions(
|
||||||
|
secrets
|
||||||
|
.command("create")
|
||||||
|
.description("Create a Paperclip-managed secret")
|
||||||
|
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||||
|
.requiredOption("--name <name>", "Secret display name")
|
||||||
|
.option("--key <key>", "Portable secret key")
|
||||||
|
.option("--provider <provider>", "Secret provider id")
|
||||||
|
.option("--value <value>", "Secret value")
|
||||||
|
.option("--value-env <name>", "Read secret value from an environment variable")
|
||||||
|
.option("--description <text>", "Description")
|
||||||
|
.action(async (opts: SecretCreateOptions) => {
|
||||||
|
try {
|
||||||
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||||
|
const created = await ctx.api.post<CompanySecret>(`/api/companies/${ctx.companyId}/secrets`, {
|
||||||
|
name: opts.name,
|
||||||
|
key: opts.key,
|
||||||
|
provider: opts.provider,
|
||||||
|
value: readValueFromOptions(opts),
|
||||||
|
description: opts.description,
|
||||||
|
});
|
||||||
|
printOutput(ctx.json ? created : renderSecret(created!), { json: ctx.json });
|
||||||
|
} catch (err) {
|
||||||
|
handleCommandError(err);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
addCommonClientOptions(
|
||||||
|
secrets
|
||||||
|
.command("link")
|
||||||
|
.description("Link an external provider-owned secret without storing its value in Paperclip")
|
||||||
|
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||||
|
.requiredOption("--name <name>", "Secret display name")
|
||||||
|
.requiredOption("--provider <provider>", "Secret provider id")
|
||||||
|
.requiredOption("--external-ref <ref>", "Provider secret ARN/name/path/reference")
|
||||||
|
.option("--key <key>", "Portable secret key")
|
||||||
|
.option("--provider-version-ref <ref>", "Provider version id or label")
|
||||||
|
.option("--description <text>", "Description")
|
||||||
|
.action(async (opts: SecretLinkOptions) => {
|
||||||
|
try {
|
||||||
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||||
|
const created = await ctx.api.post<CompanySecret>(`/api/companies/${ctx.companyId}/secrets`, {
|
||||||
|
name: opts.name,
|
||||||
|
key: opts.key,
|
||||||
|
provider: opts.provider,
|
||||||
|
managedMode: "external_reference",
|
||||||
|
externalRef: opts.externalRef,
|
||||||
|
providerVersionRef: opts.providerVersionRef,
|
||||||
|
description: opts.description,
|
||||||
|
});
|
||||||
|
printOutput(ctx.json ? created : renderSecret(created!), { json: ctx.json });
|
||||||
|
} catch (err) {
|
||||||
|
handleCommandError(err);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
addCommonClientOptions(
|
||||||
|
secrets
|
||||||
|
.command("doctor")
|
||||||
|
.description("Run secret provider health checks through the Paperclip API")
|
||||||
|
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||||
|
.action(async (opts: SecretDoctorOptions) => {
|
||||||
|
try {
|
||||||
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||||
|
const health = await ctx.api.get<SecretProviderHealthResponse>(
|
||||||
|
`/api/companies/${ctx.companyId}/secret-providers/health`,
|
||||||
|
);
|
||||||
|
printProviderHealth(health?.providers ?? [], ctx.json);
|
||||||
|
} catch (err) {
|
||||||
|
handleCommandError(err);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
addCommonClientOptions(
|
||||||
|
secrets
|
||||||
|
.command("providers")
|
||||||
|
.description("List configured secret provider descriptors")
|
||||||
|
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||||
|
.action(async (opts: SecretDoctorOptions) => {
|
||||||
|
try {
|
||||||
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||||
|
const rows = (await ctx.api.get<SecretProviderDescriptor[]>(
|
||||||
|
`/api/companies/${ctx.companyId}/secret-providers`,
|
||||||
|
)) ?? [];
|
||||||
|
printOutput(rows, { json: ctx.json });
|
||||||
|
} catch (err) {
|
||||||
|
handleCommandError(err);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
addCommonClientOptions(
|
||||||
|
secrets
|
||||||
|
.command("migrate-inline-env")
|
||||||
|
.description("Migrate inline sensitive agent env values into secret references")
|
||||||
|
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||||
|
.option("--apply", "Persist changes; default is a dry run", false)
|
||||||
|
.action(async (opts: SecretMigrateInlineEnvOptions) => {
|
||||||
|
try {
|
||||||
|
await migrateInlineEnv(opts);
|
||||||
|
} catch (err) {
|
||||||
|
handleCommandError(err);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import { registerActivityCommands } from "./commands/client/activity.js";
|
||||||
import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
||||||
import { registerRoutineCommands } from "./commands/routines.js";
|
import { registerRoutineCommands } from "./commands/routines.js";
|
||||||
import { registerFeedbackCommands } from "./commands/client/feedback.js";
|
import { registerFeedbackCommands } from "./commands/client/feedback.js";
|
||||||
|
import { registerSecretCommands } from "./commands/client/secrets.js";
|
||||||
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
||||||
import { loadPaperclipEnvFile } from "./config/env.js";
|
import { loadPaperclipEnvFile } from "./config/env.js";
|
||||||
import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js";
|
import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js";
|
||||||
|
|
@ -147,6 +148,7 @@ registerActivityCommands(program);
|
||||||
registerDashboardCommands(program);
|
registerDashboardCommands(program);
|
||||||
registerRoutineCommands(program);
|
registerRoutineCommands(program);
|
||||||
registerFeedbackCommands(program);
|
registerFeedbackCommands(program);
|
||||||
|
registerSecretCommands(program);
|
||||||
registerWorktreeCommands(program);
|
registerWorktreeCommands(program);
|
||||||
registerEnvLabCommands(program);
|
registerEnvLabCommands(program);
|
||||||
registerPluginCommands(program);
|
registerPluginCommands(program);
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export async function promptSecrets(current?: SecretsConfig): Promise<SecretsCon
|
||||||
{
|
{
|
||||||
value: "aws_secrets_manager" as const,
|
value: "aws_secrets_manager" as const,
|
||||||
label: "AWS Secrets Manager",
|
label: "AWS Secrets Manager",
|
||||||
hint: "requires external adapter integration",
|
hint: "requires runtime AWS credentials and provider env config",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "gcp_secret_manager" as const,
|
value: "gcp_secret_manager" as const,
|
||||||
|
|
@ -84,7 +84,9 @@ export async function promptSecrets(current?: SecretsConfig): Promise<SecretsCon
|
||||||
|
|
||||||
if (provider !== "local_encrypted") {
|
if (provider !== "local_encrypted") {
|
||||||
p.note(
|
p.note(
|
||||||
`${provider} is not fully wired in this build yet. Keep local_encrypted unless you are actively implementing that adapter.`,
|
provider === "aws_secrets_manager"
|
||||||
|
? "AWS credentials must come from the Paperclip server runtime (IAM role/workload identity, AWS_PROFILE/SSO/shared credentials, or short-lived shell env), not from Paperclip company secrets."
|
||||||
|
: `${provider} is not fully wired in this build yet. Keep local_encrypted unless you are actively implementing that adapter.`,
|
||||||
"Heads up",
|
"Heads up",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
26
doc/CLI.md
26
doc/CLI.md
|
|
@ -143,6 +143,32 @@ pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
|
||||||
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
|
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Secrets Commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm paperclipai secrets list --company-id <company-id>
|
||||||
|
pnpm paperclipai secrets declarations --company-id <company-id> [--include agents,projects] [--kind secret]
|
||||||
|
pnpm paperclipai secrets create --company-id <company-id> --name anthropic-api-key --value-env ANTHROPIC_API_KEY
|
||||||
|
pnpm paperclipai secrets link --company-id <company-id> --name prod-stripe-key --provider aws_secrets_manager --external-ref <provider-ref>
|
||||||
|
pnpm paperclipai secrets doctor --company-id <company-id>
|
||||||
|
pnpm paperclipai secrets migrate-inline-env --company-id <company-id> [--apply]
|
||||||
|
```
|
||||||
|
|
||||||
|
Secret listing and declarations never print secret values. `create` accepts
|
||||||
|
`--value-env` so shell history does not capture the value. `link` records
|
||||||
|
provider-owned references without copying the secret value into Paperclip.
|
||||||
|
For AWS-backed secrets, `secrets doctor` reports missing non-secret provider
|
||||||
|
env and the expected AWS SDK runtime credential source; do not store AWS
|
||||||
|
bootstrap credentials in Paperclip secrets.
|
||||||
|
|
||||||
|
Per-company provider vaults (multiple vault instances per provider, default
|
||||||
|
vault selection, coming-soon GCP/Vault) are configured from the board UI under
|
||||||
|
`Company Settings → Secrets → Provider vaults` or through
|
||||||
|
`/api/companies/{companyId}/secret-provider-configs`. There is no CLI surface
|
||||||
|
for vault management today. See the
|
||||||
|
[secrets deploy guide](../docs/deploy/secrets.md#provider-vaults) and
|
||||||
|
[API reference](../docs/api/secrets.md#provider-vaults) for the contract.
|
||||||
|
|
||||||
## Approval Commands
|
## Approval Commands
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,8 @@ For local/default installs, the active provider is `local_encrypted`:
|
||||||
- Secret material is encrypted at rest with a local master key.
|
- Secret material is encrypted at rest with a local master key.
|
||||||
- Default key file: `~/.paperclip/instances/default/secrets/master.key` (auto-created if missing).
|
- Default key file: `~/.paperclip/instances/default/secrets/master.key` (auto-created if missing).
|
||||||
- CLI config location: `~/.paperclip/instances/default/config.json` under `secrets.localEncrypted.keyFilePath`.
|
- CLI config location: `~/.paperclip/instances/default/config.json` under `secrets.localEncrypted.keyFilePath`.
|
||||||
|
- Backup/restore requires both the database metadata and the local master key file; either artifact alone is insufficient.
|
||||||
|
- The server best-effort enforces `0600` key file permissions and provider health reports permission warnings.
|
||||||
|
|
||||||
Optional overrides:
|
Optional overrides:
|
||||||
|
|
||||||
|
|
@ -192,5 +194,10 @@ pnpm paperclipai configure --section secrets
|
||||||
Inline secret migration command:
|
Inline secret migration command:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
pnpm paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
||||||
|
|
||||||
|
# direct database maintenance fallback
|
||||||
pnpm secrets:migrate-inline-env --apply
|
pnpm secrets:migrate-inline-env --apply
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Hosted AWS provider notes live in [SECRETS-AWS-PROVIDER.md](./SECRETS-AWS-PROVIDER.md).
|
||||||
|
|
|
||||||
|
|
@ -462,6 +462,7 @@ Agent env vars now support secret references. By default, secret values are stor
|
||||||
- Default local key path: `~/.paperclip/instances/default/secrets/master.key`
|
- Default local key path: `~/.paperclip/instances/default/secrets/master.key`
|
||||||
- Override key material directly: `PAPERCLIP_SECRETS_MASTER_KEY`
|
- Override key material directly: `PAPERCLIP_SECRETS_MASTER_KEY`
|
||||||
- Override key file path: `PAPERCLIP_SECRETS_MASTER_KEY_FILE`
|
- Override key file path: `PAPERCLIP_SECRETS_MASTER_KEY_FILE`
|
||||||
|
- Back up the key file and database together; either one alone is not enough to restore local encrypted secrets.
|
||||||
|
|
||||||
Strict mode (recommended outside local trusted machines):
|
Strict mode (recommended outside local trusted machines):
|
||||||
|
|
||||||
|
|
@ -470,12 +471,20 @@ PAPERCLIP_SECRETS_STRICT_MODE=true
|
||||||
```
|
```
|
||||||
|
|
||||||
When strict mode is enabled, sensitive env keys (for example `*_API_KEY`, `*_TOKEN`, `*_SECRET`) must use secret references instead of inline plain values.
|
When strict mode is enabled, sensitive env keys (for example `*_API_KEY`, `*_TOKEN`, `*_SECRET`) must use secret references instead of inline plain values.
|
||||||
|
Authenticated deployments default strict mode on unless explicitly overridden.
|
||||||
|
|
||||||
CLI configuration support:
|
CLI configuration support:
|
||||||
|
|
||||||
- `pnpm paperclipai onboard` writes a default `secrets` config section (`local_encrypted`, strict mode off, key file path set) and creates a local key file when needed.
|
- `pnpm paperclipai onboard` writes a default `secrets` config section (`local_encrypted`, strict mode off, key file path set) and creates a local key file when needed.
|
||||||
- `pnpm paperclipai configure --section secrets` lets you update provider/strict mode/key path and creates the local key file when needed.
|
- `pnpm paperclipai configure --section secrets` lets you update provider/strict mode/key path and creates the local key file when needed.
|
||||||
- `pnpm paperclipai doctor` validates secrets adapter configuration and can create a missing local key file with `--repair`.
|
- `pnpm paperclipai doctor` validates secrets adapter configuration, can create a missing local key file with `--repair`, and reports missing AWS Secrets Manager bootstrap env when that provider is selected.
|
||||||
|
- Provider health is available at `GET /api/companies/:companyId/secret-providers/health` and reports local key permission warnings plus backup guidance.
|
||||||
|
|
||||||
|
Per-company provider vaults are configured in the board UI under
|
||||||
|
`Company Settings → Secrets → Provider vaults`, backed by
|
||||||
|
`/api/companies/{companyId}/secret-provider-configs`. The CLI does not own
|
||||||
|
vault lifecycle today. See `docs/deploy/secrets.md` (`Provider Vaults` section)
|
||||||
|
for the operator model.
|
||||||
|
|
||||||
Migration helper for existing inline env secrets:
|
Migration helper for existing inline env secrets:
|
||||||
|
|
||||||
|
|
|
||||||
368
doc/SECRETS-AWS-PROVIDER.md
Normal file
368
doc/SECRETS-AWS-PROVIDER.md
Normal file
|
|
@ -0,0 +1,368 @@
|
||||||
|
# AWS Secrets Manager Provider
|
||||||
|
|
||||||
|
Operational contract for the hosted `aws_secrets_manager` secret provider used by Paperclip Cloud.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Hosted provider for Paperclip-managed secrets when Paperclip Cloud runs on AWS.
|
||||||
|
- Source of truth for secret values is AWS Secrets Manager, not Postgres.
|
||||||
|
- Paperclip stores only metadata needed for ownership, bindings, version selection, audit, and runtime resolution.
|
||||||
|
- AWS provider bootstrap credentials are deployment/runtime credentials, not Paperclip-managed company secrets.
|
||||||
|
- Remote import for existing AWS secrets is metadata-only. Preview/import uses
|
||||||
|
AWS inventory metadata and creates Paperclip external references; it does not
|
||||||
|
copy plaintext into Paperclip.
|
||||||
|
- Per-company AWS provider vaults (named instances of `aws_secrets_manager`
|
||||||
|
with their own region, namespace, prefix, KMS key id, and tags) are managed
|
||||||
|
in the board UI under `Company Settings → Secrets → Provider vaults`. See
|
||||||
|
[Provider Vaults](../docs/deploy/secrets.md#provider-vaults) for the operator
|
||||||
|
model and [Provider Vaults API](../docs/api/secrets.md#provider-vaults) for
|
||||||
|
the routes. The bootstrap trust model in this document still applies — vault
|
||||||
|
config carries non-sensitive routing metadata only, never AWS credentials.
|
||||||
|
|
||||||
|
## Bootstrap Trust Model
|
||||||
|
|
||||||
|
The AWS provider has a chicken-and-egg boundary: Paperclip cannot use
|
||||||
|
`company_secrets` to unlock the AWS provider that stores those secrets. The
|
||||||
|
initial AWS trust must exist before the Paperclip server starts.
|
||||||
|
|
||||||
|
Allowed bootstrap locations:
|
||||||
|
|
||||||
|
- Infrastructure IAM or workload identity attached to the Paperclip server
|
||||||
|
runtime.
|
||||||
|
- Process environment or orchestrator secret store used to start the Paperclip
|
||||||
|
server.
|
||||||
|
- Local AWS SDK sources such as `AWS_PROFILE`, AWS SSO/shared config, web
|
||||||
|
identity, container metadata, or instance metadata.
|
||||||
|
- Short-lived shell credentials for local development only.
|
||||||
|
|
||||||
|
Do not ask operators to paste AWS root credentials or long-lived IAM user access
|
||||||
|
keys into the Paperclip board UI. Do not store those bootstrap keys in
|
||||||
|
`company_secrets`.
|
||||||
|
|
||||||
|
## Paperclip Cloud Bootstrap
|
||||||
|
|
||||||
|
Paperclip Cloud must provision the AWS backing resources before any board user
|
||||||
|
can create AWS-backed company secrets:
|
||||||
|
|
||||||
|
1. Create or select the deployment KMS key.
|
||||||
|
2. Create the Paperclip server runtime role for the deployment.
|
||||||
|
3. Attach a minimum IAM policy scoped to the deployment Secrets Manager prefix
|
||||||
|
and the configured KMS key.
|
||||||
|
4. Configure the server runtime with the non-secret provider environment
|
||||||
|
variables below.
|
||||||
|
5. Run `paperclipai doctor` or the provider health endpoint from the deployed
|
||||||
|
runtime and confirm that the provider reports the expected region, prefix,
|
||||||
|
deployment id, KMS setting, and AWS SDK credential source.
|
||||||
|
|
||||||
|
Once this is in place, the board UI can create Paperclip-managed AWS secrets and
|
||||||
|
Paperclip will write them under the deployment/company namespace.
|
||||||
|
|
||||||
|
## Self-Hosted And Local Bootstrap
|
||||||
|
|
||||||
|
Self-hosted AWS deployments should use the AWS SDK default credential provider
|
||||||
|
chain. Preferred sources are role-based:
|
||||||
|
|
||||||
|
- EC2 instance profile.
|
||||||
|
- ECS task role.
|
||||||
|
- EKS IRSA or another OIDC web identity role.
|
||||||
|
- AWS SSO/shared config via `AWS_PROFILE`.
|
||||||
|
|
||||||
|
Local development can use:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
aws sso login --profile paperclip-dev
|
||||||
|
AWS_PROFILE=paperclip-dev \
|
||||||
|
PAPERCLIP_SECRETS_PROVIDER=aws_secrets_manager \
|
||||||
|
PAPERCLIP_SECRETS_AWS_REGION=us-east-1 \
|
||||||
|
PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID=dev-local \
|
||||||
|
PAPERCLIP_SECRETS_AWS_KMS_KEY_ID=arn:aws:kms:us-east-1:123456789012:key/abcd-... \
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Temporary `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` environment credentials
|
||||||
|
are acceptable only as a local break-glass or short-lived test source. They
|
||||||
|
should not be written to Paperclip config, committed to `.env` files, stored in
|
||||||
|
`company_secrets`, or used as the default Paperclip Cloud bootstrap path.
|
||||||
|
|
||||||
|
## Deployment Config
|
||||||
|
|
||||||
|
Required environment variables:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PAPERCLIP_SECRETS_PROVIDER=aws_secrets_manager
|
||||||
|
PAPERCLIP_SECRETS_AWS_REGION=us-east-1
|
||||||
|
PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID=prod-us-1
|
||||||
|
PAPERCLIP_SECRETS_AWS_KMS_KEY_ID=arn:aws:kms:us-east-1:123456789012:key/abcd-...
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional environment variables:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PAPERCLIP_SECRETS_AWS_PREFIX=paperclip
|
||||||
|
PAPERCLIP_SECRETS_AWS_ENVIRONMENT=production
|
||||||
|
PAPERCLIP_SECRETS_AWS_PROVIDER_OWNER=paperclip
|
||||||
|
PAPERCLIP_SECRETS_AWS_ENDPOINT=
|
||||||
|
PAPERCLIP_SECRETS_AWS_DELETE_RECOVERY_DAYS=30
|
||||||
|
```
|
||||||
|
|
||||||
|
Naming convention for Paperclip-managed secrets:
|
||||||
|
|
||||||
|
```text
|
||||||
|
paperclip/{deploymentId}/{companyId}/{secretKey}
|
||||||
|
```
|
||||||
|
|
||||||
|
Tag set for Paperclip-managed secrets:
|
||||||
|
|
||||||
|
- `paperclip:managed-by=paperclip`
|
||||||
|
- `paperclip:provider-owner=<owner tag>`
|
||||||
|
- `paperclip:deployment-id=<deployment id>`
|
||||||
|
- `paperclip:company-id=<company id>`
|
||||||
|
- `paperclip:secret-key=<secret key>`
|
||||||
|
- `paperclip:environment=<environment tag>`
|
||||||
|
|
||||||
|
## IAM And KMS Assumptions
|
||||||
|
|
||||||
|
Launch posture:
|
||||||
|
|
||||||
|
- One Paperclip app role per deployment.
|
||||||
|
- One deployment-scoped KMS key per deployment at launch.
|
||||||
|
- Future per-company KMS keys remain compatible because Paperclip stores provider refs and version metadata separately from values.
|
||||||
|
|
||||||
|
Minimum IAM boundary:
|
||||||
|
|
||||||
|
- Allow `secretsmanager:CreateSecret`, `PutSecretValue`, `GetSecretValue`, and `DeleteSecret`.
|
||||||
|
- Scope resources to the deployment prefix:
|
||||||
|
|
||||||
|
```text
|
||||||
|
arn:aws:secretsmanager:<region>:<account-id>:secret:paperclip/<deployment-id>/*
|
||||||
|
```
|
||||||
|
|
||||||
|
- Allow `kms:Encrypt`, `kms:Decrypt`, `kms:GenerateDataKey`, and `kms:DescribeKey` for the configured deployment CMK.
|
||||||
|
- Deny wildcard access outside the deployment prefix.
|
||||||
|
- Prefer workload identity / role-based auth. Do not store AWS credentials inline in Paperclip config.
|
||||||
|
|
||||||
|
Example minimum policy shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Sid": "PaperclipDeploymentSecrets",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": [
|
||||||
|
"secretsmanager:CreateSecret",
|
||||||
|
"secretsmanager:PutSecretValue",
|
||||||
|
"secretsmanager:GetSecretValue",
|
||||||
|
"secretsmanager:DeleteSecret"
|
||||||
|
],
|
||||||
|
"Resource": "arn:aws:secretsmanager:<region>:<account-id>:secret:paperclip/<deployment-id>/*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Sid": "PaperclipDeploymentKms",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": [
|
||||||
|
"kms:Encrypt",
|
||||||
|
"kms:Decrypt",
|
||||||
|
"kms:GenerateDataKey",
|
||||||
|
"kms:DescribeKey"
|
||||||
|
],
|
||||||
|
"Resource": "arn:aws:kms:<region>:<account-id>:key/<key-id>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Operational expectation:
|
||||||
|
|
||||||
|
- Paperclip-managed secrets may be deleted only by Paperclip or an operator with equivalent break-glass access.
|
||||||
|
- External references may resolve through Paperclip runtime, but Paperclip should not delete the external secret resource.
|
||||||
|
|
||||||
|
## Remote Import Inventory IAM
|
||||||
|
|
||||||
|
Remote import preview needs one additional AWS permission:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Sid": "PaperclipRemoteSecretInventory",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": "secretsmanager:ListSecrets",
|
||||||
|
"Resource": "*"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is intentionally separate from the managed create/rotate/delete policy.
|
||||||
|
AWS treats `ListSecrets` as an account/Region inventory action; do not document
|
||||||
|
secret ARNs, names, tags, or AWS request filters as an IAM boundary for it. Use
|
||||||
|
`Resource: "*"` and decide whether inventory exposure is acceptable for the AWS
|
||||||
|
account and Region behind each provider vault.
|
||||||
|
|
||||||
|
Remote import preview/import must not call:
|
||||||
|
|
||||||
|
- `secretsmanager:GetSecretValue`
|
||||||
|
- `secretsmanager:BatchGetSecretValue`
|
||||||
|
- `kms:Decrypt`
|
||||||
|
|
||||||
|
Those permissions are only needed later when a bound runtime resolves an
|
||||||
|
imported external reference. For imported refs, scope read permissions to the
|
||||||
|
operator-approved external prefixes that Paperclip is allowed to consume:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Sid": "PaperclipResolveImportedExternalReferences",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": "secretsmanager:GetSecretValue",
|
||||||
|
"Resource": [
|
||||||
|
"arn:aws:secretsmanager:<region>:<account-id>:secret:<approved-external-prefix>/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If selected external secrets use customer-managed KMS keys, also grant
|
||||||
|
`kms:Decrypt` and `kms:DescribeKey` on those keys. Keep managed write/delete
|
||||||
|
permissions scoped to `paperclip/<deployment-id>/*`; do not broaden them for
|
||||||
|
remote import.
|
||||||
|
|
||||||
|
Safe scoping guidance:
|
||||||
|
|
||||||
|
- Prefer one Paperclip runtime role per environment/account.
|
||||||
|
- Point provider vaults at the intended AWS account and Region instead of a
|
||||||
|
broad central admin role.
|
||||||
|
- Enable `ListSecrets` only in accounts where inventory exposure is acceptable.
|
||||||
|
- Keep preview/import board-only; agent API keys must not call these routes.
|
||||||
|
- Treat AWS tag/name filters as search UX only, not permission enforcement.
|
||||||
|
|
||||||
|
Paperclip also blocks importing refs under its own managed namespace as
|
||||||
|
external references. Use the Paperclip-managed flow for
|
||||||
|
`paperclip/{deploymentId}/{companyId}/{secretKey}` resources.
|
||||||
|
|
||||||
|
## Existing AWS Secrets
|
||||||
|
|
||||||
|
V1 keeps existing AWS Secrets Manager entries as **linked external references**, not adopted
|
||||||
|
Paperclip-managed resources.
|
||||||
|
|
||||||
|
Use the Paperclip-managed flow when Paperclip should create and rotate the value. The AWS
|
||||||
|
secret name is derived from deployment and company scope:
|
||||||
|
|
||||||
|
```text
|
||||||
|
paperclip/{deploymentId}/{companyId}/{secretKey}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the external-reference flow when the secret already exists at an operator-owned path such
|
||||||
|
as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/paperclip-bench/anthropic_api_key
|
||||||
|
```
|
||||||
|
|
||||||
|
In that mode Paperclip stores only the path or ARN, resolves it at runtime, and records
|
||||||
|
redacted access events. Operators rotate the actual value in AWS. Update the Paperclip
|
||||||
|
reference only when the AWS path, ARN, or pinned provider version changes.
|
||||||
|
|
||||||
|
Paperclip does not currently offer an "adopt existing AWS secret" flow that takes over future
|
||||||
|
`PutSecretValue` writes for an arbitrary existing secret. Adding that later requires explicit
|
||||||
|
confirmation UX, scope validation, expected Paperclip tags, and security/cloud-ops review.
|
||||||
|
|
||||||
|
## Data Custody
|
||||||
|
|
||||||
|
- Paperclip stores `externalRef`, `providerVersionRef`, provider id, fingerprint hash, status, and binding metadata.
|
||||||
|
- Paperclip does not store AWS secret plaintext in `company_secret_versions.material`.
|
||||||
|
- Runtime resolution fetches the value from AWS only when a bound consumer needs it.
|
||||||
|
|
||||||
|
## Rotation Runbook
|
||||||
|
|
||||||
|
Manual Paperclip-managed rotation:
|
||||||
|
|
||||||
|
1. Write the new value through the Paperclip secret rotate flow.
|
||||||
|
2. Paperclip creates a new AWS secret version with `PutSecretValue`.
|
||||||
|
3. Paperclip records the new `providerVersionRef` in `company_secret_versions`.
|
||||||
|
4. Re-run or restart affected workloads that consume `latest`, or pin consumers to a specific Paperclip version before rollout when you need staged release safety.
|
||||||
|
|
||||||
|
Guidance:
|
||||||
|
|
||||||
|
- Prefer pinned Paperclip secret versions for risky rollouts.
|
||||||
|
- Treat provider-native automatic rotation as a later enhancement; current V1 flow is explicit create-new-version plus controlled rollout.
|
||||||
|
|
||||||
|
## Backup And Restore Runbook
|
||||||
|
|
||||||
|
What must survive:
|
||||||
|
|
||||||
|
- Paperclip database metadata for secret ownership, bindings, status, and provider version refs.
|
||||||
|
- AWS Secrets Manager namespace under the configured deployment prefix.
|
||||||
|
- The configured KMS key and its decrypt permissions.
|
||||||
|
|
||||||
|
Restore checklist:
|
||||||
|
|
||||||
|
1. Restore Paperclip database metadata.
|
||||||
|
2. Confirm the same AWS Secrets Manager namespace still exists.
|
||||||
|
3. Confirm the Paperclip runtime role can call `GetSecretValue` on the restored prefix.
|
||||||
|
4. Confirm the role still has decrypt access to the CMK referenced by `PAPERCLIP_SECRETS_AWS_KMS_KEY_ID`.
|
||||||
|
5. Run the live smoke below or a targeted runtime secret resolution test.
|
||||||
|
|
||||||
|
## Provider Outage Runbook
|
||||||
|
|
||||||
|
Symptoms:
|
||||||
|
|
||||||
|
- Secret create/rotate/resolve operations fail with AWS provider errors.
|
||||||
|
- Agent runs fail before adapter invocation on required secret resolution.
|
||||||
|
- Remote import preview fails to list AWS inventory.
|
||||||
|
|
||||||
|
Immediate actions:
|
||||||
|
|
||||||
|
1. Confirm AWS regional health and Secrets Manager availability.
|
||||||
|
2. Confirm the runtime role still has `GetSecretValue` and KMS decrypt permissions.
|
||||||
|
3. Check for accidental prefix, region, deployment id, or KMS key config drift.
|
||||||
|
4. Retry a single resolution after AWS service health is green.
|
||||||
|
5. If outage persists, pause high-risk runs that require secret access rather than churning retries.
|
||||||
|
|
||||||
|
Remote import-specific actions:
|
||||||
|
|
||||||
|
- Missing list permission: add `secretsmanager:ListSecrets` with
|
||||||
|
`Resource: "*"` only when inventory import is approved for that vault's
|
||||||
|
AWS account and Region.
|
||||||
|
- Throttling: narrow the search, wait briefly, and retry with backoff. Avoid
|
||||||
|
full-account enumeration.
|
||||||
|
- Invalid or stale cursor: refresh the preview and discard the old
|
||||||
|
`NextToken`.
|
||||||
|
- Large account: load pages intentionally, keep one in-flight preview request
|
||||||
|
per vault/search, and do not run background full-account crawls.
|
||||||
|
- Runtime read failure after import: verify `GetSecretValue` and KMS decrypt
|
||||||
|
on the selected external secret. Visibility in `ListSecrets` does not prove
|
||||||
|
read permission.
|
||||||
|
|
||||||
|
## Incident Response Runbook
|
||||||
|
|
||||||
|
Potential incidents:
|
||||||
|
|
||||||
|
- Cross-company access caused by IAM scoping drift.
|
||||||
|
- KMS policy drift causing decrypt failures or over-broad access.
|
||||||
|
- Suspected secret exposure in logs, transcripts, or downstream agent output.
|
||||||
|
|
||||||
|
Response steps:
|
||||||
|
|
||||||
|
1. Stop or pause affected Paperclip runs.
|
||||||
|
2. Audit recent Paperclip secret access events for impacted secret ids and consumers.
|
||||||
|
3. Audit AWS CloudTrail for `ListSecrets`, `GetSecretValue`,
|
||||||
|
`PutSecretValue`, and `DeleteSecret` calls on the relevant vault account,
|
||||||
|
Region, deployment prefix, and approved external prefixes.
|
||||||
|
4. Rotate impacted secrets in AWS through Paperclip-managed versioning.
|
||||||
|
5. Re-scope IAM and KMS policies before resuming normal traffic.
|
||||||
|
6. If a value may have reached an agent transcript or external system, treat it as exposed and rotate immediately.
|
||||||
|
|
||||||
|
## Optional Live Smoke
|
||||||
|
|
||||||
|
This is safe to skip locally. Run it only against a dedicated AWS test namespace.
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
|
||||||
|
- AWS credentials or workload identity with the deployment-scoped IAM permissions above.
|
||||||
|
- `PAPERCLIP_SECRETS_PROVIDER=aws_secrets_manager`
|
||||||
|
- The required `PAPERCLIP_SECRETS_AWS_*` environment variables set.
|
||||||
|
|
||||||
|
Suggested smoke:
|
||||||
|
|
||||||
|
1. Create a test secret through the Paperclip board or API under a throwaway company.
|
||||||
|
2. Confirm the resulting AWS secret name matches `paperclip/{deploymentId}/{companyId}/{secretKey}`.
|
||||||
|
3. Rotate the secret once and confirm a new `providerVersionRef` appears in Paperclip metadata.
|
||||||
|
4. Resolve the secret through a bound runtime path, not by adding a general-purpose reveal endpoint.
|
||||||
|
5. Delete the throwaway secret and confirm AWS schedules deletion with the configured recovery window.
|
||||||
86
doc/plans/2026-04-26-plugin-secret-ref-company-scope.md
Normal file
86
doc/plans/2026-04-26-plugin-secret-ref-company-scope.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# Plugin Secret Refs: Company Scope Reintroduction Plan
|
||||||
|
|
||||||
|
Date: 2026-04-26
|
||||||
|
Status: follow-up after fail-closed mitigation
|
||||||
|
Related issue: PAP-2394
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
`PAP-2394` now fails closed:
|
||||||
|
|
||||||
|
- `POST /api/plugins/:pluginId/config` rejects any config containing plugin secret refs.
|
||||||
|
- `ctx.secrets.resolve()` is disabled for plugin workers.
|
||||||
|
|
||||||
|
This removes the release-blocking cross-company exposure path, but it also disables plugin secret-ref support until the runtime carries company scope end to end.
|
||||||
|
|
||||||
|
## Vulnerability summary
|
||||||
|
|
||||||
|
The original design mixed an instance-global config store with company-scoped secret bindings:
|
||||||
|
|
||||||
|
- [server/src/routes/plugins.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/server/src/routes/plugins.ts:1898) saved one global plugin config row, then wrote bindings into `company_secret_bindings` grouped by each referenced secret's owning company.
|
||||||
|
- [packages/db/src/schema/plugin_config.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/packages/db/src/schema/plugin_config.ts:15) stored one config row per plugin, with no company dimension.
|
||||||
|
- [packages/db/src/schema/company_secret_bindings.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/packages/db/src/schema/company_secret_bindings.ts:5) already modeled bindings as company-scoped.
|
||||||
|
- [server/src/services/plugin-secrets-handler.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/server/src/services/plugin-secrets-handler.ts:212) resolved by `pluginId` + secret UUID, with no active company context from the bridge call.
|
||||||
|
- [packages/plugins/sdk/src/worker-rpc-host.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/packages/plugins/sdk/src/worker-rpc-host.ts:384) exposed `ctx.config.get()` and `ctx.secrets.resolve()` without a company parameter.
|
||||||
|
|
||||||
|
This violated Least Privilege, Complete Mediation, and Secure Defaults.
|
||||||
|
|
||||||
|
## Recommended end state
|
||||||
|
|
||||||
|
Re-enable plugin secret refs only after both of these are true:
|
||||||
|
|
||||||
|
1. Plugin config reads/writes are company-scoped.
|
||||||
|
2. Runtime secret resolution carries explicit company context and enforces it at resolution time.
|
||||||
|
|
||||||
|
## Implementation plan
|
||||||
|
|
||||||
|
### 1. Make plugin config company-scoped
|
||||||
|
|
||||||
|
- Add `company_id` to `plugin_config`, with a unique index on `(plugin_id, company_id)`.
|
||||||
|
- Update registry helpers to require `companyId` for `getConfig`, `upsertConfig`, `patchConfig`, and `deleteConfig`.
|
||||||
|
- Update plugin config routes to require `companyId` and call `assertCompanyAccess(req, companyId)`.
|
||||||
|
- Keep instance-global plugin lifecycle state separate from company-scoped plugin config.
|
||||||
|
|
||||||
|
### 2. Propagate company context through the worker runtime
|
||||||
|
|
||||||
|
- Extend the SDK so `ctx.config.get()` and `ctx.secrets.resolve()` can receive or derive `companyId`.
|
||||||
|
- Introduce worker request context storage for handlers that already run with company scope:
|
||||||
|
- `getData`
|
||||||
|
- `performAction`
|
||||||
|
- scoped API routes
|
||||||
|
- tool executions
|
||||||
|
- environment driver calls
|
||||||
|
- Fail closed when plugin code tries to read company-scoped config or secrets outside an active company context.
|
||||||
|
|
||||||
|
### 3. Rebind secrets by `(companyId, pluginId, configPath)`
|
||||||
|
|
||||||
|
- On config save, validate every referenced secret belongs to the authorized company.
|
||||||
|
- Store bindings only for that company.
|
||||||
|
- Resolve secrets only by the current company-scoped binding, never by bare plugin ID plus UUID.
|
||||||
|
- Treat stale bindings as invalid and remove them on config replacement.
|
||||||
|
|
||||||
|
### 4. Prevent cross-company config disclosure
|
||||||
|
|
||||||
|
- When returning config to the UI, only materialize the selected company's secret refs.
|
||||||
|
- Never expose another company's secret UUIDs through the global plugin config surface.
|
||||||
|
|
||||||
|
## Required regression coverage
|
||||||
|
|
||||||
|
- Company A board user cannot save plugin config that references a Company B secret.
|
||||||
|
- Company A plugin execution cannot resolve a Company B secret even if the same plugin is configured for Company B.
|
||||||
|
- Company-scoped config reads only return the selected company's secret bindings.
|
||||||
|
- Config replacement removes stale bindings for the same `(companyId, pluginId)` target.
|
||||||
|
- Runtime calls without company context fail closed.
|
||||||
|
|
||||||
|
## Migration notes
|
||||||
|
|
||||||
|
- Existing `plugin_config` rows need a migration strategy before re-enable.
|
||||||
|
- Safest default: do not auto-assume a company for historical secret refs.
|
||||||
|
- Prefer one of:
|
||||||
|
- explicit admin migration per company, or
|
||||||
|
- import existing rows as non-secret config only and require re-entry of secret refs.
|
||||||
|
|
||||||
|
## Release posture
|
||||||
|
|
||||||
|
- Keep plugin secret refs disabled until all steps above land.
|
||||||
|
- Do not restore the feature behind a soft warning; the insecure path must remain unavailable by default.
|
||||||
BIN
doc/pr/5429/env-editor-with-secrets.png
Normal file
BIN
doc/pr/5429/env-editor-with-secrets.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
BIN
doc/pr/5429/secret-binding-picker.png
Normal file
BIN
doc/pr/5429/secret-binding-picker.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
BIN
doc/pr/5429/secrets-inventory.png
Normal file
BIN
doc/pr/5429/secrets-inventory.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
133
docs/api/secrets-remote-import.md
Normal file
133
docs/api/secrets-remote-import.md
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
---
|
||||||
|
title: Secrets Remote Import
|
||||||
|
summary: AWS Secrets Manager metadata-only remote import API
|
||||||
|
---
|
||||||
|
|
||||||
|
Remote import lets the board link existing AWS Secrets Manager entries as
|
||||||
|
Paperclip `external_reference` secrets without copying plaintext into
|
||||||
|
Paperclip.
|
||||||
|
|
||||||
|
Both routes are board-only and company-scoped. The selected provider vault must
|
||||||
|
belong to the company, use `aws_secrets_manager`, and have a selectable status
|
||||||
|
(`ready` or `warning`). Disabled, coming-soon, or cross-company vaults are
|
||||||
|
rejected.
|
||||||
|
|
||||||
|
Remote import is an inventory and metadata workflow. Preview calls AWS
|
||||||
|
`ListSecrets` only and import stores a Paperclip external reference plus
|
||||||
|
fingerprint/version metadata. Neither route calls `GetSecretValue` or
|
||||||
|
`BatchGetSecretValue`, requests `SecretString`, requires KMS decrypt, logs raw
|
||||||
|
remote metadata, or copies secret plaintext into Paperclip.
|
||||||
|
|
||||||
|
## Preview Remote AWS Secrets
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/companies/{companyId}/secrets/remote-import/preview
|
||||||
|
{
|
||||||
|
"providerConfigId": "<aws-vault-uuid>",
|
||||||
|
"query": "stripe",
|
||||||
|
"nextToken": "optional-provider-page-token",
|
||||||
|
"pageSize": 50
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`query` is optional and is sent to AWS as an inventory filter. Treat it as
|
||||||
|
non-secret metadata because AWS may record list request parameters in
|
||||||
|
CloudTrail. `nextToken` is an opaque AWS cursor; pass it back unchanged.
|
||||||
|
`pageSize` is capped at 100.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providerConfigId": "<aws-vault-uuid>",
|
||||||
|
"provider": "aws_secrets_manager",
|
||||||
|
"nextToken": null,
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
|
||||||
|
"remoteName": "prod/stripe",
|
||||||
|
"name": "prod/stripe",
|
||||||
|
"key": "prod-stripe",
|
||||||
|
"providerVersionRef": null,
|
||||||
|
"providerMetadata": {
|
||||||
|
"lastChangedDate": "2026-05-06T00:00:00.000Z",
|
||||||
|
"hasDescription": true
|
||||||
|
},
|
||||||
|
"status": "ready",
|
||||||
|
"importable": true,
|
||||||
|
"conflicts": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Candidate `status` values:
|
||||||
|
|
||||||
|
- `ready`: no existing exact external reference and no name/key collision.
|
||||||
|
- `duplicate`: an existing secret already has the exact provider `externalRef`.
|
||||||
|
- `conflict`: the suggested Paperclip `name` or `key` is already in use.
|
||||||
|
|
||||||
|
Conflict `type` values are `exact_reference`, `name`, `key`, and
|
||||||
|
`provider_guardrail`. AWS refs under Paperclip's own managed namespace are
|
||||||
|
blocked as external references so one company cannot import another company's
|
||||||
|
Paperclip-managed AWS secret through a broad runtime role.
|
||||||
|
|
||||||
|
## Import Remote AWS Secret References
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/companies/{companyId}/secrets/remote-import
|
||||||
|
{
|
||||||
|
"providerConfigId": "<aws-vault-uuid>",
|
||||||
|
"secrets": [
|
||||||
|
{
|
||||||
|
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
|
||||||
|
"name": "Stripe production key",
|
||||||
|
"key": "stripe-production-key",
|
||||||
|
"description": "Stripe key used by production checkout",
|
||||||
|
"providerVersionRef": null,
|
||||||
|
"providerMetadata": {
|
||||||
|
"lastChangedDate": "2026-05-06T00:00:00.000Z",
|
||||||
|
"hasDescription": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The import response is row-level. Ready rows become active
|
||||||
|
`external_reference` secrets with version metadata only. Exact-reference
|
||||||
|
duplicates and name/key conflicts are skipped without failing the whole request.
|
||||||
|
The `secrets` array accepts 1-100 rows, and the backend re-checks duplicates and
|
||||||
|
conflicts at submit time.
|
||||||
|
Each row may include an optional Paperclip `description` entered during review;
|
||||||
|
blank descriptions are stored as `null`. AWS provider descriptions are not
|
||||||
|
copied into this field.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providerConfigId": "<aws-vault-uuid>",
|
||||||
|
"provider": "aws_secrets_manager",
|
||||||
|
"importedCount": 1,
|
||||||
|
"skippedCount": 1,
|
||||||
|
"errorCount": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
|
||||||
|
"name": "Stripe production key",
|
||||||
|
"key": "stripe-production-key",
|
||||||
|
"status": "imported",
|
||||||
|
"reason": null,
|
||||||
|
"secretId": "<paperclip-secret-id>",
|
||||||
|
"conflicts": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Activity logs record aggregate counts and provider/vault ids only, not remote
|
||||||
|
secret names, ARNs, tags, or values.
|
||||||
|
|
||||||
|
Imported references may still fail during a future bound runtime resolution if
|
||||||
|
the Paperclip runtime role can list the AWS secret but lacks
|
||||||
|
`secretsmanager:GetSecretValue` or required KMS decrypt permission for that
|
||||||
|
specific secret.
|
||||||
|
|
@ -25,16 +25,357 @@ POST /api/companies/{companyId}/secrets
|
||||||
|
|
||||||
The value is encrypted at rest. Only the secret ID and metadata are returned.
|
The value is encrypted at rest. Only the secret ID and metadata are returned.
|
||||||
|
|
||||||
## Update Secret
|
To link a provider-owned secret without copying the value into Paperclip, create
|
||||||
|
an external-reference secret:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "prod-stripe-key",
|
||||||
|
"provider": "aws_secrets_manager",
|
||||||
|
"managedMode": "external_reference",
|
||||||
|
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/stripe",
|
||||||
|
"providerVersionRef": "version-id-or-label"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Paperclip stores the provider reference and a non-sensitive fingerprint only.
|
||||||
|
The value is resolved, when the provider is configured, through the server
|
||||||
|
runtime path that enforces binding context and records access events.
|
||||||
|
|
||||||
|
## Provider Health
|
||||||
|
|
||||||
```
|
```
|
||||||
PATCH /api/secrets/{secretId}
|
GET /api/companies/{companyId}/secret-providers/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns provider setup diagnostics, warnings, and local backup guidance. Health
|
||||||
|
responses must not include secret values or provider credentials.
|
||||||
|
|
||||||
|
For `aws_secrets_manager`, an unready health response names the missing
|
||||||
|
non-secret provider environment variables, the AWS SDK default credential source
|
||||||
|
expected by the server runtime, and the custody rule that AWS bootstrap
|
||||||
|
credentials must not be stored in Paperclip `company_secrets`.
|
||||||
|
|
||||||
|
The equivalent CLI check is:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm paperclipai secrets doctor --company-id {companyId}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Provider Vaults
|
||||||
|
|
||||||
|
Provider vaults are named, company-scoped configurations that route secret
|
||||||
|
material to one of the supported provider backends. See the
|
||||||
|
[secrets deploy guide](/deploy/secrets#provider-vaults) for the operator model
|
||||||
|
and custody rules.
|
||||||
|
|
||||||
|
All routes below require board auth and company access. Mutating routes emit
|
||||||
|
`secret_provider_config.*` activity-log entries. No route in this surface
|
||||||
|
returns provider credential values; submitting credential-shaped fields in
|
||||||
|
`config` is rejected at validation time.
|
||||||
|
|
||||||
|
### List Vaults
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/companies/{companyId}/secret-provider-configs
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns every vault for the company (including disabled rows for audit), each
|
||||||
|
with id, provider, displayName, status, isDefault, non-sensitive `config`,
|
||||||
|
latest health snapshot (`healthStatus`, `healthCheckedAt`, `healthMessage`,
|
||||||
|
`healthDetails`), `disabledAt`, and audit columns.
|
||||||
|
|
||||||
|
### Create Vault
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/companies/{companyId}/secret-provider-configs
|
||||||
|
{
|
||||||
|
"provider": "aws_secrets_manager",
|
||||||
|
"displayName": "Prod US-East",
|
||||||
|
"isDefault": true,
|
||||||
|
"config": {
|
||||||
|
"region": "us-east-1",
|
||||||
|
"namespace": "paperclip",
|
||||||
|
"secretNamePrefix": "paperclip",
|
||||||
|
"kmsKeyId": "arn:aws:kms:us-east-1:123456789012:key/abcd-...",
|
||||||
|
"environmentTag": "production"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-provider `config` shapes:
|
||||||
|
|
||||||
|
- `local_encrypted`: optional `backupReminderAcknowledged: boolean`.
|
||||||
|
- `aws_secrets_manager`: required `region`; optional `namespace`,
|
||||||
|
`secretNamePrefix`, `kmsKeyId`, `ownerTag`, `environmentTag`.
|
||||||
|
- `gcp_secret_manager` (coming soon): optional `projectId`, `location`,
|
||||||
|
`namespace`, `secretNamePrefix`.
|
||||||
|
- `vault` (coming soon): optional origin-only HTTPS `address`, `namespace`,
|
||||||
|
`mountPath`, `secretPathPrefix`. `address` values with embedded credentials,
|
||||||
|
paths, query strings, or fragments are rejected.
|
||||||
|
|
||||||
|
`status` defaults to `ready` for `local_encrypted` and `aws_secrets_manager`,
|
||||||
|
and to `coming_soon` for `gcp_secret_manager` and `vault`. Coming-soon and
|
||||||
|
disabled vaults cannot be marked `isDefault`. Setting `isDefault: true` clears
|
||||||
|
the previous default for the same provider in the same transaction.
|
||||||
|
|
||||||
|
### Get Vault
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/secret-provider-configs/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Vault
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /api/secret-provider-configs/{id}
|
||||||
|
{
|
||||||
|
"displayName": "Prod US-East-2",
|
||||||
|
"config": {
|
||||||
|
"region": "us-east-2",
|
||||||
|
"kmsKeyId": "arn:aws:kms:us-east-2:123456789012:key/abcd-..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`config` is replaced wholesale on update — pass the full provider config
|
||||||
|
payload, not a partial diff. Status transitions for `gcp_secret_manager` and
|
||||||
|
`vault` are constrained to `coming_soon` and `disabled` until their runtime
|
||||||
|
modules ship.
|
||||||
|
|
||||||
|
### Disable Vault
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/secret-provider-configs/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
Soft-deletes the vault: status flips to `disabled`, `isDefault` clears, and
|
||||||
|
`disabledAt` is stamped. Disabled vaults remain in `GET` results for audit
|
||||||
|
purposes but are no longer offered in the secret create/rotate flow.
|
||||||
|
|
||||||
|
### Set Default
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/secret-provider-configs/{id}/default
|
||||||
|
```
|
||||||
|
|
||||||
|
Marks the target vault as the default for its provider family and clears the
|
||||||
|
previous default. Returns 422 when the target is `coming_soon` or `disabled`.
|
||||||
|
|
||||||
|
### Run Health Check
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/secret-provider-configs/{id}/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs a provider-specific health probe and persists the result on the vault.
|
||||||
|
Response shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"configId": "<uuid>",
|
||||||
|
"provider": "aws_secrets_manager",
|
||||||
|
"status": "ready" | "warning" | "error" | "coming_soon" | "disabled",
|
||||||
|
"message": "Provider vault is ready to handle managed writes",
|
||||||
|
"details": {
|
||||||
|
"code": "provider_ready",
|
||||||
|
"message": "...",
|
||||||
|
"guidance": ["..."]
|
||||||
|
},
|
||||||
|
"checkedAt": "2026-05-06T14:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Health responses never include provider credentials or secret values. For AWS
|
||||||
|
vaults, `details.guidance` may include missing non-secret env names and the
|
||||||
|
expected AWS SDK credential source; coming-soon vaults always return
|
||||||
|
`status: "coming_soon"` with `code: "runtime_locked"` and never call into
|
||||||
|
provider modules.
|
||||||
|
|
||||||
|
### Selecting A Vault When Creating Or Rotating Secrets
|
||||||
|
|
||||||
|
`POST /api/companies/{companyId}/secrets` and
|
||||||
|
`POST /api/secrets/{secretId}/rotate` both accept an optional
|
||||||
|
`providerConfigId` field that pins the secret to a specific vault. When
|
||||||
|
omitted (or null), the operation runs through the deployment-level provider
|
||||||
|
configuration — the same path existing installs already use. The board UI
|
||||||
|
preselects the company's default vault for the chosen provider before
|
||||||
|
submitting, so callers should usually send an explicit `providerConfigId`.
|
||||||
|
Coming-soon and disabled vaults are rejected with a 422; a vault that does not
|
||||||
|
match the secret's provider is rejected the same way.
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /api/companies/{companyId}/secrets
|
||||||
|
{
|
||||||
|
"name": "prod-stripe-key",
|
||||||
|
"provider": "aws_secrets_manager",
|
||||||
|
"providerConfigId": "<vault-uuid>",
|
||||||
|
"managedMode": "external_reference",
|
||||||
|
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/stripe"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Redaction Rules
|
||||||
|
|
||||||
|
Every route in this surface enforces the same redaction contract:
|
||||||
|
|
||||||
|
- Secret values are never returned. The board UI never has a "reveal value"
|
||||||
|
affordance; resolution happens server-side at runtime under a binding.
|
||||||
|
- Provider credential values are never accepted, stored, returned, logged, or
|
||||||
|
echoed in error messages. Submitting credential-shaped fields fails
|
||||||
|
validation with a non-leaking error.
|
||||||
|
- Activity log entries record vault id, provider, displayName, status, and
|
||||||
|
isDefault transitions — never `config` payloads or health detail bodies.
|
||||||
|
|
||||||
|
## Remote Import From AWS Secrets Manager
|
||||||
|
|
||||||
|
Remote import links existing AWS Secrets Manager entries into Paperclip as
|
||||||
|
`external_reference` secrets. Import stores provider reference metadata only; it
|
||||||
|
does not copy the remote secret plaintext into Paperclip.
|
||||||
|
|
||||||
|
The routes are board-only and company-scoped. `providerConfigId` must point to
|
||||||
|
a same-company AWS provider vault with status `ready` or `warning`. Disabled,
|
||||||
|
coming-soon, non-AWS, and cross-company vaults are rejected. Imported secrets
|
||||||
|
resolve later through the selected vault, so runtime reads still need
|
||||||
|
`secretsmanager:GetSecretValue` and any required KMS decrypt permission on the
|
||||||
|
selected external secret.
|
||||||
|
|
||||||
|
### Preview Remote Import Candidates
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/companies/{companyId}/secrets/remote-import/preview
|
||||||
|
{
|
||||||
|
"providerConfigId": "<aws-vault-uuid>",
|
||||||
|
"query": "stripe",
|
||||||
|
"nextToken": "opaque-provider-token",
|
||||||
|
"pageSize": 50
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`query` is optional and is passed to AWS Secrets Manager inventory filtering.
|
||||||
|
Treat it as non-secret metadata because AWS may record list request parameters
|
||||||
|
in CloudTrail. `nextToken` is an opaque AWS cursor; callers must pass it back
|
||||||
|
unchanged and must not synthesize offsets. `pageSize` is optional, defaults to
|
||||||
|
50 in the UI, and is capped at 100.
|
||||||
|
|
||||||
|
Preview uses AWS `ListSecrets` only. It must not call `GetSecretValue` or
|
||||||
|
`BatchGetSecretValue`, must not request `SecretString`, and must not require KMS
|
||||||
|
decrypt. The response contains sanitized metadata for display and conflict
|
||||||
|
decisions:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providerConfigId": "<aws-vault-uuid>",
|
||||||
|
"provider": "aws_secrets_manager",
|
||||||
|
"nextToken": null,
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
|
||||||
|
"remoteName": "prod/stripe",
|
||||||
|
"name": "prod/stripe",
|
||||||
|
"key": "prod-stripe",
|
||||||
|
"providerVersionRef": null,
|
||||||
|
"providerMetadata": {
|
||||||
|
"createdDate": "2026-05-06T00:00:00.000Z",
|
||||||
|
"lastChangedDate": "2026-05-06T00:00:00.000Z",
|
||||||
|
"hasDescription": true,
|
||||||
|
"hasKmsKey": true,
|
||||||
|
"tagCount": 3
|
||||||
|
},
|
||||||
|
"status": "ready",
|
||||||
|
"importable": true,
|
||||||
|
"conflicts": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Candidate statuses:
|
||||||
|
|
||||||
|
- `ready`: the row can be selected for import.
|
||||||
|
- `duplicate`: a Paperclip secret already links the same canonical provider
|
||||||
|
reference for the same provider vault.
|
||||||
|
- `conflict`: the row has a name/key collision or provider guardrail failure.
|
||||||
|
|
||||||
|
Conflict types are `exact_reference`, `name`, `key`, and
|
||||||
|
`provider_guardrail`. AWS refs under Paperclip's own managed namespace are
|
||||||
|
blocked as external references; use the Paperclip-managed secret flow for those
|
||||||
|
resources instead.
|
||||||
|
|
||||||
|
### Import Selected Remote References
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/companies/{companyId}/secrets/remote-import
|
||||||
|
{
|
||||||
|
"providerConfigId": "<aws-vault-uuid>",
|
||||||
|
"secrets": [
|
||||||
|
{
|
||||||
|
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
|
||||||
|
"name": "Stripe production key",
|
||||||
|
"key": "stripe-production-key",
|
||||||
|
"description": "Stripe key used by production checkout",
|
||||||
|
"providerVersionRef": null,
|
||||||
|
"providerMetadata": {
|
||||||
|
"createdDate": "2026-05-06T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `secrets` array accepts 1-100 rows. Each row may override the suggested
|
||||||
|
Paperclip `name`, `key`, optional Paperclip `description`,
|
||||||
|
`providerVersionRef`, and sanitized `providerMetadata`. Blank descriptions are
|
||||||
|
stored as `null`; AWS provider descriptions are not copied into Paperclip
|
||||||
|
descriptions. The backend re-checks duplicate refs and name/key conflicts at
|
||||||
|
submit time; a stale preview does not bypass those checks.
|
||||||
|
|
||||||
|
The import response is row-level:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providerConfigId": "<aws-vault-uuid>",
|
||||||
|
"provider": "aws_secrets_manager",
|
||||||
|
"importedCount": 1,
|
||||||
|
"skippedCount": 1,
|
||||||
|
"errorCount": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
|
||||||
|
"name": "Stripe production key",
|
||||||
|
"key": "stripe-production-key",
|
||||||
|
"status": "imported",
|
||||||
|
"reason": null,
|
||||||
|
"secretId": "<paperclip-secret-id>",
|
||||||
|
"conflicts": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Row statuses:
|
||||||
|
|
||||||
|
- `imported`: Paperclip created an active `external_reference` secret and one
|
||||||
|
metadata-only version row.
|
||||||
|
- `skipped`: the row had an exact-reference duplicate or name/key conflict.
|
||||||
|
- `error`: the provider rejected the reference or the row failed validation.
|
||||||
|
|
||||||
|
Activity logs for preview/import store aggregate counts, provider id, and vault
|
||||||
|
id only. They must not store remote secret names, ARNs, descriptions, tags,
|
||||||
|
plaintext values, provider credentials, or raw AWS error blobs.
|
||||||
|
|
||||||
|
## Rotate Secret
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/secrets/{secretId}/rotate
|
||||||
{
|
{
|
||||||
"value": "sk-ant-new-value..."
|
"value": "sk-ant-new-value..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Creates a new version of the secret. Agents referencing `"version": "latest"` automatically get the new value on next heartbeat.
|
Creates a new version of the secret. Agents referencing `"version": "latest"`
|
||||||
|
automatically get the new value on next heartbeat. Pin to a specific version
|
||||||
|
when a bad `latest` rollout would affect many agents at once.
|
||||||
|
|
||||||
## Using Secrets in Agent Config
|
## Using Secrets in Agent Config
|
||||||
|
|
||||||
|
|
@ -52,4 +393,20 @@ Reference secrets in agent adapter config instead of inline values:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The server resolves and decrypts secret references at runtime, injecting the real value into the agent process environment.
|
The server resolves and decrypts secret references at runtime, injecting the
|
||||||
|
real value into the agent process environment. Paperclip's custody guarantees
|
||||||
|
end at injection: the agent process can read, log, or forward the value, so
|
||||||
|
treat any secret bound to an agent as exposed to that agent. See the custody
|
||||||
|
boundaries note in the [secrets deploy guide](/deploy/secrets#custody-boundaries).
|
||||||
|
|
||||||
|
## Portability
|
||||||
|
|
||||||
|
Company export/import APIs represent agent and project environment requirements
|
||||||
|
as declarations in the package manifest. Exports omit secret values, secret IDs,
|
||||||
|
provider references, and encrypted provider material. Use:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm paperclipai secrets declarations --company-id {companyId}
|
||||||
|
```
|
||||||
|
|
||||||
|
to inspect the declarations that an export would emit before moving a package.
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,16 @@ pnpm paperclipai context set --api-key-env-var-name PAPERCLIP_API_KEY
|
||||||
export PAPERCLIP_API_KEY=...
|
export PAPERCLIP_API_KEY=...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Secret operations are available under `paperclipai secrets`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm paperclipai secrets declarations --company-id <company-id> --kind secret
|
||||||
|
pnpm paperclipai secrets create --company-id <company-id> --name anthropic-api-key --value-env ANTHROPIC_API_KEY
|
||||||
|
pnpm paperclipai secrets link --company-id <company-id> --name prod-stripe-key --provider aws_secrets_manager --external-ref <provider-ref>
|
||||||
|
pnpm paperclipai secrets doctor --company-id <company-id>
|
||||||
|
pnpm paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
||||||
|
```
|
||||||
|
|
||||||
Context is stored at `~/.paperclip/context.json`.
|
Context is stored at `~/.paperclip/context.json`.
|
||||||
|
|
||||||
## Command Categories
|
## Command Categories
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,8 @@ Validates:
|
||||||
|
|
||||||
- Server configuration
|
- Server configuration
|
||||||
- Database connectivity
|
- Database connectivity
|
||||||
- Secrets adapter configuration
|
- Secrets adapter configuration, including AWS Secrets Manager non-secret env
|
||||||
|
config when selected
|
||||||
- Storage configuration
|
- Storage configuration
|
||||||
- Missing key files
|
- Missing key files
|
||||||
|
|
||||||
|
|
@ -81,6 +82,13 @@ pnpm paperclipai configure --section secrets
|
||||||
pnpm paperclipai configure --section storage
|
pnpm paperclipai configure --section storage
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--section secrets` updates the deployment-level provider used as the fallback
|
||||||
|
for secrets that do not target a specific company vault. Per-company provider
|
||||||
|
vaults (named instances, default vault selection, multiple vaults per provider,
|
||||||
|
coming-soon GCP/Vault) live in the board UI under
|
||||||
|
`Company Settings → Secrets → Provider vaults` and the
|
||||||
|
`/api/companies/{companyId}/secret-provider-configs` API.
|
||||||
|
|
||||||
## `paperclipai env`
|
## `paperclipai env`
|
||||||
|
|
||||||
Show resolved environment configuration:
|
Show resolved environment configuration:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,52 @@ summary: Master key, encryption, and strict mode
|
||||||
|
|
||||||
Paperclip encrypts secrets at rest using a local master key. Agent environment variables that contain sensitive values (API keys, tokens) are stored as encrypted secret references.
|
Paperclip encrypts secrets at rest using a local master key. Agent environment variables that contain sensitive values (API keys, tokens) are stored as encrypted secret references.
|
||||||
|
|
||||||
|
## Custody Boundaries
|
||||||
|
|
||||||
|
Paperclip protects secret values up to the moment they are handed to an agent
|
||||||
|
or workload:
|
||||||
|
|
||||||
|
- Storage: values are encrypted at rest by the active provider. The local
|
||||||
|
provider keeps them encrypted with a key that never leaves the host.
|
||||||
|
- Transport: values are decrypted server-side and injected into the agent
|
||||||
|
process environment, SSH command env, sandbox driver, or HTTP request
|
||||||
|
immediately before the call. Paperclip does not return decrypted values to
|
||||||
|
the board UI.
|
||||||
|
- Audit: each resolution records a non-sensitive event (secret id, version,
|
||||||
|
provider id, consumer, outcome) without the value or provider credentials.
|
||||||
|
|
||||||
|
Once a value reaches the consuming process, Paperclip can no longer guarantee
|
||||||
|
secrecy. The agent (or sandbox, or remote host) can read the value, write it to
|
||||||
|
its own logs or transcript, or pass it to downstream tools. Treat any secret
|
||||||
|
you bind to an agent as exposed to that agent. Limit blast radius with bindings
|
||||||
|
(only bind what each agent needs), short-lived provider credentials where the
|
||||||
|
provider supports them, and rotation when an agent transcript or downstream
|
||||||
|
system might have captured a value.
|
||||||
|
|
||||||
|
## Using Secrets In Runs
|
||||||
|
|
||||||
|
Creating a company secret does not automatically create an environment variable.
|
||||||
|
You use a secret by binding it into an agent, project, environment, or plugin
|
||||||
|
configuration field that supports secret references.
|
||||||
|
|
||||||
|
For agent and project environment variables:
|
||||||
|
|
||||||
|
1. Create or link the secret in `Company Settings > Secrets`.
|
||||||
|
2. Open the agent's `Environment variables` field, or the project's `Env`
|
||||||
|
field.
|
||||||
|
3. Add the environment variable key the process expects, such as `GH_TOKEN` or
|
||||||
|
`OPENAI_API_KEY`.
|
||||||
|
4. Set the row source to `Secret`, select the stored secret, and choose either
|
||||||
|
`latest` or a pinned version.
|
||||||
|
|
||||||
|
At runtime, Paperclip resolves the selected secret server-side and injects the
|
||||||
|
resolved value under the env key from the binding row. The stored secret name
|
||||||
|
can be human-readable; the binding key is what the agent process receives.
|
||||||
|
|
||||||
|
Project env applies to every issue run in that project. When a project env key
|
||||||
|
matches an agent env key, the project value wins before Paperclip injects its
|
||||||
|
own `PAPERCLIP_*` runtime variables.
|
||||||
|
|
||||||
## Default Provider: `local_encrypted`
|
## Default Provider: `local_encrypted`
|
||||||
|
|
||||||
Secrets are encrypted with a local master key stored at:
|
Secrets are encrypted with a local master key stored at:
|
||||||
|
|
@ -14,6 +60,13 @@ Secrets are encrypted with a local master key stored at:
|
||||||
```
|
```
|
||||||
|
|
||||||
This key is auto-created during onboarding. The key never leaves your machine.
|
This key is auto-created during onboarding. The key never leaves your machine.
|
||||||
|
Paperclip best-effort enforces `0600` permissions when it creates or loads the
|
||||||
|
key file. `paperclipai doctor` and the provider health API warn when the file is
|
||||||
|
readable by group or other users.
|
||||||
|
|
||||||
|
Back up the key file together with database backups. A database backup without
|
||||||
|
the key cannot decrypt local secrets, and a key backup without the database
|
||||||
|
metadata is not enough to restore named secret versions.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|
@ -35,6 +88,7 @@ Validate secrets config:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm paperclipai doctor
|
pnpm paperclipai doctor
|
||||||
|
pnpm paperclipai secrets doctor --company-id <company-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Overrides
|
### Environment Overrides
|
||||||
|
|
@ -55,15 +109,279 @@ PAPERCLIP_SECRETS_STRICT_MODE=true
|
||||||
|
|
||||||
Recommended for any deployment beyond local trusted.
|
Recommended for any deployment beyond local trusted.
|
||||||
|
|
||||||
|
Authenticated deployments default strict mode on unless explicitly overridden by
|
||||||
|
configuration or `PAPERCLIP_SECRETS_STRICT_MODE=false`.
|
||||||
|
|
||||||
|
## External References
|
||||||
|
|
||||||
|
Provider-owned secrets can be linked without copying values into Paperclip by
|
||||||
|
using `managedMode: "external_reference"` plus a provider `externalRef`.
|
||||||
|
Paperclip stores metadata and a non-sensitive fingerprint, never the value.
|
||||||
|
Runtime resolution remains server-side and binding-enforced.
|
||||||
|
|
||||||
|
The built-in AWS, GCP, and Vault provider IDs currently accept external
|
||||||
|
reference metadata, but runtime resolution requires provider configuration in the
|
||||||
|
deployment. Their provider health check reports this as a warning until
|
||||||
|
configured.
|
||||||
|
|
||||||
|
For hosted Paperclip Cloud on AWS, see the AWS Secrets Manager operational
|
||||||
|
contract — required env vars, IAM/KMS scoping, naming and tag conventions, and
|
||||||
|
backup/rotation/incident runbooks — in `doc/SECRETS-AWS-PROVIDER.md`.
|
||||||
|
|
||||||
|
## Provider Vaults
|
||||||
|
|
||||||
|
A *provider vault* is a named, company-scoped configuration that points secret
|
||||||
|
material at one of the supported provider backends. Each company can configure
|
||||||
|
multiple vaults, including more than one vault per provider family, and pick a
|
||||||
|
default vault per family for new secret operations. Existing secrets created
|
||||||
|
before any vault was configured continue to resolve through the deployment-level
|
||||||
|
default provider — no migration is required.
|
||||||
|
|
||||||
|
### Where to configure
|
||||||
|
|
||||||
|
Open `Company Settings → Secrets` in the board UI and switch to the
|
||||||
|
`Provider vaults` tab. From there you can:
|
||||||
|
|
||||||
|
- Create a vault for any supported provider family.
|
||||||
|
- Edit the non-secret config of an existing vault.
|
||||||
|
- Set one ready vault per provider family as the company default.
|
||||||
|
- Disable a vault (a soft delete that keeps audit history).
|
||||||
|
- Run a health check against a vault and read the latest result inline.
|
||||||
|
|
||||||
|
The same operations are exposed under
|
||||||
|
`/api/companies/{companyId}/secret-provider-configs` for automation. See the
|
||||||
|
[secrets API reference](/api/secrets#provider-vaults) for the full route table.
|
||||||
|
|
||||||
|
### Custody Of Provider Credentials
|
||||||
|
|
||||||
|
Provider vaults intentionally store only **non-sensitive** configuration:
|
||||||
|
region, project id, namespace, prefix, KMS key id, mount path, address, and
|
||||||
|
similar routing metadata. The API, UI, and activity log never accept, return,
|
||||||
|
or display provider credential values. Submitting fields with names like
|
||||||
|
`accessKeyId`, `secretAccessKey`, `token`, `password`, `serviceAccountJson`,
|
||||||
|
`privateKey`, `keyFile`, `unsealKey`, or any common credential alias is rejected
|
||||||
|
at validation time.
|
||||||
|
|
||||||
|
That keeps the bootstrap rule from the AWS provider applicable to every
|
||||||
|
provider family: **provider credentials live in deployment infrastructure
|
||||||
|
identity, not in Paperclip company secrets**. Allowed credential sources are
|
||||||
|
workload identity attached to the Paperclip server (instance profile, IRSA, ECS
|
||||||
|
task role), `AWS_PROFILE` / SSO / shared config for local runs, an orchestrator
|
||||||
|
secret store that boots the server, or short-lived shell credentials for local
|
||||||
|
development. Do not paste long-lived API keys into the vault config.
|
||||||
|
|
||||||
|
### Vault Status
|
||||||
|
|
||||||
|
Each vault carries a status that drives what the runtime can do with it:
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|---------------|-----------------------------------------------------------------------------------------------|
|
||||||
|
| `ready` | Selectable for create/rotate/resolve. Eligible to be the default. |
|
||||||
|
| `warning` | Saved config exists but health needs attention (for example missing AWS env). Still selectable. |
|
||||||
|
| `coming_soon` | Visible and editable as draft metadata, but locked out of all runtime operations. |
|
||||||
|
| `disabled` | Soft-deleted. Hidden from the secret create/rotate flow. |
|
||||||
|
|
||||||
|
`gcp_secret_manager` and `vault` are pinned to `coming_soon` until their
|
||||||
|
runtime modules ship. The settings UI lets you save draft configuration for
|
||||||
|
those providers (and surfaces them on the vault list), but secret create,
|
||||||
|
rotate, and resolve calls that target a coming-soon vault fail with a clear
|
||||||
|
runtime-locked error.
|
||||||
|
|
||||||
|
### Default Vault Behavior
|
||||||
|
|
||||||
|
A company can mark **one** ready (or warning) vault per provider family as the
|
||||||
|
default. The secret create and rotate dialogs preselect the default vault for
|
||||||
|
the chosen provider so operators don't have to remember which vault to pick.
|
||||||
|
Coming-soon and disabled vaults cannot be marked default; attempting to do so
|
||||||
|
returns a validation error. Setting a new default automatically clears the
|
||||||
|
previous default for that provider.
|
||||||
|
|
||||||
|
If a secret is created without any `providerConfigId` (no vaults exist yet, or
|
||||||
|
the operator clears the selector), runtime resolution falls back to the
|
||||||
|
deployment-level provider configuration — the same path existing installs use.
|
||||||
|
This keeps secrets created before any provider vault was configured working
|
||||||
|
without migration. Picking the default in the UI is an explicit selection, not
|
||||||
|
a runtime fallback: the create call still sends an explicit `providerConfigId`.
|
||||||
|
|
||||||
|
### Multiple Vaults Per Provider
|
||||||
|
|
||||||
|
Multiple vaults from the same provider family are first-class. Common patterns:
|
||||||
|
|
||||||
|
- Two AWS vaults pointing at different regions or KMS keys for environment
|
||||||
|
separation.
|
||||||
|
- A staging Vault address alongside a production address.
|
||||||
|
- A dedicated GCP project for a single product line while the rest of the
|
||||||
|
company uses another.
|
||||||
|
|
||||||
|
Each vault has its own display name, status, default flag, and health record.
|
||||||
|
Operators choose the vault explicitly when creating or rotating a secret; the
|
||||||
|
default vault is preselected to avoid accidental routing to the wrong account.
|
||||||
|
|
||||||
|
### Per-Vault Health Checks
|
||||||
|
|
||||||
|
`POST /api/secret-provider-configs/{id}/health` runs a provider-specific health
|
||||||
|
probe and stores the result on the vault row. The settings UI exposes the same
|
||||||
|
action and renders the result inline. Health responses include a status,
|
||||||
|
operator-facing message, and structured guidance (such as missing env var
|
||||||
|
names, expected credential sources, and backup reminders). They never include
|
||||||
|
provider credentials or secret values. Coming-soon vaults always return a
|
||||||
|
`runtime_locked` health code and never call into provider modules.
|
||||||
|
|
||||||
|
### Provider-Specific Notes
|
||||||
|
|
||||||
|
**Local encrypted vaults** wrap the existing `local_encrypted` provider. The
|
||||||
|
master key path and rotation guidance described above still applies. A local
|
||||||
|
vault config is mostly bookkeeping plus an explicit acknowledgement that the
|
||||||
|
key file is backed up alongside the database.
|
||||||
|
|
||||||
|
**AWS Secrets Manager vaults** read the per-vault `region`, `namespace`,
|
||||||
|
`secretNamePrefix`, `kmsKeyId`, `ownerTag`, and `environmentTag` to route
|
||||||
|
managed writes and external-reference reads. The vault config supplements (and
|
||||||
|
can override) the deployment-level `PAPERCLIP_SECRETS_AWS_*` env. Bootstrap
|
||||||
|
credentials still come from the AWS SDK default credential chain — see
|
||||||
|
`doc/SECRETS-AWS-PROVIDER.md` for the full IAM and KMS contract.
|
||||||
|
|
||||||
|
**GCP Secret Manager** and **HashiCorp Vault** vaults are coming soon. You can
|
||||||
|
save draft `projectId`, `location`, `namespace`, `address`, and `mountPath`
|
||||||
|
metadata so the company is ready to flip them on when the provider modules
|
||||||
|
ship. Vault `address` values must be origin-only `http(s)://host[:port]` URLs;
|
||||||
|
addresses with embedded credentials, paths, query strings, or fragments are
|
||||||
|
rejected.
|
||||||
|
|
||||||
|
### Remote Import From AWS Vaults
|
||||||
|
|
||||||
|
AWS provider vaults can import existing AWS Secrets Manager entries as
|
||||||
|
Paperclip `external_reference` secrets. This is a metadata-only link: Paperclip
|
||||||
|
stores the AWS ARN/path, a fingerprint/version reference, and binding metadata.
|
||||||
|
It does not read, copy, store, log, or display the remote plaintext secret
|
||||||
|
value during preview or import.
|
||||||
|
|
||||||
|
Operator flow in the board UI:
|
||||||
|
|
||||||
|
1. Open `Company Settings -> Secrets`.
|
||||||
|
2. Confirm at least one AWS provider vault is `ready` or `warning`.
|
||||||
|
3. In the `Secrets` tab, choose `Import from vault`.
|
||||||
|
4. Select an AWS vault, search the remote inventory, and load more pages as
|
||||||
|
needed.
|
||||||
|
5. Check the rows to import, review/edit the Paperclip name and key, then
|
||||||
|
submit.
|
||||||
|
6. Review the result summary for created, skipped, and failed rows.
|
||||||
|
|
||||||
|
The preview list is intentionally paged and search-first. AWS accounts can have
|
||||||
|
large per-Region inventories, and `ListSecrets` returns opaque `NextToken`
|
||||||
|
cursors. Do not expect Paperclip to crawl a whole account in the background;
|
||||||
|
load pages deliberately and retry throttled requests with backoff.
|
||||||
|
|
||||||
|
Remote import exposes AWS secret metadata visible to the Paperclip runtime
|
||||||
|
role, including names/ARNs and safe derived fields such as dates, whether a
|
||||||
|
description or KMS key exists, and tag count. Treat names, ARNs, tags, and
|
||||||
|
search text as operational metadata that may be sensitive. The API and activity
|
||||||
|
log must not store raw descriptions, tags, plaintext values, provider
|
||||||
|
credentials, or raw AWS error blobs.
|
||||||
|
|
||||||
|
Required AWS posture:
|
||||||
|
|
||||||
|
- Preview needs optional `secretsmanager:ListSecrets` permission on
|
||||||
|
`Resource: "*"`. AWS does not support constraining `ListSecrets` to
|
||||||
|
individual secret ARNs or tags as an IAM boundary.
|
||||||
|
- Preview/import must not call `secretsmanager:GetSecretValue`,
|
||||||
|
`secretsmanager:BatchGetSecretValue`, or KMS decrypt.
|
||||||
|
- Runtime resolution of an imported reference still needs
|
||||||
|
`secretsmanager:GetSecretValue` on the selected external ARN/path and KMS
|
||||||
|
decrypt when that secret uses a customer-managed key.
|
||||||
|
- Keep managed create/rotate/delete permissions scoped to the Paperclip
|
||||||
|
deployment prefix. Do not broaden managed write/delete permissions just
|
||||||
|
because import inventory is enabled.
|
||||||
|
|
||||||
|
Safe scoping comes from deployment posture rather than AWS list filtering:
|
||||||
|
dedicated Paperclip runtime roles per environment/account, AWS vaults pointed at
|
||||||
|
the intended account and Region, import-enabled roles only where inventory
|
||||||
|
exposure is acceptable, and board-only access to the import routes. Tags and
|
||||||
|
name filters are search aids, not a permission model.
|
||||||
|
|
||||||
|
If import preview fails:
|
||||||
|
|
||||||
|
- `AccessDenied` or `not authorized`: the runtime role is missing
|
||||||
|
`secretsmanager:ListSecrets`; add the optional inventory statement only if
|
||||||
|
remote import should be enabled for that vault.
|
||||||
|
- Throttling: retry after a short delay and narrow the search before loading
|
||||||
|
more pages.
|
||||||
|
- Invalid cursor: refresh the preview; AWS `NextToken` values are opaque and
|
||||||
|
can expire or become stale.
|
||||||
|
- Runtime resolution failure after import: verify `GetSecretValue` and KMS
|
||||||
|
decrypt scope for the selected external secret. Being visible in inventory is
|
||||||
|
not proof that the runtime role can read the value.
|
||||||
|
|
||||||
|
### Backup And Restore
|
||||||
|
|
||||||
|
Each provider family has a different backup story:
|
||||||
|
|
||||||
|
- `local_encrypted`: back up the local master key file and the Paperclip
|
||||||
|
database together. Either alone is not enough to restore the encrypted
|
||||||
|
values, and the vault row only records the path and acknowledgement, not the
|
||||||
|
key bytes.
|
||||||
|
- `aws_secrets_manager`: back up Paperclip's database for vault metadata
|
||||||
|
(vault id, region, prefix, KMS key id, default flag, bindings, version
|
||||||
|
pointers). The actual secret values live in AWS Secrets Manager under the
|
||||||
|
configured prefix; restore by pointing the same Paperclip company at the
|
||||||
|
same AWS namespace and confirming the runtime role still has
|
||||||
|
`GetSecretValue` plus KMS decrypt. The full restore checklist lives in
|
||||||
|
`doc/SECRETS-AWS-PROVIDER.md`.
|
||||||
|
- `gcp_secret_manager` and `vault`: while these are coming soon, only the
|
||||||
|
draft vault config exists in Paperclip. Database backups capture it. There
|
||||||
|
is nothing to restore on the provider side until runtime support lands.
|
||||||
|
|
||||||
|
### AWS Provider Bootstrap Boundary
|
||||||
|
|
||||||
|
The AWS Secrets Manager provider cannot bootstrap itself from Paperclip
|
||||||
|
`company_secrets`. Its initial AWS access must be present before the server can
|
||||||
|
create or resolve AWS-backed company secrets, regardless of whether you use the
|
||||||
|
deployment-level default or a per-company vault.
|
||||||
|
|
||||||
|
For Paperclip Cloud, provision the server runtime IAM role/workload identity,
|
||||||
|
KMS key, deployment prefix, and non-secret `PAPERCLIP_SECRETS_AWS_*` environment
|
||||||
|
configuration before enabling AWS-backed secrets in the board UI. For
|
||||||
|
self-hosted and local runs, use the AWS SDK default credential chain: instance
|
||||||
|
profile, ECS task role, EKS IRSA/OIDC web identity, AWS SSO/shared config via
|
||||||
|
`AWS_PROFILE`, or short-lived shell credentials for local development.
|
||||||
|
|
||||||
|
Do not store AWS root credentials or long-lived IAM user access keys in
|
||||||
|
Paperclip secrets. Bootstrap material belongs in infrastructure IAM/workload
|
||||||
|
identity, the process environment, an AWS profile, or the orchestrator secret
|
||||||
|
store.
|
||||||
|
|
||||||
## Migrating Inline Secrets
|
## Migrating Inline Secrets
|
||||||
|
|
||||||
If you have existing agents with inline API keys in their config, migrate them to encrypted secret refs:
|
If you have existing agents with inline API keys in their config, migrate them to encrypted secret refs:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
pnpm paperclipai secrets migrate-inline-env --company-id <company-id>
|
||||||
|
pnpm paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
||||||
|
|
||||||
|
# low-level script for direct database maintenance
|
||||||
pnpm secrets:migrate-inline-env # dry run
|
pnpm secrets:migrate-inline-env # dry run
|
||||||
pnpm secrets:migrate-inline-env --apply # apply migration
|
pnpm secrets:migrate-inline-env --apply # apply migration
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use the CLI command for normal operations because it goes through the Paperclip
|
||||||
|
API, creates or rotates secret records, and updates agent env bindings with
|
||||||
|
audit logging.
|
||||||
|
|
||||||
|
## Portable Declarations
|
||||||
|
|
||||||
|
Company exports include only environment declarations. They do not include
|
||||||
|
secret IDs, provider references, encrypted material, or plaintext values.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm paperclipai secrets declarations --company-id <company-id> --kind secret
|
||||||
|
```
|
||||||
|
|
||||||
|
Before importing a package into another instance, use those declarations to
|
||||||
|
create local values or link hosted provider references in the target deployment.
|
||||||
|
For hosted providers such as AWS Secrets Manager, the hosted provider remains
|
||||||
|
the value custodian; Paperclip stores metadata and provider version references,
|
||||||
|
not provider credentials or plaintext secret values.
|
||||||
|
|
||||||
## Secret References in Agent Config
|
## Secret References in Agent Config
|
||||||
|
|
||||||
Agent environment variables use secret references:
|
Agent environment variables use secret references:
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ describe("command managed runtime", () => {
|
||||||
if (
|
if (
|
||||||
input.stdin != null &&
|
input.stdin != null &&
|
||||||
(input.command === "sh" || input.command === "bash") &&
|
(input.command === "sh" || input.command === "bash") &&
|
||||||
args[0] === "-lc" &&
|
(args[0] === "-c" || args[0] === "-lc") &&
|
||||||
typeof args[1] === "string"
|
typeof args[1] === "string"
|
||||||
) {
|
) {
|
||||||
env.PAPERCLIP_TEST_STDIN = input.stdin;
|
env.PAPERCLIP_TEST_STDIN = input.stdin;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
type SandboxManagedRuntimeClient,
|
type SandboxManagedRuntimeClient,
|
||||||
type SandboxRemoteExecutionSpec,
|
type SandboxRemoteExecutionSpec,
|
||||||
} from "./sandbox-managed-runtime.js";
|
} from "./sandbox-managed-runtime.js";
|
||||||
import { preferredShellForSandbox } from "./sandbox-shell.js";
|
import { preferredShellForSandbox, shellCommandArgs } from "./sandbox-shell.js";
|
||||||
import type { RunProcessResult } from "./server-utils.js";
|
import type { RunProcessResult } from "./server-utils.js";
|
||||||
|
|
||||||
export interface CommandManagedRuntimeRunner {
|
export interface CommandManagedRuntimeRunner {
|
||||||
|
|
@ -65,7 +65,7 @@ export function createCommandManagedRuntimeClient(input: {
|
||||||
const runShell = async (script: string, opts: { stdin?: string; timeoutMs?: number } = {}) => {
|
const runShell = async (script: string, opts: { stdin?: string; timeoutMs?: number } = {}) => {
|
||||||
const result = await input.runner.execute({
|
const result = await input.runner.execute({
|
||||||
command: shellCommand,
|
command: shellCommand,
|
||||||
args: ["-lc", script],
|
args: shellCommandArgs(script),
|
||||||
cwd: input.commandCwd,
|
cwd: input.commandCwd,
|
||||||
stdin: opts.stdin,
|
stdin: opts.stdin,
|
||||||
timeoutMs: opts.timeoutMs ?? input.timeoutMs,
|
timeoutMs: opts.timeoutMs ?? input.timeoutMs,
|
||||||
|
|
@ -116,7 +116,7 @@ export function createCommandManagedRuntimeClient(input: {
|
||||||
remove: async (remotePath) => {
|
remove: async (remotePath) => {
|
||||||
const result = await input.runner.execute({
|
const result = await input.runner.execute({
|
||||||
command: shellCommand,
|
command: shellCommand,
|
||||||
args: ["-lc", `rm -rf ${shellQuote(remotePath)}`],
|
args: shellCommandArgs(`rm -rf ${shellQuote(remotePath)}`),
|
||||||
cwd: input.commandCwd,
|
cwd: input.commandCwd,
|
||||||
timeoutMs: input.timeoutMs,
|
timeoutMs: input.timeoutMs,
|
||||||
});
|
});
|
||||||
|
|
@ -125,7 +125,7 @@ export function createCommandManagedRuntimeClient(input: {
|
||||||
run: async (command, options) => {
|
run: async (command, options) => {
|
||||||
const result = await input.runner.execute({
|
const result = await input.runner.execute({
|
||||||
command: shellCommand,
|
command: shellCommand,
|
||||||
args: ["-lc", command],
|
args: shellCommandArgs(command),
|
||||||
cwd: input.commandCwd,
|
cwd: input.commandCwd,
|
||||||
timeoutMs: options.timeoutMs,
|
timeoutMs: options.timeoutMs,
|
||||||
});
|
});
|
||||||
|
|
@ -176,7 +176,7 @@ export async function prepareCommandManagedRuntime(input: {
|
||||||
if (detectCommand) {
|
if (detectCommand) {
|
||||||
const probe = await input.runner.execute({
|
const probe = await input.runner.execute({
|
||||||
command: shellCommand,
|
command: shellCommand,
|
||||||
args: ["-lc", `command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`],
|
args: shellCommandArgs(`command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`),
|
||||||
cwd: commandCwd,
|
cwd: commandCwd,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
});
|
});
|
||||||
|
|
@ -195,7 +195,7 @@ export async function prepareCommandManagedRuntime(input: {
|
||||||
}
|
}
|
||||||
const result = await input.runner.execute({
|
const result = await input.runner.execute({
|
||||||
command: shellCommand,
|
command: shellCommand,
|
||||||
args: ["-lc", installCommand],
|
args: shellCommandArgs(installCommand),
|
||||||
cwd: commandCwd,
|
cwd: commandCwd,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ describe("sandbox adapter execution targets", () => {
|
||||||
|
|
||||||
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
command: "sh",
|
command: "sh",
|
||||||
args: ["-lc", 'printf %s "$HOME"'],
|
args: ["-c", 'printf %s "$HOME"'],
|
||||||
cwd: "/workspace",
|
cwd: "/workspace",
|
||||||
timeoutMs: 7000,
|
timeoutMs: 7000,
|
||||||
}));
|
}));
|
||||||
|
|
@ -284,7 +284,7 @@ describe("sandbox adapter execution targets", () => {
|
||||||
|
|
||||||
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
command: "bash",
|
command: "bash",
|
||||||
args: ["-lc", 'printf %s "$HOME"'],
|
args: ["-c", 'printf %s "$HOME"'],
|
||||||
cwd: "/workspace",
|
cwd: "/workspace",
|
||||||
timeoutMs: 7000,
|
timeoutMs: 7000,
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ describe("runAdapterExecutionTargetShellCommand", () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// runSshCommand owns profile sourcing and the outer `sh -lc` wrapper —
|
// runSshCommand owns profile sourcing and the outer shell wrapper —
|
||||||
// the caller passes the raw command string. Wrapping it here would
|
// the caller passes the raw command string. Wrapping it here would
|
||||||
// double-nest the login shell and re-source profiles after the explicit
|
// double-nest the login shell and re-source profiles after the explicit
|
||||||
// env override, silently undoing identity-var preservation.
|
// env override, silently undoing identity-var preservation.
|
||||||
|
|
@ -317,7 +317,7 @@ describe("ensureAdapterExecutionTargetRuntimeCommandInstalled", () => {
|
||||||
|
|
||||||
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
command: "sh",
|
command: "sh",
|
||||||
args: ["-lc", "npm install -g @google/gemini-cli"],
|
args: ["-c", "npm install -g @google/gemini-cli"],
|
||||||
cwd: "/remote/workspace",
|
cwd: "/remote/workspace",
|
||||||
env: { PATH: "/usr/bin" },
|
env: { PATH: "/usr/bin" },
|
||||||
timeoutMs: 30_000,
|
timeoutMs: 30_000,
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import {
|
||||||
type TerminalResultCleanupOptions,
|
type TerminalResultCleanupOptions,
|
||||||
} from "./server-utils.js";
|
} from "./server-utils.js";
|
||||||
import { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js";
|
import { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js";
|
||||||
import { preferredShellForSandbox } from "./sandbox-shell.js";
|
import { preferredShellForSandbox, shellCommandArgs } from "./sandbox-shell.js";
|
||||||
|
|
||||||
export interface AdapterLocalExecutionTarget {
|
export interface AdapterLocalExecutionTarget {
|
||||||
kind: "local";
|
kind: "local";
|
||||||
|
|
@ -319,7 +319,7 @@ async function ensureSandboxCommandResolvable(
|
||||||
try {
|
try {
|
||||||
const installResult = await runner.execute({
|
const installResult = await runner.execute({
|
||||||
command: "sh",
|
command: "sh",
|
||||||
args: ["-lc", installCommand],
|
args: shellCommandArgs(installCommand),
|
||||||
cwd: target.remoteCwd,
|
cwd: target.remoteCwd,
|
||||||
timeoutMs: target.timeoutMs ?? 300_000,
|
timeoutMs: target.timeoutMs ?? 300_000,
|
||||||
});
|
});
|
||||||
|
|
@ -417,8 +417,8 @@ export async function runAdapterExecutionTargetShellCommand(
|
||||||
if (target.transport === "ssh") {
|
if (target.transport === "ssh") {
|
||||||
try {
|
try {
|
||||||
// Pass the raw command — `runSshCommand` owns profile sourcing and
|
// Pass the raw command — `runSshCommand` owns profile sourcing and
|
||||||
// the outer `sh -lc` wrapper. Wrapping again here would nest a second
|
// the outer shell wrapper. Wrapping again here would nest a second
|
||||||
// `sh -lc` after the explicit `env KEY=VAL` overrides, re-sourcing
|
// shell after the explicit `env KEY=VAL` overrides, re-sourcing
|
||||||
// login profiles AFTER the override and silently undoing any
|
// login profiles AFTER the override and silently undoing any
|
||||||
// identity var (NVM_DIR / PATH / etc.) that a profile re-exports.
|
// identity var (NVM_DIR / PATH / etc.) that a profile re-exports.
|
||||||
const result = await runSshCommand(target.spec, command, {
|
const result = await runSshCommand(target.spec, command, {
|
||||||
|
|
@ -477,7 +477,7 @@ export async function runAdapterExecutionTargetShellCommand(
|
||||||
const shellCommand = preferredSandboxShell(target);
|
const shellCommand = preferredSandboxShell(target);
|
||||||
return await requireSandboxRunner(target).execute({
|
return await requireSandboxRunner(target).execute({
|
||||||
command: shellCommand,
|
command: shellCommand,
|
||||||
args: ["-lc", command],
|
args: shellCommandArgs(command),
|
||||||
cwd: target.remoteCwd,
|
cwd: target.remoteCwd,
|
||||||
env,
|
env,
|
||||||
timeoutMs: (options.timeoutSec ?? 15) * 1000,
|
timeoutMs: (options.timeoutSec ?? 15) * 1000,
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ describe("sandbox callback bridge", () => {
|
||||||
if (
|
if (
|
||||||
input.stdin != null &&
|
input.stdin != null &&
|
||||||
(input.command === "sh" || input.command === "bash") &&
|
(input.command === "sh" || input.command === "bash") &&
|
||||||
args[0] === "-lc" &&
|
(args[0] === "-c" || args[0] === "-lc") &&
|
||||||
typeof args[1] === "string"
|
typeof args[1] === "string"
|
||||||
) {
|
) {
|
||||||
env.PAPERCLIP_TEST_STDIN = input.stdin;
|
env.PAPERCLIP_TEST_STDIN = input.stdin;
|
||||||
|
|
@ -508,7 +508,7 @@ describe("sandbox callback bridge", () => {
|
||||||
authorizeRequest: async () => null,
|
authorizeRequest: async () => null,
|
||||||
handleRequest: async (request) => {
|
handleRequest: async (request) => {
|
||||||
seenRequestIds.push(request.id);
|
seenRequestIds.push(request.id);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
|
|
@ -551,7 +551,7 @@ describe("sandbox callback bridge", () => {
|
||||||
error: "Bridge worker stopped before request could be handled.",
|
error: "Bridge worker stopped before request could be handled.",
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
|
||||||
await expect(readdir(directories.responsesDir)).resolves.toEqual([]);
|
await expect(readdir(directories.responsesDir)).resolves.toEqual([]);
|
||||||
await expect(
|
await expect(
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js";
|
import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js";
|
||||||
import { preferredShellForSandbox } from "./sandbox-shell.js";
|
import { preferredShellForSandbox, shellCommandArgs } from "./sandbox-shell.js";
|
||||||
import type { RunProcessResult } from "./server-utils.js";
|
import type { RunProcessResult } from "./server-utils.js";
|
||||||
|
|
||||||
const DEFAULT_BRIDGE_TOKEN_BYTES = 24;
|
const DEFAULT_BRIDGE_TOKEN_BYTES = 24;
|
||||||
|
|
@ -207,7 +207,7 @@ async function runShell(
|
||||||
): Promise<RunProcessResult> {
|
): Promise<RunProcessResult> {
|
||||||
return await runner.execute({
|
return await runner.execute({
|
||||||
command: shellCommand,
|
command: shellCommand,
|
||||||
args: ["-lc", script],
|
args: shellCommandArgs(script),
|
||||||
cwd,
|
cwd,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
stdin,
|
stdin,
|
||||||
|
|
@ -569,10 +569,11 @@ async function writeBridgeResponse(
|
||||||
requestPath: string,
|
requestPath: string,
|
||||||
responsePath: string,
|
responsePath: string,
|
||||||
response: SandboxCallbackBridgeResponse,
|
response: SandboxCallbackBridgeResponse,
|
||||||
|
options: { requireRequestPath?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
const body = `${JSON.stringify(response)}\n`;
|
const body = `${JSON.stringify(response)}\n`;
|
||||||
if (client.writeResponseFile) {
|
if (client.writeResponseFile) {
|
||||||
await client.writeResponseFile(responsePath, body, { requestPath });
|
await client.writeResponseFile(responsePath, body, options.requireRequestPath === false ? {} : { requestPath });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tempPath = `${responsePath}.tmp`;
|
const tempPath = `${responsePath}.tmp`;
|
||||||
|
|
@ -686,12 +687,15 @@ export async function startSandboxCallbackBridgeWorker(input: {
|
||||||
try {
|
try {
|
||||||
const raw = await input.client.readTextFile(requestPath);
|
const raw = await input.client.readTextFile(requestPath);
|
||||||
const parsed = JSON.parse(raw) as Partial<SandboxCallbackBridgeRequest>;
|
const parsed = JSON.parse(raw) as Partial<SandboxCallbackBridgeRequest>;
|
||||||
|
await input.client.remove(requestPath).catch(() => undefined);
|
||||||
await writeBridgeResponse(input.client, requestPath, responsePath, {
|
await writeBridgeResponse(input.client, requestPath, responsePath, {
|
||||||
id: typeof parsed.id === "string" && parsed.id.length > 0 ? parsed.id : requestId,
|
id: typeof parsed.id === "string" && parsed.id.length > 0 ? parsed.id : requestId,
|
||||||
status: 503,
|
status: 503,
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ error: message }),
|
body: JSON.stringify({ error: message }),
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
|
}, {
|
||||||
|
requireRequestPath: false,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|
@ -901,8 +905,7 @@ export async function startSandboxCallbackBridgeServer(input: {
|
||||||
const nodeCommand = input.nodeCommand?.trim() || "node";
|
const nodeCommand = input.nodeCommand?.trim() || "node";
|
||||||
const startResult = await input.runner.execute({
|
const startResult = await input.runner.execute({
|
||||||
command: shellCommand,
|
command: shellCommand,
|
||||||
args: [
|
args: shellCommandArgs(
|
||||||
"-lc",
|
|
||||||
[
|
[
|
||||||
`mkdir -p ${shellQuote(directories.requestsDir)} ${shellQuote(directories.responsesDir)} ${shellQuote(directories.logsDir)}`,
|
`mkdir -p ${shellQuote(directories.requestsDir)} ${shellQuote(directories.responsesDir)} ${shellQuote(directories.logsDir)}`,
|
||||||
`rm -f ${shellQuote(directories.readyFile)} ${shellQuote(directories.pidFile)}`,
|
`rm -f ${shellQuote(directories.readyFile)} ${shellQuote(directories.pidFile)}`,
|
||||||
|
|
@ -913,7 +916,7 @@ export async function startSandboxCallbackBridgeServer(input: {
|
||||||
`printf '%s\\n' \"$pid\" > ${shellQuote(directories.pidFile)}`,
|
`printf '%s\\n' \"$pid\" > ${shellQuote(directories.pidFile)}`,
|
||||||
"printf '{\"pid\":%s}\\n' \"$pid\"",
|
"printf '{\"pid\":%s}\\n' \"$pid\"",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
],
|
),
|
||||||
cwd: input.remoteCwd,
|
cwd: input.remoteCwd,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
});
|
});
|
||||||
|
|
@ -975,8 +978,7 @@ export async function startSandboxCallbackBridgeServer(input: {
|
||||||
stop: async () => {
|
stop: async () => {
|
||||||
const stopResult = await input.runner.execute({
|
const stopResult = await input.runner.execute({
|
||||||
command: shellCommand,
|
command: shellCommand,
|
||||||
args: [
|
args: shellCommandArgs(
|
||||||
"-lc",
|
|
||||||
[
|
[
|
||||||
`if [ -s ${shellQuote(directories.pidFile)} ]; then`,
|
`if [ -s ${shellQuote(directories.pidFile)} ]; then`,
|
||||||
` pid="$(cat ${shellQuote(directories.pidFile)})"`,
|
` pid="$(cat ${shellQuote(directories.pidFile)})"`,
|
||||||
|
|
@ -989,7 +991,7 @@ export async function startSandboxCallbackBridgeServer(input: {
|
||||||
"fi",
|
"fi",
|
||||||
`rm -f ${shellQuote(directories.pidFile)} ${shellQuote(directories.readyFile)}`,
|
`rm -f ${shellQuote(directories.pidFile)} ${shellQuote(directories.readyFile)}`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
],
|
),
|
||||||
cwd: input.remoteCwd,
|
cwd: input.remoteCwd,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ describe("sandbox managed runtime", () => {
|
||||||
await rm(remotePath, { recursive: true, force: true });
|
await rm(remotePath, { recursive: true, force: true });
|
||||||
},
|
},
|
||||||
run: async (command) => {
|
run: async (command) => {
|
||||||
await execFile("sh", ["-lc", command], {
|
await execFile("sh", ["-c", command], {
|
||||||
maxBuffer: 32 * 1024 * 1024,
|
maxBuffer: 32 * 1024 * 1024,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -267,7 +267,7 @@ export async function prepareSandboxManagedRuntime(input: {
|
||||||
const preservedNames = new Set([".paperclip-runtime", ...(input.preserveAbsentOnRestore ?? [])]);
|
const preservedNames = new Set([".paperclip-runtime", ...(input.preserveAbsentOnRestore ?? [])]);
|
||||||
const findPreserveArgs = [...preservedNames].map((entry) => `! -name ${shellQuote(entry)}`).join(" ");
|
const findPreserveArgs = [...preservedNames].map((entry) => `! -name ${shellQuote(entry)}`).join(" ");
|
||||||
await input.client.run(
|
await input.client.run(
|
||||||
`sh -lc ${shellQuote(
|
`sh -c ${shellQuote(
|
||||||
`mkdir -p ${shellQuote(workspaceRemoteDir)} && ` +
|
`mkdir -p ${shellQuote(workspaceRemoteDir)} && ` +
|
||||||
`find ${shellQuote(workspaceRemoteDir)} -mindepth 1 -maxdepth 1 ${findPreserveArgs} -exec rm -rf -- {} + && ` +
|
`find ${shellQuote(workspaceRemoteDir)} -mindepth 1 -maxdepth 1 ${findPreserveArgs} -exec rm -rf -- {} + && ` +
|
||||||
`tar -xf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} && ` +
|
`tar -xf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} && ` +
|
||||||
|
|
@ -289,7 +289,7 @@ export async function prepareSandboxManagedRuntime(input: {
|
||||||
const remoteAssetTar = path.posix.join(runtimeRootDir, `${asset.key}-upload.tar`);
|
const remoteAssetTar = path.posix.join(runtimeRootDir, `${asset.key}-upload.tar`);
|
||||||
await input.client.writeFile(remoteAssetTar, toArrayBuffer(assetTarBytes));
|
await input.client.writeFile(remoteAssetTar, toArrayBuffer(assetTarBytes));
|
||||||
await input.client.run(
|
await input.client.run(
|
||||||
`sh -lc ${shellQuote(
|
`sh -c ${shellQuote(
|
||||||
`rm -rf ${shellQuote(remoteAssetDir)} && ` +
|
`rm -rf ${shellQuote(remoteAssetDir)} && ` +
|
||||||
`mkdir -p ${shellQuote(remoteAssetDir)} && ` +
|
`mkdir -p ${shellQuote(remoteAssetDir)} && ` +
|
||||||
`tar -xf ${shellQuote(remoteAssetTar)} -C ${shellQuote(remoteAssetDir)} && ` +
|
`tar -xf ${shellQuote(remoteAssetTar)} -C ${shellQuote(remoteAssetDir)} && ` +
|
||||||
|
|
@ -314,7 +314,7 @@ export async function prepareSandboxManagedRuntime(input: {
|
||||||
await withTempDir("paperclip-sandbox-restore-", async (tempDir) => {
|
await withTempDir("paperclip-sandbox-restore-", async (tempDir) => {
|
||||||
const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-download.tar");
|
const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-download.tar");
|
||||||
await input.client.run(
|
await input.client.run(
|
||||||
`sh -lc ${shellQuote(
|
`sh -c ${shellQuote(
|
||||||
`mkdir -p ${shellQuote(runtimeRootDir)} && ` +
|
`mkdir -p ${shellQuote(runtimeRootDir)} && ` +
|
||||||
`tar -cf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} ` +
|
`tar -cf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} ` +
|
||||||
`${tarExcludeFlags(input.workspaceExclude)} .`,
|
`${tarExcludeFlags(input.workspaceExclude)} .`,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
export function preferredShellForSandbox(shellCommand: string | null | undefined): "bash" | "sh" {
|
export function preferredShellForSandbox(shellCommand: string | null | undefined): "bash" | "sh" {
|
||||||
return shellCommand === "bash" ? "bash" : "sh";
|
return shellCommand === "bash" ? "bash" : "sh";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shellCommandArgs(script: string): string[] {
|
||||||
|
return ["-c", script];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ import {
|
||||||
} from "./ssh.js";
|
} from "./ssh.js";
|
||||||
import { prepareRemoteManagedRuntime } from "./remote-managed-runtime.js";
|
import { prepareRemoteManagedRuntime } from "./remote-managed-runtime.js";
|
||||||
|
|
||||||
|
const SSH_FIXTURE_TEST_TIMEOUT_MS = 30_000;
|
||||||
|
let sshEnvLabUnsupportedReason: string | null = null;
|
||||||
|
|
||||||
async function git(cwd: string, args: string[]): Promise<string> {
|
async function git(cwd: string, args: string[]): Promise<string> {
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
execFile("git", ["-C", cwd, ...args], (error, stdout, stderr) => {
|
execFile("git", ["-C", cwd, ...args], (error, stdout, stderr) => {
|
||||||
|
|
@ -29,6 +32,28 @@ async function git(cwd: string, args: string[]): Promise<string> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startSshEnvLabFixtureOrSkip(statePath: string, label: string) {
|
||||||
|
if (sshEnvLabUnsupportedReason) {
|
||||||
|
console.warn(`Skipping ${label}: ${sshEnvLabUnsupportedReason}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const support = await getSshEnvLabSupport();
|
||||||
|
if (!support.supported) {
|
||||||
|
sshEnvLabUnsupportedReason = support.reason ?? "unsupported environment";
|
||||||
|
console.warn(`Skipping ${label}: ${sshEnvLabUnsupportedReason}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await startSshEnvLabFixture({ statePath });
|
||||||
|
} catch (error) {
|
||||||
|
sshEnvLabUnsupportedReason = error instanceof Error ? error.message : String(error);
|
||||||
|
console.warn(`Skipping ${label}: ${sshEnvLabUnsupportedReason}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("ssh env-lab fixture", () => {
|
describe("ssh env-lab fixture", () => {
|
||||||
const cleanupDirs: string[] = [];
|
const cleanupDirs: string[] = [];
|
||||||
|
|
||||||
|
|
@ -41,24 +66,17 @@ describe("ssh env-lab fixture", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts an isolated sshd fixture and executes commands through it", async () => {
|
it("starts an isolated sshd fixture and executes commands through it", async () => {
|
||||||
const support = await getSshEnvLabSupport();
|
|
||||||
if (!support.supported) {
|
|
||||||
console.warn(
|
|
||||||
`Skipping SSH env-lab fixture test: ${support.reason ?? "unsupported environment"}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
||||||
cleanupDirs.push(rootDir);
|
cleanupDirs.push(rootDir);
|
||||||
const statePath = path.join(rootDir, "state.json");
|
const statePath = path.join(rootDir, "state.json");
|
||||||
|
|
||||||
const started = await startSshEnvLabFixture({ statePath });
|
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH env-lab fixture test");
|
||||||
|
if (!started) return;
|
||||||
const config = await buildSshEnvLabFixtureConfig(started);
|
const config = await buildSshEnvLabFixtureConfig(started);
|
||||||
const quotedWorkspace = JSON.stringify(started.workspaceDir);
|
const quotedWorkspace = JSON.stringify(started.workspaceDir);
|
||||||
const result = await runSshCommand(
|
const result = await runSshCommand(
|
||||||
config,
|
config,
|
||||||
`sh -lc 'cd ${quotedWorkspace} && pwd'`,
|
`cd ${quotedWorkspace} && pwd`,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.stdout.trim()).toBe(started.workspaceDir);
|
expect(result.stdout.trim()).toBe(started.workspaceDir);
|
||||||
|
|
@ -69,28 +87,21 @@ describe("ssh env-lab fixture", () => {
|
||||||
|
|
||||||
const stopped = await readSshEnvLabFixtureStatus(statePath);
|
const stopped = await readSshEnvLabFixtureStatus(statePath);
|
||||||
expect(stopped.running).toBe(false);
|
expect(stopped.running).toBe(false);
|
||||||
});
|
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
|
||||||
|
|
||||||
it("forwards stdin to remote SSH commands", async () => {
|
it("forwards stdin to remote SSH commands", async () => {
|
||||||
const support = await getSshEnvLabSupport();
|
|
||||||
if (!support.supported) {
|
|
||||||
console.warn(
|
|
||||||
`Skipping SSH stdin forwarding test: ${support.reason ?? "unsupported environment"}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
||||||
cleanupDirs.push(rootDir);
|
cleanupDirs.push(rootDir);
|
||||||
const statePath = path.join(rootDir, "state.json");
|
const statePath = path.join(rootDir, "state.json");
|
||||||
|
|
||||||
const started = await startSshEnvLabFixture({ statePath });
|
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH stdin forwarding test");
|
||||||
|
if (!started) return;
|
||||||
const config = await buildSshEnvLabFixtureConfig(started);
|
const config = await buildSshEnvLabFixtureConfig(started);
|
||||||
const remotePath = path.posix.join(started.workspaceDir, "stdin-forwarded.txt");
|
const remotePath = path.posix.join(started.workspaceDir, "stdin-forwarded.txt");
|
||||||
|
|
||||||
await runSshCommand(
|
await runSshCommand(
|
||||||
config,
|
config,
|
||||||
`sh -lc 'cat > ${JSON.stringify(remotePath)}'`,
|
`cat > ${JSON.stringify(remotePath)}`,
|
||||||
{
|
{
|
||||||
stdin: "hello over ssh stdin\n",
|
stdin: "hello over ssh stdin\n",
|
||||||
timeoutMs: 30_000,
|
timeoutMs: 30_000,
|
||||||
|
|
@ -100,27 +111,20 @@ describe("ssh env-lab fixture", () => {
|
||||||
|
|
||||||
const result = await runSshCommand(
|
const result = await runSshCommand(
|
||||||
config,
|
config,
|
||||||
`sh -lc 'cat ${JSON.stringify(remotePath)}'`,
|
`cat ${JSON.stringify(remotePath)}`,
|
||||||
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.stdout).toBe("hello over ssh stdin\n");
|
expect(result.stdout).toBe("hello over ssh stdin\n");
|
||||||
});
|
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
|
||||||
|
|
||||||
it("does not treat an unrelated reused pid as the running fixture", async () => {
|
it("does not treat an unrelated reused pid as the running fixture", async () => {
|
||||||
const support = await getSshEnvLabSupport();
|
|
||||||
if (!support.supported) {
|
|
||||||
console.warn(
|
|
||||||
`Skipping SSH env-lab fixture test: ${support.reason ?? "unsupported environment"}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
||||||
cleanupDirs.push(rootDir);
|
cleanupDirs.push(rootDir);
|
||||||
const statePath = path.join(rootDir, "state.json");
|
const statePath = path.join(rootDir, "state.json");
|
||||||
|
|
||||||
const started = await startSshEnvLabFixture({ statePath });
|
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH env-lab fixture test");
|
||||||
|
if (!started) return;
|
||||||
await stopSshEnvLabFixture(statePath);
|
await stopSshEnvLabFixture(statePath);
|
||||||
await mkdir(path.dirname(statePath), { recursive: true });
|
await mkdir(path.dirname(statePath), { recursive: true });
|
||||||
|
|
||||||
|
|
@ -133,11 +137,12 @@ describe("ssh env-lab fixture", () => {
|
||||||
const staleStatus = await readSshEnvLabFixtureStatus(statePath);
|
const staleStatus = await readSshEnvLabFixtureStatus(statePath);
|
||||||
expect(staleStatus.running).toBe(false);
|
expect(staleStatus.running).toBe(false);
|
||||||
|
|
||||||
const restarted = await startSshEnvLabFixture({ statePath });
|
const restarted = await startSshEnvLabFixtureOrSkip(statePath, "SSH env-lab fixture restart test");
|
||||||
|
if (!restarted) return;
|
||||||
expect(restarted.pid).not.toBe(process.pid);
|
expect(restarted.pid).not.toBe(process.pid);
|
||||||
|
|
||||||
await stopSshEnvLabFixture(statePath);
|
await stopSshEnvLabFixture(statePath);
|
||||||
});
|
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
|
||||||
|
|
||||||
it("rejects invalid environment variable keys when constructing SSH spawn targets", async () => {
|
it("rejects invalid environment variable keys when constructing SSH spawn targets", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
|
|
@ -162,14 +167,6 @@ describe("ssh env-lab fixture", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("syncs a local directory into the remote fixture workspace", async () => {
|
it("syncs a local directory into the remote fixture workspace", async () => {
|
||||||
const support = await getSshEnvLabSupport();
|
|
||||||
if (!support.supported) {
|
|
||||||
console.warn(
|
|
||||||
`Skipping SSH env-lab fixture test: ${support.reason ?? "unsupported environment"}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
||||||
cleanupDirs.push(rootDir);
|
cleanupDirs.push(rootDir);
|
||||||
const statePath = path.join(rootDir, "state.json");
|
const statePath = path.join(rootDir, "state.json");
|
||||||
|
|
@ -179,7 +176,8 @@ describe("ssh env-lab fixture", () => {
|
||||||
await writeFile(path.join(localDir, "message.txt"), "hello from paperclip\n", "utf8");
|
await writeFile(path.join(localDir, "message.txt"), "hello from paperclip\n", "utf8");
|
||||||
await writeFile(path.join(localDir, "._message.txt"), "should never sync\n", "utf8");
|
await writeFile(path.join(localDir, "._message.txt"), "should never sync\n", "utf8");
|
||||||
|
|
||||||
const started = await startSshEnvLabFixture({ statePath });
|
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH env-lab fixture test");
|
||||||
|
if (!started) return;
|
||||||
const config = await buildSshEnvLabFixtureConfig(started);
|
const config = await buildSshEnvLabFixtureConfig(started);
|
||||||
const remoteDir = path.posix.join(started.workspaceDir, "overlay");
|
const remoteDir = path.posix.join(started.workspaceDir, "overlay");
|
||||||
|
|
||||||
|
|
@ -194,22 +192,14 @@ describe("ssh env-lab fixture", () => {
|
||||||
|
|
||||||
const result = await runSshCommand(
|
const result = await runSshCommand(
|
||||||
config,
|
config,
|
||||||
`sh -lc 'cat ${JSON.stringify(path.posix.join(remoteDir, "message.txt"))} && if [ -e ${JSON.stringify(path.posix.join(remoteDir, "._message.txt"))} ]; then echo appledouble-present; fi'`,
|
`cat ${JSON.stringify(path.posix.join(remoteDir, "message.txt"))} && if [ -e ${JSON.stringify(path.posix.join(remoteDir, "._message.txt"))} ]; then echo appledouble-present; fi`,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.stdout).toContain("hello from paperclip");
|
expect(result.stdout).toContain("hello from paperclip");
|
||||||
expect(result.stdout).not.toContain("appledouble-present");
|
expect(result.stdout).not.toContain("appledouble-present");
|
||||||
});
|
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
|
||||||
|
|
||||||
it("can dereference local symlinks while syncing to the remote fixture", async () => {
|
it("can dereference local symlinks while syncing to the remote fixture", async () => {
|
||||||
const support = await getSshEnvLabSupport();
|
|
||||||
if (!support.supported) {
|
|
||||||
console.warn(
|
|
||||||
`Skipping SSH symlink sync test: ${support.reason ?? "unsupported environment"}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
||||||
cleanupDirs.push(rootDir);
|
cleanupDirs.push(rootDir);
|
||||||
const statePath = path.join(rootDir, "state.json");
|
const statePath = path.join(rootDir, "state.json");
|
||||||
|
|
@ -221,7 +211,8 @@ describe("ssh env-lab fixture", () => {
|
||||||
await writeFile(path.join(sourceDir, "auth.json"), "{\"token\":\"secret\"}\n", "utf8");
|
await writeFile(path.join(sourceDir, "auth.json"), "{\"token\":\"secret\"}\n", "utf8");
|
||||||
await symlink(path.join(sourceDir, "auth.json"), path.join(localDir, "auth.json"));
|
await symlink(path.join(sourceDir, "auth.json"), path.join(localDir, "auth.json"));
|
||||||
|
|
||||||
const started = await startSshEnvLabFixture({ statePath });
|
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH symlink sync test");
|
||||||
|
if (!started) return;
|
||||||
const config = await buildSshEnvLabFixtureConfig(started);
|
const config = await buildSshEnvLabFixtureConfig(started);
|
||||||
const remoteDir = path.posix.join(started.workspaceDir, "overlay-follow-links");
|
const remoteDir = path.posix.join(started.workspaceDir, "overlay-follow-links");
|
||||||
|
|
||||||
|
|
@ -237,29 +228,22 @@ describe("ssh env-lab fixture", () => {
|
||||||
|
|
||||||
const result = await runSshCommand(
|
const result = await runSshCommand(
|
||||||
config,
|
config,
|
||||||
`sh -lc 'if [ -L ${JSON.stringify(path.posix.join(remoteDir, "auth.json"))} ]; then echo symlink; else echo regular; fi && cat ${JSON.stringify(path.posix.join(remoteDir, "auth.json"))}'`,
|
`if [ -L ${JSON.stringify(path.posix.join(remoteDir, "auth.json"))} ]; then echo symlink; else echo regular; fi && cat ${JSON.stringify(path.posix.join(remoteDir, "auth.json"))}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.stdout).toContain("regular");
|
expect(result.stdout).toContain("regular");
|
||||||
expect(result.stdout).toContain("{\"token\":\"secret\"}");
|
expect(result.stdout).toContain("{\"token\":\"secret\"}");
|
||||||
});
|
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
|
||||||
|
|
||||||
it("round-trips a git workspace through the SSH fixture", async () => {
|
it("round-trips a git workspace through the SSH fixture", async () => {
|
||||||
const support = await getSshEnvLabSupport();
|
|
||||||
if (!support.supported) {
|
|
||||||
console.warn(
|
|
||||||
`Skipping SSH workspace round-trip test: ${support.reason ?? "unsupported environment"}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
||||||
cleanupDirs.push(rootDir);
|
cleanupDirs.push(rootDir);
|
||||||
const statePath = path.join(rootDir, "state.json");
|
const statePath = path.join(rootDir, "state.json");
|
||||||
const localRepo = path.join(rootDir, "local-workspace");
|
const localRepo = path.join(rootDir, "local-workspace");
|
||||||
|
|
||||||
await mkdir(localRepo, { recursive: true });
|
await mkdir(localRepo, { recursive: true });
|
||||||
await git(localRepo, ["init", "-b", "main"]);
|
await git(localRepo, ["init"]);
|
||||||
|
await git(localRepo, ["checkout", "-b", "main"]);
|
||||||
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
|
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
|
||||||
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
|
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
|
||||||
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
|
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
|
||||||
|
|
@ -270,7 +254,8 @@ describe("ssh env-lab fixture", () => {
|
||||||
await writeFile(path.join(localRepo, "tracked.txt"), "dirty local\n", "utf8");
|
await writeFile(path.join(localRepo, "tracked.txt"), "dirty local\n", "utf8");
|
||||||
await writeFile(path.join(localRepo, "untracked.txt"), "from local\n", "utf8");
|
await writeFile(path.join(localRepo, "untracked.txt"), "from local\n", "utf8");
|
||||||
|
|
||||||
const started = await startSshEnvLabFixture({ statePath });
|
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH workspace round-trip test");
|
||||||
|
if (!started) return;
|
||||||
const config = await buildSshEnvLabFixtureConfig(started);
|
const config = await buildSshEnvLabFixtureConfig(started);
|
||||||
const spec = {
|
const spec = {
|
||||||
...config,
|
...config,
|
||||||
|
|
@ -285,7 +270,7 @@ describe("ssh env-lab fixture", () => {
|
||||||
|
|
||||||
const remoteStatus = await runSshCommand(
|
const remoteStatus = await runSshCommand(
|
||||||
config,
|
config,
|
||||||
`sh -lc 'cd ${JSON.stringify(started.workspaceDir)} && git status --short'`,
|
`cd ${JSON.stringify(started.workspaceDir)} && git status --short`,
|
||||||
);
|
);
|
||||||
expect(remoteStatus.stdout).toContain("M tracked.txt");
|
expect(remoteStatus.stdout).toContain("M tracked.txt");
|
||||||
expect(remoteStatus.stdout).toContain("?? untracked.txt");
|
expect(remoteStatus.stdout).toContain("?? untracked.txt");
|
||||||
|
|
@ -293,7 +278,7 @@ describe("ssh env-lab fixture", () => {
|
||||||
|
|
||||||
await runSshCommand(
|
await runSshCommand(
|
||||||
config,
|
config,
|
||||||
`sh -lc 'cd ${JSON.stringify(started.workspaceDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && git add tracked.txt untracked.txt && git commit -m "remote update" >/dev/null && printf "remote dirty\\n" > tracked.txt && printf "remote extra\\n" > remote-only.txt'`,
|
`cd ${JSON.stringify(started.workspaceDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && git add tracked.txt untracked.txt && git commit -m "remote update" >/dev/null && printf "remote dirty\\n" > tracked.txt && printf "remote extra\\n" > remote-only.txt`,
|
||||||
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -308,31 +293,25 @@ describe("ssh env-lab fixture", () => {
|
||||||
expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toBe("remote update");
|
expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toBe("remote update");
|
||||||
expect(await git(localRepo, ["status", "--short"])).toContain("M tracked.txt");
|
expect(await git(localRepo, ["status", "--short"])).toContain("M tracked.txt");
|
||||||
expect(await git(localRepo, ["status", "--short"])).not.toContain("._tracked.txt");
|
expect(await git(localRepo, ["status", "--short"])).not.toContain("._tracked.txt");
|
||||||
});
|
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
|
||||||
|
|
||||||
it("preserves both concurrent SSH restores in a shared git workspace", async () => {
|
it("preserves both concurrent SSH restores in a shared git workspace", async () => {
|
||||||
const support = await getSshEnvLabSupport();
|
|
||||||
if (!support.supported) {
|
|
||||||
console.warn(
|
|
||||||
`Skipping concurrent SSH restore test: ${support.reason ?? "unsupported environment"}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
||||||
cleanupDirs.push(rootDir);
|
cleanupDirs.push(rootDir);
|
||||||
const statePath = path.join(rootDir, "state.json");
|
const statePath = path.join(rootDir, "state.json");
|
||||||
const localRepo = path.join(rootDir, "local-workspace");
|
const localRepo = path.join(rootDir, "local-workspace");
|
||||||
|
|
||||||
await mkdir(localRepo, { recursive: true });
|
await mkdir(localRepo, { recursive: true });
|
||||||
await git(localRepo, ["init", "-b", "main"]);
|
await git(localRepo, ["init"]);
|
||||||
|
await git(localRepo, ["checkout", "-b", "main"]);
|
||||||
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
|
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
|
||||||
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
|
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
|
||||||
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
|
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
|
||||||
await git(localRepo, ["add", "tracked.txt"]);
|
await git(localRepo, ["add", "tracked.txt"]);
|
||||||
await git(localRepo, ["commit", "-m", "initial"]);
|
await git(localRepo, ["commit", "-m", "initial"]);
|
||||||
|
|
||||||
const started = await startSshEnvLabFixture({ statePath });
|
const started = await startSshEnvLabFixtureOrSkip(statePath, "concurrent SSH restore test");
|
||||||
|
if (!started) return;
|
||||||
const config = await buildSshEnvLabFixtureConfig(started);
|
const config = await buildSshEnvLabFixtureConfig(started);
|
||||||
const spec = {
|
const spec = {
|
||||||
...config,
|
...config,
|
||||||
|
|
@ -356,12 +335,12 @@ describe("ssh env-lab fixture", () => {
|
||||||
|
|
||||||
await runSshCommand(
|
await runSshCommand(
|
||||||
config,
|
config,
|
||||||
`sh -lc 'printf "from run a\\n" > ${JSON.stringify(path.posix.join(preparedA.workspaceRemoteDir, "run-a.txt"))}'`,
|
`printf "from run a\\n" > ${JSON.stringify(path.posix.join(preparedA.workspaceRemoteDir, "run-a.txt"))}`,
|
||||||
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
||||||
);
|
);
|
||||||
await runSshCommand(
|
await runSshCommand(
|
||||||
config,
|
config,
|
||||||
`sh -lc 'printf "from run b\\n" > ${JSON.stringify(path.posix.join(preparedB.workspaceRemoteDir, "run-b.txt"))}'`,
|
`printf "from run b\\n" > ${JSON.stringify(path.posix.join(preparedB.workspaceRemoteDir, "run-b.txt"))}`,
|
||||||
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -372,31 +351,25 @@ describe("ssh env-lab fixture", () => {
|
||||||
|
|
||||||
await expect(readFile(path.join(localRepo, "run-a.txt"), "utf8")).resolves.toBe("from run a\n");
|
await expect(readFile(path.join(localRepo, "run-a.txt"), "utf8")).resolves.toBe("from run a\n");
|
||||||
await expect(readFile(path.join(localRepo, "run-b.txt"), "utf8")).resolves.toBe("from run b\n");
|
await expect(readFile(path.join(localRepo, "run-b.txt"), "utf8")).resolves.toBe("from run b\n");
|
||||||
});
|
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
|
||||||
|
|
||||||
it("preserves nested per-run files across sequential SSH restores with stale baselines", async () => {
|
it("preserves nested per-run files across sequential SSH restores with stale baselines", async () => {
|
||||||
const support = await getSshEnvLabSupport();
|
|
||||||
if (!support.supported) {
|
|
||||||
console.warn(
|
|
||||||
`Skipping sequential nested SSH restore test: ${support.reason ?? "unsupported environment"}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
||||||
cleanupDirs.push(rootDir);
|
cleanupDirs.push(rootDir);
|
||||||
const statePath = path.join(rootDir, "state.json");
|
const statePath = path.join(rootDir, "state.json");
|
||||||
const localRepo = path.join(rootDir, "local-workspace");
|
const localRepo = path.join(rootDir, "local-workspace");
|
||||||
|
|
||||||
await mkdir(localRepo, { recursive: true });
|
await mkdir(localRepo, { recursive: true });
|
||||||
await git(localRepo, ["init", "-b", "main"]);
|
await git(localRepo, ["init"]);
|
||||||
|
await git(localRepo, ["checkout", "-b", "main"]);
|
||||||
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
|
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
|
||||||
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
|
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
|
||||||
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
|
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
|
||||||
await git(localRepo, ["add", "tracked.txt"]);
|
await git(localRepo, ["add", "tracked.txt"]);
|
||||||
await git(localRepo, ["commit", "-m", "initial"]);
|
await git(localRepo, ["commit", "-m", "initial"]);
|
||||||
|
|
||||||
const started = await startSshEnvLabFixture({ statePath });
|
const started = await startSshEnvLabFixtureOrSkip(statePath, "sequential nested SSH restore test");
|
||||||
|
if (!started) return;
|
||||||
const config = await buildSshEnvLabFixtureConfig(started);
|
const config = await buildSshEnvLabFixtureConfig(started);
|
||||||
const spec = {
|
const spec = {
|
||||||
...config,
|
...config,
|
||||||
|
|
@ -418,12 +391,12 @@ describe("ssh env-lab fixture", () => {
|
||||||
|
|
||||||
await runSshCommand(
|
await runSshCommand(
|
||||||
config,
|
config,
|
||||||
`sh -lc 'mkdir -p ${JSON.stringify(path.posix.join(preparedA.workspaceRemoteDir, "manual-qa/environment-matrix/ssh"))} && printf "from run a\\n" > ${JSON.stringify(path.posix.join(preparedA.workspaceRemoteDir, "manual-qa/environment-matrix/ssh/claude_local.md"))}'`,
|
`mkdir -p ${JSON.stringify(path.posix.join(preparedA.workspaceRemoteDir, "manual-qa/environment-matrix/ssh"))} && printf "from run a\\n" > ${JSON.stringify(path.posix.join(preparedA.workspaceRemoteDir, "manual-qa/environment-matrix/ssh/claude_local.md"))}`,
|
||||||
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
||||||
);
|
);
|
||||||
await runSshCommand(
|
await runSshCommand(
|
||||||
config,
|
config,
|
||||||
`sh -lc 'mkdir -p ${JSON.stringify(path.posix.join(preparedB.workspaceRemoteDir, "manual-qa/environment-matrix/ssh"))} && printf "from run b\\n" > ${JSON.stringify(path.posix.join(preparedB.workspaceRemoteDir, "manual-qa/environment-matrix/ssh/codex_local.md"))}'`,
|
`mkdir -p ${JSON.stringify(path.posix.join(preparedB.workspaceRemoteDir, "manual-qa/environment-matrix/ssh"))} && printf "from run b\\n" > ${JSON.stringify(path.posix.join(preparedB.workspaceRemoteDir, "manual-qa/environment-matrix/ssh/codex_local.md"))}`,
|
||||||
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -434,31 +407,25 @@ describe("ssh env-lab fixture", () => {
|
||||||
.toBe("from run a\n");
|
.toBe("from run a\n");
|
||||||
await expect(readFile(path.join(localRepo, "manual-qa/environment-matrix/ssh/codex_local.md"), "utf8")).resolves
|
await expect(readFile(path.join(localRepo, "manual-qa/environment-matrix/ssh/codex_local.md"), "utf8")).resolves
|
||||||
.toBe("from run b\n");
|
.toBe("from run b\n");
|
||||||
});
|
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
|
||||||
|
|
||||||
it("round-trips remote git commits through the managed runtime restore path", async () => {
|
it("round-trips remote git commits through the managed runtime restore path", async () => {
|
||||||
const support = await getSshEnvLabSupport();
|
|
||||||
if (!support.supported) {
|
|
||||||
console.warn(
|
|
||||||
`Skipping managed-runtime SSH git round-trip test: ${support.reason ?? "unsupported environment"}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
||||||
cleanupDirs.push(rootDir);
|
cleanupDirs.push(rootDir);
|
||||||
const statePath = path.join(rootDir, "state.json");
|
const statePath = path.join(rootDir, "state.json");
|
||||||
const localRepo = path.join(rootDir, "local-workspace");
|
const localRepo = path.join(rootDir, "local-workspace");
|
||||||
|
|
||||||
await mkdir(localRepo, { recursive: true });
|
await mkdir(localRepo, { recursive: true });
|
||||||
await git(localRepo, ["init", "-b", "main"]);
|
await git(localRepo, ["init"]);
|
||||||
|
await git(localRepo, ["checkout", "-b", "main"]);
|
||||||
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
|
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
|
||||||
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
|
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
|
||||||
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
|
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
|
||||||
await git(localRepo, ["add", "tracked.txt"]);
|
await git(localRepo, ["add", "tracked.txt"]);
|
||||||
await git(localRepo, ["commit", "-m", "initial"]);
|
await git(localRepo, ["commit", "-m", "initial"]);
|
||||||
|
|
||||||
const started = await startSshEnvLabFixture({ statePath });
|
const started = await startSshEnvLabFixtureOrSkip(statePath, "managed-runtime SSH git round-trip test");
|
||||||
|
if (!started) return;
|
||||||
const config = await buildSshEnvLabFixtureConfig(started);
|
const config = await buildSshEnvLabFixtureConfig(started);
|
||||||
const spec = {
|
const spec = {
|
||||||
...config,
|
...config,
|
||||||
|
|
@ -474,7 +441,7 @@ describe("ssh env-lab fixture", () => {
|
||||||
|
|
||||||
await runSshCommand(
|
await runSshCommand(
|
||||||
config,
|
config,
|
||||||
`sh -lc 'cd ${JSON.stringify(prepared.workspaceRemoteDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && printf "committed\\n" > tracked.txt && git add tracked.txt && git commit -m "remote update" >/dev/null && printf "dirty remote\\n" > tracked.txt'`,
|
`cd ${JSON.stringify(prepared.workspaceRemoteDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && printf "committed\\n" > tracked.txt && git add tracked.txt && git commit -m "remote update" >/dev/null && printf "dirty remote\\n" > tracked.txt`,
|
||||||
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -482,31 +449,25 @@ describe("ssh env-lab fixture", () => {
|
||||||
|
|
||||||
expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toBe("remote update");
|
expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toBe("remote update");
|
||||||
await expect(readFile(path.join(localRepo, "tracked.txt"), "utf8")).resolves.toBe("dirty remote\n");
|
await expect(readFile(path.join(localRepo, "tracked.txt"), "utf8")).resolves.toBe("dirty remote\n");
|
||||||
});
|
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
|
||||||
|
|
||||||
it("merges concurrent remote commits through the managed runtime restore path", async () => {
|
it("merges concurrent remote commits through the managed runtime restore path", async () => {
|
||||||
const support = await getSshEnvLabSupport();
|
|
||||||
if (!support.supported) {
|
|
||||||
console.warn(
|
|
||||||
`Skipping concurrent managed-runtime SSH git merge test: ${support.reason ?? "unsupported environment"}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
||||||
cleanupDirs.push(rootDir);
|
cleanupDirs.push(rootDir);
|
||||||
const statePath = path.join(rootDir, "state.json");
|
const statePath = path.join(rootDir, "state.json");
|
||||||
const localRepo = path.join(rootDir, "local-workspace");
|
const localRepo = path.join(rootDir, "local-workspace");
|
||||||
|
|
||||||
await mkdir(localRepo, { recursive: true });
|
await mkdir(localRepo, { recursive: true });
|
||||||
await git(localRepo, ["init", "-b", "main"]);
|
await git(localRepo, ["init"]);
|
||||||
|
await git(localRepo, ["checkout", "-b", "main"]);
|
||||||
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
|
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
|
||||||
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
|
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
|
||||||
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
|
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
|
||||||
await git(localRepo, ["add", "tracked.txt"]);
|
await git(localRepo, ["add", "tracked.txt"]);
|
||||||
await git(localRepo, ["commit", "-m", "initial"]);
|
await git(localRepo, ["commit", "-m", "initial"]);
|
||||||
|
|
||||||
const started = await startSshEnvLabFixture({ statePath });
|
const started = await startSshEnvLabFixtureOrSkip(statePath, "concurrent managed-runtime SSH git merge test");
|
||||||
|
if (!started) return;
|
||||||
const config = await buildSshEnvLabFixtureConfig(started);
|
const config = await buildSshEnvLabFixtureConfig(started);
|
||||||
const spec = {
|
const spec = {
|
||||||
...config,
|
...config,
|
||||||
|
|
@ -528,12 +489,12 @@ describe("ssh env-lab fixture", () => {
|
||||||
|
|
||||||
await runSshCommand(
|
await runSshCommand(
|
||||||
config,
|
config,
|
||||||
`sh -lc 'cd ${JSON.stringify(preparedA.workspaceRemoteDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && printf "from run a\\n" > run-a.txt && git add run-a.txt && git commit -m "remote update a" >/dev/null'`,
|
`cd ${JSON.stringify(preparedA.workspaceRemoteDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && printf "from run a\\n" > run-a.txt && git add run-a.txt && git commit -m "remote update a" >/dev/null`,
|
||||||
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
||||||
);
|
);
|
||||||
await runSshCommand(
|
await runSshCommand(
|
||||||
config,
|
config,
|
||||||
`sh -lc 'cd ${JSON.stringify(preparedB.workspaceRemoteDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && printf "from run b\\n" > run-b.txt && git add run-b.txt && git commit -m "remote update b" >/dev/null'`,
|
`cd ${JSON.stringify(preparedB.workspaceRemoteDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && printf "from run b\\n" > run-b.txt && git add run-b.txt && git commit -m "remote update b" >/dev/null`,
|
||||||
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -549,5 +510,5 @@ describe("ssh env-lab fixture", () => {
|
||||||
const recentSubjects = await git(localRepo, ["log", "--pretty=%s", "-3"]);
|
const recentSubjects = await git(localRepo, ["log", "--pretty=%s", "-3"]);
|
||||||
expect(recentSubjects).toContain("remote update a");
|
expect(recentSubjects).toContain("remote update a");
|
||||||
expect(recentSubjects).toContain("remote update b");
|
expect(recentSubjects).toContain("remote update b");
|
||||||
});
|
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -54,13 +54,11 @@ export function createSshCommandManagedRuntimeRunner(input: {
|
||||||
? envEntries.map(([key, value]) => `export ${key}=${shellQuote(value)};`).join(" ") + " "
|
? envEntries.map(([key, value]) => `export ${key}=${shellQuote(value)};`).join(" ") + " "
|
||||||
: "";
|
: "";
|
||||||
const commandScript = command === "sh" || command === "bash"
|
const commandScript = command === "sh" || command === "bash"
|
||||||
? args[0] === "-lc" && typeof args[1] === "string"
|
? (args[0] === "-c" || args[0] === "-lc") && typeof args[1] === "string"
|
||||||
? `${exportPrefix}${args[1]}`
|
? `${exportPrefix}${args[1]}`
|
||||||
: `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`
|
: `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`
|
||||||
: `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`;
|
: `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`;
|
||||||
const remoteCommand = `${command === "bash" ? "bash" : "sh"} -lc ${
|
const remoteCommand = `cd ${shellQuote(cwd)} && ${commandScript}`;
|
||||||
shellQuote(`cd ${shellQuote(cwd)} && ${commandScript}`)
|
|
||||||
}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runSshCommand(input.spec, remoteCommand, {
|
const result = await runSshCommand(input.spec, remoteCommand, {
|
||||||
|
|
@ -333,7 +331,7 @@ async function commandExists(command: string): Promise<boolean> {
|
||||||
|
|
||||||
async function resolveCommandPath(command: string): Promise<string | null> {
|
async function resolveCommandPath(command: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const result = await execFileText("sh", ["-lc", `command -v ${shellQuote(command)}`], {
|
const result = await execFileText("sh", ["-c", `command -v ${shellQuote(command)}`], {
|
||||||
timeout: 5_000,
|
timeout: 5_000,
|
||||||
maxBuffer: 8 * 1024,
|
maxBuffer: 8 * 1024,
|
||||||
});
|
});
|
||||||
|
|
@ -421,7 +419,7 @@ async function runSshScript(
|
||||||
): Promise<SshCommandResult> {
|
): Promise<SshCommandResult> {
|
||||||
return await runSshCommand(
|
return await runSshCommand(
|
||||||
config,
|
config,
|
||||||
`sh -lc ${shellQuote(script)}`,
|
script,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -502,7 +500,7 @@ async function streamLocalFileToSsh(input: {
|
||||||
"-p",
|
"-p",
|
||||||
String(input.spec.port),
|
String(input.spec.port),
|
||||||
`${input.spec.username}@${input.spec.host}`,
|
`${input.spec.username}@${input.spec.host}`,
|
||||||
`sh -lc ${shellQuote(input.remoteScript)}`,
|
`sh -c ${shellQuote(input.remoteScript)}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
|
@ -551,7 +549,7 @@ async function streamSshToLocalFile(input: {
|
||||||
"-p",
|
"-p",
|
||||||
String(input.spec.port),
|
String(input.spec.port),
|
||||||
`${input.spec.username}@${input.spec.host}`,
|
`${input.spec.username}@${input.spec.host}`,
|
||||||
`sh -lc ${shellQuote(input.remoteScript)}`,
|
`sh -c ${shellQuote(input.remoteScript)}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
|
@ -889,6 +887,13 @@ async function isSshEnvLabFixtureProcess(state: Pick<SshEnvLabFixtureState, "pid
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSshEnvLabSupport(): Promise<SshEnvLabSupport> {
|
export async function getSshEnvLabSupport(): Promise<SshEnvLabSupport> {
|
||||||
|
if (process.platform === "darwin" && process.env.PAPERCLIP_ENABLE_DARWIN_SSH_ENV_LAB !== "1") {
|
||||||
|
return {
|
||||||
|
supported: false,
|
||||||
|
reason: "SSH env-lab fixture is disabled on macOS; set PAPERCLIP_ENABLE_DARWIN_SSH_ENV_LAB=1 to opt in.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
for (const command of ["ssh", "sshd", "ssh-keygen"]) {
|
for (const command of ["ssh", "sshd", "ssh-keygen"]) {
|
||||||
if (!(await commandExists(command))) {
|
if (!(await commandExists(command))) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -953,7 +958,7 @@ export async function runSshCommand(
|
||||||
"-p",
|
"-p",
|
||||||
String(config.port),
|
String(config.port),
|
||||||
`${config.username}@${config.host}`,
|
`${config.username}@${config.host}`,
|
||||||
`sh -lc ${shellQuote(remoteScript)}`,
|
`sh -c ${shellQuote(remoteScript)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return options.stdin != null
|
return options.stdin != null
|
||||||
|
|
@ -1008,7 +1013,7 @@ export async function buildSshSpawnTarget(input: {
|
||||||
"-p",
|
"-p",
|
||||||
String(input.spec.port),
|
String(input.spec.port),
|
||||||
`${input.spec.username}@${input.spec.host}`,
|
`${input.spec.username}@${input.spec.host}`,
|
||||||
`sh -lc ${shellQuote(remoteScript)}`,
|
`sh -c ${shellQuote(remoteScript)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -1031,7 +1036,7 @@ export async function syncDirectoryToSsh(input: {
|
||||||
"-p",
|
"-p",
|
||||||
String(input.spec.port),
|
String(input.spec.port),
|
||||||
`${input.spec.username}@${input.spec.host}`,
|
`${input.spec.username}@${input.spec.host}`,
|
||||||
`sh -lc ${shellQuote(`mkdir -p ${shellQuote(input.remoteDir)} && tar -xf - -C ${shellQuote(input.remoteDir)}`)}`,
|
`sh -c ${shellQuote(`mkdir -p ${shellQuote(input.remoteDir)} && tar -xf - -C ${shellQuote(input.remoteDir)}`)}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
|
@ -1127,7 +1132,7 @@ export async function syncDirectoryFromSsh(input: {
|
||||||
"-p",
|
"-p",
|
||||||
String(input.spec.port),
|
String(input.spec.port),
|
||||||
`${input.spec.username}@${input.spec.host}`,
|
`${input.spec.username}@${input.spec.host}`,
|
||||||
`sh -lc ${shellQuote(remoteTarScript)}`,
|
`sh -c ${shellQuote(remoteTarScript)}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -1329,7 +1334,7 @@ export async function ensureSshWorkspaceReady(
|
||||||
): Promise<{ remoteCwd: string }> {
|
): Promise<{ remoteCwd: string }> {
|
||||||
const result = await runSshCommand(
|
const result = await runSshCommand(
|
||||||
config,
|
config,
|
||||||
`sh -lc ${shellQuote(`mkdir -p ${shellQuote(config.remoteWorkspacePath)} && cd ${shellQuote(config.remoteWorkspacePath)} && pwd`)}`,
|
`mkdir -p ${shellQuote(config.remoteWorkspacePath)} && cd ${shellQuote(config.remoteWorkspacePath)} && pwd`,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
remoteCwd: result.stdout.trim(),
|
remoteCwd: result.stdout.trim(),
|
||||||
|
|
|
||||||
124
packages/db/src/migrations/0082_dry_vision.sql
Normal file
124
packages/db/src/migrations/0082_dry_vision.sql
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS "company_secret_bindings" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"secret_id" uuid NOT NULL,
|
||||||
|
"target_type" text NOT NULL,
|
||||||
|
"target_id" text NOT NULL,
|
||||||
|
"config_path" text NOT NULL,
|
||||||
|
"version_selector" text DEFAULT 'latest' NOT NULL,
|
||||||
|
"required" boolean DEFAULT true NOT NULL,
|
||||||
|
"label" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "secret_access_events" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"secret_id" uuid NOT NULL,
|
||||||
|
"version" integer,
|
||||||
|
"provider" text NOT NULL,
|
||||||
|
"actor_type" text NOT NULL,
|
||||||
|
"actor_id" text,
|
||||||
|
"consumer_type" text NOT NULL,
|
||||||
|
"consumer_id" text NOT NULL,
|
||||||
|
"config_path" text,
|
||||||
|
"issue_id" uuid,
|
||||||
|
"heartbeat_run_id" uuid,
|
||||||
|
"plugin_id" uuid,
|
||||||
|
"outcome" text NOT NULL,
|
||||||
|
"error_code" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "key" text;--> statement-breakpoint
|
||||||
|
UPDATE "company_secrets"
|
||||||
|
SET "key" = left(
|
||||||
|
regexp_replace(
|
||||||
|
regexp_replace(lower(trim(coalesce("name", "id"::text))), '[^a-z0-9_.-]+', '-', 'g'),
|
||||||
|
'^-+|-+$',
|
||||||
|
'',
|
||||||
|
'g'
|
||||||
|
),
|
||||||
|
120
|
||||||
|
)
|
||||||
|
WHERE "key" IS NULL;--> statement-breakpoint
|
||||||
|
UPDATE "company_secrets"
|
||||||
|
SET "key" = "id"::text
|
||||||
|
WHERE "key" IS NULL OR "key" = '';--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_secrets" ALTER COLUMN "key" SET NOT NULL;--> statement-breakpoint
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT
|
||||||
|
"id",
|
||||||
|
"key",
|
||||||
|
row_number() OVER (PARTITION BY "company_id", "key" ORDER BY "created_at", "id") AS rn
|
||||||
|
FROM "company_secrets"
|
||||||
|
)
|
||||||
|
UPDATE "company_secrets"
|
||||||
|
SET "key" = left(ranked."key", 100) || '-' || ranked.rn::text
|
||||||
|
FROM ranked
|
||||||
|
WHERE "company_secrets"."id" = ranked."id"
|
||||||
|
AND ranked.rn > 1;--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "status" text DEFAULT 'active' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "managed_mode" text DEFAULT 'paperclip_managed' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "provider_config_id" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "provider_metadata" jsonb;--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "last_resolved_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "last_rotated_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
UPDATE "company_secrets"
|
||||||
|
SET "last_rotated_at" = "updated_at"
|
||||||
|
WHERE "last_rotated_at" IS NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "deleted_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_secret_versions" ADD COLUMN IF NOT EXISTS "provider_version_ref" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_secret_versions" ADD COLUMN IF NOT EXISTS "status" text DEFAULT 'current' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_secret_versions" ADD COLUMN IF NOT EXISTS "fingerprint_sha256" text;--> statement-breakpoint
|
||||||
|
UPDATE "company_secret_versions"
|
||||||
|
SET "fingerprint_sha256" = "value_sha256"
|
||||||
|
WHERE "fingerprint_sha256" IS NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_secret_versions" ALTER COLUMN "fingerprint_sha256" SET NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_secret_versions" ADD COLUMN IF NOT EXISTS "rotation_job_id" text;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secret_bindings_company_id_companies_id_fk') THEN
|
||||||
|
ALTER TABLE "company_secret_bindings" ADD CONSTRAINT "company_secret_bindings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secret_bindings_secret_id_company_secrets_id_fk') THEN
|
||||||
|
ALTER TABLE "company_secret_bindings" ADD CONSTRAINT "company_secret_bindings_secret_id_company_secrets_id_fk" FOREIGN KEY ("secret_id") REFERENCES "public"."company_secrets"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'secret_access_events_company_id_companies_id_fk') THEN
|
||||||
|
ALTER TABLE "secret_access_events" ADD CONSTRAINT "secret_access_events_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'secret_access_events_secret_id_company_secrets_id_fk') THEN
|
||||||
|
ALTER TABLE "secret_access_events" ADD CONSTRAINT "secret_access_events_secret_id_company_secrets_id_fk" FOREIGN KEY ("secret_id") REFERENCES "public"."company_secrets"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'secret_access_events_issue_id_issues_id_fk') THEN
|
||||||
|
ALTER TABLE "secret_access_events" ADD CONSTRAINT "secret_access_events_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'secret_access_events_heartbeat_run_id_heartbeat_runs_id_fk') THEN
|
||||||
|
ALTER TABLE "secret_access_events" ADD CONSTRAINT "secret_access_events_heartbeat_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("heartbeat_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'secret_access_events_plugin_id_plugins_id_fk') THEN
|
||||||
|
ALTER TABLE "secret_access_events" ADD CONSTRAINT "secret_access_events_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "company_secret_bindings_company_idx" ON "company_secret_bindings" USING btree ("company_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "company_secret_bindings_secret_idx" ON "company_secret_bindings" USING btree ("secret_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "company_secret_bindings_target_idx" ON "company_secret_bindings" USING btree ("company_id","target_type","target_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "company_secret_bindings_target_path_uq" ON "company_secret_bindings" USING btree ("company_id","target_type","target_id","config_path");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "secret_access_events_company_created_idx" ON "secret_access_events" USING btree ("company_id","created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "secret_access_events_secret_created_idx" ON "secret_access_events" USING btree ("secret_id","created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "secret_access_events_consumer_idx" ON "secret_access_events" USING btree ("company_id","consumer_type","consumer_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "secret_access_events_run_idx" ON "secret_access_events" USING btree ("heartbeat_run_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "company_secret_versions_fingerprint_idx" ON "company_secret_versions" USING btree ("fingerprint_sha256");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "company_secrets_company_key_uq" ON "company_secrets" USING btree ("company_id","key");
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS "company_secret_provider_configs" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"provider" text NOT NULL,
|
||||||
|
"display_name" text NOT NULL,
|
||||||
|
"status" text DEFAULT 'ready' NOT NULL,
|
||||||
|
"is_default" boolean DEFAULT false NOT NULL,
|
||||||
|
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"health_status" text,
|
||||||
|
"health_checked_at" timestamp with time zone,
|
||||||
|
"health_message" text,
|
||||||
|
"health_details" jsonb,
|
||||||
|
"disabled_at" timestamp with time zone,
|
||||||
|
"created_by_agent_id" uuid,
|
||||||
|
"created_by_user_id" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secret_provider_configs_company_id_companies_id_fk') THEN
|
||||||
|
ALTER TABLE "company_secret_provider_configs" ADD CONSTRAINT "company_secret_provider_configs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secret_provider_configs_created_by_agent_id_agents_id_fk') THEN
|
||||||
|
ALTER TABLE "company_secret_provider_configs" ADD CONSTRAINT "company_secret_provider_configs_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "company_secrets"
|
||||||
|
SET "provider_config_id" = NULL
|
||||||
|
WHERE "provider_config_id" IS NOT NULL
|
||||||
|
AND "provider_config_id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_secrets" ALTER COLUMN "provider_config_id" TYPE uuid USING "provider_config_id"::uuid;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secrets_provider_config_id_company_secret_provider_configs_id_fk') THEN
|
||||||
|
ALTER TABLE "company_secrets" ADD CONSTRAINT "company_secrets_provider_config_id_company_secret_provider_configs_id_fk" FOREIGN KEY ("provider_config_id") REFERENCES "public"."company_secret_provider_configs"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "company_secret_provider_configs_company_idx" ON "company_secret_provider_configs" USING btree ("company_id");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "company_secret_provider_configs_company_provider_idx" ON "company_secret_provider_configs" USING btree ("company_id","provider");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "company_secret_provider_configs_default_uq" ON "company_secret_provider_configs" USING btree ("company_id","provider") WHERE "is_default" = true;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "company_secrets_provider_config_idx" ON "company_secrets" USING btree ("provider_config_id");
|
||||||
|
|
@ -575,6 +575,20 @@
|
||||||
"when": 1778067785040,
|
"when": 1778067785040,
|
||||||
"tag": "0081_optimal_dormammu",
|
"tag": "0081_optimal_dormammu",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 82,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778067785041,
|
||||||
|
"tag": "0082_dry_vision",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 83,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778074536410,
|
||||||
|
"tag": "0083_company_secret_provider_configs",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
packages/db/src/schema/company_secret_bindings.ts
Normal file
31
packages/db/src/schema/company_secret_bindings.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { boolean, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
||||||
|
import { companies } from "./companies.js";
|
||||||
|
import { companySecrets } from "./company_secrets.js";
|
||||||
|
|
||||||
|
export const companySecretBindings = pgTable(
|
||||||
|
"company_secret_bindings",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||||
|
secretId: uuid("secret_id").notNull().references(() => companySecrets.id, { onDelete: "cascade" }),
|
||||||
|
targetType: text("target_type").notNull(),
|
||||||
|
targetId: text("target_id").notNull(),
|
||||||
|
configPath: text("config_path").notNull(),
|
||||||
|
versionSelector: text("version_selector").notNull().default("latest"),
|
||||||
|
required: boolean("required").notNull().default(true),
|
||||||
|
label: text("label"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
companyIdx: index("company_secret_bindings_company_idx").on(table.companyId),
|
||||||
|
secretIdx: index("company_secret_bindings_secret_idx").on(table.secretId),
|
||||||
|
targetIdx: index("company_secret_bindings_target_idx").on(table.companyId, table.targetType, table.targetId),
|
||||||
|
targetPathUq: uniqueIndex("company_secret_bindings_target_path_uq").on(
|
||||||
|
table.companyId,
|
||||||
|
table.targetType,
|
||||||
|
table.targetId,
|
||||||
|
table.configPath,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
33
packages/db/src/schema/company_secret_provider_configs.ts
Normal file
33
packages/db/src/schema/company_secret_provider_configs.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex, boolean } from "drizzle-orm/pg-core";
|
||||||
|
import { companies } from "./companies.js";
|
||||||
|
import { agents } from "./agents.js";
|
||||||
|
|
||||||
|
export const companySecretProviderConfigs = pgTable(
|
||||||
|
"company_secret_provider_configs",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||||
|
provider: text("provider").notNull(),
|
||||||
|
displayName: text("display_name").notNull(),
|
||||||
|
status: text("status").notNull().default("ready"),
|
||||||
|
isDefault: boolean("is_default").notNull().default(false),
|
||||||
|
config: jsonb("config").$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
healthStatus: text("health_status"),
|
||||||
|
healthCheckedAt: timestamp("health_checked_at", { withTimezone: true }),
|
||||||
|
healthMessage: text("health_message"),
|
||||||
|
healthDetails: jsonb("health_details").$type<Record<string, unknown>>(),
|
||||||
|
disabledAt: timestamp("disabled_at", { withTimezone: true }),
|
||||||
|
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||||
|
createdByUserId: text("created_by_user_id"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
companyIdx: index("company_secret_provider_configs_company_idx").on(table.companyId),
|
||||||
|
companyProviderIdx: index("company_secret_provider_configs_company_provider_idx").on(table.companyId, table.provider),
|
||||||
|
companyDefaultProviderUq: uniqueIndex("company_secret_provider_configs_default_uq")
|
||||||
|
.on(table.companyId, table.provider)
|
||||||
|
.where(sql`${table.isDefault} = true`),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -10,6 +10,10 @@ export const companySecretVersions = pgTable(
|
||||||
version: integer("version").notNull(),
|
version: integer("version").notNull(),
|
||||||
material: jsonb("material").$type<Record<string, unknown>>().notNull(),
|
material: jsonb("material").$type<Record<string, unknown>>().notNull(),
|
||||||
valueSha256: text("value_sha256").notNull(),
|
valueSha256: text("value_sha256").notNull(),
|
||||||
|
providerVersionRef: text("provider_version_ref"),
|
||||||
|
status: text("status").notNull().default("current"),
|
||||||
|
fingerprintSha256: text("fingerprint_sha256").notNull(),
|
||||||
|
rotationJobId: text("rotation_job_id"),
|
||||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||||
createdByUserId: text("created_by_user_id"),
|
createdByUserId: text("created_by_user_id"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
|
@ -18,6 +22,7 @@ export const companySecretVersions = pgTable(
|
||||||
(table) => ({
|
(table) => ({
|
||||||
secretIdx: index("company_secret_versions_secret_idx").on(table.secretId, table.createdAt),
|
secretIdx: index("company_secret_versions_secret_idx").on(table.secretId, table.createdAt),
|
||||||
valueHashIdx: index("company_secret_versions_value_sha256_idx").on(table.valueSha256),
|
valueHashIdx: index("company_secret_versions_value_sha256_idx").on(table.valueSha256),
|
||||||
|
fingerprintIdx: index("company_secret_versions_fingerprint_idx").on(table.fingerprintSha256),
|
||||||
secretVersionUq: uniqueIndex("company_secret_versions_secret_version_uq").on(table.secretId, table.version),
|
secretVersionUq: uniqueIndex("company_secret_versions_secret_version_uq").on(table.secretId, table.version),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,26 @@
|
||||||
import { pgTable, uuid, text, timestamp, integer, index, uniqueIndex } from "drizzle-orm/pg-core";
|
import { pgTable, uuid, text, timestamp, integer, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||||
import { companies } from "./companies.js";
|
import { companies } from "./companies.js";
|
||||||
import { agents } from "./agents.js";
|
import { agents } from "./agents.js";
|
||||||
|
import { companySecretProviderConfigs } from "./company_secret_provider_configs.js";
|
||||||
|
|
||||||
export const companySecrets = pgTable(
|
export const companySecrets = pgTable(
|
||||||
"company_secrets",
|
"company_secrets",
|
||||||
{
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||||
|
key: text("key").notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
provider: text("provider").notNull().default("local_encrypted"),
|
provider: text("provider").notNull().default("local_encrypted"),
|
||||||
|
status: text("status").notNull().default("active"),
|
||||||
|
managedMode: text("managed_mode").notNull().default("paperclip_managed"),
|
||||||
externalRef: text("external_ref"),
|
externalRef: text("external_ref"),
|
||||||
|
providerConfigId: uuid("provider_config_id").references(() => companySecretProviderConfigs.id, { onDelete: "set null" }),
|
||||||
|
providerMetadata: jsonb("provider_metadata").$type<Record<string, unknown>>(),
|
||||||
latestVersion: integer("latest_version").notNull().default(1),
|
latestVersion: integer("latest_version").notNull().default(1),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
|
lastResolvedAt: timestamp("last_resolved_at", { withTimezone: true }),
|
||||||
|
lastRotatedAt: timestamp("last_rotated_at", { withTimezone: true }),
|
||||||
|
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||||
createdByUserId: text("created_by_user_id"),
|
createdByUserId: text("created_by_user_id"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
|
@ -20,6 +29,8 @@ export const companySecrets = pgTable(
|
||||||
(table) => ({
|
(table) => ({
|
||||||
companyIdx: index("company_secrets_company_idx").on(table.companyId),
|
companyIdx: index("company_secrets_company_idx").on(table.companyId),
|
||||||
companyProviderIdx: index("company_secrets_company_provider_idx").on(table.companyId, table.provider),
|
companyProviderIdx: index("company_secrets_company_provider_idx").on(table.companyId, table.provider),
|
||||||
|
providerConfigIdx: index("company_secrets_provider_config_idx").on(table.providerConfigId),
|
||||||
companyNameUq: uniqueIndex("company_secrets_company_name_uq").on(table.companyId, table.name),
|
companyNameUq: uniqueIndex("company_secrets_company_name_uq").on(table.companyId, table.name),
|
||||||
|
companyKeyUq: uniqueIndex("company_secrets_company_key_uq").on(table.companyId, table.key),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,11 @@ export { financeEvents } from "./finance_events.js";
|
||||||
export { approvals } from "./approvals.js";
|
export { approvals } from "./approvals.js";
|
||||||
export { approvalComments } from "./approval_comments.js";
|
export { approvalComments } from "./approval_comments.js";
|
||||||
export { activityLog } from "./activity_log.js";
|
export { activityLog } from "./activity_log.js";
|
||||||
|
export { companySecretProviderConfigs } from "./company_secret_provider_configs.js";
|
||||||
export { companySecrets } from "./company_secrets.js";
|
export { companySecrets } from "./company_secrets.js";
|
||||||
export { companySecretVersions } from "./company_secret_versions.js";
|
export { companySecretVersions } from "./company_secret_versions.js";
|
||||||
|
export { companySecretBindings } from "./company_secret_bindings.js";
|
||||||
|
export { secretAccessEvents } from "./secret_access_events.js";
|
||||||
export { companySkills } from "./company_skills.js";
|
export { companySkills } from "./company_skills.js";
|
||||||
export { plugins } from "./plugins.js";
|
export { plugins } from "./plugins.js";
|
||||||
export { pluginConfig } from "./plugin_config.js";
|
export { pluginConfig } from "./plugin_config.js";
|
||||||
|
|
|
||||||
34
packages/db/src/schema/secret_access_events.ts
Normal file
34
packages/db/src/schema/secret_access_events.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { index, integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||||
|
import { companies } from "./companies.js";
|
||||||
|
import { companySecrets } from "./company_secrets.js";
|
||||||
|
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||||
|
import { issues } from "./issues.js";
|
||||||
|
import { plugins } from "./plugins.js";
|
||||||
|
|
||||||
|
export const secretAccessEvents = pgTable(
|
||||||
|
"secret_access_events",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||||
|
secretId: uuid("secret_id").notNull().references(() => companySecrets.id, { onDelete: "cascade" }),
|
||||||
|
version: integer("version"),
|
||||||
|
provider: text("provider").notNull(),
|
||||||
|
actorType: text("actor_type").notNull(),
|
||||||
|
actorId: text("actor_id"),
|
||||||
|
consumerType: text("consumer_type").notNull(),
|
||||||
|
consumerId: text("consumer_id").notNull(),
|
||||||
|
configPath: text("config_path"),
|
||||||
|
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||||
|
heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||||
|
pluginId: uuid("plugin_id").references(() => plugins.id, { onDelete: "set null" }),
|
||||||
|
outcome: text("outcome").notNull(),
|
||||||
|
errorCode: text("error_code"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
companyCreatedIdx: index("secret_access_events_company_created_idx").on(table.companyId, table.createdAt),
|
||||||
|
secretCreatedIdx: index("secret_access_events_secret_created_idx").on(table.secretId, table.createdAt),
|
||||||
|
consumerIdx: index("secret_access_events_consumer_idx").on(table.companyId, table.consumerType, table.consumerId),
|
||||||
|
runIdx: index("secret_access_events_run_idx").on(table.heartbeatRunId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -11,6 +11,7 @@ export const API = {
|
||||||
goals: `${API_PREFIX}/goals`,
|
goals: `${API_PREFIX}/goals`,
|
||||||
approvals: `${API_PREFIX}/approvals`,
|
approvals: `${API_PREFIX}/approvals`,
|
||||||
secrets: `${API_PREFIX}/secrets`,
|
secrets: `${API_PREFIX}/secrets`,
|
||||||
|
secretProviderConfigs: `${API_PREFIX}/secret-provider-configs`,
|
||||||
costs: `${API_PREFIX}/costs`,
|
costs: `${API_PREFIX}/costs`,
|
||||||
activity: `${API_PREFIX}/activity`,
|
activity: `${API_PREFIX}/activity`,
|
||||||
dashboard: `${API_PREFIX}/dashboard`,
|
dashboard: `${API_PREFIX}/dashboard`,
|
||||||
|
|
|
||||||
|
|
@ -395,6 +395,54 @@ export const SECRET_PROVIDERS = [
|
||||||
] as const;
|
] as const;
|
||||||
export type SecretProvider = (typeof SECRET_PROVIDERS)[number];
|
export type SecretProvider = (typeof SECRET_PROVIDERS)[number];
|
||||||
|
|
||||||
|
export const SECRET_PROVIDER_CONFIG_STATUSES = [
|
||||||
|
"ready",
|
||||||
|
"warning",
|
||||||
|
"coming_soon",
|
||||||
|
"disabled",
|
||||||
|
] as const;
|
||||||
|
export type SecretProviderConfigStatus = (typeof SECRET_PROVIDER_CONFIG_STATUSES)[number];
|
||||||
|
|
||||||
|
export const SECRET_PROVIDER_CONFIG_HEALTH_STATUSES = [
|
||||||
|
"ready",
|
||||||
|
"warning",
|
||||||
|
"error",
|
||||||
|
"coming_soon",
|
||||||
|
"disabled",
|
||||||
|
] as const;
|
||||||
|
export type SecretProviderConfigHealthStatus =
|
||||||
|
(typeof SECRET_PROVIDER_CONFIG_HEALTH_STATUSES)[number];
|
||||||
|
|
||||||
|
export const SECRET_STATUSES = ["active", "disabled", "archived", "deleted"] as const;
|
||||||
|
export type SecretStatus = (typeof SECRET_STATUSES)[number];
|
||||||
|
|
||||||
|
export const SECRET_MANAGED_MODES = ["paperclip_managed", "external_reference"] as const;
|
||||||
|
export type SecretManagedMode = (typeof SECRET_MANAGED_MODES)[number];
|
||||||
|
|
||||||
|
export const SECRET_VERSION_STATUSES = [
|
||||||
|
"current",
|
||||||
|
"previous",
|
||||||
|
"disabled",
|
||||||
|
"destroyed",
|
||||||
|
"failed",
|
||||||
|
] as const;
|
||||||
|
export type SecretVersionStatus = (typeof SECRET_VERSION_STATUSES)[number];
|
||||||
|
|
||||||
|
export const SECRET_BINDING_TARGET_TYPES = [
|
||||||
|
"agent",
|
||||||
|
"project",
|
||||||
|
"environment",
|
||||||
|
"routine",
|
||||||
|
"plugin",
|
||||||
|
"issue",
|
||||||
|
"run",
|
||||||
|
"system",
|
||||||
|
] as const;
|
||||||
|
export type SecretBindingTargetType = (typeof SECRET_BINDING_TARGET_TYPES)[number];
|
||||||
|
|
||||||
|
export const SECRET_ACCESS_OUTCOMES = ["success", "failure"] as const;
|
||||||
|
export type SecretAccessOutcome = (typeof SECRET_ACCESS_OUTCOMES)[number];
|
||||||
|
|
||||||
export const STORAGE_PROVIDERS = ["local_disk", "s3"] as const;
|
export const STORAGE_PROVIDERS = ["local_disk", "s3"] as const;
|
||||||
export type StorageProvider = (typeof STORAGE_PROVIDERS)[number];
|
export type StorageProvider = (typeof STORAGE_PROVIDERS)[number];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,8 @@ export {
|
||||||
APPROVAL_TYPES,
|
APPROVAL_TYPES,
|
||||||
APPROVAL_STATUSES,
|
APPROVAL_STATUSES,
|
||||||
SECRET_PROVIDERS,
|
SECRET_PROVIDERS,
|
||||||
|
SECRET_PROVIDER_CONFIG_STATUSES,
|
||||||
|
SECRET_PROVIDER_CONFIG_HEALTH_STATUSES,
|
||||||
STORAGE_PROVIDERS,
|
STORAGE_PROVIDERS,
|
||||||
BILLING_TYPES,
|
BILLING_TYPES,
|
||||||
FINANCE_EVENT_KINDS,
|
FINANCE_EVENT_KINDS,
|
||||||
|
|
@ -182,6 +184,8 @@ export {
|
||||||
type ApprovalType,
|
type ApprovalType,
|
||||||
type ApprovalStatus,
|
type ApprovalStatus,
|
||||||
type SecretProvider,
|
type SecretProvider,
|
||||||
|
type SecretProviderConfigStatus,
|
||||||
|
type SecretProviderConfigHealthStatus,
|
||||||
type StorageProvider,
|
type StorageProvider,
|
||||||
type BillingType,
|
type BillingType,
|
||||||
type FinanceEventKind,
|
type FinanceEventKind,
|
||||||
|
|
@ -530,7 +534,29 @@ export type {
|
||||||
EnvBinding,
|
EnvBinding,
|
||||||
AgentEnvConfig,
|
AgentEnvConfig,
|
||||||
CompanySecret,
|
CompanySecret,
|
||||||
|
CompanySecretProviderConfig,
|
||||||
|
SecretProviderConfigPayload,
|
||||||
|
SecretProviderConfigHealthDetails,
|
||||||
|
SecretProviderConfigHealthResponse,
|
||||||
|
CompanySecretBinding,
|
||||||
|
CompanySecretBindingTarget,
|
||||||
|
CompanySecretUsageBinding,
|
||||||
|
CompanySecretVersion,
|
||||||
|
SecretAccessEvent,
|
||||||
|
RemoteSecretImportCandidate,
|
||||||
|
RemoteSecretImportCandidateStatus,
|
||||||
|
RemoteSecretImportConflict,
|
||||||
|
RemoteSecretImportPreviewResult,
|
||||||
|
RemoteSecretImportResult,
|
||||||
|
RemoteSecretImportRowResult,
|
||||||
|
RemoteSecretImportRowStatus,
|
||||||
|
SecretAccessOutcome,
|
||||||
|
SecretBindingTargetType,
|
||||||
|
SecretManagedMode,
|
||||||
SecretProviderDescriptor,
|
SecretProviderDescriptor,
|
||||||
|
SecretStatus,
|
||||||
|
SecretVersionSelector,
|
||||||
|
SecretVersionStatus,
|
||||||
Routine,
|
Routine,
|
||||||
RoutineManagedByPlugin,
|
RoutineManagedByPlugin,
|
||||||
RoutineVariable,
|
RoutineVariable,
|
||||||
|
|
@ -826,7 +852,19 @@ export {
|
||||||
envBindingSchema,
|
envBindingSchema,
|
||||||
envConfigSchema,
|
envConfigSchema,
|
||||||
createSecretSchema,
|
createSecretSchema,
|
||||||
|
createSecretProviderConfigSchema,
|
||||||
|
updateSecretProviderConfigSchema,
|
||||||
|
remoteSecretImportPreviewSchema,
|
||||||
|
remoteSecretImportSchema,
|
||||||
|
remoteSecretImportSelectionSchema,
|
||||||
|
localEncryptedProviderConfigSchema,
|
||||||
|
awsSecretsManagerProviderConfigSchema,
|
||||||
|
gcpSecretManagerProviderConfigSchema,
|
||||||
|
vaultProviderConfigSchema,
|
||||||
|
secretProviderConfigPayloadSchema,
|
||||||
|
createSecretBindingSchema,
|
||||||
rotateSecretSchema,
|
rotateSecretSchema,
|
||||||
|
secretBindingTargetSchema,
|
||||||
updateSecretSchema,
|
updateSecretSchema,
|
||||||
createRoutineSchema,
|
createRoutineSchema,
|
||||||
updateRoutineSchema,
|
updateRoutineSchema,
|
||||||
|
|
@ -840,6 +878,11 @@ export {
|
||||||
routineRevisionSnapshotV1Schema,
|
routineRevisionSnapshotV1Schema,
|
||||||
routineRevisionSnapshotSchema,
|
routineRevisionSnapshotSchema,
|
||||||
type CreateSecret,
|
type CreateSecret,
|
||||||
|
type CreateSecretProviderConfig,
|
||||||
|
type UpdateSecretProviderConfig,
|
||||||
|
type RemoteSecretImportPreview,
|
||||||
|
type RemoteSecretImport,
|
||||||
|
type RemoteSecretImportSelection,
|
||||||
type RotateSecret,
|
type RotateSecret,
|
||||||
type UpdateSecret,
|
type UpdateSecret,
|
||||||
type CreateRoutine,
|
type CreateRoutine,
|
||||||
|
|
|
||||||
|
|
@ -244,7 +244,28 @@ export type {
|
||||||
EnvBinding,
|
EnvBinding,
|
||||||
AgentEnvConfig,
|
AgentEnvConfig,
|
||||||
CompanySecret,
|
CompanySecret,
|
||||||
|
CompanySecretProviderConfig,
|
||||||
|
SecretProviderConfigPayload,
|
||||||
|
SecretProviderConfigHealthDetails,
|
||||||
|
SecretProviderConfigHealthResponse,
|
||||||
|
CompanySecretBinding,
|
||||||
|
CompanySecretBindingTarget,
|
||||||
|
CompanySecretUsageBinding,
|
||||||
|
CompanySecretVersion,
|
||||||
|
SecretAccessEvent,
|
||||||
|
RemoteSecretImportCandidate,
|
||||||
|
RemoteSecretImportCandidateStatus,
|
||||||
|
RemoteSecretImportConflict,
|
||||||
|
RemoteSecretImportPreviewResult,
|
||||||
|
RemoteSecretImportResult,
|
||||||
|
RemoteSecretImportRowResult,
|
||||||
|
RemoteSecretImportRowStatus,
|
||||||
|
SecretAccessOutcome,
|
||||||
|
SecretBindingTargetType,
|
||||||
|
SecretManagedMode,
|
||||||
SecretProviderDescriptor,
|
SecretProviderDescriptor,
|
||||||
|
SecretStatus,
|
||||||
|
SecretVersionStatus,
|
||||||
} from "./secrets.js";
|
} from "./secrets.js";
|
||||||
export type {
|
export type {
|
||||||
Routine,
|
Routine,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,24 @@
|
||||||
export type SecretProvider =
|
import type {
|
||||||
| "local_encrypted"
|
SecretAccessOutcome,
|
||||||
| "aws_secrets_manager"
|
SecretBindingTargetType,
|
||||||
| "gcp_secret_manager"
|
SecretManagedMode,
|
||||||
| "vault";
|
SecretProvider,
|
||||||
|
SecretProviderConfigHealthStatus,
|
||||||
|
SecretProviderConfigStatus,
|
||||||
|
SecretStatus,
|
||||||
|
SecretVersionStatus,
|
||||||
|
} from "../constants.js";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
SecretAccessOutcome,
|
||||||
|
SecretBindingTargetType,
|
||||||
|
SecretManagedMode,
|
||||||
|
SecretProvider,
|
||||||
|
SecretProviderConfigHealthStatus,
|
||||||
|
SecretProviderConfigStatus,
|
||||||
|
SecretStatus,
|
||||||
|
SecretVersionStatus,
|
||||||
|
};
|
||||||
|
|
||||||
export type SecretVersionSelector = number | "latest";
|
export type SecretVersionSelector = number | "latest";
|
||||||
|
|
||||||
|
|
@ -25,13 +41,22 @@ export type AgentEnvConfig = Record<string, EnvBinding>;
|
||||||
export interface CompanySecret {
|
export interface CompanySecret {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
provider: SecretProvider;
|
provider: SecretProvider;
|
||||||
|
status: SecretStatus;
|
||||||
|
managedMode: SecretManagedMode;
|
||||||
externalRef: string | null;
|
externalRef: string | null;
|
||||||
|
providerConfigId: string | null;
|
||||||
|
providerMetadata: Record<string, unknown> | null;
|
||||||
latestVersion: number;
|
latestVersion: number;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
lastResolvedAt: Date | null;
|
||||||
|
lastRotatedAt: Date | null;
|
||||||
|
deletedAt: Date | null;
|
||||||
createdByAgentId: string | null;
|
createdByAgentId: string | null;
|
||||||
createdByUserId: string | null;
|
createdByUserId: string | null;
|
||||||
|
referenceCount?: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
@ -40,4 +65,180 @@ export interface SecretProviderDescriptor {
|
||||||
id: SecretProvider;
|
id: SecretProvider;
|
||||||
label: string;
|
label: string;
|
||||||
requiresExternalRef: boolean;
|
requiresExternalRef: boolean;
|
||||||
|
supportsManagedValues?: boolean;
|
||||||
|
supportsExternalReferences?: boolean;
|
||||||
|
configured?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalEncryptedProviderConfig {
|
||||||
|
backupReminderAcknowledged?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AwsSecretsManagerProviderConfig {
|
||||||
|
region: string;
|
||||||
|
namespace?: string | null;
|
||||||
|
secretNamePrefix?: string | null;
|
||||||
|
kmsKeyId?: string | null;
|
||||||
|
ownerTag?: string | null;
|
||||||
|
environmentTag?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GcpSecretManagerProviderConfig {
|
||||||
|
projectId?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
namespace?: string | null;
|
||||||
|
secretNamePrefix?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VaultProviderConfig {
|
||||||
|
address?: string | null;
|
||||||
|
namespace?: string | null;
|
||||||
|
mountPath?: string | null;
|
||||||
|
secretPathPrefix?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SecretProviderConfigPayload =
|
||||||
|
| LocalEncryptedProviderConfig
|
||||||
|
| AwsSecretsManagerProviderConfig
|
||||||
|
| GcpSecretManagerProviderConfig
|
||||||
|
| VaultProviderConfig;
|
||||||
|
|
||||||
|
export interface SecretProviderConfigHealthDetails {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
missingFields?: string[];
|
||||||
|
guidance?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanySecretProviderConfig {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
provider: SecretProvider;
|
||||||
|
displayName: string;
|
||||||
|
status: SecretProviderConfigStatus;
|
||||||
|
isDefault: boolean;
|
||||||
|
config: SecretProviderConfigPayload;
|
||||||
|
healthStatus: SecretProviderConfigHealthStatus | null;
|
||||||
|
healthCheckedAt: Date | null;
|
||||||
|
healthMessage: string | null;
|
||||||
|
healthDetails: SecretProviderConfigHealthDetails | null;
|
||||||
|
disabledAt: Date | null;
|
||||||
|
createdByAgentId: string | null;
|
||||||
|
createdByUserId: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecretProviderConfigHealthResponse {
|
||||||
|
configId: string;
|
||||||
|
provider: SecretProvider;
|
||||||
|
status: SecretProviderConfigHealthStatus;
|
||||||
|
message: string;
|
||||||
|
details: SecretProviderConfigHealthDetails;
|
||||||
|
checkedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanySecretVersion {
|
||||||
|
id: string;
|
||||||
|
secretId: string;
|
||||||
|
version: number;
|
||||||
|
providerVersionRef: string | null;
|
||||||
|
status: SecretVersionStatus;
|
||||||
|
fingerprintSha256: string;
|
||||||
|
rotationJobId: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
revokedAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanySecretBinding {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
secretId: string;
|
||||||
|
targetType: SecretBindingTargetType;
|
||||||
|
targetId: string;
|
||||||
|
configPath: string;
|
||||||
|
versionSelector: SecretVersionSelector;
|
||||||
|
required: boolean;
|
||||||
|
label: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanySecretBindingTarget {
|
||||||
|
type: SecretBindingTargetType;
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
href: string | null;
|
||||||
|
status: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanySecretUsageBinding extends CompanySecretBinding {
|
||||||
|
target: CompanySecretBindingTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecretAccessEvent {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
secretId: string;
|
||||||
|
version: number | null;
|
||||||
|
provider: SecretProvider;
|
||||||
|
actorType: "agent" | "user" | "system" | "plugin";
|
||||||
|
actorId: string | null;
|
||||||
|
consumerType: SecretBindingTargetType;
|
||||||
|
consumerId: string;
|
||||||
|
configPath: string | null;
|
||||||
|
issueId: string | null;
|
||||||
|
heartbeatRunId: string | null;
|
||||||
|
pluginId: string | null;
|
||||||
|
outcome: SecretAccessOutcome;
|
||||||
|
errorCode: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RemoteSecretImportCandidateStatus = "ready" | "duplicate" | "conflict";
|
||||||
|
|
||||||
|
export interface RemoteSecretImportConflict {
|
||||||
|
type: "exact_reference" | "name" | "key" | "provider_guardrail";
|
||||||
|
message: string;
|
||||||
|
existingSecretId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteSecretImportCandidate {
|
||||||
|
externalRef: string;
|
||||||
|
remoteName: string;
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
providerVersionRef: string | null;
|
||||||
|
providerMetadata: Record<string, unknown> | null;
|
||||||
|
status: RemoteSecretImportCandidateStatus;
|
||||||
|
importable: boolean;
|
||||||
|
conflicts: RemoteSecretImportConflict[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteSecretImportPreviewResult {
|
||||||
|
providerConfigId: string;
|
||||||
|
provider: SecretProvider;
|
||||||
|
nextToken: string | null;
|
||||||
|
candidates: RemoteSecretImportCandidate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RemoteSecretImportRowStatus = "imported" | "skipped" | "error";
|
||||||
|
|
||||||
|
export interface RemoteSecretImportRowResult {
|
||||||
|
externalRef: string;
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
status: RemoteSecretImportRowStatus;
|
||||||
|
reason: string | null;
|
||||||
|
secretId: string | null;
|
||||||
|
conflicts: RemoteSecretImportConflict[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteSecretImportResult {
|
||||||
|
providerConfigId: string;
|
||||||
|
provider: SecretProvider;
|
||||||
|
importedCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
errorCount: number;
|
||||||
|
results: RemoteSecretImportRowResult[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -282,9 +282,27 @@ export {
|
||||||
envBindingSchema,
|
envBindingSchema,
|
||||||
envConfigSchema,
|
envConfigSchema,
|
||||||
createSecretSchema,
|
createSecretSchema,
|
||||||
|
createSecretProviderConfigSchema,
|
||||||
|
updateSecretProviderConfigSchema,
|
||||||
|
remoteSecretImportPreviewSchema,
|
||||||
|
remoteSecretImportSchema,
|
||||||
|
remoteSecretImportSelectionSchema,
|
||||||
|
localEncryptedProviderConfigSchema,
|
||||||
|
awsSecretsManagerProviderConfigSchema,
|
||||||
|
gcpSecretManagerProviderConfigSchema,
|
||||||
|
vaultProviderConfigSchema,
|
||||||
|
secretProviderConfigPayloadSchema,
|
||||||
|
createSecretBindingSchema,
|
||||||
rotateSecretSchema,
|
rotateSecretSchema,
|
||||||
|
secretBindingTargetSchema,
|
||||||
updateSecretSchema,
|
updateSecretSchema,
|
||||||
|
type CreateSecretBinding,
|
||||||
type CreateSecret,
|
type CreateSecret,
|
||||||
|
type CreateSecretProviderConfig,
|
||||||
|
type UpdateSecretProviderConfig,
|
||||||
|
type RemoteSecretImportPreview,
|
||||||
|
type RemoteSecretImport,
|
||||||
|
type RemoteSecretImportSelection,
|
||||||
type RotateSecret,
|
type RotateSecret,
|
||||||
type UpdateSecret,
|
type UpdateSecret,
|
||||||
} from "./secret.js";
|
} from "./secret.js";
|
||||||
|
|
|
||||||
157
packages/shared/src/validators/secret.test.ts
Normal file
157
packages/shared/src/validators/secret.test.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
createSecretProviderConfigSchema,
|
||||||
|
createSecretSchema,
|
||||||
|
remoteSecretImportPreviewSchema,
|
||||||
|
remoteSecretImportSchema,
|
||||||
|
secretProviderConfigPayloadSchema,
|
||||||
|
updateSecretProviderConfigSchema,
|
||||||
|
} from "./secret.js";
|
||||||
|
|
||||||
|
describe("secret validators", () => {
|
||||||
|
it("rejects externalRef on managed secrets", () => {
|
||||||
|
expect(() =>
|
||||||
|
createSecretSchema.parse({
|
||||||
|
name: "OpenAI API Key",
|
||||||
|
managedMode: "paperclip_managed",
|
||||||
|
value: "secret-value",
|
||||||
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/other",
|
||||||
|
}),
|
||||||
|
).toThrow(/Managed secrets cannot set externalRef/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows externalRef on external reference secrets", () => {
|
||||||
|
const parsed = createSecretSchema.parse({
|
||||||
|
name: "Shared Secret",
|
||||||
|
managedMode: "external_reference",
|
||||||
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/other",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.externalRef).toContain(":secret:shared/other");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts non-sensitive local and AWS provider vault metadata", () => {
|
||||||
|
expect(() =>
|
||||||
|
createSecretProviderConfigSchema.parse({
|
||||||
|
provider: "local_encrypted",
|
||||||
|
displayName: "Local",
|
||||||
|
config: { backupReminderAcknowledged: true },
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
createSecretProviderConfigSchema.parse({
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
displayName: "AWS",
|
||||||
|
config: {
|
||||||
|
region: "us-east-1",
|
||||||
|
namespace: "production",
|
||||||
|
secretNamePrefix: "paperclip",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts origin-only Vault provider vault addresses", () => {
|
||||||
|
expect(() =>
|
||||||
|
createSecretProviderConfigSchema.parse({
|
||||||
|
provider: "vault",
|
||||||
|
displayName: "Vault draft",
|
||||||
|
config: { address: " https://vault.example.com/ " },
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
|
||||||
|
const parsed = secretProviderConfigPayloadSchema.parse({
|
||||||
|
provider: "vault",
|
||||||
|
config: { address: " https://vault.example.com/ " },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.provider).toBe("vault");
|
||||||
|
if (parsed.provider !== "vault") throw new Error("Expected vault provider payload");
|
||||||
|
expect(parsed.config.address).toBe("https://vault.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"https://user:pass@vault.example.com",
|
||||||
|
"https://vault.example.com?token=hvs.x",
|
||||||
|
"https://vault.example.com#token=hvs.x",
|
||||||
|
"https://vault.example.com/v1/secret",
|
||||||
|
])("rejects credential-bearing or non-origin Vault addresses: %s", (address) => {
|
||||||
|
expect(() =>
|
||||||
|
createSecretProviderConfigSchema.parse({
|
||||||
|
provider: "vault",
|
||||||
|
displayName: "Vault draft",
|
||||||
|
config: { address },
|
||||||
|
}),
|
||||||
|
).toThrow(/origin-only HTTP\(S\) URL/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsafe Vault addresses in provider payload validation used by updates", () => {
|
||||||
|
expect(() =>
|
||||||
|
secretProviderConfigPayloadSchema.parse({
|
||||||
|
provider: "vault",
|
||||||
|
config: { address: "https://vault.example.com?client_token=hvs.x" },
|
||||||
|
}),
|
||||||
|
).toThrow(/origin-only HTTP\(S\) URL/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsafe Vault addresses in provider vault update payloads", () => {
|
||||||
|
expect(() =>
|
||||||
|
updateSecretProviderConfigSchema.parse({
|
||||||
|
config: { address: "https://vault.example.com#token=hvs.x" },
|
||||||
|
}),
|
||||||
|
).toThrow(/origin-only HTTP\(S\) URL/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates AWS remote import preview and import payloads", () => {
|
||||||
|
expect(
|
||||||
|
remoteSecretImportPreviewSchema.parse({
|
||||||
|
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
query: "openai",
|
||||||
|
pageSize: 50,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
query: "openai",
|
||||||
|
pageSize: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
remoteSecretImportSchema.parse({
|
||||||
|
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 ",
|
||||||
|
providerMetadata: { name: "prod/openai" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
secrets: [
|
||||||
|
expect.objectContaining({
|
||||||
|
key: "OPENAI_API_KEY",
|
||||||
|
description: "Operator-entered Paperclip description",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps AWS remote import paging and row counts", () => {
|
||||||
|
expect(() =>
|
||||||
|
remoteSecretImportPreviewSchema.parse({
|
||||||
|
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
pageSize: 101,
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
remoteSecretImportSchema.parse({
|
||||||
|
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
secrets: [],
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { SECRET_PROVIDERS } from "../constants.js";
|
import {
|
||||||
|
SECRET_BINDING_TARGET_TYPES,
|
||||||
|
SECRET_MANAGED_MODES,
|
||||||
|
SECRET_PROVIDER_CONFIG_STATUSES,
|
||||||
|
SECRET_PROVIDERS,
|
||||||
|
SECRET_STATUSES,
|
||||||
|
} from "../constants.js";
|
||||||
|
|
||||||
export const envBindingPlainSchema = z.object({
|
export const envBindingPlainSchema = z.object({
|
||||||
type: z.literal("plain"),
|
type: z.literal("plain"),
|
||||||
|
|
@ -23,25 +29,252 @@ export const envConfigSchema = z.record(envBindingSchema);
|
||||||
|
|
||||||
export const createSecretSchema = z.object({
|
export const createSecretSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
|
key: z.string().min(1).regex(/^[a-zA-Z0-9_.-]+$/).optional(),
|
||||||
provider: z.enum(SECRET_PROVIDERS).optional(),
|
provider: z.enum(SECRET_PROVIDERS).optional(),
|
||||||
value: z.string().min(1),
|
providerConfigId: z.string().uuid().optional().nullable(),
|
||||||
|
managedMode: z.enum(SECRET_MANAGED_MODES).optional(),
|
||||||
|
value: z.string().min(1).optional().nullable(),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
externalRef: z.string().optional().nullable(),
|
externalRef: z.string().optional().nullable(),
|
||||||
|
providerMetadata: z.record(z.unknown()).optional().nullable(),
|
||||||
|
providerVersionRef: z.string().optional().nullable(),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
if ((value.managedMode ?? "paperclip_managed") === "external_reference") {
|
||||||
|
if (!value.externalRef?.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["externalRef"],
|
||||||
|
message: "External reference secrets require externalRef",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.externalRef?.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["externalRef"],
|
||||||
|
message: "Managed secrets cannot set externalRef",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!value.value?.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["value"],
|
||||||
|
message: "Managed secrets require value",
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateSecret = z.infer<typeof createSecretSchema>;
|
export type CreateSecret = z.infer<typeof createSecretSchema>;
|
||||||
|
|
||||||
export const rotateSecretSchema = z.object({
|
export const rotateSecretSchema = z.object({
|
||||||
value: z.string().min(1),
|
value: z.string().min(1).optional().nullable(),
|
||||||
externalRef: z.string().optional().nullable(),
|
externalRef: z.string().optional().nullable(),
|
||||||
|
providerVersionRef: z.string().optional().nullable(),
|
||||||
|
providerConfigId: z.string().uuid().optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RotateSecret = z.infer<typeof rotateSecretSchema>;
|
export type RotateSecret = z.infer<typeof rotateSecretSchema>;
|
||||||
|
|
||||||
export const updateSecretSchema = z.object({
|
export const updateSecretSchema = z.object({
|
||||||
name: z.string().min(1).optional(),
|
name: z.string().min(1).optional(),
|
||||||
|
key: z.string().min(1).regex(/^[a-zA-Z0-9_.-]+$/).optional(),
|
||||||
|
status: z.enum(SECRET_STATUSES).optional(),
|
||||||
|
providerConfigId: z.string().uuid().optional().nullable(),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
externalRef: z.string().optional().nullable(),
|
externalRef: z.string().optional().nullable(),
|
||||||
|
providerMetadata: z.record(z.unknown()).optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UpdateSecret = z.infer<typeof updateSecretSchema>;
|
export type UpdateSecret = z.infer<typeof updateSecretSchema>;
|
||||||
|
|
||||||
|
export const secretBindingTargetSchema = z.object({
|
||||||
|
targetType: z.enum(SECRET_BINDING_TARGET_TYPES),
|
||||||
|
targetId: z.string().min(1),
|
||||||
|
configPath: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createSecretBindingSchema = secretBindingTargetSchema.extend({
|
||||||
|
secretId: z.string().uuid(),
|
||||||
|
versionSelector: z.union([z.literal("latest"), z.number().int().positive()]).default("latest"),
|
||||||
|
required: z.boolean().default(true),
|
||||||
|
label: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateSecretBinding = z.infer<typeof createSecretBindingSchema>;
|
||||||
|
|
||||||
|
const safeShortText = z.string().trim().min(1).max(160);
|
||||||
|
const optionalSafeShortText = safeShortText.optional().nullable();
|
||||||
|
|
||||||
|
const deniedProviderConfigKeyPattern =
|
||||||
|
/^(access[-_]?key([-_]?id)?|secret[-_]?access[-_]?key|secret[-_]?key|token|password|passwd|credential|credentials|private[-_]?key|pem|jwt|session[-_]?token|service[-_]?account([-_]?json)?|client[-_]?secret|secret[-_]?id|unseal[-_]?key|recovery[-_]?key|key[-_]?file([-_]?path)?|token[-_]?file([-_]?path)?)$/i;
|
||||||
|
|
||||||
|
function rejectSensitiveProviderConfigKeys(value: unknown, ctx: z.RefinementCtx) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
||||||
|
for (const key of Object.keys(value)) {
|
||||||
|
if (!deniedProviderConfigKeyPattern.test(key)) continue;
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["config", key],
|
||||||
|
message: `Provider vault config cannot persist sensitive field: ${key}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const localEncryptedProviderConfigSchema = z.object({
|
||||||
|
backupReminderAcknowledged: z.boolean().optional(),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const awsSecretsManagerProviderConfigSchema = z.object({
|
||||||
|
region: z.string().trim().regex(/^[a-z]{2}(?:-gov)?-[a-z]+-\d+$/, "Invalid AWS region"),
|
||||||
|
namespace: optionalSafeShortText,
|
||||||
|
secretNamePrefix: optionalSafeShortText,
|
||||||
|
kmsKeyId: z.string().trim().min(1).max(512).optional().nullable(),
|
||||||
|
ownerTag: optionalSafeShortText,
|
||||||
|
environmentTag: optionalSafeShortText,
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const gcpSecretManagerProviderConfigSchema = z.object({
|
||||||
|
projectId: z.string().trim().min(1).max(128).regex(/^[a-z][a-z0-9-]{4,127}$/).optional().nullable(),
|
||||||
|
location: optionalSafeShortText,
|
||||||
|
namespace: optionalSafeShortText,
|
||||||
|
secretNamePrefix: optionalSafeShortText,
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const vaultAddressSchema = z.preprocess(
|
||||||
|
(value) => typeof value === "string" ? value.trim() : value,
|
||||||
|
z.string().url().superRefine((value, ctx) => {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(value);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hasPath = url.pathname !== "" && url.pathname !== "/";
|
||||||
|
if (
|
||||||
|
(url.protocol !== "http:" && url.protocol !== "https:") ||
|
||||||
|
url.username ||
|
||||||
|
url.password ||
|
||||||
|
url.search ||
|
||||||
|
url.hash ||
|
||||||
|
hasPath
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Vault address must be an origin-only HTTP(S) URL without credentials, path, query, or fragment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).transform((value) => new URL(value).origin),
|
||||||
|
);
|
||||||
|
|
||||||
|
function rejectUnsafeVaultAddress(value: unknown, ctx: z.RefinementCtx) {
|
||||||
|
if (value === undefined || value === null) return;
|
||||||
|
const parsed = vaultAddressSchema.safeParse(value);
|
||||||
|
if (parsed.success) return;
|
||||||
|
for (const issue of parsed.error.issues) {
|
||||||
|
ctx.addIssue({
|
||||||
|
...issue,
|
||||||
|
path: ["config", "address", ...issue.path],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const vaultProviderConfigSchema = z.object({
|
||||||
|
address: vaultAddressSchema.optional().nullable(),
|
||||||
|
namespace: optionalSafeShortText,
|
||||||
|
mountPath: optionalSafeShortText,
|
||||||
|
secretPathPrefix: optionalSafeShortText,
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const secretProviderConfigPayloadSchema = z.discriminatedUnion("provider", [
|
||||||
|
z.object({ provider: z.literal("local_encrypted"), config: localEncryptedProviderConfigSchema }),
|
||||||
|
z.object({ provider: z.literal("aws_secrets_manager"), config: awsSecretsManagerProviderConfigSchema }),
|
||||||
|
z.object({ provider: z.literal("gcp_secret_manager"), config: gcpSecretManagerProviderConfigSchema }),
|
||||||
|
z.object({ provider: z.literal("vault"), config: vaultProviderConfigSchema }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const createSecretProviderConfigSchema = z.object({
|
||||||
|
provider: z.enum(SECRET_PROVIDERS),
|
||||||
|
displayName: z.string().trim().min(1).max(120),
|
||||||
|
status: z.enum(SECRET_PROVIDER_CONFIG_STATUSES).optional(),
|
||||||
|
isDefault: z.boolean().optional(),
|
||||||
|
config: z.record(z.unknown()).default({}),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
rejectSensitiveProviderConfigKeys(value.config, ctx);
|
||||||
|
const parsed = secretProviderConfigPayloadSchema.safeParse({
|
||||||
|
provider: value.provider,
|
||||||
|
config: value.config,
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
for (const issue of parsed.error.issues) {
|
||||||
|
ctx.addIssue({
|
||||||
|
...issue,
|
||||||
|
path: issue.path[0] === "config" ? issue.path : ["config", ...issue.path],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const status = value.status ?? (["gcp_secret_manager", "vault"].includes(value.provider) ? "coming_soon" : "ready");
|
||||||
|
if ((value.provider === "gcp_secret_manager" || value.provider === "vault") && status !== "coming_soon" && status !== "disabled") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["status"],
|
||||||
|
message: `${value.provider} provider vaults are locked while coming soon`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ((status === "coming_soon" || status === "disabled") && value.isDefault) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["isDefault"],
|
||||||
|
message: "Only ready or warning provider vaults can be default",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateSecretProviderConfig = z.infer<typeof createSecretProviderConfigSchema>;
|
||||||
|
|
||||||
|
export const updateSecretProviderConfigSchema = z.object({
|
||||||
|
displayName: z.string().trim().min(1).max(120).optional(),
|
||||||
|
status: z.enum(SECRET_PROVIDER_CONFIG_STATUSES).optional(),
|
||||||
|
isDefault: z.boolean().optional(),
|
||||||
|
config: z.record(z.unknown()).optional(),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
if (value.config !== undefined) {
|
||||||
|
rejectSensitiveProviderConfigKeys(value.config, ctx);
|
||||||
|
rejectUnsafeVaultAddress(value.config.address, ctx);
|
||||||
|
}
|
||||||
|
if ((value.status === "coming_soon" || value.status === "disabled") && value.isDefault) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["isDefault"],
|
||||||
|
message: "Only ready or warning provider vaults can be default",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateSecretProviderConfig = z.infer<typeof updateSecretProviderConfigSchema>;
|
||||||
|
|
||||||
|
export const remoteSecretImportPreviewSchema = z.object({
|
||||||
|
providerConfigId: z.string().uuid(),
|
||||||
|
query: z.string().trim().max(200).optional().nullable(),
|
||||||
|
nextToken: z.string().trim().min(1).max(4096).optional().nullable(),
|
||||||
|
pageSize: z.number().int().min(1).max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RemoteSecretImportPreview = z.infer<typeof remoteSecretImportPreviewSchema>;
|
||||||
|
|
||||||
|
export const remoteSecretImportSelectionSchema = z.object({
|
||||||
|
externalRef: z.string().trim().min(1).max(2048),
|
||||||
|
name: z.string().trim().min(1).max(160).optional().nullable(),
|
||||||
|
key: z.string().trim().min(1).max(120).regex(/^[a-zA-Z0-9_.-]+$/).optional().nullable(),
|
||||||
|
description: z.string().trim().max(500).optional().nullable(),
|
||||||
|
providerVersionRef: z.string().trim().min(1).max(512).optional().nullable(),
|
||||||
|
providerMetadata: z.record(z.unknown()).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const remoteSecretImportSchema = z.object({
|
||||||
|
providerConfigId: z.string().uuid(),
|
||||||
|
secrets: z.array(remoteSecretImportSelectionSchema).min(1).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RemoteSecretImportSelection = z.infer<typeof remoteSecretImportSelectionSchema>;
|
||||||
|
export type RemoteSecretImport = z.infer<typeof remoteSecretImportSchema>;
|
||||||
|
|
|
||||||
115
scripts/capture-pap-2351-binding-picker.mjs
Normal file
115
scripts/capture-pap-2351-binding-picker.mjs
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Captures the BindingPicker storybook screenshot for PAP-2351 re-review.
|
||||||
|
// Boots a tiny static server over `ui/storybook-static` and screenshots the
|
||||||
|
// happy-path picker grid in dark mode at 1440x900 (matches the original
|
||||||
|
// PAP-2350 capture).
|
||||||
|
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
const localRequire = createRequire(import.meta.url);
|
||||||
|
const { chromium } = localRequire("playwright");
|
||||||
|
import http from "node:http";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRoot = path.resolve(__dirname, "..");
|
||||||
|
const storybookRoot = path.join(repoRoot, "ui", "storybook-static");
|
||||||
|
const outDir = process.argv[2]
|
||||||
|
? path.resolve(process.argv[2])
|
||||||
|
: path.join(repoRoot, "screenshots", "pap-2351");
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
".html": "text/html",
|
||||||
|
".js": "application/javascript",
|
||||||
|
".mjs": "application/javascript",
|
||||||
|
".css": "text/css",
|
||||||
|
".json": "application/json",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".map": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
function startStaticServer(rootDir) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
||||||
|
let filePath = path.join(rootDir, urlPath === "/" ? "index.html" : urlPath);
|
||||||
|
let stat;
|
||||||
|
try {
|
||||||
|
stat = await fs.stat(filePath);
|
||||||
|
} catch {
|
||||||
|
stat = null;
|
||||||
|
}
|
||||||
|
if (stat?.isDirectory()) {
|
||||||
|
filePath = path.join(filePath, "index.html");
|
||||||
|
stat = await fs.stat(filePath).catch(() => null);
|
||||||
|
}
|
||||||
|
if (!stat) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end("not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
res.setHeader("content-type", MIME[ext] ?? "application/octet-stream");
|
||||||
|
res.setHeader("cache-control", "no-cache");
|
||||||
|
const data = await fs.readFile(filePath);
|
||||||
|
res.end(data);
|
||||||
|
} catch (err) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address();
|
||||||
|
const port = typeof address === "object" && address ? address.port : 0;
|
||||||
|
resolve({ server, baseUrl: `http://127.0.0.1:${port}` });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHOTS = [
|
||||||
|
{
|
||||||
|
storyId: "product-secrets--binding-picker",
|
||||||
|
label: "secrets-binding-picker",
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
theme: "dark",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await fs.mkdir(outDir, { recursive: true });
|
||||||
|
const { server, baseUrl } = await startStaticServer(storybookRoot);
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const ctx = await browser.newContext({ deviceScaleFactor: 1 });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
const captured = [];
|
||||||
|
try {
|
||||||
|
for (const shot of SHOTS) {
|
||||||
|
await page.setViewportSize(shot.viewport);
|
||||||
|
const url = `${baseUrl}/iframe.html?id=${encodeURIComponent(shot.storyId)}&viewMode=story&globals=theme:${shot.theme}`;
|
||||||
|
await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
|
||||||
|
// Allow the storybook fixture to swap CompanyContext to the storybook id and
|
||||||
|
// for the picker's useQuery to settle from cache.
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
const dest = path.join(outDir, `${shot.label}.png`);
|
||||||
|
await page.screenshot({ path: dest, fullPage: false });
|
||||||
|
captured.push(dest);
|
||||||
|
console.log("captured", dest);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
server.close();
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify({ captured }, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -53,6 +53,14 @@ vi.mock("../services/index.js", () => ({
|
||||||
workspaceOperationService: () => ({}),
|
workspaceOperationService: () => ({}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/secrets.js", () => ({
|
||||||
|
secretService: () => mockSecretService,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/environments.js", () => ({
|
||||||
|
environmentService: () => mockEnvironmentService,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../adapters/index.js", () => ({
|
vi.mock("../adapters/index.js", () => ({
|
||||||
findServerAdapter: mockFindServerAdapter,
|
findServerAdapter: mockFindServerAdapter,
|
||||||
listAdapterModels: vi.fn(),
|
listAdapterModels: vi.fn(),
|
||||||
|
|
@ -75,6 +83,14 @@ function registerModuleMocks() {
|
||||||
workspaceOperationService: () => ({}),
|
workspaceOperationService: () => ({}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.doMock("../services/secrets.js", () => ({
|
||||||
|
secretService: () => mockSecretService,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.doMock("../services/environments.js", () => ({
|
||||||
|
environmentService: () => mockEnvironmentService,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.doMock("../adapters/index.js", () => ({
|
vi.doMock("../adapters/index.js", () => ({
|
||||||
findServerAdapter: mockFindServerAdapter,
|
findServerAdapter: mockFindServerAdapter,
|
||||||
listAdapterModels: vi.fn(),
|
listAdapterModels: vi.fn(),
|
||||||
|
|
|
||||||
820
server/src/__tests__/aws-secrets-manager-provider.test.ts
Normal file
820
server/src/__tests__/aws-secrets-manager-provider.test.ts
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -648,7 +648,7 @@ describe("claude execute", () => {
|
||||||
else process.env.PATH = previousPath;
|
else process.env.PATH = previousPath;
|
||||||
await fs.rm(root, { recursive: true, force: true });
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
}, 10_000);
|
||||||
|
|
||||||
it("reuses a stable Paperclip-managed Claude prompt bundle across equivalent runs", async () => {
|
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-"));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-bundle-"));
|
||||||
|
|
|
||||||
|
|
@ -497,6 +497,70 @@ describe("company portability", () => {
|
||||||
expect(asTextFile(exported.files[".paperclip.yaml"])).toContain("requireBoardApprovalForNewAgents: true");
|
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 () => {
|
it("exports default sidebar order into the Paperclip extension and manifest", async () => {
|
||||||
const portability = companyPortabilityService({} as any);
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -385,7 +385,7 @@ describe("cursor execute", () => {
|
||||||
else process.env.HOME = previousHome;
|
else process.env.HOME = previousHome;
|
||||||
await fs.rm(root, { recursive: true, force: true });
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
}, 10_000);
|
||||||
|
|
||||||
it("keeps explicit command overrides for remote sandbox execution", async () => {
|
it("keeps explicit command overrides for remote sandbox execution", async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-sandbox-explicit-"));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-sandbox-explicit-"));
|
||||||
|
|
|
||||||
|
|
@ -161,9 +161,10 @@ describeLiveSsh("live SSH environment smoke", () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resolvedConfig) {
|
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.",
|
"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;
|
const config = resolvedConfig;
|
||||||
|
|
@ -171,7 +172,7 @@ describeLiveSsh("live SSH environment smoke", () => {
|
||||||
const quotedRemoteWorkspacePath = JSON.stringify(config.remoteWorkspacePath);
|
const quotedRemoteWorkspacePath = JSON.stringify(config.remoteWorkspacePath);
|
||||||
const result = await runSshCommand(
|
const result = await runSshCommand(
|
||||||
config,
|
config,
|
||||||
`sh -lc "cd ${quotedRemoteWorkspacePath} && which git && which tar && pwd"`,
|
`cd ${quotedRemoteWorkspacePath} && which git && which tar && pwd`,
|
||||||
{ timeoutMs: 30000, maxBuffer: 256 * 1024 },
|
{ timeoutMs: 30000, maxBuffer: 256 * 1024 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,13 @@ const mockProbeEnvironment = vi.hoisted(() => vi.fn());
|
||||||
const mockSecretService = vi.hoisted(() => ({
|
const mockSecretService = vi.hoisted(() => ({
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
resolveSecretValue: vi.fn(),
|
resolveSecretValue: vi.fn(),
|
||||||
|
syncSecretRefsForTarget: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
}));
|
}));
|
||||||
const mockValidatePluginEnvironmentDriverConfig = vi.hoisted(() => vi.fn());
|
const mockValidatePluginEnvironmentDriverConfig = vi.hoisted(() => vi.fn());
|
||||||
const mockValidatePluginSandboxProviderConfig = vi.hoisted(() => vi.fn());
|
const mockValidatePluginSandboxProviderConfig = vi.hoisted(() => vi.fn());
|
||||||
const mockListReadyPluginEnvironmentDrivers = vi.hoisted(() => vi.fn());
|
const mockListReadyPluginEnvironmentDrivers = vi.hoisted(() => vi.fn());
|
||||||
|
const mockResolvePluginSandboxProviderDriverByKey = vi.hoisted(() => vi.fn());
|
||||||
const mockExecutionWorkspaceService = vi.hoisted(() => ({}));
|
const mockExecutionWorkspaceService = vi.hoisted(() => ({}));
|
||||||
|
|
||||||
vi.mock("../services/index.js", () => ({
|
vi.mock("../services/index.js", () => ({
|
||||||
|
|
@ -69,6 +72,7 @@ vi.mock("../services/execution-workspaces.js", () => ({
|
||||||
|
|
||||||
vi.mock("../services/plugin-environment-driver.js", () => ({
|
vi.mock("../services/plugin-environment-driver.js", () => ({
|
||||||
listReadyPluginEnvironmentDrivers: mockListReadyPluginEnvironmentDrivers,
|
listReadyPluginEnvironmentDrivers: mockListReadyPluginEnvironmentDrivers,
|
||||||
|
resolvePluginSandboxProviderDriverByKey: mockResolvePluginSandboxProviderDriverByKey,
|
||||||
validatePluginEnvironmentDriverConfig: mockValidatePluginEnvironmentDriverConfig,
|
validatePluginEnvironmentDriverConfig: mockValidatePluginEnvironmentDriverConfig,
|
||||||
validatePluginSandboxProviderConfig: mockValidatePluginSandboxProviderConfig,
|
validatePluginSandboxProviderConfig: mockValidatePluginSandboxProviderConfig,
|
||||||
}));
|
}));
|
||||||
|
|
@ -96,6 +100,7 @@ let currentActor: Record<string, unknown> = {
|
||||||
source: "local_implicit",
|
source: "local_implicit",
|
||||||
};
|
};
|
||||||
const routeOptions: Record<string, unknown> = {};
|
const routeOptions: Record<string, unknown> = {};
|
||||||
|
const originalSecretsProviderEnv = process.env.PAPERCLIP_SECRETS_PROVIDER;
|
||||||
|
|
||||||
function createApp(actor: Record<string, unknown>, options: Record<string, unknown> = {}) {
|
function createApp(actor: Record<string, unknown>, options: Record<string, unknown> = {}) {
|
||||||
currentActor = actor;
|
currentActor = actor;
|
||||||
|
|
@ -119,6 +124,11 @@ function createApp(actor: Record<string, unknown>, options: Record<string, unkno
|
||||||
|
|
||||||
describe("environment routes", () => {
|
describe("environment routes", () => {
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
if (originalSecretsProviderEnv === undefined) {
|
||||||
|
delete process.env.PAPERCLIP_SECRETS_PROVIDER;
|
||||||
|
} else {
|
||||||
|
process.env.PAPERCLIP_SECRETS_PROVIDER = originalSecretsProviderEnv;
|
||||||
|
}
|
||||||
if (!server) return;
|
if (!server) return;
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
server?.close((err) => {
|
server?.close((err) => {
|
||||||
|
|
@ -145,9 +155,14 @@ describe("environment routes", () => {
|
||||||
mockProbeEnvironment.mockReset();
|
mockProbeEnvironment.mockReset();
|
||||||
mockSecretService.create.mockReset();
|
mockSecretService.create.mockReset();
|
||||||
mockSecretService.resolveSecretValue.mockReset();
|
mockSecretService.resolveSecretValue.mockReset();
|
||||||
|
mockSecretService.syncSecretRefsForTarget.mockReset();
|
||||||
|
mockSecretService.remove.mockReset();
|
||||||
mockSecretService.create.mockResolvedValue({
|
mockSecretService.create.mockResolvedValue({
|
||||||
id: "11111111-1111-1111-1111-111111111111",
|
id: "11111111-1111-1111-1111-111111111111",
|
||||||
});
|
});
|
||||||
|
mockSecretService.syncSecretRefsForTarget.mockResolvedValue([]);
|
||||||
|
mockSecretService.remove.mockResolvedValue(null);
|
||||||
|
delete process.env.PAPERCLIP_SECRETS_PROVIDER;
|
||||||
mockValidatePluginEnvironmentDriverConfig.mockReset();
|
mockValidatePluginEnvironmentDriverConfig.mockReset();
|
||||||
mockValidatePluginEnvironmentDriverConfig.mockImplementation(async ({ config }) => config);
|
mockValidatePluginEnvironmentDriverConfig.mockImplementation(async ({ config }) => config);
|
||||||
mockValidatePluginSandboxProviderConfig.mockReset();
|
mockValidatePluginSandboxProviderConfig.mockReset();
|
||||||
|
|
@ -162,6 +177,29 @@ describe("environment routes", () => {
|
||||||
configSchema: { type: "object" },
|
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.mockReset();
|
||||||
mockListReadyPluginEnvironmentDrivers.mockResolvedValue([]);
|
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 () => {
|
it("rejects persisted fake sandbox environments", async () => {
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
type: "board",
|
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 () => {
|
it("validates plugin environment config through the plugin driver host", async () => {
|
||||||
const environment = {
|
const environment = {
|
||||||
...createEnvironment(),
|
...createEnvironment(),
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,13 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
|
||||||
provider: "local_encrypted",
|
provider: "local_encrypted",
|
||||||
value: config.privateKey,
|
value: config.privateKey,
|
||||||
});
|
});
|
||||||
|
await secretService(db).createBinding({
|
||||||
|
companyId,
|
||||||
|
secretId: secret.id,
|
||||||
|
targetType: "environment",
|
||||||
|
targetId: environmentId,
|
||||||
|
configPath: "privateKeySecretRef",
|
||||||
|
});
|
||||||
config = {
|
config = {
|
||||||
...config,
|
...config,
|
||||||
privateKey: null,
|
privateKey: null,
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,13 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||||
provider: "local_encrypted",
|
provider: "local_encrypted",
|
||||||
value: config.privateKey,
|
value: config.privateKey,
|
||||||
});
|
});
|
||||||
|
await secretService(db).createBinding({
|
||||||
|
companyId,
|
||||||
|
secretId: secret.id,
|
||||||
|
targetType: "environment",
|
||||||
|
targetId: environmentId,
|
||||||
|
configPath: "privateKeySecretRef",
|
||||||
|
});
|
||||||
config = {
|
config = {
|
||||||
...config,
|
...config,
|
||||||
privateKey: null,
|
privateKey: null,
|
||||||
|
|
@ -548,6 +555,13 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||||
driver: "sandbox",
|
driver: "sandbox",
|
||||||
config: providerConfig,
|
config: providerConfig,
|
||||||
};
|
};
|
||||||
|
await secretService(db).createBinding({
|
||||||
|
companyId,
|
||||||
|
secretId: apiSecret.id,
|
||||||
|
targetType: "environment",
|
||||||
|
targetId: environment.id,
|
||||||
|
configPath: "apiKey",
|
||||||
|
});
|
||||||
await environmentService(db).update(environment.id, {
|
await environmentService(db).update(environment.id, {
|
||||||
driver: "sandbox",
|
driver: "sandbox",
|
||||||
name: environment.name,
|
name: environment.name,
|
||||||
|
|
|
||||||
|
|
@ -2080,6 +2080,83 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||||
expect(comments[0]?.body).toContain(`Recovery issue: [${recovery.identifier}]`);
|
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 () => {
|
it("assigns open unassigned blockers back to their creator agent", async () => {
|
||||||
const companyId = randomUUID();
|
const companyId = randomUUID();
|
||||||
const creatorAgentId = randomUUID();
|
const creatorAgentId = randomUUID();
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,17 @@ describe("resolveExecutionRunAdapterConfig", () => {
|
||||||
other: "value",
|
other: "value",
|
||||||
},
|
},
|
||||||
secretKeys: new Set(["AGENT_SECRET"]),
|
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({
|
const resolveEnvBindings = vi.fn().mockResolvedValue({
|
||||||
env: {
|
env: {
|
||||||
|
|
@ -24,6 +35,17 @@ describe("resolveExecutionRunAdapterConfig", () => {
|
||||||
PROJECT_ONLY: "project-only",
|
PROJECT_ONLY: "project-only",
|
||||||
},
|
},
|
||||||
secretKeys: new Set(["PROJECT_SECRET"]),
|
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({
|
const result = await resolveExecutionRunAdapterConfig({
|
||||||
|
|
@ -45,12 +67,19 @@ describe("resolveExecutionRunAdapterConfig", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(Array.from(result.secretKeys).sort()).toEqual(["AGENT_SECRET", "PROJECT_SECRET"]);
|
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 () => {
|
it("skips project env resolution when the project has no bindings", async () => {
|
||||||
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
|
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
|
||||||
config: { env: { AGENT_ONLY: "agent-only" } },
|
config: { env: { AGENT_ONLY: "agent-only" } },
|
||||||
secretKeys: new Set<string>(),
|
secretKeys: new Set<string>(),
|
||||||
|
manifest: [],
|
||||||
});
|
});
|
||||||
const resolveEnvBindings = vi.fn();
|
const resolveEnvBindings = vi.fn();
|
||||||
|
|
||||||
|
|
@ -65,6 +94,7 @@ describe("resolveExecutionRunAdapterConfig", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.resolvedConfig.env).toEqual({ AGENT_ONLY: "agent-only" });
|
expect(result.resolvedConfig.env).toEqual({ AGENT_ONLY: "agent-only" });
|
||||||
|
expect(result.secretManifest).toEqual([]);
|
||||||
expect(resolveEnvBindings).not.toHaveBeenCalled();
|
expect(resolveEnvBindings).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,7 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
||||||
await db.delete(documents);
|
await db.delete(documents);
|
||||||
await db.delete(issueRelations);
|
await db.delete(issueRelations);
|
||||||
await db.delete(issueTreeHolds);
|
await db.delete(issueTreeHolds);
|
||||||
|
await db.delete(issueComments);
|
||||||
await db.delete(issues);
|
await db.delete(issues);
|
||||||
await db.delete(heartbeatRunEvents);
|
await db.delete(heartbeatRunEvents);
|
||||||
await db.delete(activityLog);
|
await db.delete(activityLog);
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,30 @@ describe.sequential("plugin install and upgrade authz", () => {
|
||||||
expect(mockLifecycle.disable).not.toHaveBeenCalled();
|
expect(mockLifecycle.disable).not.toHaveBeenCalled();
|
||||||
}, 20_000);
|
}, 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 () => {
|
it("allows instance admins to upgrade plugins", async () => {
|
||||||
const pluginId = "11111111-1111-4111-8111-111111111111";
|
const pluginId = "11111111-1111-4111-8111-111111111111";
|
||||||
mockRegistry.getById.mockResolvedValue({
|
mockRegistry.getById.mockResolvedValue({
|
||||||
|
|
|
||||||
29
server/src/__tests__/plugin-secrets-handler.test.ts
Normal file
29
server/src/__tests__/plugin-secrets-handler.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { createHmac, randomUUID } from "node:crypto";
|
import { createHmac, randomUUID } from "node:crypto";
|
||||||
import { eq } from "drizzle-orm";
|
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 {
|
import {
|
||||||
activityLog,
|
activityLog,
|
||||||
agents,
|
agents,
|
||||||
|
|
@ -26,10 +26,12 @@ import {
|
||||||
} from "./helpers/embedded-postgres.js";
|
} from "./helpers/embedded-postgres.js";
|
||||||
import { issueService } from "../services/issues.ts";
|
import { issueService } from "../services/issues.ts";
|
||||||
import { instanceSettingsService } from "../services/instance-settings.ts";
|
import { instanceSettingsService } from "../services/instance-settings.ts";
|
||||||
|
import * as providerRegistry from "../secrets/provider-registry.ts";
|
||||||
import { routineService } from "../services/routines.ts";
|
import { routineService } from "../services/routines.ts";
|
||||||
|
|
||||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||||
|
const originalSecretsProviderEnv = process.env.PAPERCLIP_SECRETS_PROVIDER;
|
||||||
|
|
||||||
if (!embeddedPostgresSupport.supported) {
|
if (!embeddedPostgresSupport.supported) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|
@ -47,6 +49,11 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
afterEach(async () => {
|
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(activityLog);
|
||||||
await db.delete(issueInboxArchives);
|
await db.delete(issueInboxArchives);
|
||||||
await db.delete(issueReadStates);
|
await db.delete(issueReadStates);
|
||||||
|
|
@ -1272,6 +1279,82 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||||
expect(run.linkedIssueId).toBeTruthy();
|
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 () => {
|
it("accepts GitHub-style X-Hub-Signature-256 with github_hmac signing mode", async () => {
|
||||||
const { routine, svc } = await seedFixture();
|
const { routine, svc } = await seedFixture();
|
||||||
const { trigger, secretMaterial } = await svc.createTrigger(
|
const { trigger, secretMaterial } = await svc.createTrigger(
|
||||||
|
|
|
||||||
70
server/src/__tests__/secret-provider-registry.test.ts
Normal file
70
server/src/__tests__/secret-provider-registry.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
454
server/src/__tests__/secrets-routes.test.ts
Normal file
454
server/src/__tests__/secrets-routes.test.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
1672
server/src/__tests__/secrets-service.test.ts
Normal file
1672
server/src/__tests__/secrets-service.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -120,11 +120,6 @@ export function loadConfig(): Config {
|
||||||
const fileDatabaseBackup = fileConfig?.database.backup;
|
const fileDatabaseBackup = fileConfig?.database.backup;
|
||||||
const fileSecrets = fileConfig?.secrets;
|
const fileSecrets = fileConfig?.secrets;
|
||||||
const fileStorage = fileConfig?.storage;
|
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 providerFromEnvRaw = process.env.PAPERCLIP_SECRETS_PROVIDER;
|
||||||
const providerFromEnv =
|
const providerFromEnv =
|
||||||
|
|
@ -168,6 +163,11 @@ export function loadConfig(): Config {
|
||||||
? (deploymentModeFromEnvRaw as DeploymentMode)
|
? (deploymentModeFromEnvRaw as DeploymentMode)
|
||||||
: null;
|
: null;
|
||||||
const deploymentMode: DeploymentMode = deploymentModeFromEnv ?? fileConfig?.server.deploymentMode ?? "local_trusted";
|
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 deploymentExposureFromEnvRaw = process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE;
|
||||||
const deploymentExposureFromEnv =
|
const deploymentExposureFromEnv =
|
||||||
deploymentExposureFromEnvRaw &&
|
deploymentExposureFromEnvRaw &&
|
||||||
|
|
|
||||||
|
|
@ -2189,6 +2189,14 @@ export function agentRoutes(
|
||||||
lastHeartbeatAt: null,
|
lastHeartbeatAt: null,
|
||||||
});
|
});
|
||||||
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent, instructionsBundle);
|
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);
|
const actor = getActorInfo(req);
|
||||||
await logActivity(db, {
|
await logActivity(db, {
|
||||||
|
|
@ -2665,6 +2673,14 @@ export function agentRoutes(
|
||||||
res.status(404).json({ error: "Agent not found" });
|
res.status(404).json({ error: "Agent not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (touchesAdapterConfiguration) {
|
||||||
|
const agentEnv = asRecord(agent.adapterConfig)?.env;
|
||||||
|
await secretsSvc.syncEnvBindingsForTarget?.(
|
||||||
|
agent.companyId,
|
||||||
|
{ targetType: "agent", targetId: agent.id },
|
||||||
|
agentEnv,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await logActivity(db, {
|
await logActivity(db, {
|
||||||
companyId: agent.companyId,
|
companyId: agent.companyId,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
projectService,
|
projectService,
|
||||||
} from "../services/index.js";
|
} from "../services/index.js";
|
||||||
import {
|
import {
|
||||||
|
collectEnvironmentSecretRefs,
|
||||||
normalizeEnvironmentConfigForPersistence,
|
normalizeEnvironmentConfigForPersistence,
|
||||||
normalizeEnvironmentConfigForProbe,
|
normalizeEnvironmentConfigForProbe,
|
||||||
parseEnvironmentDriverConfig,
|
parseEnvironmentDriverConfig,
|
||||||
|
|
@ -26,6 +27,7 @@ import {
|
||||||
import { probeEnvironment } from "../services/environment-probe.js";
|
import { probeEnvironment } from "../services/environment-probe.js";
|
||||||
import { secretService } from "../services/secrets.js";
|
import { secretService } from "../services/secrets.js";
|
||||||
import { listReadyPluginEnvironmentDrivers } from "../services/plugin-environment-driver.js";
|
import { listReadyPluginEnvironmentDrivers } from "../services/plugin-environment-driver.js";
|
||||||
|
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
|
||||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||||
import { environmentService } from "../services/environments.js";
|
import { environmentService } from "../services/environments.js";
|
||||||
|
|
@ -202,6 +204,7 @@ export function environmentRoutes(
|
||||||
companyId,
|
companyId,
|
||||||
environmentName: req.body.name,
|
environmentName: req.body.name,
|
||||||
driver: req.body.driver,
|
driver: req.body.driver,
|
||||||
|
secretProvider: getConfiguredSecretProvider(),
|
||||||
config: req.body.config,
|
config: req.body.config,
|
||||||
actor: {
|
actor: {
|
||||||
agentId: actor.agentId,
|
agentId: actor.agentId,
|
||||||
|
|
@ -211,6 +214,11 @@ export function environmentRoutes(
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const environment = await svc.create(companyId, input);
|
const environment = await svc.create(companyId, input);
|
||||||
|
await secrets.syncSecretRefsForTarget(
|
||||||
|
companyId,
|
||||||
|
{ targetType: "environment", targetId: environment.id },
|
||||||
|
await collectEnvironmentSecretRefs({ db, environment }),
|
||||||
|
);
|
||||||
await logActivity(db, {
|
await logActivity(db, {
|
||||||
companyId,
|
companyId,
|
||||||
actorType: actor.actorType,
|
actorType: actor.actorType,
|
||||||
|
|
@ -305,6 +313,7 @@ export function environmentRoutes(
|
||||||
companyId: existing.companyId,
|
companyId: existing.companyId,
|
||||||
environmentName: nextName,
|
environmentName: nextName,
|
||||||
driver: nextDriver,
|
driver: nextDriver,
|
||||||
|
secretProvider: getConfiguredSecretProvider(),
|
||||||
config: configSource,
|
config: configSource,
|
||||||
actor: {
|
actor: {
|
||||||
agentId: actor.agentId,
|
agentId: actor.agentId,
|
||||||
|
|
@ -320,6 +329,13 @@ export function environmentRoutes(
|
||||||
res.status(404).json({ error: "Environment not found" });
|
res.status(404).json({ error: "Environment not found" });
|
||||||
return;
|
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, {
|
await logActivity(db, {
|
||||||
companyId: environment.companyId,
|
companyId: environment.companyId,
|
||||||
actorType: actor.actorType,
|
actorType: actor.actorType,
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,10 @@ import {
|
||||||
requireLocalFolderDeclaration,
|
requireLocalFolderDeclaration,
|
||||||
setStoredLocalFolder,
|
setStoredLocalFolder,
|
||||||
} from "../services/plugin-local-folders.js";
|
} 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";
|
import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
|
||||||
|
|
||||||
/** UI slot declaration extracted from plugin manifest */
|
/** UI slot declaration extracted from plugin manifest */
|
||||||
|
|
@ -1941,6 +1945,12 @@ export function pluginRoutes(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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, {
|
const result = await registry.upsertConfig(plugin.id, {
|
||||||
configJson: body.configJson,
|
configJson: body.configJson,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,13 @@ export function projectRoutes(db: Db) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const project = await svc.create(companyId, projectData);
|
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;
|
let createdWorkspaceId: string | null = null;
|
||||||
if (workspace) {
|
if (workspace) {
|
||||||
const createdWorkspace = await svc.createWorkspace(project.id, 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" });
|
res.status(404).json({ error: "Project not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (body.env !== undefined) {
|
||||||
|
await secretsSvc.syncEnvBindingsForTarget?.(
|
||||||
|
project.companyId,
|
||||||
|
{ targetType: "project", targetId: project.id },
|
||||||
|
project.env,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
await logActivity(db, {
|
await logActivity(db, {
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,23 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
SECRET_PROVIDERS,
|
createSecretProviderConfigSchema,
|
||||||
type SecretProvider,
|
|
||||||
createSecretSchema,
|
createSecretSchema,
|
||||||
|
remoteSecretImportPreviewSchema,
|
||||||
|
remoteSecretImportSchema,
|
||||||
rotateSecretSchema,
|
rotateSecretSchema,
|
||||||
|
updateSecretProviderConfigSchema,
|
||||||
updateSecretSchema,
|
updateSecretSchema,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { validate } from "../middleware/validate.js";
|
import { validate } from "../middleware/validate.js";
|
||||||
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||||
import { logActivity, secretService } from "../services/index.js";
|
import { logActivity, secretService } from "../services/index.js";
|
||||||
|
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
|
||||||
|
|
||||||
export function secretRoutes(db: Db) {
|
export function secretRoutes(db: Db) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const svc = secretService(db);
|
const svc = secretService(db);
|
||||||
const configuredDefaultProvider = process.env.PAPERCLIP_SECRETS_PROVIDER;
|
const defaultProvider = getConfiguredSecretProvider();
|
||||||
const defaultProvider = (
|
|
||||||
configuredDefaultProvider && SECRET_PROVIDERS.includes(configuredDefaultProvider as SecretProvider)
|
|
||||||
? configuredDefaultProvider
|
|
||||||
: "local_encrypted"
|
|
||||||
) as SecretProvider;
|
|
||||||
|
|
||||||
router.get("/companies/:companyId/secret-providers", (req, res) => {
|
router.get("/companies/:companyId/secret-providers", (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
|
|
@ -28,6 +26,205 @@ export function secretRoutes(db: Db) {
|
||||||
res.json(svc.listProviders());
|
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) => {
|
router.get("/companies/:companyId/secrets", async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
|
|
@ -45,10 +242,15 @@ export function secretRoutes(db: Db) {
|
||||||
companyId,
|
companyId,
|
||||||
{
|
{
|
||||||
name: req.body.name,
|
name: req.body.name,
|
||||||
|
key: req.body.key,
|
||||||
provider: req.body.provider ?? defaultProvider,
|
provider: req.body.provider ?? defaultProvider,
|
||||||
|
providerConfigId: req.body.providerConfigId,
|
||||||
|
managedMode: req.body.managedMode,
|
||||||
value: req.body.value,
|
value: req.body.value,
|
||||||
description: req.body.description,
|
description: req.body.description,
|
||||||
externalRef: req.body.externalRef,
|
externalRef: req.body.externalRef,
|
||||||
|
providerVersionRef: req.body.providerVersionRef,
|
||||||
|
providerMetadata: req.body.providerMetadata,
|
||||||
},
|
},
|
||||||
{ userId: req.actor.userId ?? "board", agentId: null },
|
{ userId: req.actor.userId ?? "board", agentId: null },
|
||||||
);
|
);
|
||||||
|
|
@ -66,6 +268,77 @@ export function secretRoutes(db: Db) {
|
||||||
res.status(201).json(created);
|
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) => {
|
router.post("/secrets/:id/rotate", validate(rotateSecretSchema), async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
|
|
@ -75,12 +348,18 @@ export function secretRoutes(db: Db) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
assertCompanyAccess(req, existing.companyId);
|
assertCompanyAccess(req, existing.companyId);
|
||||||
|
if (existing.status === "deleted") {
|
||||||
|
res.status(404).json({ error: "Secret not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const rotated = await svc.rotate(
|
const rotated = await svc.rotate(
|
||||||
id,
|
id,
|
||||||
{
|
{
|
||||||
value: req.body.value,
|
value: req.body.value,
|
||||||
externalRef: req.body.externalRef,
|
externalRef: req.body.externalRef,
|
||||||
|
providerVersionRef: req.body.providerVersionRef,
|
||||||
|
providerConfigId: req.body.providerConfigId,
|
||||||
},
|
},
|
||||||
{ userId: req.actor.userId ?? "board", agentId: null },
|
{ userId: req.actor.userId ?? "board", agentId: null },
|
||||||
);
|
);
|
||||||
|
|
@ -107,11 +386,19 @@ export function secretRoutes(db: Db) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
assertCompanyAccess(req, existing.companyId);
|
assertCompanyAccess(req, existing.companyId);
|
||||||
|
if (existing.status === "deleted") {
|
||||||
|
res.status(404).json({ error: "Secret not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await svc.update(id, {
|
const updated = await svc.update(id, {
|
||||||
name: req.body.name,
|
name: req.body.name,
|
||||||
|
key: req.body.key,
|
||||||
|
status: req.body.status,
|
||||||
|
providerConfigId: req.body.providerConfigId,
|
||||||
description: req.body.description,
|
description: req.body.description,
|
||||||
externalRef: req.body.externalRef,
|
externalRef: req.body.externalRef,
|
||||||
|
providerMetadata: req.body.providerMetadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
|
|
@ -132,6 +419,32 @@ export function secretRoutes(db: Db) {
|
||||||
res.json(updated);
|
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) => {
|
router.delete("/secrets/:id", async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
|
|
|
||||||
1053
server/src/secrets/aws-secrets-manager-provider.ts
Normal file
1053
server/src/secrets/aws-secrets-manager-provider.ts
Normal file
File diff suppressed because it is too large
Load diff
8
server/src/secrets/configured-provider.ts
Normal file
8
server/src/secrets/configured-provider.ts
Normal 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";
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,78 @@
|
||||||
import { unprocessable } from "../errors.js";
|
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(
|
function unavailableProvider(
|
||||||
id: "aws_secrets_manager" | "gcp_secret_manager" | "vault",
|
id: "aws_secrets_manager" | "gcp_secret_manager" | "vault",
|
||||||
label: string,
|
label: string,
|
||||||
): SecretProviderModule {
|
): 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 {
|
return {
|
||||||
id,
|
id,
|
||||||
descriptor: {
|
descriptor() {
|
||||||
id,
|
return {
|
||||||
label,
|
id,
|
||||||
requiresExternalRef: true,
|
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() {
|
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() {
|
async resolveVersion() {
|
||||||
throw unprocessable(`${id} provider is not configured in this deployment`);
|
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.",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
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 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";
|
import { badRequest } from "../errors.js";
|
||||||
|
|
||||||
interface LocalEncryptedMaterial extends StoredSecretVersionMaterial {
|
interface LocalEncryptedMaterial extends StoredSecretVersionMaterial {
|
||||||
|
|
@ -14,7 +21,7 @@ interface LocalEncryptedMaterial extends StoredSecretVersionMaterial {
|
||||||
function resolveMasterKeyFilePath() {
|
function resolveMasterKeyFilePath() {
|
||||||
const fromEnv = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
const fromEnv = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||||
if (fromEnv && fromEnv.trim().length > 0) return path.resolve(fromEnv.trim());
|
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 {
|
function decodeMasterKey(raw: string): Buffer | null {
|
||||||
|
|
@ -52,6 +59,7 @@ function loadOrCreateMasterKey(): Buffer {
|
||||||
|
|
||||||
const keyPath = resolveMasterKeyFilePath();
|
const keyPath = resolveMasterKeyFilePath();
|
||||||
if (existsSync(keyPath)) {
|
if (existsSync(keyPath)) {
|
||||||
|
enforceKeyFilePermissionsBestEffort(keyPath);
|
||||||
const raw = readFileSync(keyPath, "utf8");
|
const raw = readFileSync(keyPath, "utf8");
|
||||||
const decoded = decodeMasterKey(raw);
|
const decoded = decodeMasterKey(raw);
|
||||||
if (!decoded) {
|
if (!decoded) {
|
||||||
|
|
@ -72,10 +80,118 @@ function loadOrCreateMasterKey(): Buffer {
|
||||||
return generated;
|
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 {
|
function sha256Hex(value: string): string {
|
||||||
return createHash("sha256").update(value).digest("hex");
|
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 {
|
function encryptValue(masterKey: Buffer, value: string): LocalEncryptedMaterial {
|
||||||
const iv = randomBytes(12);
|
const iv = randomBytes(12);
|
||||||
const cipher = createCipheriv("aes-256-gcm", masterKey, iv);
|
const cipher = createCipheriv("aes-256-gcm", masterKey, iv);
|
||||||
|
|
@ -115,21 +231,45 @@ function asLocalEncryptedMaterial(value: StoredSecretVersionMaterial): LocalEncr
|
||||||
|
|
||||||
export const localEncryptedProvider: SecretProviderModule = {
|
export const localEncryptedProvider: SecretProviderModule = {
|
||||||
id: "local_encrypted",
|
id: "local_encrypted",
|
||||||
descriptor: {
|
descriptor() {
|
||||||
id: "local_encrypted",
|
return {
|
||||||
label: "Local encrypted (default)",
|
id: "local_encrypted",
|
||||||
requiresExternalRef: false,
|
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) {
|
async createVersion(input) {
|
||||||
const masterKey = loadOrCreateMasterKey();
|
return prepareManagedVersion(input.value);
|
||||||
return {
|
},
|
||||||
material: encryptValue(masterKey, input.value),
|
async linkExternalSecret() {
|
||||||
valueSha256: sha256Hex(input.value),
|
throw badRequest("local_encrypted does not support external reference secrets");
|
||||||
externalRef: null,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
async resolveVersion(input) {
|
async resolveVersion(input) {
|
||||||
const masterKey = loadOrCreateMasterKey();
|
const masterKey = loadOrCreateMasterKey();
|
||||||
return decryptValue(masterKey, asLocalEncryptedMaterial(input.material));
|
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();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import type { SecretProvider, SecretProviderDescriptor } from "@paperclipai/shared";
|
import type { SecretProvider, SecretProviderDescriptor } from "@paperclipai/shared";
|
||||||
|
import { awsSecretsManagerProvider } from "./aws-secrets-manager-provider.js";
|
||||||
import { localEncryptedProvider } from "./local-encrypted-provider.js";
|
import { localEncryptedProvider } from "./local-encrypted-provider.js";
|
||||||
import {
|
import {
|
||||||
awsSecretsManagerProvider,
|
|
||||||
gcpSecretManagerProvider,
|
gcpSecretManagerProvider,
|
||||||
vaultProvider,
|
vaultProvider,
|
||||||
} from "./external-stub-providers.js";
|
} from "./external-stub-providers.js";
|
||||||
import type { SecretProviderModule } from "./types.js";
|
import type { SecretProviderHealthCheck, SecretProviderModule } from "./types.js";
|
||||||
import { unprocessable } from "../errors.js";
|
import { unprocessable } from "../errors.js";
|
||||||
|
|
||||||
const providers: SecretProviderModule[] = [
|
const providers: SecretProviderModule[] = [
|
||||||
|
|
@ -26,5 +26,9 @@ export function getSecretProvider(id: SecretProvider): SecretProviderModule {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listSecretProviders(): SecretProviderDescriptor[] {
|
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()));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,180 @@
|
||||||
import type { SecretProvider, SecretProviderDescriptor } from "@paperclipai/shared";
|
import type { SecretProvider, SecretProviderDescriptor } from "@paperclipai/shared";
|
||||||
|
import type { DeploymentMode } from "@paperclipai/shared";
|
||||||
|
|
||||||
export interface StoredSecretVersionMaterial {
|
export interface StoredSecretVersionMaterial {
|
||||||
[key: string]: unknown;
|
[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 {
|
export interface SecretProviderModule {
|
||||||
id: SecretProvider;
|
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: {
|
createVersion(input: {
|
||||||
value: string;
|
value: string;
|
||||||
externalRef: string | null;
|
externalRef?: string | null;
|
||||||
}): Promise<{
|
context?: SecretProviderWriteContext;
|
||||||
material: StoredSecretVersionMaterial;
|
providerConfig?: SecretProviderVaultRuntimeConfig | null;
|
||||||
valueSha256: string;
|
}): Promise<PreparedSecretVersion>;
|
||||||
externalRef: string | null;
|
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: {
|
resolveVersion(input: {
|
||||||
material: StoredSecretVersionMaterial;
|
material: StoredSecretVersionMaterial;
|
||||||
externalRef: string | null;
|
externalRef: string | null;
|
||||||
|
providerVersionRef?: string | null;
|
||||||
|
context?: SecretProviderRuntimeContext;
|
||||||
|
providerConfig?: SecretProviderVaultRuntimeConfig | null;
|
||||||
}): Promise<string>;
|
}): 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>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import type {
|
||||||
PluginEnvironmentConfig,
|
PluginEnvironmentConfig,
|
||||||
PluginSandboxEnvironmentConfig,
|
PluginSandboxEnvironmentConfig,
|
||||||
SandboxEnvironmentConfig,
|
SandboxEnvironmentConfig,
|
||||||
|
SecretProvider,
|
||||||
|
SecretVersionSelector,
|
||||||
SshEnvironmentConfig,
|
SshEnvironmentConfig,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { unprocessable } from "../errors.js";
|
import { unprocessable } from "../errors.js";
|
||||||
|
|
@ -165,6 +167,7 @@ async function createEnvironmentSecret(input: {
|
||||||
environmentName: string;
|
environmentName: string;
|
||||||
driver: EnvironmentDriver;
|
driver: EnvironmentDriver;
|
||||||
field: string;
|
field: string;
|
||||||
|
provider: SecretProvider;
|
||||||
value: string;
|
value: string;
|
||||||
actor?: { userId?: string | null; agentId?: string | null };
|
actor?: { userId?: string | null; agentId?: string | null };
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -172,7 +175,7 @@ async function createEnvironmentSecret(input: {
|
||||||
input.companyId,
|
input.companyId,
|
||||||
{
|
{
|
||||||
name: secretName(input),
|
name: secretName(input),
|
||||||
provider: "local_encrypted",
|
provider: input.provider,
|
||||||
value: input.value,
|
value: input.value,
|
||||||
description: `Secret for ${input.environmentName} ${input.field}.`,
|
description: `Secret for ${input.environmentName} ${input.field}.`,
|
||||||
},
|
},
|
||||||
|
|
@ -190,6 +193,7 @@ async function persistConfigSecretRefs(input: {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
environmentName: string;
|
environmentName: string;
|
||||||
driver: EnvironmentDriver;
|
driver: EnvironmentDriver;
|
||||||
|
secretProvider: SecretProvider;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
schema: Record<string, unknown> | null;
|
schema: Record<string, unknown> | null;
|
||||||
actor?: { userId?: string | null; agentId?: string | null };
|
actor?: { userId?: string | null; agentId?: string | null };
|
||||||
|
|
@ -213,6 +217,7 @@ async function persistConfigSecretRefs(input: {
|
||||||
environmentName: input.environmentName,
|
environmentName: input.environmentName,
|
||||||
driver: input.driver,
|
driver: input.driver,
|
||||||
field: path.replace(/[^a-z0-9]+/gi, "-").toLowerCase(),
|
field: path.replace(/[^a-z0-9]+/gi, "-").toLowerCase(),
|
||||||
|
provider: input.secretProvider,
|
||||||
value: trimmed,
|
value: trimmed,
|
||||||
actor: input.actor,
|
actor: input.actor,
|
||||||
});
|
});
|
||||||
|
|
@ -226,6 +231,11 @@ async function resolveConfigSecretRefsForRuntime(input: {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
schema: Record<string, unknown> | null;
|
schema: Record<string, unknown> | null;
|
||||||
|
context: {
|
||||||
|
consumerId: string;
|
||||||
|
issueId?: string | null;
|
||||||
|
heartbeatRunId?: string | null;
|
||||||
|
};
|
||||||
}): Promise<Record<string, unknown>> {
|
}): Promise<Record<string, unknown>> {
|
||||||
const secrets = secretService(input.db);
|
const secrets = secretService(input.db);
|
||||||
let nextConfig = { ...input.config };
|
let nextConfig = { ...input.config };
|
||||||
|
|
@ -234,15 +244,52 @@ async function resolveConfigSecretRefsForRuntime(input: {
|
||||||
if (typeof current !== "string") continue;
|
if (typeof current !== "string") continue;
|
||||||
const trimmed = current.trim();
|
const trimmed = current.trim();
|
||||||
if (!isUuidSecretRef(trimmed)) continue;
|
if (!isUuidSecretRef(trimmed)) continue;
|
||||||
|
if (!input.context.consumerId) {
|
||||||
|
throw unprocessable("Runtime secret resolution requires an environment id");
|
||||||
|
}
|
||||||
nextConfig = writeConfigValueAtPath(
|
nextConfig = writeConfigValueAtPath(
|
||||||
nextConfig,
|
nextConfig,
|
||||||
path,
|
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;
|
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> {
|
export function stripSandboxProviderEnvelope(config: SandboxEnvironmentConfig): Record<string, unknown> {
|
||||||
const { provider: _provider, ...driverConfig } = config as Record<string, unknown>;
|
const { provider: _provider, ...driverConfig } = config as Record<string, unknown>;
|
||||||
return driverConfig;
|
return driverConfig;
|
||||||
|
|
@ -340,6 +387,7 @@ export async function normalizeEnvironmentConfigForPersistence(input: {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
environmentName: string;
|
environmentName: string;
|
||||||
driver: EnvironmentDriver;
|
driver: EnvironmentDriver;
|
||||||
|
secretProvider: SecretProvider;
|
||||||
config: Record<string, unknown> | null | undefined;
|
config: Record<string, unknown> | null | undefined;
|
||||||
actor?: { userId?: string | null; agentId?: string | null };
|
actor?: { userId?: string | null; agentId?: string | null };
|
||||||
pluginWorkerManager?: PluginWorkerManager;
|
pluginWorkerManager?: PluginWorkerManager;
|
||||||
|
|
@ -361,6 +409,7 @@ export async function normalizeEnvironmentConfigForPersistence(input: {
|
||||||
environmentName: input.environmentName,
|
environmentName: input.environmentName,
|
||||||
driver: input.driver,
|
driver: input.driver,
|
||||||
field: "private-key",
|
field: "private-key",
|
||||||
|
provider: input.secretProvider,
|
||||||
value: privateKey,
|
value: privateKey,
|
||||||
actor: input.actor,
|
actor: input.actor,
|
||||||
});
|
});
|
||||||
|
|
@ -404,6 +453,7 @@ export async function normalizeEnvironmentConfigForPersistence(input: {
|
||||||
companyId: input.companyId,
|
companyId: input.companyId,
|
||||||
environmentName: input.environmentName,
|
environmentName: input.environmentName,
|
||||||
driver: input.driver,
|
driver: input.driver,
|
||||||
|
secretProvider: input.secretProvider,
|
||||||
config: {
|
config: {
|
||||||
provider: parsed.data.provider,
|
provider: parsed.data.provider,
|
||||||
...validated.normalizedConfig,
|
...validated.normalizedConfig,
|
||||||
|
|
@ -442,10 +492,15 @@ export async function normalizeEnvironmentConfigForPersistence(input: {
|
||||||
export async function resolveEnvironmentDriverConfigForRuntime(
|
export async function resolveEnvironmentDriverConfigForRuntime(
|
||||||
db: Db,
|
db: Db,
|
||||||
companyId: string,
|
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> {
|
): Promise<ParsedEnvironmentConfig> {
|
||||||
const parsed = parseEnvironmentDriverConfig(environment);
|
const parsed = parseEnvironmentDriverConfig(environment);
|
||||||
const secrets = secretService(db);
|
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) {
|
if (parsed.driver === "ssh" && parsed.config.privateKeySecretRef) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -456,6 +511,15 @@ export async function resolveEnvironmentDriverConfigForRuntime(
|
||||||
companyId,
|
companyId,
|
||||||
parsed.config.privateKeySecretRef.secretId,
|
parsed.config.privateKeySecretRef.secretId,
|
||||||
parsed.config.privateKeySecretRef.version ?? "latest",
|
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,
|
companyId,
|
||||||
config: parsed.config as Record<string, unknown>,
|
config: parsed.config as Record<string, unknown>,
|
||||||
schema: await getSandboxProviderConfigSchema(db, parsed.config.provider),
|
schema: await getSandboxProviderConfigSchema(db, parsed.config.provider),
|
||||||
|
context: {
|
||||||
|
consumerId: environmentId!,
|
||||||
|
issueId: context?.issueId ?? null,
|
||||||
|
heartbeatRunId: context?.heartbeatRunId ?? null,
|
||||||
|
},
|
||||||
}) as SandboxEnvironmentConfig,
|
}) as SandboxEnvironmentConfig,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,10 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver {
|
||||||
driver: "ssh",
|
driver: "ssh",
|
||||||
|
|
||||||
async acquireRunLease(input) {
|
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") {
|
if (parsed.driver !== "ssh") {
|
||||||
throw new Error(`Expected SSH environment config for driver "${input.environment.driver}".`);
|
throw new Error(`Expected SSH environment config for driver "${input.environment.driver}".`);
|
||||||
}
|
}
|
||||||
|
|
@ -346,6 +349,7 @@ function createSandboxEnvironmentDriver(
|
||||||
const metadataConfig = sandboxConfigFromLeaseMetadataLoose(input.lease);
|
const metadataConfig = sandboxConfigFromLeaseMetadataLoose(input.lease);
|
||||||
if (metadataConfig && metadataConfig.provider === input.provider) {
|
if (metadataConfig && metadataConfig.provider === input.provider) {
|
||||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.lease.companyId, {
|
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.lease.companyId, {
|
||||||
|
id: input.environment.id,
|
||||||
driver: "sandbox",
|
driver: "sandbox",
|
||||||
config: sandboxConfigForLeaseMetadata(metadataConfig),
|
config: sandboxConfigForLeaseMetadata(metadataConfig),
|
||||||
});
|
});
|
||||||
|
|
@ -381,7 +385,10 @@ function createSandboxEnvironmentDriver(
|
||||||
|
|
||||||
async acquireRunLease(input) {
|
async acquireRunLease(input) {
|
||||||
const storedParsed = parseEnvironmentDriverConfig(input.environment);
|
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") {
|
if (parsed.driver !== "sandbox" || storedParsed.driver !== "sandbox") {
|
||||||
throw new Error(`Expected sandbox environment config for driver "${input.environment.driver}".`);
|
throw new Error(`Expected sandbox environment config for driver "${input.environment.driver}".`);
|
||||||
}
|
}
|
||||||
|
|
@ -562,6 +569,7 @@ function createSandboxEnvironmentDriver(
|
||||||
|
|
||||||
const parsed = metadataConfig
|
const parsed = metadataConfig
|
||||||
? await resolveEnvironmentDriverConfigForRuntime(db, input.lease.companyId, {
|
? await resolveEnvironmentDriverConfigForRuntime(db, input.lease.companyId, {
|
||||||
|
id: input.environment.id,
|
||||||
driver: "sandbox",
|
driver: "sandbox",
|
||||||
config: metadataConfig as unknown as Record<string, unknown>,
|
config: metadataConfig as unknown as Record<string, unknown>,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -327,17 +327,44 @@ type RuntimeConfigSecretResolver = Pick<
|
||||||
|
|
||||||
export async function resolveExecutionRunAdapterConfig(input: {
|
export async function resolveExecutionRunAdapterConfig(input: {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
|
agentId?: string | null;
|
||||||
|
issueId?: string | null;
|
||||||
|
heartbeatRunId?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
executionRunConfig: Record<string, unknown>;
|
executionRunConfig: Record<string, unknown>;
|
||||||
projectEnv: unknown;
|
projectEnv: unknown;
|
||||||
secretsSvc: RuntimeConfigSecretResolver;
|
secretsSvc: RuntimeConfigSecretResolver;
|
||||||
}) {
|
}) {
|
||||||
const { config: resolvedConfig, secretKeys } = await input.secretsSvc.resolveAdapterConfigForRuntime(
|
const { config: resolvedConfig, secretKeys, manifest } = await input.secretsSvc.resolveAdapterConfigForRuntime(
|
||||||
input.companyId,
|
input.companyId,
|
||||||
input.executionRunConfig,
|
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
|
const projectEnvResolution = input.projectEnv
|
||||||
? await input.secretsSvc.resolveEnvBindings(input.companyId, input.projectEnv)
|
? await input.secretsSvc.resolveEnvBindings(
|
||||||
: { env: {}, secretKeys: new Set<string>() };
|
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) {
|
if (Object.keys(projectEnvResolution.env).length > 0) {
|
||||||
resolvedConfig.env = {
|
resolvedConfig.env = {
|
||||||
...parseObject(resolvedConfig.env),
|
...parseObject(resolvedConfig.env),
|
||||||
|
|
@ -347,7 +374,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||||
secretKeys.add(key);
|
secretKeys.add(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { resolvedConfig, secretKeys };
|
return {
|
||||||
|
resolvedConfig,
|
||||||
|
secretKeys,
|
||||||
|
secretManifest: [...(manifest ?? []), ...(projectEnvResolution.manifest ?? [])],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractMentionedSkillIdsFromSources(
|
export function extractMentionedSkillIdsFromSources(
|
||||||
|
|
@ -6790,6 +6821,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||||
const projectContext = executionProjectId
|
const projectContext = executionProjectId
|
||||||
? await db
|
? await db
|
||||||
.select({
|
.select({
|
||||||
|
id: projects.id,
|
||||||
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
||||||
env: projects.env,
|
env: projects.env,
|
||||||
})
|
})
|
||||||
|
|
@ -6995,12 +7027,23 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||||
});
|
});
|
||||||
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig, selectedEnvironmentId);
|
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig, selectedEnvironmentId);
|
||||||
const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
|
const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
|
||||||
const { resolvedConfig, secretKeys } = await resolveExecutionRunAdapterConfig({
|
const { resolvedConfig, secretKeys, secretManifest } = await resolveExecutionRunAdapterConfig({
|
||||||
companyId: agent.companyId,
|
companyId: agent.companyId,
|
||||||
|
agentId: agent.id,
|
||||||
|
issueId,
|
||||||
|
heartbeatRunId: run.id,
|
||||||
|
projectId: projectContext?.id ?? null,
|
||||||
executionRunConfig,
|
executionRunConfig,
|
||||||
projectEnv: projectContext?.env ?? null,
|
projectEnv: projectContext?.env ?? null,
|
||||||
secretsSvc,
|
secretsSvc,
|
||||||
});
|
});
|
||||||
|
if (secretManifest.length > 0) {
|
||||||
|
context.paperclipSecrets = {
|
||||||
|
manifest: secretManifest,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
delete context.paperclipSecrets;
|
||||||
|
}
|
||||||
const runScopedMentionedSkillKeys = await resolveRunScopedMentionedSkillKeys({
|
const runScopedMentionedSkillKeys = await resolveRunScopedMentionedSkillKeys({
|
||||||
db,
|
db,
|
||||||
companyId: agent.companyId,
|
companyId: agent.companyId,
|
||||||
|
|
@ -8320,8 +8363,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||||
return { kind: "released" as const };
|
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 =
|
const shouldBlockImmediately =
|
||||||
issue.originKind === RECOVERY_ORIGIN_KINDS.strandedIssueRecovery ||
|
|
||||||
!recoveryAgentInvokable ||
|
!recoveryAgentInvokable ||
|
||||||
!recoveryAgent ||
|
!recoveryAgent ||
|
||||||
didAutomaticRecoveryFail(run, issue.status === "todo" ? "assignment_recovery" : "issue_continuation_needed");
|
didAutomaticRecoveryFail(run, issue.status === "todo" ? "assignment_recovery" : "issue_continuation_needed");
|
||||||
|
|
@ -8421,6 +8471,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||||
return;
|
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;
|
const promotedRun = promotionResult?.run ?? null;
|
||||||
if (!promotedRun) return;
|
if (!promotedRun) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,38 +33,20 @@
|
||||||
* @see services/secrets.ts — secretService used by agent env bindings
|
* @see services/secrets.ts — secretService used by agent env bindings
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { eq, and, desc } from "drizzle-orm";
|
|
||||||
import type { Db } from "@paperclipai/db";
|
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 {
|
import {
|
||||||
collectSecretRefPaths,
|
collectSecretRefPaths,
|
||||||
isUuidSecretRef,
|
isUuidSecretRef,
|
||||||
readConfigValueAtPath,
|
readConfigValueAtPath,
|
||||||
} from "./json-schema-secret-refs.js";
|
} 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
|
// 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 {
|
function invalidSecretRef(secretRef: string): Error {
|
||||||
const err = new Error(`Invalid secret reference: ${secretRef}`);
|
const err = new Error(`Invalid secret reference: ${secretRef}`);
|
||||||
err.name = "InvalidSecretRefError";
|
err.name = "InvalidSecretRefError";
|
||||||
|
|
@ -86,8 +68,20 @@ export function extractSecretRefsFromConfig(
|
||||||
configJson: unknown,
|
configJson: unknown,
|
||||||
schema?: Record<string, unknown> | null,
|
schema?: Record<string, unknown> | null,
|
||||||
): Set<string> {
|
): Set<string> {
|
||||||
const refs = new Set<string>();
|
return new Set(extractSecretRefPathsFromConfig(configJson, schema).keys());
|
||||||
if (configJson == null || typeof configJson !== "object") return refs;
|
}
|
||||||
|
|
||||||
|
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);
|
const secretPaths = collectSecretRefPaths(schema);
|
||||||
|
|
||||||
|
|
@ -96,7 +90,7 @@ export function extractSecretRefsFromConfig(
|
||||||
for (const dotPath of secretPaths) {
|
for (const dotPath of secretPaths) {
|
||||||
const current = readConfigValueAtPath(configJson as Record<string, unknown>, dotPath);
|
const current = readConfigValueAtPath(configJson as Record<string, unknown>, dotPath);
|
||||||
if (typeof current === "string" && isUuidSecretRef(current)) {
|
if (typeof current === "string" && isUuidSecretRef(current)) {
|
||||||
refs.add(current);
|
addRef(current, dotPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return refs;
|
return refs;
|
||||||
|
|
@ -107,7 +101,7 @@ export function extractSecretRefsFromConfig(
|
||||||
// instanceConfigSchema.
|
// instanceConfigSchema.
|
||||||
function walkAll(value: unknown): void {
|
function walkAll(value: unknown): void {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
if (isUuidSecretRef(value)) refs.add(value);
|
if (isUuidSecretRef(value)) addRef(value, "$");
|
||||||
} else if (Array.isArray(value)) {
|
} else if (Array.isArray(value)) {
|
||||||
for (const item of value) walkAll(item);
|
for (const item of value) walkAll(item);
|
||||||
} else if (value !== null && typeof value === "object") {
|
} else if (value !== null && typeof value === "object") {
|
||||||
|
|
@ -205,16 +199,11 @@ function createRateLimiter(maxAttempts: number, windowMs: number) {
|
||||||
export function createPluginSecretsHandler(
|
export function createPluginSecretsHandler(
|
||||||
options: PluginSecretsHandlerOptions,
|
options: PluginSecretsHandlerOptions,
|
||||||
): PluginSecretsService {
|
): PluginSecretsService {
|
||||||
const { db, pluginId } = options;
|
const { pluginId } = options;
|
||||||
const registry = pluginRegistryService(db);
|
|
||||||
|
|
||||||
// Rate limit: max 30 resolution attempts per plugin per minute
|
// Rate limit: max 30 resolution attempts per plugin per minute
|
||||||
const rateLimiter = createRateLimiter(30, 60_000);
|
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 {
|
return {
|
||||||
async resolve(params: PluginSecretsResolveParams): Promise<string> {
|
async resolve(params: PluginSecretsResolveParams): Promise<string> {
|
||||||
const { secretRef } = params;
|
const { secretRef } = params;
|
||||||
|
|
@ -241,72 +230,9 @@ export function createPluginSecretsHandler(
|
||||||
throw invalidSecretRef(trimmedRef);
|
throw invalidSecretRef(trimmedRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// Fail closed until plugin config and worker runtime both carry an
|
||||||
// 1b. Scope check — only allow secrets referenced in this plugin's config
|
// explicit company scope for secret bindings and resolution.
|
||||||
// ---------------------------------------------------------------
|
throw new Error(PLUGIN_SECRET_REFS_DISABLED_MESSAGE);
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1313,6 +1313,33 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||||
.then((rows) => rows[0] ?? null);
|
.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) {
|
async function resolveStrandedIssueRecoveryOwnerAgentId(issue: typeof issues.$inferSelect) {
|
||||||
const candidateIds: string[] = [];
|
const candidateIds: string[] = [];
|
||||||
if (issue.assigneeAgentId) {
|
if (issue.assigneeAgentId) {
|
||||||
|
|
@ -1623,21 +1650,17 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||||
recoveryCause?: StrandedRecoveryCause;
|
recoveryCause?: StrandedRecoveryCause;
|
||||||
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
|
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
|
||||||
}) {
|
}) {
|
||||||
if (isStrandedIssueRecoveryIssue(input.issue)) {
|
const nestedRecoverySuppressed = isStrandedIssueRecoveryIssue(input.issue);
|
||||||
return escalateStrandedRecoveryIssueInPlace({
|
let recoveryIssue: typeof issues.$inferSelect | null = null;
|
||||||
|
if (!nestedRecoverySuppressed) {
|
||||||
|
recoveryIssue = await ensureStrandedIssueRecoveryIssue({
|
||||||
issue: input.issue,
|
issue: input.issue,
|
||||||
previousStatus: input.previousStatus,
|
previousStatus: input.previousStatus,
|
||||||
latestRun: input.latestRun,
|
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 blockerIds = await existingUnresolvedBlockerIssueIds(input.issue.companyId, input.issue.id);
|
||||||
const nextBlockerIds = recoveryIssue
|
const nextBlockerIds = recoveryIssue
|
||||||
? [...new Set([...blockerIds, recoveryIssue.id])]
|
? [...new Set([...blockerIds, recoveryIssue.id])]
|
||||||
|
|
@ -1667,18 +1690,23 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||||
missingDisposition: input.successfulRunHandoffEvidence.missingDisposition,
|
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 issue: ${issueUiLink({ identifier: recoveryIssue.identifier, id: recoveryIssue.id }, prefix)}`,
|
||||||
`- Recovery owner: ${agentUiLink(recoveryOwner, 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.",
|
"- 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.",
|
"- 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.",
|
"- Next action: a board operator should assign an invokable recovery owner, fix the agent/runtime state, or record an intentional manual resolution.",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
if (notice) {
|
if (notice) {
|
||||||
await issuesSvc.addComment(input.issue.id, notice.body, {}, {
|
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,
|
latestRunStatus: input.latestRun?.status ?? null,
|
||||||
latestRunErrorCode: input.latestRun?.errorCode ?? null,
|
latestRunErrorCode: input.latestRun?.errorCode ?? null,
|
||||||
recoveryIssueId: recoveryIssue?.id ?? null,
|
recoveryIssueId: recoveryIssue?.id ?? null,
|
||||||
|
nestedRecoverySuppressed,
|
||||||
blockerIssueIds: nextBlockerIds,
|
blockerIssueIds: nextBlockerIds,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -2768,6 +2797,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
buildRunOutputSilence,
|
buildRunOutputSilence,
|
||||||
|
escalateStrandedRecoveryIssueInPlace,
|
||||||
escalateStrandedAssignedIssue,
|
escalateStrandedAssignedIssue,
|
||||||
recordWatchdogDecision,
|
recordWatchdogDecision,
|
||||||
scanSilentActiveRuns,
|
scanSilentActiveRuns,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { and, asc, desc, eq, inArray, isNotNull, isNull, lte, ne, not, or, sql }
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
agents,
|
agents,
|
||||||
|
companySecretBindings,
|
||||||
companySecretVersions,
|
companySecretVersions,
|
||||||
companySecrets,
|
companySecrets,
|
||||||
executionWorkspaces,
|
executionWorkspaces,
|
||||||
|
|
@ -49,6 +50,7 @@ import { trackRoutineRun } from "@paperclipai/shared/telemetry";
|
||||||
import { conflict, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
|
import { conflict, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
|
||||||
import { logger } from "../middleware/logger.js";
|
import { logger } from "../middleware/logger.js";
|
||||||
import { getTelemetryClient } from "../telemetry.js";
|
import { getTelemetryClient } from "../telemetry.js";
|
||||||
|
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
|
||||||
import { issueService } from "./issues.js";
|
import { issueService } from "./issues.js";
|
||||||
import { secretService } from "./secrets.js";
|
import { secretService } from "./secrets.js";
|
||||||
import { getSecretProvider } from "../secrets/provider-registry.js";
|
import { getSecretProvider } from "../secrets/provider-registry.js";
|
||||||
|
|
@ -81,6 +83,10 @@ interface RoutineTriggerSecretRestoreMaterial extends RoutineTriggerSecretMateri
|
||||||
triggerId: string;
|
triggerId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function routineWebhookSecretConfigPath(secretId: string) {
|
||||||
|
return `webhookSecret:${secretId}`;
|
||||||
|
}
|
||||||
|
|
||||||
function assertTimeZone(timeZone: string) {
|
function assertTimeZone(timeZone: string) {
|
||||||
try {
|
try {
|
||||||
new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date());
|
new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date());
|
||||||
|
|
@ -950,16 +956,23 @@ export function routineService(
|
||||||
executor?: Db,
|
executor?: Db,
|
||||||
) {
|
) {
|
||||||
const secretValue = crypto.randomBytes(24).toString("hex");
|
const secretValue = crypto.randomBytes(24).toString("hex");
|
||||||
|
const providerId = getConfiguredSecretProvider();
|
||||||
const input = {
|
const input = {
|
||||||
name: `routine-${routineId}-${crypto.randomBytes(6).toString("hex")}`,
|
name: `routine-${routineId}-${crypto.randomBytes(6).toString("hex")}`,
|
||||||
provider: "local_encrypted" as const,
|
provider: providerId,
|
||||||
value: secretValue,
|
value: secretValue,
|
||||||
description: `Webhook auth for routine ${routineId}`,
|
description: `Webhook auth for routine ${routineId}`,
|
||||||
};
|
};
|
||||||
const provider = getSecretProvider(input.provider);
|
const provider = getSecretProvider(input.provider);
|
||||||
const prepared = await provider.createVersion({
|
const prepared = await provider.createSecret({
|
||||||
value: input.value,
|
value: input.value,
|
||||||
externalRef: null,
|
externalRef: null,
|
||||||
|
context: {
|
||||||
|
companyId,
|
||||||
|
secretKey: input.name,
|
||||||
|
secretName: input.name,
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const insertSecret = async (secretDb: Db) => {
|
const insertSecret = async (secretDb: Db) => {
|
||||||
|
|
@ -967,11 +980,16 @@ export function routineService(
|
||||||
.insert(companySecrets)
|
.insert(companySecrets)
|
||||||
.values({
|
.values({
|
||||||
companyId,
|
companyId,
|
||||||
|
key: input.name,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
provider: input.provider,
|
provider: input.provider,
|
||||||
|
status: "active",
|
||||||
|
managedMode: "paperclip_managed",
|
||||||
externalRef: prepared.externalRef,
|
externalRef: prepared.externalRef,
|
||||||
|
providerMetadata: null,
|
||||||
latestVersion: 1,
|
latestVersion: 1,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
|
lastRotatedAt: new Date(),
|
||||||
createdByAgentId: actor.agentId ?? null,
|
createdByAgentId: actor.agentId ?? null,
|
||||||
createdByUserId: actor.userId ?? null,
|
createdByUserId: actor.userId ?? null,
|
||||||
})
|
})
|
||||||
|
|
@ -983,10 +1001,21 @@ export function routineService(
|
||||||
version: 1,
|
version: 1,
|
||||||
material: prepared.material,
|
material: prepared.material,
|
||||||
valueSha256: prepared.valueSha256,
|
valueSha256: prepared.valueSha256,
|
||||||
|
fingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256,
|
||||||
|
providerVersionRef: prepared.providerVersionRef ?? null,
|
||||||
|
status: "current",
|
||||||
createdByAgentId: actor.agentId ?? null,
|
createdByAgentId: actor.agentId ?? null,
|
||||||
createdByUserId: actor.userId ?? null,
|
createdByUserId: actor.userId ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await secretDb.insert(companySecretBindings).values({
|
||||||
|
companyId,
|
||||||
|
secretId: secret.id,
|
||||||
|
targetType: "routine",
|
||||||
|
targetId: routineId,
|
||||||
|
configPath: routineWebhookSecretConfigPath(secret.id),
|
||||||
|
});
|
||||||
|
|
||||||
return secret;
|
return secret;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1004,7 +1033,13 @@ export function routineService(
|
||||||
.where(eq(companySecrets.id, trigger.secretId))
|
.where(eq(companySecrets.id, trigger.secretId))
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
if (!secret || secret.companyId !== companyId) throw notFound("Routine trigger secret not found");
|
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;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -32,6 +32,7 @@ import { CompanyEnvironments } from "./pages/CompanyEnvironments";
|
||||||
import { CompanyAccess } from "./pages/CompanyAccess";
|
import { CompanyAccess } from "./pages/CompanyAccess";
|
||||||
import { CompanyInvites } from "./pages/CompanyInvites";
|
import { CompanyInvites } from "./pages/CompanyInvites";
|
||||||
import { CompanySkills } from "./pages/CompanySkills";
|
import { CompanySkills } from "./pages/CompanySkills";
|
||||||
|
import { Secrets } from "./pages/Secrets";
|
||||||
import { CompanyExport } from "./pages/CompanyExport";
|
import { CompanyExport } from "./pages/CompanyExport";
|
||||||
import { CompanyImport } from "./pages/CompanyImport";
|
import { CompanyImport } from "./pages/CompanyImport";
|
||||||
import { DesignGuide } from "./pages/DesignGuide";
|
import { DesignGuide } from "./pages/DesignGuide";
|
||||||
|
|
@ -71,6 +72,7 @@ function boardRoutes() {
|
||||||
<Route path="company/settings/invites" element={<CompanyInvites />} />
|
<Route path="company/settings/invites" element={<CompanyInvites />} />
|
||||||
<Route path="company/export/*" element={<CompanyExport />} />
|
<Route path="company/export/*" element={<CompanyExport />} />
|
||||||
<Route path="company/import" element={<CompanyImport />} />
|
<Route path="company/import" element={<CompanyImport />} />
|
||||||
|
<Route path="company/settings/secrets" element={<Secrets />} />
|
||||||
<Route path="skills/*" element={<CompanySkills />} />
|
<Route path="skills/*" element={<CompanySkills />} />
|
||||||
<Route path="settings" element={<LegacySettingsRedirect />} />
|
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||||
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,138 @@
|
||||||
import type { CompanySecret, SecretProviderDescriptor, SecretProvider } from "@paperclipai/shared";
|
import type {
|
||||||
|
CompanySecret,
|
||||||
|
CompanySecretUsageBinding,
|
||||||
|
CompanySecretProviderConfig,
|
||||||
|
RemoteSecretImportPreviewResult,
|
||||||
|
RemoteSecretImportResult,
|
||||||
|
SecretAccessEvent,
|
||||||
|
SecretManagedMode,
|
||||||
|
SecretProvider,
|
||||||
|
SecretProviderConfigStatus,
|
||||||
|
SecretProviderConfigHealthResponse,
|
||||||
|
SecretProviderDescriptor,
|
||||||
|
SecretStatus,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export interface SecretUsageResponse {
|
||||||
|
secretId: string;
|
||||||
|
bindings: CompanySecretUsageBinding[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSecretInput {
|
||||||
|
name: string;
|
||||||
|
key?: string;
|
||||||
|
provider?: SecretProvider;
|
||||||
|
managedMode?: SecretManagedMode;
|
||||||
|
value?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
externalRef?: string | null;
|
||||||
|
providerVersionRef?: string | null;
|
||||||
|
providerConfigId?: string | null;
|
||||||
|
providerMetadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecretProviderHealthResponse {
|
||||||
|
providers: Array<{
|
||||||
|
provider: SecretProvider;
|
||||||
|
status: "ok" | "warn" | "error";
|
||||||
|
message: string;
|
||||||
|
warnings?: string[];
|
||||||
|
backupGuidance?: string[];
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSecretInput {
|
||||||
|
name?: string;
|
||||||
|
key?: string;
|
||||||
|
status?: SecretStatus;
|
||||||
|
description?: string | null;
|
||||||
|
externalRef?: string | null;
|
||||||
|
providerMetadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RotateSecretInput {
|
||||||
|
value?: string | null;
|
||||||
|
externalRef?: string | null;
|
||||||
|
providerVersionRef?: string | null;
|
||||||
|
providerConfigId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSecretProviderConfigInput {
|
||||||
|
provider: SecretProvider;
|
||||||
|
displayName: string;
|
||||||
|
status?: SecretProviderConfigStatus;
|
||||||
|
isDefault?: boolean;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSecretProviderConfigInput {
|
||||||
|
displayName?: string;
|
||||||
|
status?: SecretProviderConfigStatus;
|
||||||
|
isDefault?: boolean;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteImportPreviewInput {
|
||||||
|
providerConfigId: string;
|
||||||
|
query?: string | null;
|
||||||
|
nextToken?: string | null;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteImportSelectionInput {
|
||||||
|
externalRef: string;
|
||||||
|
name?: string | null;
|
||||||
|
key?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
providerVersionRef?: string | null;
|
||||||
|
providerMetadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteImportInput {
|
||||||
|
providerConfigId: string;
|
||||||
|
secrets: RemoteImportSelectionInput[];
|
||||||
|
}
|
||||||
|
|
||||||
export const secretsApi = {
|
export const secretsApi = {
|
||||||
list: (companyId: string) => api.get<CompanySecret[]>(`/companies/${companyId}/secrets`),
|
list: (companyId: string) => api.get<CompanySecret[]>(`/companies/${companyId}/secrets`),
|
||||||
providers: (companyId: string) =>
|
providers: (companyId: string) =>
|
||||||
api.get<SecretProviderDescriptor[]>(`/companies/${companyId}/secret-providers`),
|
api.get<SecretProviderDescriptor[]>(`/companies/${companyId}/secret-providers`),
|
||||||
create: (
|
providerHealth: (companyId: string) =>
|
||||||
companyId: string,
|
api.get<SecretProviderHealthResponse>(`/companies/${companyId}/secret-providers/health`),
|
||||||
data: {
|
providerConfigs: (companyId: string) =>
|
||||||
name: string;
|
api.get<CompanySecretProviderConfig[]>(`/companies/${companyId}/secret-provider-configs`),
|
||||||
value: string;
|
createProviderConfig: (companyId: string, data: CreateSecretProviderConfigInput) =>
|
||||||
provider?: SecretProvider;
|
api.post<CompanySecretProviderConfig>(`/companies/${companyId}/secret-provider-configs`, data),
|
||||||
description?: string | null;
|
updateProviderConfig: (id: string, data: UpdateSecretProviderConfigInput) =>
|
||||||
externalRef?: string | null;
|
api.patch<CompanySecretProviderConfig>(`/secret-provider-configs/${id}`, data),
|
||||||
},
|
disableProviderConfig: (id: string) =>
|
||||||
) => api.post<CompanySecret>(`/companies/${companyId}/secrets`, data),
|
api.delete<CompanySecretProviderConfig>(`/secret-provider-configs/${id}`),
|
||||||
rotate: (id: string, data: { value: string; externalRef?: string | null }) =>
|
setDefaultProviderConfig: (id: string) =>
|
||||||
|
api.post<CompanySecretProviderConfig>(`/secret-provider-configs/${id}/default`, {}),
|
||||||
|
checkProviderConfigHealth: (id: string) =>
|
||||||
|
api.post<SecretProviderConfigHealthResponse>(`/secret-provider-configs/${id}/health`, {}),
|
||||||
|
create: (companyId: string, data: CreateSecretInput) =>
|
||||||
|
api.post<CompanySecret>(`/companies/${companyId}/secrets`, data),
|
||||||
|
update: (id: string, data: UpdateSecretInput) =>
|
||||||
|
api.patch<CompanySecret>(`/secrets/${id}`, data),
|
||||||
|
rotate: (id: string, data: RotateSecretInput) =>
|
||||||
api.post<CompanySecret>(`/secrets/${id}/rotate`, data),
|
api.post<CompanySecret>(`/secrets/${id}/rotate`, data),
|
||||||
update: (
|
disable: (id: string) =>
|
||||||
id: string,
|
api.patch<CompanySecret>(`/secrets/${id}`, { status: "disabled" satisfies SecretStatus }),
|
||||||
data: { name?: string; description?: string | null; externalRef?: string | null },
|
enable: (id: string) =>
|
||||||
) => api.patch<CompanySecret>(`/secrets/${id}`, data),
|
api.patch<CompanySecret>(`/secrets/${id}`, { status: "active" satisfies SecretStatus }),
|
||||||
|
archive: (id: string) =>
|
||||||
|
api.patch<CompanySecret>(`/secrets/${id}`, { status: "archived" satisfies SecretStatus }),
|
||||||
remove: (id: string) => api.delete<{ ok: true }>(`/secrets/${id}`),
|
remove: (id: string) => api.delete<{ ok: true }>(`/secrets/${id}`),
|
||||||
|
usage: (id: string) => api.get<SecretUsageResponse>(`/secrets/${id}/usage`),
|
||||||
|
accessEvents: (id: string) => api.get<SecretAccessEvent[]>(`/secrets/${id}/access-events`),
|
||||||
|
remoteImportPreview: (companyId: string, data: RemoteImportPreviewInput) =>
|
||||||
|
api.post<RemoteSecretImportPreviewResult>(
|
||||||
|
`/companies/${companyId}/secrets/remote-import/preview`,
|
||||||
|
data,
|
||||||
|
),
|
||||||
|
remoteImport: (companyId: string, data: RemoteImportInput) =>
|
||||||
|
api.post<RemoteSecretImportResult>(`/companies/${companyId}/secrets/remote-import`, data),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ describe("CompanySettingsSidebar", () => {
|
||||||
expect(container.textContent).toContain("Environments");
|
expect(container.textContent).toContain("Environments");
|
||||||
expect(container.textContent).toContain("Access");
|
expect(container.textContent).toContain("Access");
|
||||||
expect(container.textContent).toContain("Invites");
|
expect(container.textContent).toContain("Invites");
|
||||||
|
expect(container.textContent).toContain("Secrets");
|
||||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
to: "/company/settings",
|
to: "/company/settings",
|
||||||
|
|
@ -141,6 +142,13 @@ describe("CompanySettingsSidebar", () => {
|
||||||
end: true,
|
end: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "/company/settings/secrets",
|
||||||
|
label: "Secrets",
|
||||||
|
end: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ChevronLeft, MailPlus, MonitorCog, Settings, Shield, SlidersHorizontal } from "lucide-react";
|
import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Settings, Shield, SlidersHorizontal } from "lucide-react";
|
||||||
import { sidebarBadgesApi } from "@/api/sidebarBadges";
|
import { sidebarBadgesApi } from "@/api/sidebarBadges";
|
||||||
import { ApiError } from "@/api/client";
|
import { ApiError } from "@/api/client";
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
|
|
@ -68,6 +68,7 @@ export function CompanySettingsSidebar() {
|
||||||
end
|
end
|
||||||
/>
|
/>
|
||||||
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
|
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
|
||||||
|
<SidebarNavItem to="/company/settings/secrets" label="Secrets" icon={KeyRound} end />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { CompanySecret, EnvBinding } from "@paperclipai/shared";
|
import type { CompanySecret, EnvBinding, SecretVersionSelector } from "@paperclipai/shared";
|
||||||
import { X } from "lucide-react";
|
import { AlertCircle, X } from "lucide-react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
|
|
@ -11,15 +11,20 @@ type Row = {
|
||||||
source: "plain" | "secret";
|
source: "plain" | "secret";
|
||||||
plainValue: string;
|
plainValue: string;
|
||||||
secretId: string;
|
secretId: string;
|
||||||
|
version: SecretVersionSelector;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function emptyRow(): Row {
|
||||||
|
return { key: "", source: "plain", plainValue: "", secretId: "", version: "latest" };
|
||||||
|
}
|
||||||
|
|
||||||
function toRows(rec: Record<string, EnvBinding> | null | undefined): Row[] {
|
function toRows(rec: Record<string, EnvBinding> | null | undefined): Row[] {
|
||||||
if (!rec || typeof rec !== "object") {
|
if (!rec || typeof rec !== "object") {
|
||||||
return [{ key: "", source: "plain", plainValue: "", secretId: "" }];
|
return [emptyRow()];
|
||||||
}
|
}
|
||||||
const entries = Object.entries(rec).map(([key, binding]) => {
|
const entries = Object.entries(rec).map(([key, binding]) => {
|
||||||
if (typeof binding === "string") {
|
if (typeof binding === "string") {
|
||||||
return { key, source: "plain" as const, plainValue: binding, secretId: "" };
|
return { key, source: "plain" as const, plainValue: binding, secretId: "", version: "latest" as const };
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
typeof binding === "object" &&
|
typeof binding === "object" &&
|
||||||
|
|
@ -27,12 +32,16 @@ function toRows(rec: Record<string, EnvBinding> | null | undefined): Row[] {
|
||||||
"type" in binding &&
|
"type" in binding &&
|
||||||
(binding as { type?: unknown }).type === "secret_ref"
|
(binding as { type?: unknown }).type === "secret_ref"
|
||||||
) {
|
) {
|
||||||
const record = binding as { secretId?: unknown };
|
const record = binding as { secretId?: unknown; version?: unknown };
|
||||||
|
const version: SecretVersionSelector = typeof record.version === "number"
|
||||||
|
? record.version
|
||||||
|
: "latest";
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
source: "secret" as const,
|
source: "secret" as const,
|
||||||
plainValue: "",
|
plainValue: "",
|
||||||
secretId: typeof record.secretId === "string" ? record.secretId : "",
|
secretId: typeof record.secretId === "string" ? record.secretId : "",
|
||||||
|
version,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|
@ -47,11 +56,12 @@ function toRows(rec: Record<string, EnvBinding> | null | undefined): Row[] {
|
||||||
source: "plain" as const,
|
source: "plain" as const,
|
||||||
plainValue: typeof record.value === "string" ? record.value : "",
|
plainValue: typeof record.value === "string" ? record.value : "",
|
||||||
secretId: "",
|
secretId: "",
|
||||||
|
version: "latest" as const,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { key, source: "plain" as const, plainValue: "", secretId: "" };
|
return { key, source: "plain" as const, plainValue: "", secretId: "", version: "latest" as const };
|
||||||
});
|
});
|
||||||
return [...entries, { key: "", source: "plain", plainValue: "", secretId: "" }];
|
return [...entries, emptyRow()];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EnvVarEditor({
|
export function EnvVarEditor({
|
||||||
|
|
@ -89,7 +99,7 @@ export function EnvVarEditor({
|
||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
if (row.source === "secret") {
|
if (row.source === "secret") {
|
||||||
if (row.secretId) {
|
if (row.secretId) {
|
||||||
rec[key] = { type: "secret_ref", secretId: row.secretId, version: "latest" };
|
rec[key] = { type: "secret_ref", secretId: row.secretId, version: row.version };
|
||||||
} else {
|
} else {
|
||||||
rec[key] = { type: "plain", value: row.plainValue };
|
rec[key] = { type: "plain", value: row.plainValue };
|
||||||
}
|
}
|
||||||
|
|
@ -102,13 +112,15 @@ export function EnvVarEditor({
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRow(index: number, patch: Partial<Row>) {
|
function updateRow(index: number, patch: Partial<Row>) {
|
||||||
const withPatch = rows.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row));
|
const withPatch: Row[] = rows.map((row, rowIndex) =>
|
||||||
|
rowIndex === index ? { ...row, ...patch, version: patch.version ?? row.version } : row,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
withPatch[withPatch.length - 1].key ||
|
withPatch[withPatch.length - 1].key ||
|
||||||
withPatch[withPatch.length - 1].plainValue ||
|
withPatch[withPatch.length - 1].plainValue ||
|
||||||
withPatch[withPatch.length - 1].secretId
|
withPatch[withPatch.length - 1].secretId
|
||||||
) {
|
) {
|
||||||
withPatch.push({ key: "", source: "plain", plainValue: "", secretId: "" });
|
withPatch.push(emptyRow());
|
||||||
}
|
}
|
||||||
setRows(withPatch);
|
setRows(withPatch);
|
||||||
emit(withPatch);
|
emit(withPatch);
|
||||||
|
|
@ -122,7 +134,7 @@ export function EnvVarEditor({
|
||||||
next[next.length - 1].plainValue ||
|
next[next.length - 1].plainValue ||
|
||||||
next[next.length - 1].secretId
|
next[next.length - 1].secretId
|
||||||
) {
|
) {
|
||||||
next.push({ key: "", source: "plain", plainValue: "", secretId: "" });
|
next.push(emptyRow());
|
||||||
}
|
}
|
||||||
setRows(next);
|
setRows(next);
|
||||||
emit(next);
|
emit(next);
|
||||||
|
|
@ -189,17 +201,46 @@ export function EnvVarEditor({
|
||||||
{row.source === "secret" ? (
|
{row.source === "secret" ? (
|
||||||
<>
|
<>
|
||||||
<select
|
<select
|
||||||
className={cn(inputClass, "flex-[3] bg-background")}
|
className={cn(inputClass, "flex-[3] bg-background", row.secretId && !secrets.some((s) => s.id === row.secretId) && "border-destructive text-destructive")}
|
||||||
value={row.secretId}
|
value={row.secretId}
|
||||||
onChange={(event) => updateRow(index, { secretId: event.target.value })}
|
onChange={(event) => updateRow(index, { secretId: event.target.value })}
|
||||||
>
|
>
|
||||||
<option value="">Select secret...</option>
|
<option value="">Select secret...</option>
|
||||||
|
{row.secretId && !secrets.some((s) => s.id === row.secretId) ? (
|
||||||
|
<option value={row.secretId}>Missing ({row.secretId.slice(0, 8)}…)</option>
|
||||||
|
) : null}
|
||||||
{secrets.map((secret) => (
|
{secrets.map((secret) => (
|
||||||
<option key={secret.id} value={secret.id}>
|
<option key={secret.id} value={secret.id}>
|
||||||
{secret.name}
|
{secret.name}
|
||||||
|
{secret.status !== "active" ? ` (${secret.status})` : ""}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<select
|
||||||
|
className={cn(inputClass, "flex-[1] bg-background")}
|
||||||
|
value={row.version === "latest" ? "latest" : String(row.version)}
|
||||||
|
onChange={(event) => {
|
||||||
|
const raw = event.target.value;
|
||||||
|
updateRow(index, { version: raw === "latest" ? "latest" : Number.parseInt(raw, 10) });
|
||||||
|
}}
|
||||||
|
disabled={!row.secretId}
|
||||||
|
aria-label="Version"
|
||||||
|
>
|
||||||
|
<option value="latest">latest</option>
|
||||||
|
{(() => {
|
||||||
|
const selected = secrets.find((s) => s.id === row.secretId);
|
||||||
|
if (!selected) return null;
|
||||||
|
return Array.from({ length: Math.max(0, selected.latestVersion) }, (_, idx) => {
|
||||||
|
const version = selected.latestVersion - idx;
|
||||||
|
if (version <= 0) return null;
|
||||||
|
return (
|
||||||
|
<option key={version} value={version}>
|
||||||
|
v{version}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
</select>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
|
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
|
||||||
|
|
@ -244,8 +285,38 @@ export function EnvVarEditor({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{sealError && <p className="text-[11px] text-destructive">{sealError}</p>}
|
{sealError && <p className="text-[11px] text-destructive">{sealError}</p>}
|
||||||
|
{(() => {
|
||||||
|
const issues: { key: string; reason: string }[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.source !== "secret" || !row.secretId) continue;
|
||||||
|
const secret = secrets.find((s) => s.id === row.secretId);
|
||||||
|
if (!secret) {
|
||||||
|
issues.push({ key: row.key.trim() || row.secretId, reason: "missing" });
|
||||||
|
} else if (secret.status !== "active") {
|
||||||
|
issues.push({ key: row.key.trim() || secret.name, reason: secret.status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!issues.length) return null;
|
||||||
|
return (
|
||||||
|
<p className="text-[11px] text-amber-700 dark:text-amber-400 inline-flex items-start gap-1">
|
||||||
|
<AlertCircle className="h-3 w-3 mt-0.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{issues.length} secret binding{issues.length === 1 ? "" : "s"} need attention:{" "}
|
||||||
|
{issues.map((issue, idx) => (
|
||||||
|
<span key={idx} className="font-mono">
|
||||||
|
{issue.key}
|
||||||
|
<span className="text-muted-foreground"> ({issue.reason})</span>
|
||||||
|
{idx < issues.length - 1 ? ", " : ""}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
. Runs will fail until you remap or re-enable.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<p className="text-[11px] text-muted-foreground/60">
|
<p className="text-[11px] text-muted-foreground/60">
|
||||||
PAPERCLIP_* variables are injected automatically at runtime.
|
Set KEY to the env var name the process expects, for example GH_TOKEN. Choose Secret to resolve a stored
|
||||||
|
value at run start. PAPERCLIP_* variables are injected automatically.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,17 @@ async function waitForUi(assertion: () => void) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForRetryButtonText(expected: string) {
|
||||||
|
for (let i = 0; i < 20; i += 1) {
|
||||||
|
if ((getRetryNowButton()?.textContent ?? "").includes(expected)) return;
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(getRetryNowButton()!.textContent ?? "").toContain(expected);
|
||||||
|
}
|
||||||
|
|
||||||
function renderWithProviders(ui: ReactNode) {
|
function renderWithProviders(ui: ReactNode) {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
|
@ -174,12 +185,11 @@ describe("IssueScheduledRetryCard", () => {
|
||||||
act(() => {
|
act(() => {
|
||||||
button!.click();
|
button!.click();
|
||||||
});
|
});
|
||||||
await waitForUi(() => {
|
await waitForRetryButtonText("Promoted");
|
||||||
expect(retryNowMock).toHaveBeenCalledWith("issue-1");
|
expect(retryNowMock).toHaveBeenCalledWith("issue-1");
|
||||||
const finalButton = getRetryNowButton();
|
const finalButton = getRetryNowButton();
|
||||||
expect(finalButton!.textContent ?? "").toContain("Promoted");
|
expect(finalButton!.textContent ?? "").toContain("Promoted");
|
||||||
expect(finalButton!.disabled).toBe(true);
|
expect(finalButton!.disabled).toBe(true);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows already promoted state when backend reports duplicate click", async () => {
|
it("shows already promoted state when backend reports duplicate click", async () => {
|
||||||
|
|
@ -190,10 +200,9 @@ describe("IssueScheduledRetryCard", () => {
|
||||||
act(() => {
|
act(() => {
|
||||||
getRetryNowButton()!.click();
|
getRetryNowButton()!.click();
|
||||||
});
|
});
|
||||||
await waitForUi(() => {
|
await waitForRetryButtonText("Already promoted");
|
||||||
expect(getRetryNowButton()!.textContent ?? "").toContain("Already promoted");
|
expect(getRetryNowButton()!.textContent ?? "").toContain("Already promoted");
|
||||||
expect(container.querySelector('[data-testid="issue-scheduled-retry-error-band"]')).toBeNull();
|
expect(container.querySelector('[data-testid="issue-scheduled-retry-error-band"]')).toBeNull();
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders an inline error band on backend failure", async () => {
|
it("renders an inline error band on backend failure", async () => {
|
||||||
|
|
|
||||||
282
ui/src/components/SecretBindingPicker.tsx
Normal file
282
ui/src/components/SecretBindingPicker.tsx
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { AlertCircle, KeyRound, Loader2, Plus, X } from "lucide-react";
|
||||||
|
import type { CompanySecret, SecretVersionSelector } from "@paperclipai/shared";
|
||||||
|
import { secretsApi } from "../api/secrets";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
export interface SecretBindingValue {
|
||||||
|
secretId: string;
|
||||||
|
version?: SecretVersionSelector;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecretBindingPickerProps {
|
||||||
|
value: SecretBindingValue | null;
|
||||||
|
onChange: (next: SecretBindingValue | null) => void;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
allowVersionSelector?: boolean;
|
||||||
|
emptyHint?: string;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
/**
|
||||||
|
* Optional whitelist of secret statuses to show. Defaults to "active".
|
||||||
|
* Pass null to disable the filter and show every secret in the company.
|
||||||
|
*/
|
||||||
|
statusFilter?: Array<CompanySecret["status"]> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERSION_LATEST: SecretVersionSelector = "latest";
|
||||||
|
|
||||||
|
function describeSecret(secret: CompanySecret): string {
|
||||||
|
const provider = secret.provider.replaceAll("_", " ");
|
||||||
|
if (secret.managedMode === "external_reference") {
|
||||||
|
return `External · ${provider}`;
|
||||||
|
}
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(status: CompanySecret["status"]): string {
|
||||||
|
switch (status) {
|
||||||
|
case "active":
|
||||||
|
return "text-emerald-600 dark:text-emerald-400";
|
||||||
|
case "disabled":
|
||||||
|
return "text-amber-600 dark:text-amber-400";
|
||||||
|
case "archived":
|
||||||
|
return "text-muted-foreground";
|
||||||
|
case "deleted":
|
||||||
|
return "text-destructive";
|
||||||
|
default:
|
||||||
|
return "text-muted-foreground";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SecretBindingPicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label = "Secret",
|
||||||
|
placeholder = "Select secret",
|
||||||
|
allowVersionSelector = true,
|
||||||
|
emptyHint = "No matching secrets. Create one to bind it here.",
|
||||||
|
className,
|
||||||
|
disabled,
|
||||||
|
statusFilter = ["active"],
|
||||||
|
}: SecretBindingPickerProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { selectedCompanyId } = useCompany();
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createName, setCreateName] = useState("");
|
||||||
|
const [createValue, setCreateValue] = useState("");
|
||||||
|
const [createDescription, setCreateDescription] = useState("");
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const secretsQuery = useQuery({
|
||||||
|
queryKey: selectedCompanyId
|
||||||
|
? queryKeys.secrets.list(selectedCompanyId)
|
||||||
|
: ["secrets", "__disabled__"],
|
||||||
|
queryFn: () => secretsApi.list(selectedCompanyId!),
|
||||||
|
enabled: Boolean(selectedCompanyId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredSecrets = useMemo(() => {
|
||||||
|
const all = secretsQuery.data ?? [];
|
||||||
|
if (statusFilter === null) return all;
|
||||||
|
return all.filter((secret) => statusFilter.includes(secret.status));
|
||||||
|
}, [secretsQuery.data, statusFilter]);
|
||||||
|
|
||||||
|
const selectedSecret = useMemo(() => {
|
||||||
|
if (!value) return null;
|
||||||
|
return (secretsQuery.data ?? []).find((secret) => secret.id === value.secretId) ?? null;
|
||||||
|
}, [secretsQuery.data, value]);
|
||||||
|
|
||||||
|
const selectedMissing = Boolean(value && !selectedSecret);
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
secretsApi.create(selectedCompanyId!, {
|
||||||
|
name: createName.trim(),
|
||||||
|
value: createValue,
|
||||||
|
description: createDescription.trim() || null,
|
||||||
|
}),
|
||||||
|
onSuccess: (created) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId!) });
|
||||||
|
onChange({ secretId: created.id, version: VERSION_LATEST });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setCreateName("");
|
||||||
|
setCreateValue("");
|
||||||
|
setCreateDescription("");
|
||||||
|
setCreateError(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setCreateError(error instanceof Error ? error.message : "Failed to create secret");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const versionDisplay = (selector: SecretVersionSelector | undefined) => {
|
||||||
|
if (selector === undefined || selector === VERSION_LATEST) return "latest";
|
||||||
|
return `v${selector}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-1.5", className)}>
|
||||||
|
{label ? (
|
||||||
|
<div className="flex items-center justify-between text-xs font-medium text-foreground/80">
|
||||||
|
<span>{label}</span>
|
||||||
|
{value ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-[11px] text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||||
|
onClick={() => onChange(null)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" /> Clear
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<KeyRound className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<select
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-full rounded-md border border-border bg-background pl-7 pr-2 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
|
selectedMissing && "border-destructive text-destructive",
|
||||||
|
)}
|
||||||
|
value={value?.secretId ?? ""}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = event.target.value;
|
||||||
|
if (!next) {
|
||||||
|
onChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange({ secretId: next, version: value?.version ?? VERSION_LATEST });
|
||||||
|
}}
|
||||||
|
disabled={disabled || secretsQuery.isPending}
|
||||||
|
>
|
||||||
|
<option value="">{secretsQuery.isPending ? "Loading…" : placeholder}</option>
|
||||||
|
{selectedMissing && value ? (
|
||||||
|
<option value={value.secretId}>Missing secret ({value.secretId.slice(0, 8)}…)</option>
|
||||||
|
) : null}
|
||||||
|
{filteredSecrets.map((secret) => (
|
||||||
|
<option key={secret.id} value={secret.id}>
|
||||||
|
{secret.name} — {describeSecret(secret)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{allowVersionSelector ? (
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-border bg-background px-2 text-xs outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
value={value?.version === undefined ? VERSION_LATEST : String(value.version)}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (!value) return;
|
||||||
|
const raw = event.target.value;
|
||||||
|
const next: SecretVersionSelector = raw === VERSION_LATEST ? VERSION_LATEST : Number.parseInt(raw, 10);
|
||||||
|
onChange({ ...value, version: next });
|
||||||
|
}}
|
||||||
|
disabled={disabled || !value || !selectedSecret}
|
||||||
|
aria-label="Version"
|
||||||
|
>
|
||||||
|
<option value={VERSION_LATEST}>latest</option>
|
||||||
|
{selectedSecret
|
||||||
|
? Array.from({ length: Math.max(0, selectedSecret.latestVersion) }, (_, index) => {
|
||||||
|
const version = selectedSecret.latestVersion - index;
|
||||||
|
if (version <= 0) return null;
|
||||||
|
return (
|
||||||
|
<option key={version} value={version}>
|
||||||
|
v{version}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: null}
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
disabled={disabled || !selectedCompanyId}
|
||||||
|
aria-label="Create secret"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedSecret ? (
|
||||||
|
<p className={cn("text-[11px] text-muted-foreground", statusTone(selectedSecret.status))}>
|
||||||
|
{selectedSecret.status !== "active" ? `Status: ${selectedSecret.status}. ` : null}
|
||||||
|
Bound to {versionDisplay(value?.version)} · {selectedSecret.key}
|
||||||
|
</p>
|
||||||
|
) : selectedMissing ? (
|
||||||
|
<p className="text-[11px] text-destructive flex items-center gap-1">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
The previously selected secret is no longer available. Pick another or remove the binding.
|
||||||
|
</p>
|
||||||
|
) : (filteredSecrets.length === 0 && !secretsQuery.isPending) ? (
|
||||||
|
<p className="text-[11px] text-muted-foreground">{emptyHint}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create new secret</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-foreground/80" htmlFor="secret-name">Name</label>
|
||||||
|
<Input
|
||||||
|
id="secret-name"
|
||||||
|
value={createName}
|
||||||
|
onChange={(event) => setCreateName(event.target.value)}
|
||||||
|
placeholder="OPENAI_API_KEY"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-foreground/80" htmlFor="secret-value">Value</label>
|
||||||
|
<Textarea
|
||||||
|
id="secret-value"
|
||||||
|
value={createValue}
|
||||||
|
onChange={(event) => setCreateValue(event.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Paste the secret value"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
The value is stored once and never re-displayed. Rotate to replace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-foreground/80" htmlFor="secret-description">Description</label>
|
||||||
|
<Input
|
||||||
|
id="secret-description"
|
||||||
|
value={createDescription}
|
||||||
|
onChange={(event) => setCreateDescription(event.target.value)}
|
||||||
|
placeholder="Optional notes (no values)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{createError ? <p className="text-xs text-destructive">{createError}</p> : null}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
disabled={!createName.trim() || !createValue || createMutation.isPending}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||||
|
Create & bind
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -135,6 +135,9 @@ export const queryKeys = {
|
||||||
secrets: {
|
secrets: {
|
||||||
list: (companyId: string) => ["secrets", companyId] as const,
|
list: (companyId: string) => ["secrets", companyId] as const,
|
||||||
providers: (companyId: string) => ["secret-providers", companyId] as const,
|
providers: (companyId: string) => ["secret-providers", companyId] as const,
|
||||||
|
providerConfigs: (companyId: string) => ["secret-provider-configs", companyId] as const,
|
||||||
|
usage: (secretId: string) => ["secrets", "usage", secretId] as const,
|
||||||
|
accessEvents: (secretId: string) => ["secrets", "access-events", secretId] as const,
|
||||||
},
|
},
|
||||||
companySearch: {
|
companySearch: {
|
||||||
search: (companyId: string, q: string, scope: string, limit: number, offset: number) =>
|
search: (companyId: string, q: string, scope: string, limit: number, offset: number) =>
|
||||||
|
|
|
||||||
308
ui/src/pages/Secrets.render.test.tsx
Normal file
308
ui/src/pages/Secrets.render.test.tsx
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { CompanySecretProviderConfig, SecretProviderDescriptor } from "@paperclipai/shared";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ProviderVaultsTab, Secrets } from "./Secrets";
|
||||||
|
|
||||||
|
const mockSecretsApi = vi.hoisted(() => ({
|
||||||
|
list: vi.fn(),
|
||||||
|
providers: vi.fn(),
|
||||||
|
providerHealth: vi.fn(),
|
||||||
|
providerConfigs: vi.fn(),
|
||||||
|
createProviderConfig: vi.fn(),
|
||||||
|
updateProviderConfig: vi.fn(),
|
||||||
|
disableProviderConfig: vi.fn(),
|
||||||
|
setDefaultProviderConfig: vi.fn(),
|
||||||
|
checkProviderConfigHealth: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
rotate: vi.fn(),
|
||||||
|
disable: vi.fn(),
|
||||||
|
enable: vi.fn(),
|
||||||
|
archive: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
usage: vi.fn(),
|
||||||
|
accessEvents: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||||
|
const mockPushToast = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../api/secrets", () => ({
|
||||||
|
secretsApi: mockSecretsApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../context/CompanyContext", () => ({
|
||||||
|
useCompany: () => ({
|
||||||
|
selectedCompanyId: "company-1",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../context/BreadcrumbContext", () => ({
|
||||||
|
useBreadcrumbs: () => ({
|
||||||
|
setBreadcrumbs: mockSetBreadcrumbs,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../context/ToastContext", () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
pushToast: mockPushToast,
|
||||||
|
}),
|
||||||
|
useToastActions: () => ({
|
||||||
|
pushToast: mockPushToast,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../context/SidebarContext", () => ({
|
||||||
|
useSidebar: () => ({
|
||||||
|
isMobile: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
const providers: SecretProviderDescriptor[] = [
|
||||||
|
{
|
||||||
|
id: "local_encrypted",
|
||||||
|
label: "Local encrypted",
|
||||||
|
requiresExternalRef: false,
|
||||||
|
supportsManagedValues: true,
|
||||||
|
supportsExternalReferences: false,
|
||||||
|
configured: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "aws_secrets_manager",
|
||||||
|
label: "AWS Secrets Manager",
|
||||||
|
requiresExternalRef: false,
|
||||||
|
supportsManagedValues: true,
|
||||||
|
supportsExternalReferences: true,
|
||||||
|
configured: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gcp_secret_manager",
|
||||||
|
label: "GCP Secret Manager",
|
||||||
|
requiresExternalRef: false,
|
||||||
|
supportsManagedValues: false,
|
||||||
|
supportsExternalReferences: true,
|
||||||
|
configured: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "vault",
|
||||||
|
label: "Vault",
|
||||||
|
requiresExternalRef: false,
|
||||||
|
supportsManagedValues: false,
|
||||||
|
supportsExternalReferences: true,
|
||||||
|
configured: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const providerConfigs = [
|
||||||
|
{
|
||||||
|
id: "vault-local",
|
||||||
|
provider: "local_encrypted",
|
||||||
|
displayName: "Local default",
|
||||||
|
status: "ready",
|
||||||
|
isDefault: true,
|
||||||
|
healthStatus: "ready",
|
||||||
|
healthCheckedAt: null,
|
||||||
|
healthMessage: null,
|
||||||
|
healthDetails: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "vault-aws",
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
displayName: "AWS production",
|
||||||
|
status: "ready",
|
||||||
|
isDefault: false,
|
||||||
|
healthStatus: null,
|
||||||
|
healthCheckedAt: null,
|
||||||
|
healthMessage: null,
|
||||||
|
healthDetails: null,
|
||||||
|
},
|
||||||
|
] satisfies Partial<CompanySecretProviderConfig>[];
|
||||||
|
|
||||||
|
async function flushReact() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Secrets page layout", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
mockSecretsApi.list.mockResolvedValue([]);
|
||||||
|
mockSecretsApi.providers.mockResolvedValue(providers);
|
||||||
|
mockSecretsApi.providerHealth.mockResolvedValue({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provider: "local_encrypted",
|
||||||
|
status: "warn",
|
||||||
|
message: "Local encrypted provider has a warning.",
|
||||||
|
warnings: ["Backup reminder"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
mockSecretsApi.providerConfigs.mockResolvedValue(providerConfigs);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
container.remove();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the shared search/filter/tab affordances and keeps vault sections quiet", async () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Secrets />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
expect(container.querySelector('input[data-page-search-target="true"][aria-label="Search secrets"]')).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain("Use secrets by binding them to runtime environment variables.");
|
||||||
|
expect(container.textContent).toContain("GH_TOKEN");
|
||||||
|
expect(container.querySelectorAll("select")).toHaveLength(0);
|
||||||
|
expect(container.textContent).not.toContain("Provider warnings detected");
|
||||||
|
expect(container.textContent).not.toContain("2/2 active");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
const vaultRoot = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
vaultRoot.render(
|
||||||
|
<ProviderVaultsTab
|
||||||
|
providers={providers}
|
||||||
|
providerConfigs={providerConfigs as CompanySecretProviderConfig[]}
|
||||||
|
loading={false}
|
||||||
|
error={null}
|
||||||
|
onRetry={vi.fn()}
|
||||||
|
onCreate={vi.fn()}
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onDisable={vi.fn()}
|
||||||
|
onSetDefault={vi.fn()}
|
||||||
|
onHealthCheck={vi.fn()}
|
||||||
|
pendingActionId={null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
expect(container.querySelector('a[href="#provider-vaults-local_encrypted"]')).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain("AWS production");
|
||||||
|
expect(container.textContent).not.toContain("Managed writes");
|
||||||
|
expect(container.textContent).not.toContain("External refs");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vaultRoot.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens reference details from the secrets table count", async () => {
|
||||||
|
mockSecretsApi.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "secret-openai",
|
||||||
|
companyId: "company-1",
|
||||||
|
key: "openai_api_key",
|
||||||
|
name: "OPENAI_API_KEY",
|
||||||
|
provider: "local_encrypted",
|
||||||
|
status: "active",
|
||||||
|
managedMode: "paperclip_managed",
|
||||||
|
externalRef: null,
|
||||||
|
providerConfigId: null,
|
||||||
|
providerMetadata: null,
|
||||||
|
latestVersion: 1,
|
||||||
|
description: null,
|
||||||
|
lastResolvedAt: null,
|
||||||
|
lastRotatedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: "user-1",
|
||||||
|
referenceCount: 2,
|
||||||
|
createdAt: new Date("2026-05-06T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-05-06T00:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockSecretsApi.usage.mockResolvedValue({
|
||||||
|
secretId: "secret-openai",
|
||||||
|
bindings: [
|
||||||
|
{
|
||||||
|
id: "binding-agent",
|
||||||
|
companyId: "company-1",
|
||||||
|
secretId: "secret-openai",
|
||||||
|
targetType: "agent",
|
||||||
|
targetId: "agent-1",
|
||||||
|
configPath: "env.OPENAI_API_KEY",
|
||||||
|
versionSelector: "latest",
|
||||||
|
required: true,
|
||||||
|
label: null,
|
||||||
|
target: {
|
||||||
|
type: "agent",
|
||||||
|
id: "agent-1",
|
||||||
|
label: "CodexCoder",
|
||||||
|
href: "/agents/codexcoder",
|
||||||
|
status: "idle",
|
||||||
|
},
|
||||||
|
createdAt: new Date("2026-05-06T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-05-06T00:00:00.000Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Secrets />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
const referencesButton = container.querySelector(
|
||||||
|
'button[aria-label="View references for OPENAI_API_KEY"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(referencesButton?.textContent).toBe("2");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
referencesButton?.click();
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
expect(mockSecretsApi.usage).toHaveBeenCalledWith("secret-openai");
|
||||||
|
expect(document.body.textContent).toContain("Secret references");
|
||||||
|
expect(document.body.textContent).toContain("CodexCoder");
|
||||||
|
expect(document.body.textContent).toContain("env.OPENAI_API_KEY");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
129
ui/src/pages/Secrets.test.ts
Normal file
129
ui/src/pages/Secrets.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { SecretProviderDescriptor } from "@paperclipai/shared";
|
||||||
|
import {
|
||||||
|
getAwsManagedPathPreview,
|
||||||
|
getCreateProviderBlockReason,
|
||||||
|
getDefaultProviderConfigId,
|
||||||
|
getProviderConfigBlockReason,
|
||||||
|
} from "./Secrets";
|
||||||
|
import type { SecretProviderHealthResponse } from "../api/secrets";
|
||||||
|
|
||||||
|
const awsProvider: SecretProviderDescriptor = {
|
||||||
|
id: "aws_secrets_manager",
|
||||||
|
label: "AWS Secrets Manager",
|
||||||
|
requiresExternalRef: false,
|
||||||
|
supportsManagedValues: true,
|
||||||
|
supportsExternalReferences: true,
|
||||||
|
configured: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Secrets page provider helpers", () => {
|
||||||
|
it("previews the derived AWS managed path from provider health details", () => {
|
||||||
|
const health: SecretProviderHealthResponse = {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
status: "ok",
|
||||||
|
message: "AWS Secrets Manager provider is configured",
|
||||||
|
details: {
|
||||||
|
prefix: "paperclip",
|
||||||
|
deploymentId: "prod-us-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getAwsManagedPathPreview({
|
||||||
|
provider: awsProvider,
|
||||||
|
health,
|
||||||
|
companyId: "company-123",
|
||||||
|
secretKeySource: "Anthropic API Key",
|
||||||
|
}),
|
||||||
|
).toBe("paperclip/prod-us-1/company-123/anthropic-api-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks unconfigured providers before create submission", () => {
|
||||||
|
expect(
|
||||||
|
getCreateProviderBlockReason(
|
||||||
|
{ ...awsProvider, configured: false },
|
||||||
|
"managed",
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
).toBe("AWS Secrets Manager is not configured in this deployment.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses provider health copy when an unconfigured provider reports missing bootstrap inputs", () => {
|
||||||
|
const health: SecretProviderHealthResponse = {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
status: "warn",
|
||||||
|
message:
|
||||||
|
"AWS Secrets Manager provider is not ready: missing PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getCreateProviderBlockReason(
|
||||||
|
{ ...awsProvider, configured: false },
|
||||||
|
"managed",
|
||||||
|
health,
|
||||||
|
),
|
||||||
|
).toBe(
|
||||||
|
"AWS Secrets Manager is not configured in this deployment. AWS Secrets Manager provider is not ready: missing PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks provider modes the backend does not support", () => {
|
||||||
|
expect(
|
||||||
|
getCreateProviderBlockReason(
|
||||||
|
{
|
||||||
|
id: "local_encrypted",
|
||||||
|
label: "Local encrypted (default)",
|
||||||
|
requiresExternalRef: false,
|
||||||
|
supportsManagedValues: true,
|
||||||
|
supportsExternalReferences: false,
|
||||||
|
configured: true,
|
||||||
|
},
|
||||||
|
"external",
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
).toBe("Local encrypted (default) does not support linked external references.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("chooses the ready default provider vault for a provider", () => {
|
||||||
|
expect(
|
||||||
|
getDefaultProviderConfigId(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "draft",
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
status: "disabled",
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prod",
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
status: "ready",
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
] as never,
|
||||||
|
"aws_secrets_manager",
|
||||||
|
),
|
||||||
|
).toBe("prod");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains why coming-soon provider vaults cannot be selected", () => {
|
||||||
|
expect(
|
||||||
|
getProviderConfigBlockReason({
|
||||||
|
id: "vault-draft",
|
||||||
|
provider: "vault",
|
||||||
|
status: "coming_soon",
|
||||||
|
} as never),
|
||||||
|
).toBe("This provider vault is saved as draft metadata only.");
|
||||||
|
});
|
||||||
|
});
|
||||||
2155
ui/src/pages/Secrets.tsx
Normal file
2155
ui/src/pages/Secrets.tsx
Normal file
File diff suppressed because it is too large
Load diff
820
ui/src/pages/secrets/ImportFromVaultDialog.test.tsx
Normal file
820
ui/src/pages/secrets/ImportFromVaultDialog.test.tsx
Normal file
|
|
@ -0,0 +1,820 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type {
|
||||||
|
CompanySecret,
|
||||||
|
CompanySecretProviderConfig,
|
||||||
|
RemoteSecretImportCandidate,
|
||||||
|
RemoteSecretImportPreviewResult,
|
||||||
|
RemoteSecretImportResult,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ApiError } from "../../api/client";
|
||||||
|
|
||||||
|
const mockSecretsApi = vi.hoisted(() => ({
|
||||||
|
remoteImportPreview: vi.fn(),
|
||||||
|
remoteImport: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockPushToast = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../../api/secrets", () => ({
|
||||||
|
secretsApi: mockSecretsApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../context/ToastContext", () => ({
|
||||||
|
useToastActions: () => ({
|
||||||
|
pushToast: mockPushToast,
|
||||||
|
dismissToast: vi.fn(),
|
||||||
|
clearToasts: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
import { ImportFromVaultDialog } from "./ImportFromVaultDialog";
|
||||||
|
|
||||||
|
const awsVault: CompanySecretProviderConfig = {
|
||||||
|
id: "vault-aws",
|
||||||
|
companyId: "company-1",
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
displayName: "AWS production",
|
||||||
|
status: "ready",
|
||||||
|
isDefault: true,
|
||||||
|
config: { region: "us-east-1" },
|
||||||
|
healthStatus: null,
|
||||||
|
healthCheckedAt: null,
|
||||||
|
healthMessage: null,
|
||||||
|
healthDetails: null,
|
||||||
|
disabledAt: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeCandidate(
|
||||||
|
overrides: Partial<RemoteSecretImportCandidate> = {},
|
||||||
|
): RemoteSecretImportCandidate {
|
||||||
|
return {
|
||||||
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/foo-AbCdEf",
|
||||||
|
remoteName: "prod/foo",
|
||||||
|
name: "prod/foo",
|
||||||
|
key: "prod-foo",
|
||||||
|
providerVersionRef: null,
|
||||||
|
providerMetadata: { name: "prod/foo" },
|
||||||
|
status: "ready",
|
||||||
|
importable: true,
|
||||||
|
conflicts: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePreview(
|
||||||
|
candidates: RemoteSecretImportCandidate[],
|
||||||
|
nextToken: string | null = null,
|
||||||
|
): RemoteSecretImportPreviewResult {
|
||||||
|
return {
|
||||||
|
providerConfigId: awsVault.id,
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
nextToken,
|
||||||
|
candidates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushDebounce() {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 300));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeWrapper() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
return { queryClient };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ImportFromVaultDialog", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
container.remove();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads candidates and selects rows, persisting through pagination", async () => {
|
||||||
|
mockSecretsApi.remoteImportPreview
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
makePreview(
|
||||||
|
[
|
||||||
|
makeCandidate({
|
||||||
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/stripe-ABC",
|
||||||
|
remoteName: "prod/stripe",
|
||||||
|
name: "prod/stripe",
|
||||||
|
key: "prod-stripe",
|
||||||
|
}),
|
||||||
|
makeCandidate({
|
||||||
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ",
|
||||||
|
remoteName: "prod/openai",
|
||||||
|
name: "prod/openai",
|
||||||
|
key: "prod-openai",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
"page-2",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
makePreview(
|
||||||
|
[
|
||||||
|
makeCandidate({
|
||||||
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/sendgrid-Q9",
|
||||||
|
remoteName: "prod/sendgrid",
|
||||||
|
name: "prod/sendgrid",
|
||||||
|
key: "prod-sendgrid",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { queryClient } = makeWrapper();
|
||||||
|
const root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ImportFromVaultDialog
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
companyId="company-1"
|
||||||
|
providerConfigs={[awsVault]}
|
||||||
|
existingSecrets={[]}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const tableBody = document.querySelector('[data-testid="vault-table-body"]');
|
||||||
|
expect(tableBody).not.toBeNull();
|
||||||
|
expect(document.body.textContent).toContain("prod/stripe");
|
||||||
|
expect(document.body.textContent).toContain("prod/openai");
|
||||||
|
|
||||||
|
// Select stripe via row click
|
||||||
|
const stripeRow = document.querySelector(
|
||||||
|
'[data-testid="vault-row-arn:aws:secretsmanager:us-east-1:1:secret:prod/stripe-ABC"]',
|
||||||
|
) as HTMLElement | null;
|
||||||
|
expect(stripeRow).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
stripeRow?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
expect(document.body.textContent).toContain("1 selected");
|
||||||
|
|
||||||
|
// Load more page
|
||||||
|
const loadMore = document.querySelector('[data-testid="vault-load-more"]') as HTMLButtonElement | null;
|
||||||
|
expect(loadMore).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
loadMore!.click();
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain("prod/sendgrid");
|
||||||
|
// Selection persisted through pagination.
|
||||||
|
expect(document.body.textContent).toContain("1 selected");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables checkboxes for already-imported (duplicate) rows and shows a conflict badge for conflicts", async () => {
|
||||||
|
mockSecretsApi.remoteImportPreview.mockResolvedValueOnce(
|
||||||
|
makePreview([
|
||||||
|
makeCandidate({
|
||||||
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/sendgrid-Q9",
|
||||||
|
remoteName: "prod/sendgrid",
|
||||||
|
name: "prod/sendgrid",
|
||||||
|
key: "prod-sendgrid",
|
||||||
|
status: "duplicate",
|
||||||
|
importable: false,
|
||||||
|
conflicts: [
|
||||||
|
{ type: "exact_reference", message: "Already imported", existingSecretId: "secret-sg" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
makeCandidate({
|
||||||
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ",
|
||||||
|
remoteName: "prod/openai",
|
||||||
|
name: "prod/openai",
|
||||||
|
key: "prod-openai",
|
||||||
|
status: "conflict",
|
||||||
|
importable: true,
|
||||||
|
conflicts: [
|
||||||
|
{ type: "name", message: "Name already in use" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { queryClient } = makeWrapper();
|
||||||
|
const root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ImportFromVaultDialog
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
companyId="company-1"
|
||||||
|
providerConfigs={[awsVault]}
|
||||||
|
existingSecrets={[]}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const duplicateRow = document.querySelector(
|
||||||
|
'[data-testid="vault-row-arn:aws:secretsmanager:us-east-1:1:secret:prod/sendgrid-Q9"]',
|
||||||
|
);
|
||||||
|
expect(duplicateRow?.getAttribute("data-row-state")).toBe("duplicate");
|
||||||
|
const duplicateCheckbox = duplicateRow?.querySelector(
|
||||||
|
'button[role="checkbox"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(duplicateCheckbox?.getAttribute("data-disabled")).not.toBeNull();
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain("Conflict");
|
||||||
|
expect(document.body.textContent).toContain("Name already in use");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks import when a review row collides with an existing Paperclip secret", async () => {
|
||||||
|
const conflictCandidate = makeCandidate({
|
||||||
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ",
|
||||||
|
remoteName: "prod/openai",
|
||||||
|
name: "OPENAI_API_KEY",
|
||||||
|
key: "openai_api_key",
|
||||||
|
status: "conflict",
|
||||||
|
conflicts: [{ type: "key", message: "Key already in use" }],
|
||||||
|
});
|
||||||
|
mockSecretsApi.remoteImportPreview.mockResolvedValueOnce(
|
||||||
|
makePreview([conflictCandidate]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const existing: CompanySecret[] = [
|
||||||
|
{
|
||||||
|
id: "secret-existing",
|
||||||
|
companyId: "company-1",
|
||||||
|
key: "openai_api_key",
|
||||||
|
name: "OPENAI_API_KEY",
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
status: "active",
|
||||||
|
managedMode: "external_reference",
|
||||||
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:other-XYZ",
|
||||||
|
providerConfigId: awsVault.id,
|
||||||
|
providerMetadata: null,
|
||||||
|
latestVersion: 1,
|
||||||
|
description: null,
|
||||||
|
lastResolvedAt: null,
|
||||||
|
lastRotatedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { queryClient } = makeWrapper();
|
||||||
|
const root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ImportFromVaultDialog
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
companyId="company-1"
|
||||||
|
providerConfigs={[awsVault]}
|
||||||
|
existingSecrets={existing}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// Select the conflict row
|
||||||
|
const row = document.querySelector(
|
||||||
|
'[data-testid="vault-row-arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ"]',
|
||||||
|
) as HTMLElement | null;
|
||||||
|
await act(async () => {
|
||||||
|
row?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// Click "Continue → Review" button.
|
||||||
|
const continueBtn = Array.from(document.querySelectorAll("button")).find(
|
||||||
|
(btn) => btn.textContent?.includes("Continue"),
|
||||||
|
);
|
||||||
|
expect(continueBtn).toBeTruthy();
|
||||||
|
await act(async () => {
|
||||||
|
continueBtn!.click();
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// Review step: error message visible, Import button disabled.
|
||||||
|
expect(document.body.textContent?.toLowerCase()).toContain("a paperclip secret already uses this");
|
||||||
|
|
||||||
|
const importBtn = Array.from(document.querySelectorAll("button")).find(
|
||||||
|
(btn) => btn.textContent?.startsWith("Import "),
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
expect(importBtn).toBeTruthy();
|
||||||
|
expect(importBtn?.disabled).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires lowercase operator-entered keys during review", async () => {
|
||||||
|
const externalRef = "arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ";
|
||||||
|
mockSecretsApi.remoteImportPreview.mockResolvedValueOnce(
|
||||||
|
makePreview([
|
||||||
|
makeCandidate({
|
||||||
|
externalRef,
|
||||||
|
remoteName: "prod/openai",
|
||||||
|
name: "OpenAI API key",
|
||||||
|
key: "openai-api-key",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { queryClient } = makeWrapper();
|
||||||
|
const root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ImportFromVaultDialog
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
companyId="company-1"
|
||||||
|
providerConfigs={[awsVault]}
|
||||||
|
existingSecrets={[]}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const row = document.querySelector(
|
||||||
|
`[data-testid="vault-row-${externalRef}"]`,
|
||||||
|
) as HTMLElement | null;
|
||||||
|
await act(async () => {
|
||||||
|
row?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const continueBtn = Array.from(document.querySelectorAll("button")).find(
|
||||||
|
(btn) => btn.textContent?.includes("Continue"),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
continueBtn!.click();
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const keyInput = document.querySelector(
|
||||||
|
`[data-testid="review-key-${externalRef}"]`,
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLInputElement.prototype,
|
||||||
|
"value",
|
||||||
|
)?.set;
|
||||||
|
await act(async () => {
|
||||||
|
valueSetter?.call(keyInput, "MY_KEY");
|
||||||
|
keyInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain("lowercase letters");
|
||||||
|
const importBtn = Array.from(document.querySelectorAll("button")).find(
|
||||||
|
(btn) => btn.textContent?.startsWith("Import "),
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
expect(importBtn?.disabled).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits the operator-entered review description", async () => {
|
||||||
|
const externalRef = "arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ";
|
||||||
|
mockSecretsApi.remoteImportPreview.mockResolvedValueOnce(
|
||||||
|
makePreview([
|
||||||
|
makeCandidate({
|
||||||
|
externalRef,
|
||||||
|
remoteName: "prod/openai",
|
||||||
|
name: "OpenAI API key",
|
||||||
|
key: "openai-api-key",
|
||||||
|
providerMetadata: {
|
||||||
|
description: "Raw AWS description should not seed the review field",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
mockSecretsApi.remoteImport.mockResolvedValueOnce({
|
||||||
|
providerConfigId: awsVault.id,
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
importedCount: 1,
|
||||||
|
skippedCount: 0,
|
||||||
|
errorCount: 0,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
externalRef,
|
||||||
|
name: "OpenAI API key",
|
||||||
|
key: "openai-api-key",
|
||||||
|
status: "imported",
|
||||||
|
reason: null,
|
||||||
|
secretId: "secret-openai",
|
||||||
|
conflicts: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { queryClient } = makeWrapper();
|
||||||
|
const root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ImportFromVaultDialog
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
companyId="company-1"
|
||||||
|
providerConfigs={[awsVault]}
|
||||||
|
existingSecrets={[]}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const row = document.querySelector(
|
||||||
|
`[data-testid="vault-row-${externalRef}"]`,
|
||||||
|
) as HTMLElement | null;
|
||||||
|
await act(async () => {
|
||||||
|
row?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const continueBtn = Array.from(document.querySelectorAll("button")).find(
|
||||||
|
(btn) => btn.textContent?.includes("Continue"),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
continueBtn!.click();
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const descriptionInput = document.querySelector(
|
||||||
|
`[data-testid="review-description-${externalRef}"]`,
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
expect(descriptionInput?.value).toBe("");
|
||||||
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLInputElement.prototype,
|
||||||
|
"value",
|
||||||
|
)?.set;
|
||||||
|
await act(async () => {
|
||||||
|
valueSetter?.call(descriptionInput, "Operator-entered OpenAI key");
|
||||||
|
descriptionInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const importBtn = Array.from(document.querySelectorAll("button")).find(
|
||||||
|
(btn) => btn.textContent?.startsWith("Import "),
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
importBtn!.click();
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(mockSecretsApi.remoteImport).toHaveBeenCalledWith("company-1", {
|
||||||
|
providerConfigId: awsVault.id,
|
||||||
|
secrets: [
|
||||||
|
expect.objectContaining({
|
||||||
|
externalRef,
|
||||||
|
description: "Operator-entered OpenAI key",
|
||||||
|
providerMetadata: null,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders mixed import results (created/skipped/failed) and shows error reason", async () => {
|
||||||
|
mockSecretsApi.remoteImportPreview.mockResolvedValueOnce(
|
||||||
|
makePreview([
|
||||||
|
makeCandidate({
|
||||||
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:a-AAA",
|
||||||
|
remoteName: "alpha",
|
||||||
|
name: "alpha",
|
||||||
|
key: "alpha",
|
||||||
|
}),
|
||||||
|
makeCandidate({
|
||||||
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:b-BBB",
|
||||||
|
remoteName: "beta",
|
||||||
|
name: "beta",
|
||||||
|
key: "beta",
|
||||||
|
}),
|
||||||
|
makeCandidate({
|
||||||
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:c-CCC",
|
||||||
|
remoteName: "gamma",
|
||||||
|
name: "gamma",
|
||||||
|
key: "gamma",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: RemoteSecretImportResult = {
|
||||||
|
providerConfigId: awsVault.id,
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
importedCount: 1,
|
||||||
|
skippedCount: 1,
|
||||||
|
errorCount: 1,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:a-AAA",
|
||||||
|
name: "alpha",
|
||||||
|
key: "alpha",
|
||||||
|
status: "imported",
|
||||||
|
reason: null,
|
||||||
|
secretId: "secret-alpha",
|
||||||
|
conflicts: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:b-BBB",
|
||||||
|
name: "beta",
|
||||||
|
key: "beta",
|
||||||
|
status: "skipped",
|
||||||
|
reason: "exact reference already imported",
|
||||||
|
secretId: null,
|
||||||
|
conflicts: [
|
||||||
|
{ type: "exact_reference", message: "exact reference already imported" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:c-CCC",
|
||||||
|
name: "gamma",
|
||||||
|
key: "gamma",
|
||||||
|
status: "error",
|
||||||
|
reason: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
||||||
|
secretId: null,
|
||||||
|
conflicts: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockSecretsApi.remoteImport.mockResolvedValueOnce(result);
|
||||||
|
|
||||||
|
const { queryClient } = makeWrapper();
|
||||||
|
const root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ImportFromVaultDialog
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
companyId="company-1"
|
||||||
|
providerConfigs={[awsVault]}
|
||||||
|
existingSecrets={[]}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// Select all loaded
|
||||||
|
const headerCheckbox = document.querySelector(
|
||||||
|
'[data-testid="vault-table-body"]',
|
||||||
|
)?.parentElement?.querySelector('thead button[role="checkbox"]') as HTMLButtonElement | null;
|
||||||
|
expect(headerCheckbox).toBeTruthy();
|
||||||
|
await act(async () => {
|
||||||
|
headerCheckbox!.click();
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// Continue
|
||||||
|
const continueBtn = Array.from(document.querySelectorAll("button")).find(
|
||||||
|
(btn) => btn.textContent?.includes("Continue"),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
continueBtn!.click();
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// Import
|
||||||
|
const importBtn = Array.from(document.querySelectorAll("button")).find(
|
||||||
|
(btn) => btn.textContent?.startsWith("Import "),
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
expect(importBtn).toBeTruthy();
|
||||||
|
await act(async () => {
|
||||||
|
importBtn!.click();
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(mockSecretsApi.remoteImport).toHaveBeenCalledTimes(1);
|
||||||
|
expect(document.body.textContent).toContain("Import complete");
|
||||||
|
expect(document.body.textContent).toContain("1 created");
|
||||||
|
expect(document.body.textContent).toContain("1 skipped");
|
||||||
|
expect(document.body.textContent).toContain("1 failed");
|
||||||
|
expect(document.body.textContent).toContain("AWS Secrets Manager denied the request");
|
||||||
|
expect(document.body.textContent).not.toContain("AccessDeniedException");
|
||||||
|
expect(document.body.textContent).not.toContain("123456789012");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an empty state when no AWS vault is configured", async () => {
|
||||||
|
const { queryClient } = makeWrapper();
|
||||||
|
const root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ImportFromVaultDialog
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
companyId="company-1"
|
||||||
|
providerConfigs={[]}
|
||||||
|
existingSecrets={[]}
|
||||||
|
onManageVaults={vi.fn()}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(document.querySelector('[data-testid="select-empty-vaults"]')).not.toBeNull();
|
||||||
|
expect(mockSecretsApi.remoteImportPreview).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a permission-error banner when AWS denies ListSecrets", async () => {
|
||||||
|
const error = Object.assign(new Error("AccessDeniedException"), {
|
||||||
|
name: "ApiError",
|
||||||
|
status: 403,
|
||||||
|
body: null,
|
||||||
|
});
|
||||||
|
mockSecretsApi.remoteImportPreview.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
|
const { queryClient } = makeWrapper();
|
||||||
|
const root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ImportFromVaultDialog
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
companyId="company-1"
|
||||||
|
providerConfigs={[awsVault]}
|
||||||
|
existingSecrets={[]}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const banner = document.querySelector('[data-testid="preview-error-banner"]');
|
||||||
|
expect(banner).not.toBeNull();
|
||||||
|
expect(banner?.textContent).toContain("Could not load remote secrets");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders sanitized preview provider errors without raw AWS exception text", async () => {
|
||||||
|
const rawProviderMessage =
|
||||||
|
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized";
|
||||||
|
mockSecretsApi.remoteImportPreview.mockRejectedValueOnce(
|
||||||
|
new ApiError(
|
||||||
|
"AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
||||||
|
403,
|
||||||
|
{ error: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", details: { code: "access_denied" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { queryClient } = makeWrapper();
|
||||||
|
const root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ImportFromVaultDialog
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
companyId="company-1"
|
||||||
|
providerConfigs={[awsVault]}
|
||||||
|
existingSecrets={[]}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const banner = document.querySelector('[data-testid="preview-error-banner"]');
|
||||||
|
expect(banner).not.toBeNull();
|
||||||
|
expect(banner?.textContent).toContain("AWS denied list access");
|
||||||
|
expect(banner?.textContent).toContain("missing secretsmanager:ListSecrets");
|
||||||
|
expect(banner?.textContent).not.toContain(rawProviderMessage);
|
||||||
|
expect(banner?.textContent).not.toContain("arn:aws");
|
||||||
|
expect(banner?.textContent).not.toContain("123456789012");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("debounces search and uses the new query for the next preview", async () => {
|
||||||
|
mockSecretsApi.remoteImportPreview
|
||||||
|
.mockResolvedValueOnce(makePreview([makeCandidate()]))
|
||||||
|
.mockResolvedValueOnce(makePreview([
|
||||||
|
makeCandidate({
|
||||||
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:stripe-XYZ",
|
||||||
|
remoteName: "stripe",
|
||||||
|
name: "stripe",
|
||||||
|
key: "stripe",
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
|
||||||
|
const { queryClient } = makeWrapper();
|
||||||
|
const root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ImportFromVaultDialog
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
companyId="company-1"
|
||||||
|
providerConfigs={[awsVault]}
|
||||||
|
existingSecrets={[]}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const search = document.querySelector('[data-testid="vault-search"]') as HTMLInputElement;
|
||||||
|
expect(search).not.toBeNull();
|
||||||
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLInputElement.prototype,
|
||||||
|
"value",
|
||||||
|
)?.set;
|
||||||
|
await act(async () => {
|
||||||
|
search.focus();
|
||||||
|
valueSetter?.call(search, "stripe");
|
||||||
|
search.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(mockSecretsApi.remoteImportPreview).toHaveBeenCalledTimes(2);
|
||||||
|
const lastCall = mockSecretsApi.remoteImportPreview.mock.calls.at(-1);
|
||||||
|
expect(lastCall?.[1]).toMatchObject({ query: "stripe" });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
1477
ui/src/pages/secrets/ImportFromVaultDialog.tsx
Normal file
1477
ui/src/pages/secrets/ImportFromVaultDialog.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -20,6 +20,11 @@ import {
|
||||||
storybookIssues,
|
storybookIssues,
|
||||||
storybookLiveRuns,
|
storybookLiveRuns,
|
||||||
storybookProjects,
|
storybookProjects,
|
||||||
|
storybookSecretAccessEvents,
|
||||||
|
storybookSecretBindings,
|
||||||
|
storybookSecretProviderHealth,
|
||||||
|
storybookSecretProviders,
|
||||||
|
storybookSecrets,
|
||||||
storybookSidebarBadges,
|
storybookSidebarBadges,
|
||||||
} from "../fixtures/paperclipData";
|
} from "../fixtures/paperclipData";
|
||||||
import "@mdxeditor/editor/style.css";
|
import "@mdxeditor/editor/style.css";
|
||||||
|
|
@ -164,6 +169,39 @@ function installStorybookApiFixtures() {
|
||||||
if (schema) return Response.json(schema);
|
if (schema) return Response.json(schema);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const secretsListMatch = url.pathname.match(/^\/api\/companies\/([^/]+)\/secrets$/);
|
||||||
|
if (secretsListMatch) {
|
||||||
|
const [, companyId] = secretsListMatch;
|
||||||
|
return Response.json(companyId === "company-storybook" ? storybookSecrets : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretProvidersMatch = url.pathname.match(/^\/api\/companies\/([^/]+)\/secret-providers$/);
|
||||||
|
if (secretProvidersMatch) {
|
||||||
|
return Response.json(storybookSecretProviders);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretProviderHealthMatch = url.pathname.match(
|
||||||
|
/^\/api\/companies\/([^/]+)\/secret-providers\/health$/,
|
||||||
|
);
|
||||||
|
if (secretProviderHealthMatch) {
|
||||||
|
return Response.json(storybookSecretProviderHealth);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretUsageMatch = url.pathname.match(/^\/api\/secrets\/([^/]+)\/usage$/);
|
||||||
|
if (secretUsageMatch) {
|
||||||
|
const [, secretId] = secretUsageMatch;
|
||||||
|
return Response.json({
|
||||||
|
secretId,
|
||||||
|
bindings: storybookSecretBindings.filter((binding) => binding.secretId === secretId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretEventsMatch = url.pathname.match(/^\/api\/secrets\/([^/]+)\/access-events$/);
|
||||||
|
if (secretEventsMatch) {
|
||||||
|
const [, secretId] = secretEventsMatch;
|
||||||
|
return Response.json(storybookSecretAccessEvents.filter((event) => event.secretId === secretId));
|
||||||
|
}
|
||||||
|
|
||||||
const companyResourceMatch = url.pathname.match(/^\/api\/companies\/([^/]+)\/([^/]+)$/);
|
const companyResourceMatch = url.pathname.match(/^\/api\/companies\/([^/]+)\/([^/]+)$/);
|
||||||
if (companyResourceMatch) {
|
if (companyResourceMatch) {
|
||||||
const [, companyId, resource] = companyResourceMatch;
|
const [, companyId, resource] = companyResourceMatch;
|
||||||
|
|
@ -222,6 +260,11 @@ function installStorybookApiFixtures() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Install fetch fixtures at module load so React Query never sees a real network failure.
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
installStorybookApiFixtures();
|
||||||
|
}
|
||||||
|
|
||||||
function applyStorybookTheme(theme: "light" | "dark") {
|
function applyStorybookTheme(theme: "light" | "dark") {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||||
|
|
@ -247,6 +290,10 @@ function StorybookProviders({
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
installStorybookApiFixtures();
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyStorybookTheme(theme);
|
applyStorybookTheme(theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import type {
|
||||||
AuthSession,
|
AuthSession,
|
||||||
BudgetPolicySummary,
|
BudgetPolicySummary,
|
||||||
Company,
|
Company,
|
||||||
|
CompanySecret,
|
||||||
|
CompanySecretBinding,
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
ExecutionWorkspace,
|
ExecutionWorkspace,
|
||||||
Goal,
|
Goal,
|
||||||
|
|
@ -12,6 +14,8 @@ import type {
|
||||||
IssueDocument,
|
IssueDocument,
|
||||||
IssueLabel,
|
IssueLabel,
|
||||||
Project,
|
Project,
|
||||||
|
SecretAccessEvent,
|
||||||
|
SecretProviderDescriptor,
|
||||||
SidebarBadges,
|
SidebarBadges,
|
||||||
WorkspaceRuntimeService,
|
WorkspaceRuntimeService,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
|
|
@ -1303,3 +1307,229 @@ export const storybookLiveRuns: LiveRunForIssue[] = [
|
||||||
nextAction: "Build fixture-backed navigation stories",
|
nextAction: "Build fixture-backed navigation stories",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const storybookSecretProviders: SecretProviderDescriptor[] = [
|
||||||
|
{ id: "local_encrypted", label: "Local encrypted", requiresExternalRef: false },
|
||||||
|
{ id: "aws_secrets_manager", label: "AWS Secrets Manager", requiresExternalRef: false },
|
||||||
|
{ id: "gcp_secret_manager", label: "GCP Secret Manager", requiresExternalRef: false },
|
||||||
|
{ id: "vault", label: "HashiCorp Vault", requiresExternalRef: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const storybookSecrets: CompanySecret[] = [
|
||||||
|
{
|
||||||
|
id: "secret-openai",
|
||||||
|
companyId: "company-storybook",
|
||||||
|
key: "openai_api_key",
|
||||||
|
name: "OPENAI_API_KEY",
|
||||||
|
provider: "local_encrypted",
|
||||||
|
status: "active",
|
||||||
|
managedMode: "paperclip_managed",
|
||||||
|
externalRef: null,
|
||||||
|
providerConfigId: null,
|
||||||
|
providerMetadata: null,
|
||||||
|
latestVersion: 3,
|
||||||
|
description: "OpenAI API key shared by all model adapters.",
|
||||||
|
lastResolvedAt: recent(12),
|
||||||
|
lastRotatedAt: new Date("2026-04-15T09:30:00.000Z"),
|
||||||
|
deletedAt: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: "user-board",
|
||||||
|
createdAt: new Date("2026-03-01T10:00:00.000Z"),
|
||||||
|
updatedAt: recent(12),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "secret-aws-prod",
|
||||||
|
companyId: "company-storybook",
|
||||||
|
key: "prod_aws_deploy",
|
||||||
|
name: "PROD_AWS_DEPLOY_KEY",
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
status: "active",
|
||||||
|
managedMode: "external_reference",
|
||||||
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/aws-deploy-AbCdEf",
|
||||||
|
providerConfigId: null,
|
||||||
|
providerMetadata: null,
|
||||||
|
latestVersion: 2,
|
||||||
|
description: "Deploy key for the prod ECS rollout pipeline.",
|
||||||
|
lastResolvedAt: recent(180),
|
||||||
|
lastRotatedAt: new Date("2026-04-22T14:00:00.000Z"),
|
||||||
|
deletedAt: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: "user-board",
|
||||||
|
createdAt: new Date("2026-02-01T10:00:00.000Z"),
|
||||||
|
updatedAt: recent(180),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "secret-github",
|
||||||
|
companyId: "company-storybook",
|
||||||
|
key: "github_app_pem",
|
||||||
|
name: "GITHUB_APP_PEM",
|
||||||
|
provider: "local_encrypted",
|
||||||
|
status: "disabled",
|
||||||
|
managedMode: "paperclip_managed",
|
||||||
|
externalRef: null,
|
||||||
|
providerConfigId: null,
|
||||||
|
providerMetadata: null,
|
||||||
|
latestVersion: 1,
|
||||||
|
description: "Disabled until GitHub App reinstall is approved.",
|
||||||
|
lastResolvedAt: new Date("2026-03-30T08:11:00.000Z"),
|
||||||
|
lastRotatedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: "user-board",
|
||||||
|
createdAt: new Date("2026-03-15T10:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-21T11:30:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "secret-stripe-archived",
|
||||||
|
companyId: "company-storybook",
|
||||||
|
key: "stripe_legacy",
|
||||||
|
name: "STRIPE_LEGACY",
|
||||||
|
provider: "vault",
|
||||||
|
status: "archived",
|
||||||
|
managedMode: "external_reference",
|
||||||
|
externalRef: "secret/data/payments/stripe-legacy",
|
||||||
|
providerConfigId: null,
|
||||||
|
providerMetadata: null,
|
||||||
|
latestVersion: 4,
|
||||||
|
description: "Migrated to managed billing service. Kept for backfill jobs.",
|
||||||
|
lastResolvedAt: new Date("2026-02-25T08:11:00.000Z"),
|
||||||
|
lastRotatedAt: new Date("2026-02-20T08:11:00.000Z"),
|
||||||
|
deletedAt: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: "user-board",
|
||||||
|
createdAt: new Date("2025-12-01T10:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-01T11:30:00.000Z"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const storybookSecretBindings: CompanySecretBinding[] = [
|
||||||
|
{
|
||||||
|
id: "binding-openai-agent",
|
||||||
|
companyId: "company-storybook",
|
||||||
|
secretId: "secret-openai",
|
||||||
|
targetType: "agent",
|
||||||
|
targetId: "agent-codex",
|
||||||
|
configPath: "env.OPENAI_API_KEY",
|
||||||
|
versionSelector: "latest",
|
||||||
|
required: true,
|
||||||
|
label: "Codex agent env",
|
||||||
|
createdAt: new Date("2026-03-02T09:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-02T09:00:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "binding-openai-project",
|
||||||
|
companyId: "company-storybook",
|
||||||
|
secretId: "secret-openai",
|
||||||
|
targetType: "project",
|
||||||
|
targetId: "project-app",
|
||||||
|
configPath: "env.OPENAI_API_KEY",
|
||||||
|
versionSelector: "latest",
|
||||||
|
required: true,
|
||||||
|
label: "Paperclip App project env",
|
||||||
|
createdAt: new Date("2026-03-02T09:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-02T09:00:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "binding-aws-environment",
|
||||||
|
companyId: "company-storybook",
|
||||||
|
secretId: "secret-aws-prod",
|
||||||
|
targetType: "environment",
|
||||||
|
targetId: "env-prod",
|
||||||
|
configPath: "secrets.AWS_DEPLOY",
|
||||||
|
versionSelector: 2,
|
||||||
|
required: true,
|
||||||
|
label: "Prod environment",
|
||||||
|
createdAt: new Date("2026-04-22T14:01:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-22T14:01:00.000Z"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const storybookSecretAccessEvents: SecretAccessEvent[] = [
|
||||||
|
{
|
||||||
|
id: "evt-1",
|
||||||
|
companyId: "company-storybook",
|
||||||
|
secretId: "secret-openai",
|
||||||
|
version: 3,
|
||||||
|
provider: "local_encrypted",
|
||||||
|
actorType: "agent",
|
||||||
|
actorId: "agent-codex",
|
||||||
|
consumerType: "agent",
|
||||||
|
consumerId: "agent-codex",
|
||||||
|
configPath: "env.OPENAI_API_KEY",
|
||||||
|
issueId: "issue-storybook-1",
|
||||||
|
heartbeatRunId: "run-storybook",
|
||||||
|
pluginId: null,
|
||||||
|
outcome: "success",
|
||||||
|
errorCode: null,
|
||||||
|
createdAt: recent(12),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "evt-2",
|
||||||
|
companyId: "company-storybook",
|
||||||
|
secretId: "secret-openai",
|
||||||
|
version: 3,
|
||||||
|
provider: "local_encrypted",
|
||||||
|
actorType: "system",
|
||||||
|
actorId: null,
|
||||||
|
consumerType: "project",
|
||||||
|
consumerId: "project-app",
|
||||||
|
configPath: "env.OPENAI_API_KEY",
|
||||||
|
issueId: null,
|
||||||
|
heartbeatRunId: null,
|
||||||
|
pluginId: null,
|
||||||
|
outcome: "success",
|
||||||
|
errorCode: null,
|
||||||
|
createdAt: recent(48),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "evt-3",
|
||||||
|
companyId: "company-storybook",
|
||||||
|
secretId: "secret-openai",
|
||||||
|
version: null,
|
||||||
|
provider: "local_encrypted",
|
||||||
|
actorType: "agent",
|
||||||
|
actorId: "agent-codex",
|
||||||
|
consumerType: "agent",
|
||||||
|
consumerId: "agent-codex",
|
||||||
|
configPath: "env.OPENAI_API_KEY",
|
||||||
|
issueId: "issue-storybook-1",
|
||||||
|
heartbeatRunId: "run-storybook",
|
||||||
|
pluginId: null,
|
||||||
|
outcome: "failure",
|
||||||
|
errorCode: "secret_disabled",
|
||||||
|
createdAt: recent(360),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const storybookSecretProviderHealth = {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provider: "local_encrypted" as const,
|
||||||
|
status: "ok" as const,
|
||||||
|
message: "Encryption key loaded; permissions OK.",
|
||||||
|
warnings: [] as string[],
|
||||||
|
backupGuidance: ["Backup ~/.paperclip/instances/default/secrets/key separately from the database."],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "aws_secrets_manager" as const,
|
||||||
|
status: "warn" as const,
|
||||||
|
message: "Connected; KMS key rotation policy not yet enforced.",
|
||||||
|
warnings: ["Set up automated KMS key rotation for production tenants."],
|
||||||
|
backupGuidance: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "gcp_secret_manager" as const,
|
||||||
|
status: "ok" as const,
|
||||||
|
message: "Service account reachable.",
|
||||||
|
warnings: [] as string[],
|
||||||
|
backupGuidance: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "vault" as const,
|
||||||
|
status: "ok" as const,
|
||||||
|
message: "KV v2 mount reachable.",
|
||||||
|
warnings: [] as string[],
|
||||||
|
backupGuidance: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue