From 970cc241e065aa2c26ab0687b26c2f679b354d7d Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 6 May 2026 13:44:22 -0500 Subject: [PATCH] Refine secrets page UI per review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- cli/src/__tests__/secrets.test.ts | 101 +++++++++++++++++- cli/src/checks/secrets-check.ts | 89 ++++++++++++++- cli/src/commands/client/secrets.ts | 20 ++++ cli/src/prompts/secrets.ts | 6 +- doc/CLI.md | 3 + doc/DEVELOPING.md | 2 +- doc/SECRETS-AWS-PROVIDER.md | 98 +++++++++++++++++ docs/api/secrets.md | 5 + docs/cli/setup-commands.md | 3 +- docs/deploy/secrets.md | 18 ++++ pnpm-lock.yaml | 72 +++++++++++-- .../heartbeat-process-recovery.test.ts | 79 ++++++++++++++ server/src/services/recovery/service.ts | 57 +++++++--- ui/src/pages/Secrets.tsx | 87 +++++++-------- 14 files changed, 565 insertions(+), 75 deletions(-) diff --git a/cli/src/__tests__/secrets.test.ts b/cli/src/__tests__/secrets.test.ts index 55f9f1e8d70..a1089ae0a05 100644 --- a/cli/src/__tests__/secrets.test.ts +++ b/cli/src/__tests__/secrets.test.ts @@ -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 { }; } +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"); + }); }); diff --git a/cli/src/checks/secrets-check.ts b/cli/src/checks/secrets-check.ts index a60d8e4e863..73f9c0402c1 100644 --- a/cli/src/checks/secrets-check.ts +++ b/cli/src/checks/secrets-check.ts @@ -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; +} diff --git a/cli/src/commands/client/secrets.ts b/cli/src/commands/client/secrets.ts index 3c909907fbd..98fb025d41e 100644 --- a/cli/src/commands/client/secrets.ts +++ b/cli/src/commands/client/secrets.ts @@ -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 { const ctx = resolveCommandContext(opts, { requireCompany: true }); const companyId = ctx.companyId!; diff --git a/cli/src/prompts/secrets.ts b/cli/src/prompts/secrets.ts index c65ef0bc61a..f1804bceb75 100644 --- a/cli/src/prompts/secrets.ts +++ b/cli/src/prompts/secrets.ts @@ -32,7 +32,7 @@ export async function promptSecrets(current?: SecretsConfig): Promise [--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 diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 1dd0e384e59..07a571d50c8 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -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: diff --git a/doc/SECRETS-AWS-PROVIDER.md b/doc/SECRETS-AWS-PROVIDER.md index 8e08599a6d8..94d236adc7e 100644 --- a/doc/SECRETS-AWS-PROVIDER.md +++ b/doc/SECRETS-AWS-PROVIDER.md @@ -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:::secret:paperclip//* - 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:::secret:paperclip//*" + }, + { + "Sid": "PaperclipDeploymentKms", + "Effect": "Allow", + "Action": [ + "kms:Encrypt", + "kms:Decrypt", + "kms:GenerateDataKey", + "kms:DescribeKey" + ], + "Resource": "arn:aws:kms:::key/" + } + ] +} +``` + Operational expectation: - Paperclip-managed secrets may be deleted only by Paperclip or an operator with equivalent break-glass access. diff --git a/docs/api/secrets.md b/docs/api/secrets.md index e8ca5dc8f10..d6441e8e2b6 100644 --- a/docs/api/secrets.md +++ b/docs/api/secrets.md @@ -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 diff --git a/docs/cli/setup-commands.md b/docs/cli/setup-commands.md index bb3cf17f11d..977f463a4fc 100644 --- a/docs/cli/setup-commands.md +++ b/docs/cli/setup-commands.md @@ -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 diff --git a/docs/deploy/secrets.md b/docs/deploy/secrets.md index 2e63faad64a..12a0a6ba8fd 100644 --- a/docs/deploy/secrets.md +++ b/docs/deploy/secrets.md @@ -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: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d0517dd2f9..2e229e3b30f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 2b917879e30..b9564bbe8b0 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -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(); diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index f2e527989e8..28ff8ecaada 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -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, }, }); diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx index d74438f3cb0..948c0381006 100644 --- a/ui/src/pages/Secrets.tsx +++ b/ui/src/pages/Secrets.tsx @@ -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("secrets"); + const [secretDetailTab, setSecretDetailTab] = useState("details"); const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("active"); const [providerFilter, setProviderFilter] = useState("all"); @@ -836,23 +832,17 @@ export function Secrets() { >
{secret.name}
-
{secret.key}
- - - {modeLabel(secret.managedMode)} - + + {modeLabel(secret.managedMode)}
{providerLabel(providers, secret.provider)}
-
- {providerVaultLabel(providerConfigs, secret.providerConfigId)} -
- + {secret.status} - + v{secret.latestVersion} @@ -868,7 +858,7 @@ export function Secrets() { {secret.externalRef ?? "—"} ) : ( - Owned by Paperclip + Owned )} @@ -919,16 +909,12 @@ export function Secrets() { {selectedSecret.name} - + {selectedSecret.status} - - - {modeLabel(selectedSecret.managedMode)} - + - {providerLabel(providers, selectedSecret.provider)} · v{selectedSecret.latestVersion} ·{" "} - {selectedSecret.key} + {providerLabel(providers, selectedSecret.provider)} · v{selectedSecret.latestVersion} · {modeLabel(selectedSecret.managedMode)}
@@ -947,7 +933,7 @@ export function Secrets() { }} > - {selectedSecret.managedMode === "external_reference" ? "Update reference" : "Rotate"} + {selectedSecret.managedMode === "external_reference" ? "Update reference" : "Update value"} {selectedSecret.status === "active" ? (
- - - Details - - Usage{usageQuery.data ? ` (${usageQuery.data.bindings.length})` : ""} - - Access events - + +
+ +
@@ -1337,7 +1328,7 @@ export function Secrets() { - {selectedSecret?.managedMode === "external_reference" ? "Update external reference" : "Rotate secret"} + {selectedSecret?.managedMode === "external_reference" ? "Update external reference" : "Update secret value"} {selectedSecret?.managedMode === "external_reference" @@ -1419,7 +1410,7 @@ export function Secrets() { } > {rotateMutation.isPending ? : null} - {selectedSecret?.managedMode === "external_reference" ? "Update reference" : "Rotate"} + {selectedSecret?.managedMode === "external_reference" ? "Update reference" : "Update value"} @@ -1679,19 +1670,23 @@ export function ProviderVaultsTab({
{providerRows.map(({ id, provider, Icon, isComingSoonFamily, configs }) => ( -
+

{provider?.label ?? id.replaceAll("_", " ")}

- + {isComingSoonFamily ? ( + Coming soon + ) : ( + + )}
{configs.length === 0 ? (
{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."}
) : (