mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
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:
1 parent
58dc2fe299
commit
970cc241e0
14 files changed
+565
-75
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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!;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Generated
+64
-8
@@ -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();
|
||||
|
||||
@@ -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
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in new issue
Block a user