Refine secrets page UI per review feedback

- Remove duplicate secret name/key display in table row; show name only
- Replace mode and status badges with plain text (status uses color only)
- Drop provider vault second line from table
- Change 'Owned by Paperclip' reference to 'Owned'
- Gray out GCP and Hashicorp vault sections with 'Coming soon' label
- Replace inner sheet TabsList with PageTabBar for consistent tab style
- Rename 'Rotate' → 'Update value' for managed secrets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Sonnet 4.6 committed 2026-05-07 18:59:05 -05:00
1 parent 58dc2fe299
commit 970cc241e0
14 files changed
+565 -75

No files matched your search

+100 -1
View File
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
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,
@@ -63,7 +65,81 @@ function secret(partial: Partial<CompanySecret>): CompanySecret {
};
}
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,
@@ -155,4 +231,27 @@ describe("secrets CLI helpers", () => {
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");
});
});
+87 -2
View File
@@ -5,6 +5,9 @@ import type { PaperclipConfig } from "../config/schema.js";
import type { CheckResult } from "./index.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 {
const trimmed = raw.trim();
if (!trimmed) return null;
@@ -47,13 +50,16 @@ function withStrictModeNote(
export function secretsCheck(config: PaperclipConfig, configPath?: string): CheckResult {
const provider = config.secrets.provider;
if (provider === "aws_secrets_manager") {
return withStrictModeNote(awsSecretsManagerCheck(), config);
}
if (provider !== "local_encrypted") {
return {
name: "Secrets adapter",
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,
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",
};
}
@@ -153,3 +159,82 @@ export function secretsCheck(config: PaperclipConfig, configPath?: string): Chec
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;
}
+20
View File
@@ -234,12 +234,32 @@ function printProviderHealth(rows: SecretProviderHealth[], json: boolean): void
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!;
+4 -2
View File
@@ -32,7 +32,7 @@ export async function promptSecrets(current?: SecretsConfig): Promise<SecretsCon
{
value: "aws_secrets_manager" as const,
label: "AWS Secrets Manager",
hint: "requires external adapter integration",
hint: "requires runtime AWS credentials and provider env config",
},
{
value: "gcp_secret_manager" as const,
@@ -84,7 +84,9 @@ export async function promptSecrets(current?: SecretsConfig): Promise<SecretsCon
if (provider !== "local_encrypted") {
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",
);
}
+3
View File
@@ -157,6 +157,9 @@ 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.
## Approval Commands
+1 -1
View File
@@ -477,7 +477,7 @@ 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 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.
Migration helper for existing inline env secrets:
+98
View File
@@ -7,6 +7,72 @@ Operational contract for the hosted `aws_secrets_manager` secret provider used b
- 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.
## 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
@@ -65,6 +131,38 @@ arn:aws:secretsmanager:<region>:<account-id>:secret:paperclip/<deployment-id>/*
- 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.
+5
View File
@@ -51,6 +51,11 @@ 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
+2 -1
View File
@@ -67,7 +67,8 @@ Validates:
- Server configuration
- Database connectivity
- Secrets adapter configuration
- Secrets adapter configuration, including AWS Secrets Manager non-secret env
config when selected
- Storage configuration
- Missing key files
+18
View File
@@ -104,6 +104,24 @@ 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`.
### 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.
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
If you have existing agents with inline API keys in their config, migrate them to encrypted secret refs:
+64 -8
View File
@@ -30,7 +30,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^3.0.5
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
cli:
dependencies:
@@ -280,7 +280,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^3.0.5
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
packages/mcp-server:
dependencies:
@@ -302,7 +302,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^3.0.5
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
packages/plugins/create-paperclip-plugin:
dependencies:
@@ -352,7 +352,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^3.0.5
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
packages/plugins/examples/plugin-file-browser-example:
dependencies:
@@ -470,7 +470,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
packages/plugins/sdk:
dependencies:
@@ -509,6 +509,9 @@ importers:
'@aws-sdk/client-s3':
specifier: ^3.888.0
version: 3.994.0
'@aws-sdk/credential-provider-node':
specifier: ^3.972.10
version: 3.972.10
'@paperclipai/adapter-acpx-local':
specifier: workspace:*
version: link:../packages/adapters/acpx-local
@@ -545,6 +548,15 @@ importers:
'@paperclipai/shared':
specifier: workspace:*
version: link:../packages/shared
'@smithy/hash-node':
specifier: ^4.2.8
version: 4.2.8
'@smithy/protocol-http':
specifier: ^5.3.8
version: 5.3.8
'@smithy/signature-v4':
specifier: ^5.3.8
version: 5.3.8
ajv:
specifier: ^8.18.0
version: 8.18.0
@@ -647,7 +659,7 @@ importers:
version: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
vitest:
specifier: ^3.0.5
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
ui:
dependencies:
@@ -789,7 +801,7 @@ importers:
version: 6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
vitest:
specifier: ^3.0.5
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
packages:
@@ -13593,8 +13605,52 @@ snapshots:
- terser
- tsx
- yaml
optional: true
vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 2.0.3
picomatch: 4.0.3
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
vite-node: 3.2.4(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 24.12.0
jsdom: 28.1.0(@noble/hashes@2.0.1)
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
@@ -2080,6 +2080,85 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(comments[0]?.body).toContain(`Recovery issue: [${recovery.identifier}]`);
});
it("blocks an already stranded recovery issue without creating a recovery child", async () => {
const { companyId, issueId } = await seedStrandedIssueFixture({
status: "todo",
runStatus: "failed",
retryReason: "assignment_recovery",
});
const sourceIssueId = randomUUID();
const sourceRunId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
await db.insert(issues).values({
id: sourceIssueId,
companyId,
title: "Original source issue",
status: "blocked",
priority: "medium",
issueNumber: 2,
identifier: `${issuePrefix}-2`,
});
await db
.update(issues)
.set({
title: "Recover stalled issue from previous adapter failure",
parentId: sourceIssueId,
originKind: "stranded_issue_recovery",
originId: sourceIssueId,
originRunId: sourceRunId,
originFingerprint: [
"stranded_issue_recovery",
companyId,
sourceIssueId,
sourceRunId,
].join(":"),
})
.where(eq(issues.id, issueId));
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.dispatchRequeued).toBe(0);
expect(result.escalated).toBe(1);
expect(result.issueIds).toEqual([issueId]);
const recoveryIssues = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stranded_issue_recovery")));
expect(recoveryIssues).toHaveLength(1);
expect(recoveryIssues[0]).toMatchObject({
id: issueId,
status: "blocked",
parentId: sourceIssueId,
originId: sourceIssueId,
originRunId: sourceRunId,
});
expect(recoveryIssues[0]?.checkoutRunId).toBeNull();
expect(recoveryIssues[0]?.executionRunId).toBeNull();
const blockerRelations = await db
.select()
.from(issueRelations)
.where(
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.relatedIssueId, issueId),
eq(issueRelations.type, "blocks"),
),
);
expect(blockerRelations).toHaveLength(0);
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("retried dispatch");
expect(comments[0]?.body).toContain("Nested recovery: suppressed");
expect(comments[0]?.body).toContain("already a `stranded_issue_recovery` issue");
expect(comments[0]?.body).toContain(`Original source issue: [${issuePrefix}-2]`);
expect(comments[0]?.body).toContain("Next action:");
expect(comments[0]?.body).not.toContain("Recovery issue: [");
});
it("assigns open unassigned blockers back to their creator agent", async () => {
const companyId = randomUUID();
const creatorAgentId = randomUUID();
+43 -14
View File
@@ -1313,6 +1313,33 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
.then((rows) => rows[0] ?? null);
}
function isStrandedIssueRecoveryIssue(issue: typeof issues.$inferSelect) {
return issue.originKind === STRANDED_ISSUE_RECOVERY_ORIGIN_KIND;
}
async function buildNestedStrandedRecoveryLine(issue: typeof issues.$inferSelect, prefix: string) {
const sourceIssueId = readNonEmptyString(issue.originId);
const sourceIssue = sourceIssueId
? await db
.select({ id: issues.id, identifier: issues.identifier })
.from(issues)
.where(and(eq(issues.companyId, issue.companyId), eq(issues.id, sourceIssueId)))
.then((rows) => rows[0] ?? null)
: null;
const sourceLine = sourceIssue
? `- Original source issue: ${issueUiLink(sourceIssue, prefix)}`
: sourceIssueId
? `- Original source issue: \`${sourceIssueId}\``
: "- Original source issue: unknown";
return [
"",
"- Nested recovery: suppressed because this issue is already a `stranded_issue_recovery` issue.",
sourceLine,
"- Next action: the assigned recovery owner or board operator should fix the runtime/adapter problem, resolve or reassign the original source issue, then mark this recovery issue done or cancelled.",
].join("\n");
}
async function resolveStrandedIssueRecoveryOwnerAgentId(issue: typeof issues.$inferSelect) {
const candidateIds: string[] = [];
if (issue.assigneeAgentId) {
@@ -1623,21 +1650,17 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
recoveryCause?: StrandedRecoveryCause;
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
}) {
if (isStrandedIssueRecoveryIssue(input.issue)) {
return escalateStrandedRecoveryIssueInPlace({
const nestedRecoverySuppressed = isStrandedIssueRecoveryIssue(input.issue);
let recoveryIssue: typeof issues.$inferSelect | null = null;
if (!nestedRecoverySuppressed) {
recoveryIssue = await ensureStrandedIssueRecoveryIssue({
issue: input.issue,
previousStatus: input.previousStatus,
latestRun: input.latestRun,
recoveryCause: input.recoveryCause,
successfulRunHandoffEvidence: input.successfulRunHandoffEvidence,
});
}
const recoveryIssue = await ensureStrandedIssueRecoveryIssue({
issue: input.issue,
previousStatus: input.previousStatus,
latestRun: input.latestRun,
recoveryCause: input.recoveryCause,
successfulRunHandoffEvidence: input.successfulRunHandoffEvidence,
});
const blockerIds = await existingUnresolvedBlockerIssueIds(input.issue.companyId, input.issue.id);
const nextBlockerIds = recoveryIssue
? [...new Set([...blockerIds, recoveryIssue.id])]
@@ -1667,18 +1690,23 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
missingDisposition: input.successfulRunHandoffEvidence.missingDisposition,
});
}
const recoveryLine = recoveryIssue
? [
let recoveryLine: string;
if (nestedRecoverySuppressed) {
recoveryLine = await buildNestedStrandedRecoveryLine(input.issue, prefix);
} else if (recoveryIssue) {
recoveryLine = [
"",
`- Recovery issue: ${issueUiLink({ identifier: recoveryIssue.identifier, id: recoveryIssue.id }, prefix)}`,
`- Recovery owner: ${agentUiLink(recoveryOwner, prefix)}`,
"- Next action: the recovery owner should either restore a live execution path or record the manual resolution, then mark the recovery issue done.",
].join("\n")
: [
].join("\n");
} else {
recoveryLine = [
"",
"- Recovery issue: none created because Paperclip could not find an invokable manager, creator, or executive owner with budget available.",
"- Next action: a board operator should assign an invokable recovery owner, fix the agent/runtime state, or record an intentional manual resolution.",
].join("\n");
}
if (notice) {
await issuesSvc.addComment(input.issue.id, notice.body, {}, {
@@ -1713,6 +1741,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
latestRunStatus: input.latestRun?.status ?? null,
latestRunErrorCode: input.latestRun?.errorCode ?? null,
recoveryIssueId: recoveryIssue?.id ?? null,
nestedRecoverySuppressed,
blockerIssueIds: nextBlockerIds,
},
});
+41 -46
View File
@@ -171,18 +171,18 @@ function formatRelative(value: Date | string | null | undefined): string {
return date.toLocaleDateString();
}
function statusBadgeTone(status: SecretStatus) {
function statusTextTone(status: SecretStatus) {
switch (status) {
case "active":
return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/30";
return "text-emerald-700 dark:text-emerald-300";
case "disabled":
return "bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/30";
return "text-amber-700 dark:text-amber-300";
case "archived":
return "bg-muted text-muted-foreground border-border";
return "text-muted-foreground";
case "deleted":
return "bg-destructive/10 text-destructive border-destructive/30";
return "text-destructive";
default:
return "bg-muted text-muted-foreground border-border";
return "text-muted-foreground";
}
}
@@ -199,11 +199,6 @@ function normalizeSecretKeyForPreview(input: string) {
.slice(0, 120);
}
function modeBadgeTone(managedMode: SecretManagedMode) {
return managedMode === "paperclip_managed"
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300";
}
function modeLabel(managedMode: SecretManagedMode) {
return managedMode === "paperclip_managed" ? "Paperclip-managed" : "Linked external";
@@ -347,6 +342,7 @@ export function Secrets() {
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToastActions();
const [activeTab, setActiveTab] = useState<SecretsTab>("secrets");
const [secretDetailTab, setSecretDetailTab] = useState("details");
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<SecretStatus | "all">("active");
const [providerFilter, setProviderFilter] = useState<SecretProvider | "all">("all");
@@ -836,23 +832,17 @@ export function Secrets() {
>
<td className="px-6 py-2.5">
<div className="font-medium text-foreground">{secret.name}</div>
<div className="text-[11px] text-muted-foreground font-mono">{secret.key}</div>
</td>
<td className="px-2 py-2.5">
<Badge variant="outline" className={cn("font-medium", modeBadgeTone(secret.managedMode))}>
{modeLabel(secret.managedMode)}
</Badge>
<td className="px-2 py-2.5 text-xs text-muted-foreground">
{modeLabel(secret.managedMode)}
</td>
<td className="px-2 py-2.5 text-xs">
<div>{providerLabel(providers, secret.provider)}</div>
<div className="text-[11px] text-muted-foreground">
{providerVaultLabel(providerConfigs, secret.providerConfigId)}
</div>
</td>
<td className="px-2 py-2.5">
<Badge variant="outline" className={cn("font-medium", statusBadgeTone(secret.status))}>
<span className={cn("text-xs font-medium", statusTextTone(secret.status))}>
{secret.status}
</Badge>
</span>
</td>
<td className="px-2 py-2.5 text-xs font-mono">v{secret.latestVersion}</td>
<td className="px-2 py-2.5 text-xs text-muted-foreground">
@@ -868,7 +858,7 @@ export function Secrets() {
{secret.externalRef ?? "—"}
</span>
) : (
<span className="text-muted-foreground">Owned by Paperclip</span>
<span className="text-muted-foreground">Owned</span>
)}
</td>
<td className="px-6 py-2.5 text-right">
@@ -919,16 +909,12 @@ export function Secrets() {
<SheetTitle className="flex items-center gap-2 text-base">
<KeyRound className="h-4 w-4" />
{selectedSecret.name}
<Badge variant="outline" className={cn("ml-2", statusBadgeTone(selectedSecret.status))}>
<span className={cn("ml-2 text-sm font-normal", statusTextTone(selectedSecret.status))}>
{selectedSecret.status}
</Badge>
<Badge variant="outline" className={cn(modeBadgeTone(selectedSecret.managedMode))}>
{modeLabel(selectedSecret.managedMode)}
</Badge>
</span>
</SheetTitle>
<SheetDescription>
{providerLabel(providers, selectedSecret.provider)} · v{selectedSecret.latestVersion} ·{" "}
<span className="font-mono">{selectedSecret.key}</span>
{providerLabel(providers, selectedSecret.provider)} · v{selectedSecret.latestVersion} · {modeLabel(selectedSecret.managedMode)}
</SheetDescription>
</SheetHeader>
<div className="flex flex-wrap gap-2 px-4 pb-2">
@@ -947,7 +933,7 @@ export function Secrets() {
}}
>
<RefreshCw className="h-3.5 w-3.5 mr-1" />
{selectedSecret.managedMode === "external_reference" ? "Update reference" : "Rotate"}
{selectedSecret.managedMode === "external_reference" ? "Update reference" : "Update value"}
</Button>
{selectedSecret.status === "active" ? (
<Button
@@ -996,14 +982,19 @@ export function Secrets() {
<Trash2 className="h-3.5 w-3.5 mr-1" /> Delete
</Button>
</div>
<Tabs defaultValue="details" className="flex-1 min-h-0 flex flex-col">
<TabsList className="mx-4">
<TabsTrigger value="details">Details</TabsTrigger>
<TabsTrigger value="usage">
Usage{usageQuery.data ? ` (${usageQuery.data.bindings.length})` : ""}
</TabsTrigger>
<TabsTrigger value="events">Access events</TabsTrigger>
</TabsList>
<Tabs value={secretDetailTab} onValueChange={setSecretDetailTab} className="flex-1 min-h-0 flex flex-col">
<div className="border-b border-border px-4">
<PageTabBar
items={[
{ value: "details", label: "Details" },
{ value: "usage", label: usageQuery.data ? `Usage (${usageQuery.data.bindings.length})` : "Usage" },
{ value: "events", label: "Access events" },
]}
align="start"
value={secretDetailTab}
onValueChange={setSecretDetailTab}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
<TabsContent value="details">
<SecretDetailsTab secret={selectedSecret} providerConfigs={providerConfigs} />
@@ -1337,7 +1328,7 @@ export function Secrets() {
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{selectedSecret?.managedMode === "external_reference" ? "Update external reference" : "Rotate secret"}
{selectedSecret?.managedMode === "external_reference" ? "Update external reference" : "Update secret value"}
</DialogTitle>
<DialogDescription>
{selectedSecret?.managedMode === "external_reference"
@@ -1419,7 +1410,7 @@ export function Secrets() {
}
>
{rotateMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : null}
{selectedSecret?.managedMode === "external_reference" ? "Update reference" : "Rotate"}
{selectedSecret?.managedMode === "external_reference" ? "Update reference" : "Update value"}
</Button>
</DialogFooter>
</DialogContent>
@@ -1679,19 +1670,23 @@ export function ProviderVaultsTab({
<div className="min-w-0 flex-1 space-y-6">
{providerRows.map(({ id, provider, Icon, isComingSoonFamily, configs }) => (
<section key={id} id={`provider-vaults-${id}`} className="scroll-mt-6 space-y-2">
<section key={id} id={`provider-vaults-${id}`} className={cn("scroll-mt-6 space-y-2", isComingSoonFamily && "opacity-50")}>
<div className="flex flex-wrap items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">{provider?.label ?? id.replaceAll("_", " ")}</h2>
<Button variant="outline" size="sm" className="ml-auto" onClick={() => onCreate(id)}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add vault
</Button>
{isComingSoonFamily ? (
<span className="ml-auto text-xs text-muted-foreground">Coming soon</span>
) : (
<Button variant="outline" size="sm" className="ml-auto" onClick={() => onCreate(id)}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add vault
</Button>
)}
</div>
{configs.length === 0 ? (
<div className="rounded-md border border-dashed border-border bg-muted/20 p-4 text-sm text-muted-foreground">
{isComingSoonFamily
? "No draft vaults saved. You can capture non-sensitive routing metadata before runtime support lands."
? "Not yet supported."
: "No company-specific vaults yet. Secrets can still use the deployment default provider settings."}
</div>
) : (