From d495974f95a4613e7d251fea8d490cf4674c4186 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Thu, 25 Jun 2026 21:58:30 +0000 Subject: [PATCH] Add dynamic command secret validation schema Introduce dynamic_command secret metadata and static argv binding persistence behind the existing experimental rollout path. Co-Authored-By: Paperclip --- .../0113_dynamic_secret_command_config.sql | 5 ++ packages/db/src/migrations/meta/_journal.json | 7 ++ .../db/src/schema/company_secret_bindings.ts | 3 +- packages/db/src/schema/company_secrets.ts | 5 ++ packages/shared/src/config-schema.ts | 4 +- packages/shared/src/constants.ts | 10 ++- packages/shared/src/index.ts | 5 ++ packages/shared/src/types/index.ts | 1 + packages/shared/src/types/secrets.ts | 8 ++ packages/shared/src/validators/index.ts | 2 + packages/shared/src/validators/secret.test.ts | 56 +++++++++++++ packages/shared/src/validators/secret.ts | 63 ++++++++++++++- server/src/__tests__/secrets-service.test.ts | 44 +++++++++++ server/src/config.ts | 12 +-- server/src/routes/secrets.ts | 3 +- server/src/secrets/configured-provider.ts | 8 +- server/src/services/secrets.ts | 78 ++++++++++++++++--- 17 files changed, 284 insertions(+), 30 deletions(-) create mode 100644 packages/db/src/migrations/0113_dynamic_secret_command_config.sql diff --git a/packages/db/src/migrations/0113_dynamic_secret_command_config.sql b/packages/db/src/migrations/0113_dynamic_secret_command_config.sql new file mode 100644 index 00000000000..c63fe82a3f3 --- /dev/null +++ b/packages/db/src/migrations/0113_dynamic_secret_command_config.sql @@ -0,0 +1,5 @@ +ALTER TABLE "company_secrets" + ADD COLUMN IF NOT EXISTS "dynamic_command" jsonb; +--> statement-breakpoint +ALTER TABLE "company_secret_bindings" + ADD COLUMN IF NOT EXISTS "static_argv" jsonb NOT NULL DEFAULT '[]'::jsonb; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 06d0dfe20d4..87c737a1124 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -792,6 +792,13 @@ "when": 1781902600000, "tag": "0112_rename_skill_create_permission_key", "breakpoints": true + }, + { + "idx": 113, + "version": "7", + "when": 1782417600000, + "tag": "0113_dynamic_secret_command_config", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/company_secret_bindings.ts b/packages/db/src/schema/company_secret_bindings.ts index 06f926914ae..dc6570d6b59 100644 --- a/packages/db/src/schema/company_secret_bindings.ts +++ b/packages/db/src/schema/company_secret_bindings.ts @@ -1,4 +1,4 @@ -import { boolean, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { boolean, index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; import { companies } from "./companies.js"; import { companySecrets } from "./company_secrets.js"; @@ -14,6 +14,7 @@ export const companySecretBindings = pgTable( versionSelector: text("version_selector").notNull().default("latest"), required: boolean("required").notNull().default(true), label: text("label"), + staticArgv: jsonb("static_argv").$type().notNull().default([]), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/packages/db/src/schema/company_secrets.ts b/packages/db/src/schema/company_secrets.ts index 9499d20cc8c..0d347307ddf 100644 --- a/packages/db/src/schema/company_secrets.ts +++ b/packages/db/src/schema/company_secrets.ts @@ -14,6 +14,11 @@ export const companySecrets = pgTable( status: text("status").notNull().default("active"), managedMode: text("managed_mode").notNull().default("paperclip_managed"), externalRef: text("external_ref"), + dynamicCommand: jsonb("dynamic_command").$type<{ + provider: "host-command"; + command: string; + ttlSeconds: number; + }>(), providerConfigId: uuid("provider_config_id").references(() => companySecretProviderConfigs.id, { onDelete: "set null" }), providerMetadata: jsonb("provider_metadata").$type>(), latestVersion: integer("latest_version").notNull().default(1), diff --git a/packages/shared/src/config-schema.ts b/packages/shared/src/config-schema.ts index efa1bdee1d8..ead2e2c0764 100644 --- a/packages/shared/src/config-schema.ts +++ b/packages/shared/src/config-schema.ts @@ -4,7 +4,7 @@ import { BIND_MODES, DEPLOYMENT_EXPOSURES, DEPLOYMENT_MODES, - SECRET_PROVIDERS, + SECRET_STORAGE_PROVIDERS, STORAGE_PROVIDERS, } from "./constants.js"; import { validateConfiguredBindMode } from "./network-bind.js"; @@ -92,7 +92,7 @@ export const secretsLocalEncryptedConfigSchema = z.object({ }); export const secretsConfigSchema = z.object({ - provider: z.enum(SECRET_PROVIDERS).default("local_encrypted"), + provider: z.enum(SECRET_STORAGE_PROVIDERS).default("local_encrypted"), strictMode: z.boolean().default(false), localEncrypted: secretsLocalEncryptedConfigSchema.default({ keyFilePath: "~/.paperclip/instances/default/secrets/master.key", diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index f64896909a1..6649a757e61 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -546,12 +546,18 @@ export const APPROVAL_STATUSES = [ ] as const; export type ApprovalStatus = (typeof APPROVAL_STATUSES)[number]; -export const SECRET_PROVIDERS = [ +export const SECRET_STORAGE_PROVIDERS = [ "local_encrypted", "aws_secrets_manager", "gcp_secret_manager", "vault", ] as const; +export type SecretStorageProvider = (typeof SECRET_STORAGE_PROVIDERS)[number]; + +export const SECRET_PROVIDERS = [ + ...SECRET_STORAGE_PROVIDERS, + "host_command", +] as const; export type SecretProvider = (typeof SECRET_PROVIDERS)[number]; export const SECRET_PROVIDER_CONFIG_STATUSES = [ @@ -575,7 +581,7 @@ export type SecretProviderConfigHealthStatus = export const SECRET_STATUSES = ["active", "disabled", "archived", "deleted"] as const; export type SecretStatus = (typeof SECRET_STATUSES)[number]; -export const SECRET_MANAGED_MODES = ["paperclip_managed", "external_reference"] as const; +export const SECRET_MANAGED_MODES = ["paperclip_managed", "external_reference", "dynamic_command"] as const; export type SecretManagedMode = (typeof SECRET_MANAGED_MODES)[number]; export const SECRET_VERSION_STATUSES = [ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b730c57c8b3..00bc307766c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -126,6 +126,7 @@ export { PROJECT_COLORS, APPROVAL_TYPES, APPROVAL_STATUSES, + SECRET_STORAGE_PROVIDERS, SECRET_PROVIDERS, SECRET_PROVIDER_CONFIG_STATUSES, SECRET_PROVIDER_CONFIG_HEALTH_STATUSES, @@ -255,6 +256,7 @@ export { type PauseReason, type ApprovalType, type ApprovalStatus, + type SecretStorageProvider, type SecretProvider, type SecretProviderConfigStatus, type SecretProviderConfigHealthStatus, @@ -730,6 +732,7 @@ export type { EnvPlainBinding, EnvSecretRefBinding, AgentEnvConfig, + DynamicSecretCommandConfig, CompanySecret, CompanySecretProviderConfig, SecretProviderConfigPayload, @@ -1188,6 +1191,8 @@ export { envBindingSecretRefSchema, envBindingSchema, envConfigSchema, + dynamicSecretCommandSchema, + staticArgvSchema, createSecretSchema, createSecretProviderConfigSchema, updateSecretProviderConfigSchema, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 7a84aa78c6e..f9b62a33557 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -410,6 +410,7 @@ export type { CompanySecretBindingTarget, CompanySecretUsageBinding, CompanySecretVersion, + DynamicSecretCommandConfig, SecretAccessEvent, RemoteSecretImportCandidate, RemoteSecretImportCandidateStatus, diff --git a/packages/shared/src/types/secrets.ts b/packages/shared/src/types/secrets.ts index 9c01ff40fff..92e750f82d8 100644 --- a/packages/shared/src/types/secrets.ts +++ b/packages/shared/src/types/secrets.ts @@ -38,6 +38,12 @@ export type EnvBinding = string | EnvPlainBinding | EnvSecretRefBinding; export type AgentEnvConfig = Record; +export interface DynamicSecretCommandConfig { + provider: "host-command"; + command: string; + ttlSeconds: number; +} + export interface CompanySecret { id: string; companyId: string; @@ -47,6 +53,7 @@ export interface CompanySecret { status: SecretStatus; managedMode: SecretManagedMode; externalRef: string | null; + dynamicCommand: DynamicSecretCommandConfig | null; providerConfigId: string | null; providerMetadata: Record | null; latestVersion: number; @@ -197,6 +204,7 @@ export interface CompanySecretBinding { versionSelector: SecretVersionSelector; required: boolean; label: string | null; + staticArgv: string[]; createdAt: Date; updatedAt: Date; } diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index b04e1533d78..c49ada7b0af 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -439,6 +439,8 @@ export { envBindingSecretRefSchema, envBindingSchema, envConfigSchema, + dynamicSecretCommandSchema, + staticArgvSchema, createSecretSchema, createSecretProviderConfigSchema, updateSecretProviderConfigSchema, diff --git a/packages/shared/src/validators/secret.test.ts b/packages/shared/src/validators/secret.test.ts index 10d81a9d587..b6ec07d09f7 100644 --- a/packages/shared/src/validators/secret.test.ts +++ b/packages/shared/src/validators/secret.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + createSecretBindingSchema, createSecretProviderConfigSchema, createSecretSchema, remoteSecretImportPreviewSchema, @@ -10,6 +11,61 @@ import { } from "./secret.js"; describe("secret validators", () => { + it("allows dynamic command secrets with host-command generator config", () => { + const parsed = createSecretSchema.parse({ + name: "GitHub App token", + managedMode: "dynamic_command", + dynamicCommand: { + provider: "host-command", + command: "/usr/local/bin/mint-github-token", + ttlSeconds: 3600, + }, + }); + + expect(parsed.dynamicCommand).toEqual({ + provider: "host-command", + command: "/usr/local/bin/mint-github-token", + ttlSeconds: 3600, + }); + }); + + it("rejects value and externalRef on dynamic command secrets", () => { + expect(() => + createSecretSchema.parse({ + name: "Invalid dynamic secret", + managedMode: "dynamic_command", + value: "static-secret", + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/other", + dynamicCommand: { + provider: "host-command", + command: "/usr/local/bin/mint-token", + ttlSeconds: 300, + }, + }), + ).toThrow(/Dynamic command secrets cannot set value/); + }); + + it("requires generator config for dynamic command secrets", () => { + expect(() => + createSecretSchema.parse({ + name: "Missing generator", + managedMode: "dynamic_command", + }), + ).toThrow(/require generator command config/); + }); + + it("accepts operator-set static argv params on secret bindings", () => { + const parsed = createSecretBindingSchema.parse({ + secretId: "11111111-1111-4111-8111-111111111111", + targetType: "agent", + targetId: "agent-1", + configPath: "env.GITHUB_TOKEN", + staticArgv: ["--installation", "12345", "--repo", "paperclipai/paperclip"], + }); + + expect(parsed.staticArgv).toEqual(["--installation", "12345", "--repo", "paperclipai/paperclip"]); + }); + it("rejects externalRef on managed secrets", () => { expect(() => createSecretSchema.parse({ diff --git a/packages/shared/src/validators/secret.ts b/packages/shared/src/validators/secret.ts index a90bf064f18..887e4ba7e5d 100644 --- a/packages/shared/src/validators/secret.ts +++ b/packages/shared/src/validators/secret.ts @@ -4,6 +4,7 @@ import { SECRET_MANAGED_MODES, SECRET_PROVIDER_CONFIG_STATUSES, SECRET_PROVIDERS, + SECRET_STORAGE_PROVIDERS, SECRET_STATUSES, } from "../constants.js"; @@ -27,6 +28,14 @@ export const envBindingSchema = z.union([ export const envConfigSchema = z.record(z.string(), envBindingSchema); +export const dynamicSecretCommandSchema = z.object({ + provider: z.literal("host-command"), + command: z.string().trim().min(1).max(2048), + ttlSeconds: z.number().int().min(1).max(86_400), +}).strict(); + +export const staticArgvSchema = z.array(z.string().max(4096)).max(64).default([]); + export const createSecretSchema = z.object({ name: z.string().min(1), key: z.string().min(1).regex(/^[a-zA-Z0-9_.-]+$/).optional(), @@ -34,12 +43,21 @@ export const createSecretSchema = z.object({ providerConfigId: z.string().uuid().optional().nullable(), managedMode: z.enum(SECRET_MANAGED_MODES).optional(), value: z.string().min(1).optional().nullable(), + dynamicCommand: dynamicSecretCommandSchema.optional().nullable(), description: z.string().optional().nullable(), externalRef: z.string().optional().nullable(), providerMetadata: z.record(z.string(), z.unknown()).optional().nullable(), providerVersionRef: z.string().optional().nullable(), }).superRefine((value, ctx) => { - if ((value.managedMode ?? "paperclip_managed") === "external_reference") { + const managedMode = value.managedMode ?? "paperclip_managed"; + if (managedMode === "external_reference") { + if (value.provider === "host_command") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["provider"], + message: "Host command provider requires dynamic_command mode", + }); + } if (!value.externalRef?.trim()) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -49,6 +67,44 @@ export const createSecretSchema = z.object({ } return; } + if (managedMode === "dynamic_command") { + if ((value.provider ?? "host_command") !== "host_command") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["provider"], + message: "Dynamic command secrets require host_command provider", + }); + } + if (value.value?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["value"], + message: "Dynamic command secrets cannot set value", + }); + } + if (value.externalRef?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["externalRef"], + message: "Dynamic command secrets cannot set externalRef", + }); + } + if (!value.dynamicCommand) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["dynamicCommand"], + message: "Dynamic command secrets require generator command config", + }); + } + return; + } + if (value.provider === "host_command") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["provider"], + message: "Host command provider requires dynamic_command mode", + }); + } if (value.externalRef?.trim()) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -99,6 +155,7 @@ export const createSecretBindingSchema = secretBindingTargetSchema.extend({ versionSelector: z.union([z.literal("latest"), z.number().int().positive()]).default("latest"), required: z.boolean().default(true), label: z.string().optional().nullable(), + staticArgv: staticArgvSchema, }); export type CreateSecretBinding = z.infer; @@ -194,7 +251,7 @@ export const secretProviderConfigPayloadSchema = z.discriminatedUnion("provider" ]); export const createSecretProviderConfigSchema = z.object({ - provider: z.enum(SECRET_PROVIDERS), + provider: z.enum(SECRET_STORAGE_PROVIDERS), displayName: z.string().trim().min(1).max(120), status: z.enum(SECRET_PROVIDER_CONFIG_STATUSES).optional(), isDefault: z.boolean().optional(), @@ -263,7 +320,7 @@ export const remoteSecretImportPreviewSchema = z.object({ export type RemoteSecretImportPreview = z.infer; export const secretProviderConfigDiscoveryPreviewSchema = z.object({ - provider: z.enum(SECRET_PROVIDERS), + provider: z.enum(SECRET_STORAGE_PROVIDERS), config: z.record(z.unknown()).default({}), query: z.string().trim().max(200).optional().nullable(), nextToken: z.string().trim().min(1).max(4096).optional().nullable(), diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 3bc48eb9082..8cee2d3ac8d 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -145,6 +145,50 @@ describeEmbeddedPostgres("secretService", () => { ).rejects.toThrow(/already exists/i); }); + it("persists dynamic command secrets and operator static argv bindings without secret material", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const secret = await svc.create(companyId, { + name: `dynamic-${randomUUID()}`, + provider: "host_command", + managedMode: "dynamic_command", + dynamicCommand: { + provider: "host-command", + command: "/usr/local/bin/mint-github-token", + ttlSeconds: 3600, + }, + }); + + expect(secret).toMatchObject({ + managedMode: "dynamic_command", + provider: "host_command", + externalRef: null, + latestVersion: 0, + dynamicCommand: { + provider: "host-command", + command: "/usr/local/bin/mint-github-token", + ttlSeconds: 3600, + }, + }); + + const versions = await db + .select() + .from(companySecretVersions) + .where(eq(companySecretVersions.secretId, secret.id)); + expect(versions).toHaveLength(0); + + const binding = await svc.createBinding({ + companyId, + secretId: secret.id, + targetType: "agent", + targetId: "agent-1", + configPath: "env.GITHUB_TOKEN", + staticArgv: ["--installation", "12345"], + }); + + expect(binding.staticArgv).toEqual(["--installation", "12345"]); + }); + it("syncs top-level secret refs idempotently", async () => { const companyId = await seedCompany(); const svc = secretService(db); diff --git a/server/src/config.ts b/server/src/config.ts index 90d6cbf6b14..17b15c5078b 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -10,13 +10,13 @@ import { BIND_MODES, DEPLOYMENT_EXPOSURES, DEPLOYMENT_MODES, - SECRET_PROVIDERS, + SECRET_STORAGE_PROVIDERS, STORAGE_PROVIDERS, type BindMode, type AuthBaseUrlMode, type DeploymentExposure, type DeploymentMode, - type SecretProvider, + type SecretStorageProvider, type StorageProvider, inferBindModeFromHost, resolveRuntimeBind, @@ -71,7 +71,7 @@ export interface Config { databaseBackupDir: string; serveUi: boolean; uiDevMiddleware: boolean; - secretsProvider: SecretProvider; + secretsProvider: SecretStorageProvider; secretsStrictMode: boolean; secretsMasterKeyFilePath: string; storageProvider: StorageProvider; @@ -123,11 +123,11 @@ export function loadConfig(): Config { const providerFromEnvRaw = process.env.PAPERCLIP_SECRETS_PROVIDER; const providerFromEnv = - providerFromEnvRaw && SECRET_PROVIDERS.includes(providerFromEnvRaw as SecretProvider) - ? (providerFromEnvRaw as SecretProvider) + providerFromEnvRaw && SECRET_STORAGE_PROVIDERS.includes(providerFromEnvRaw as SecretStorageProvider) + ? (providerFromEnvRaw as SecretStorageProvider) : null; const providerFromFile = fileSecrets?.provider; - const secretsProvider: SecretProvider = providerFromEnv ?? providerFromFile ?? "local_encrypted"; + const secretsProvider: SecretStorageProvider = providerFromEnv ?? providerFromFile ?? "local_encrypted"; const storageProviderFromEnvRaw = process.env.PAPERCLIP_STORAGE_PROVIDER; const storageProviderFromEnv = diff --git a/server/src/routes/secrets.ts b/server/src/routes/secrets.ts index 760d36c7cd5..06cc489614d 100644 --- a/server/src/routes/secrets.ts +++ b/server/src/routes/secrets.ts @@ -279,10 +279,11 @@ export function secretRoutes(db: Db) { { name: req.body.name, key: req.body.key, - provider: req.body.provider ?? defaultProvider, + provider: req.body.provider ?? (req.body.managedMode === "dynamic_command" ? "host_command" : defaultProvider), providerConfigId: req.body.providerConfigId, managedMode: req.body.managedMode, value: req.body.value, + dynamicCommand: req.body.dynamicCommand, description: req.body.description, externalRef: req.body.externalRef, providerVersionRef: req.body.providerVersionRef, diff --git a/server/src/secrets/configured-provider.ts b/server/src/secrets/configured-provider.ts index 25dd821e65a..ea2948a3b1a 100644 --- a/server/src/secrets/configured-provider.ts +++ b/server/src/secrets/configured-provider.ts @@ -1,8 +1,8 @@ -import { SECRET_PROVIDERS, type SecretProvider } from "@paperclipai/shared"; +import { SECRET_STORAGE_PROVIDERS, type SecretStorageProvider } from "@paperclipai/shared"; -export function getConfiguredSecretProvider(): SecretProvider { +export function getConfiguredSecretProvider(): SecretStorageProvider { const configuredProvider = process.env.PAPERCLIP_SECRETS_PROVIDER; - return configuredProvider && SECRET_PROVIDERS.includes(configuredProvider as SecretProvider) - ? configuredProvider as SecretProvider + return configuredProvider && SECRET_STORAGE_PROVIDERS.includes(configuredProvider as SecretStorageProvider) + ? configuredProvider as SecretStorageProvider : "local_encrypted"; } diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 25b1228d8e0..2b16f8bbdb2 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -27,6 +27,7 @@ import type { SecretProviderConfigHealthStatus, SecretProviderConfigStatus, SecretVersionSelector, + DynamicSecretCommandConfig, } from "@paperclipai/shared"; import { createSecretProviderConfigSchema, @@ -64,6 +65,7 @@ const COMING_SOON_SECRET_PROVIDERS: ReadonlySet = new Set([ "gcp_secret_manager", "vault", ]); +const EMPTY_STATIC_ARGV: string[] = []; type DbTransaction = Parameters[0]>[0]; type SecretBindingDb = Pick; @@ -1700,7 +1702,8 @@ export function secretService(db: Db) { providerConfigId?: string | null; value?: string | null; key?: string | null; - managedMode?: "paperclip_managed" | "external_reference"; + managedMode?: "paperclip_managed" | "external_reference" | "dynamic_command"; + dynamicCommand?: DynamicSecretCommandConfig | null; description?: string | null; externalRef?: string | null; providerVersionRef?: string | null; @@ -1724,21 +1727,61 @@ export function secretService(db: Db) { if (duplicateKey) throw conflict(`Secret key already exists: ${key}`); const managedMode = input.managedMode ?? "paperclip_managed"; + if (managedMode === "external_reference" && !input.externalRef?.trim()) { + throw unprocessable("External reference secrets require externalRef"); + } + if (managedMode === "external_reference" && input.provider === "host_command") { + throw unprocessable("Host command provider requires dynamic_command mode"); + } + if (managedMode === "paperclip_managed" && input.externalRef?.trim()) { + throw unprocessable("Managed secrets cannot override externalRef"); + } + if (managedMode === "paperclip_managed" && input.provider === "host_command") { + throw unprocessable("Host command provider requires dynamic_command mode"); + } + if (managedMode === "paperclip_managed" && !input.value?.trim()) { + throw unprocessable("Managed secrets require value"); + } + if (managedMode === "dynamic_command") { + if (input.provider !== "host_command") { + throw unprocessable("Dynamic command secrets require host_command provider"); + } + if (!input.dynamicCommand) { + throw unprocessable("Dynamic command secrets require generator command config"); + } + if (input.value?.trim()) { + throw unprocessable("Dynamic command secrets cannot set value"); + } + if (input.externalRef?.trim()) { + throw unprocessable("Dynamic command secrets cannot set externalRef"); + } + return db + .insert(companySecrets) + .values({ + companyId, + key, + name: input.name, + provider: input.provider, + providerConfigId: input.providerConfigId ?? null, + status: "active", + managedMode, + externalRef: null, + dynamicCommand: input.dynamicCommand, + providerMetadata: input.providerMetadata ?? null, + latestVersion: 0, + description: input.description ?? null, + createdByAgentId: actor?.agentId ?? null, + createdByUserId: actor?.userId ?? null, + }) + .returning() + .then((rows) => rows[0]); + } const provider = getSecretProvider(input.provider); const providerConfig = await getSelectableRuntimeProviderConfig({ companyId, provider: input.provider, providerConfigId: input.providerConfigId, }); - if (managedMode === "external_reference" && !input.externalRef?.trim()) { - throw unprocessable("External reference secrets require externalRef"); - } - if (managedMode === "paperclip_managed" && input.externalRef?.trim()) { - throw unprocessable("Managed secrets cannot override externalRef"); - } - if (managedMode === "paperclip_managed" && !input.value?.trim()) { - throw unprocessable("Managed secrets require value"); - } const providerWriteContext = { companyId, secretKey: key, @@ -1756,6 +1799,7 @@ export function secretService(db: Db) { status: "archived", managedMode, externalRef: null, + dynamicCommand: null, providerMetadata: input.providerMetadata ?? null, latestVersion: 0, description: input.description ?? null, @@ -1894,6 +1938,9 @@ export function secretService(db: Db) { providerConfigId, }); const nextVersion = secret.latestVersion + 1; + if (secret.managedMode === "dynamic_command") { + throw unprocessable("Dynamic command secrets do not support rotation; update generator config instead"); + } if (secret.managedMode === "external_reference" && !(input.externalRef ?? secret.externalRef)?.trim()) { throw unprocessable("External reference secrets require externalRef"); } @@ -2049,7 +2096,10 @@ export function secretService(db: Db) { if (deleting && secret.managedMode === "paperclip_managed") { throw unprocessable("Managed secrets must be deleted through DELETE /secrets/:id"); } - if (secret.managedMode !== "external_reference" && patch.externalRef !== undefined) { + if (secret.managedMode === "dynamic_command" && patch.externalRef !== undefined) { + throw unprocessable("Dynamic command secrets cannot override externalRef"); + } + if (secret.managedMode === "paperclip_managed" && patch.externalRef !== undefined) { throw unprocessable("Managed secrets cannot override externalRef"); } if ( @@ -2118,6 +2168,7 @@ export function secretService(db: Db) { versionSelector?: SecretVersionSelector; required?: boolean; label?: string | null; + staticArgv?: string[]; }) => { await assertSecretInCompany(input.companyId, input.secretId); const existing = await db @@ -2144,6 +2195,7 @@ export function secretService(db: Db) { versionSelector: String(input.versionSelector ?? "latest"), required: input.required ?? true, label: input.label ?? null, + staticArgv: input.staticArgv ?? EMPTY_STATIC_ARGV, }) .returning() .then((rows) => rows[0]); @@ -2158,6 +2210,7 @@ export function secretService(db: Db) { versionSelector?: SecretVersionSelector; required?: boolean; label?: string | null; + staticArgv?: string[]; }>, ) => { const normalizedRefs: Array<{ @@ -2166,6 +2219,7 @@ export function secretService(db: Db) { versionSelector: SecretVersionSelector; required: boolean; label: string | null; + staticArgv: string[]; }> = []; for (const ref of refs) { await assertSecretInCompany(companyId, ref.secretId); @@ -2175,6 +2229,7 @@ export function secretService(db: Db) { versionSelector: ref.versionSelector ?? "latest", required: ref.required ?? true, label: ref.label ?? null, + staticArgv: ref.staticArgv ?? EMPTY_STATIC_ARGV, }); } @@ -2219,6 +2274,7 @@ export function secretService(db: Db) { versionSelector: String(ref.versionSelector), required: ref.required, label: ref.label, + staticArgv: ref.staticArgv, })), ); });