From aa0bfa0e916d68c06fb431fc385b2c7edbfb1a59 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Thu, 25 Jun 2026 23:40:39 +0000 Subject: [PATCH] Fix dynamic secret gating and encrypted payloads Enforce the dynamic secrets experimental gate on create, dry-run, binding persistence, and runtime resolution. Store dynamic command config and binding argv as encrypted local secret material with redacted metadata summaries. Co-Authored-By: Paperclip --- ...0114_dynamic_secret_encrypted_payloads.sql | 21 ++ packages/db/src/migrations/meta/_journal.json | 7 + .../db/src/schema/company_secret_bindings.ts | 1 + server/src/__tests__/secrets-service.test.ts | 95 +++++- server/src/routes/secrets.ts | 1 + server/src/services/secrets.ts | 292 ++++++++++++++++-- 6 files changed, 384 insertions(+), 33 deletions(-) create mode 100644 packages/db/src/migrations/0114_dynamic_secret_encrypted_payloads.sql diff --git a/packages/db/src/migrations/0114_dynamic_secret_encrypted_payloads.sql b/packages/db/src/migrations/0114_dynamic_secret_encrypted_payloads.sql new file mode 100644 index 00000000000..b0dda4ed371 --- /dev/null +++ b/packages/db/src/migrations/0114_dynamic_secret_encrypted_payloads.sql @@ -0,0 +1,21 @@ +ALTER TABLE "company_secret_bindings" + ADD COLUMN IF NOT EXISTS "static_argv_material" jsonb; + +-- The previous experimental implementation stored host command paths and +-- operator-fixed argv in plaintext JSONB. Existing payloads cannot be safely +-- re-encrypted in SQL because the local encryption key is intentionally outside +-- the database, so remove plaintext-at-rest values. New writes store encrypted +-- material and leave only redacted summaries in these metadata columns. +UPDATE "company_secrets" +SET "dynamic_command" = jsonb_build_object( + 'provider', COALESCE("dynamic_command"->>'provider', 'host-command'), + 'command', '***REDACTED***', + 'ttlSeconds', COALESCE(NULLIF("dynamic_command"->>'ttlSeconds', '')::int, 300) +) +WHERE "managed_mode" = 'dynamic_command' + AND "dynamic_command" IS NOT NULL; + +UPDATE "company_secret_bindings" +SET "static_argv" = '[]'::jsonb +WHERE jsonb_typeof("static_argv") = 'array' + AND jsonb_array_length("static_argv") > 0; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 87c737a1124..53cfdb8aaba 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -799,6 +799,13 @@ "when": 1782417600000, "tag": "0113_dynamic_secret_command_config", "breakpoints": true + }, + { + "idx": 114, + "version": "7", + "when": 1782417900000, + "tag": "0114_dynamic_secret_encrypted_payloads", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/company_secret_bindings.ts b/packages/db/src/schema/company_secret_bindings.ts index dc6570d6b59..34332da635e 100644 --- a/packages/db/src/schema/company_secret_bindings.ts +++ b/packages/db/src/schema/company_secret_bindings.ts @@ -15,6 +15,7 @@ export const companySecretBindings = pgTable( required: boolean("required").notNull().default(true), label: text("label"), staticArgv: jsonb("static_argv").$type().notNull().default([]), + staticArgvMaterial: jsonb("static_argv_material").$type>(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 25191792e81..3f8268a1715 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -21,6 +21,7 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } fro import { awsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js"; import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js"; import { SecretProviderClientError } from "../secrets/types.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; import { secretService } from "../services/secrets.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); @@ -48,6 +49,7 @@ describeEmbeddedPostgres("secretService", () => { afterEach(async () => { vi.restoreAllMocks(); + await instanceSettingsService(db).updateExperimental({ enableDynamicSecrets: false }); await db.delete(activityLog); await db.delete(secretAccessEvents); await db.delete(companySecretBindings); @@ -119,6 +121,10 @@ describeEmbeddedPostgres("secretService", () => { }); } + async function enableDynamicSecrets() { + await instanceSettingsService(db).updateExperimental({ enableDynamicSecrets: true }); + } + it("rejects cross-company secret references during env normalization", async () => { const companyA = await seedCompany("A"); const companyB = await seedCompany("B"); @@ -169,9 +175,28 @@ describeEmbeddedPostgres("secretService", () => { ).rejects.toThrow(/already exists/i); }); - it("persists dynamic command secrets and operator static argv bindings without secret material", async () => { + it("rejects dynamic command secret creation when the experimental flag is disabled", async () => { const companyId = await seedCompany(); const svc = secretService(db); + + await expect( + svc.create(companyId, { + name: `dynamic-disabled-${randomUUID()}`, + provider: "host_command", + managedMode: "dynamic_command", + dynamicCommand: { + provider: "host-command", + command: "/usr/local/bin/mint-github-token", + ttlSeconds: 3600, + }, + }), + ).rejects.toThrow(/not enabled/i); + }); + + it("persists dynamic command secrets and operator static argv bindings as encrypted material", async () => { + const companyId = await seedCompany(); + await enableDynamicSecrets(); + const svc = secretService(db); const secret = await svc.create(companyId, { name: `dynamic-${randomUUID()}`, provider: "host_command", @@ -187,10 +212,10 @@ describeEmbeddedPostgres("secretService", () => { managedMode: "dynamic_command", provider: "host_command", externalRef: null, - latestVersion: 0, + latestVersion: 1, dynamicCommand: { provider: "host-command", - command: "/usr/local/bin/mint-github-token", + command: "***REDACTED***", ttlSeconds: 3600, }, }); @@ -199,7 +224,8 @@ describeEmbeddedPostgres("secretService", () => { .select() .from(companySecretVersions) .where(eq(companySecretVersions.secretId, secret.id)); - expect(versions).toHaveLength(0); + expect(versions).toHaveLength(1); + expect(JSON.stringify(versions[0]?.material)).not.toContain("mint-github-token"); const binding = await svc.createBinding({ companyId, @@ -210,11 +236,14 @@ describeEmbeddedPostgres("secretService", () => { staticArgv: ["--installation", "12345"], }); - expect(binding.staticArgv).toEqual(["--installation", "12345"]); + expect(binding.staticArgv).toEqual(["***REDACTED***", "***REDACTED***"]); + expect(JSON.stringify(binding.staticArgvMaterial)).not.toContain("--installation"); + expect(JSON.stringify(binding.staticArgvMaterial)).not.toContain("12345"); }); it("resolves dynamic command secrets with a TTL cache and metadata-only task activity", async () => { const companyId = await seedCompany(); + await enableDynamicSecrets(); const issue = await seedIssue(companyId); const countFile = path.join(secretsTmpDir, `count-${randomUUID()}.txt`); const scriptPath = writeGeneratorScript("dynamic-success", ` @@ -279,8 +308,54 @@ console.log("minted-" + count + ":" + process.argv.slice(2).join("|")); expect(activityJson).not.toContain(scriptPath); }); + it("fails closed at runtime when dynamic secrets are disabled after creation", async () => { + const companyId = await seedCompany(); + await enableDynamicSecrets(); + const countFile = path.join(secretsTmpDir, `disabled-count-${randomUUID()}.txt`); + const scriptPath = writeGeneratorScript("dynamic-disabled-runtime", ` +const { writeFileSync } = require("node:fs"); +writeFileSync(${JSON.stringify(countFile)}, "invoked"); +console.log("should-not-run"); +`); + const svc = secretService(db); + const secret = await svc.create(companyId, { + name: `dynamic-runtime-disabled-${randomUUID()}`, + provider: "host_command", + managedMode: "dynamic_command", + dynamicCommand: { + provider: "host-command", + command: process.execPath, + ttlSeconds: 60, + }, + }); + await svc.createBinding({ + companyId, + secretId: secret.id, + targetType: "agent", + targetId: "agent-runtime-disabled", + configPath: "env.RUNTIME_TOKEN", + staticArgv: [scriptPath], + }); + await instanceSettingsService(db).updateExperimental({ enableDynamicSecrets: false }); + + await expect( + svc.resolveAdapterConfigForRuntime( + companyId, + { env: { RUNTIME_TOKEN: { type: "secret_ref", secretId: secret.id } } }, + { + consumerType: "agent", + consumerId: "agent-runtime-disabled", + actorType: "system", + actorId: "system", + }, + ), + ).rejects.toThrow(/not enabled/i); + expect(() => readFileSync(countFile, "utf8")).toThrow(); + }); + it("fails closed for dynamic command error, timeout, and empty output", async () => { const companyId = await seedCompany(); + await enableDynamicSecrets(); const svc = secretService(db); const previousTimeout = process.env.PAPERCLIP_DYNAMIC_SECRET_COMMAND_TIMEOUT_MS; process.env.PAPERCLIP_DYNAMIC_SECRET_COMMAND_TIMEOUT_MS = "50"; @@ -341,7 +416,8 @@ console.log("minted-" + count + ":" + process.argv.slice(2).join("|")); }); it("dry-runs a dynamic generator without returning the value", async () => { - await seedCompany(); + const companyId = await seedCompany(); + await enableDynamicSecrets(); const svc = secretService(db); const scriptPath = writeGeneratorScript( "dynamic-test-ok", @@ -349,6 +425,7 @@ console.log("minted-" + count + ":" + process.argv.slice(2).join("|")); ); const ok = await svc.testDynamicCommand({ + companyId, command: process.execPath, staticArgv: [scriptPath, "--installation", "999"], }); @@ -362,6 +439,7 @@ console.log("minted-" + count + ":" + process.argv.slice(2).join("|")); "process.stderr.write('boom'); process.exit(2);", ); const failure = await svc.testDynamicCommand({ + companyId, command: process.execPath, staticArgv: [failing], }); @@ -373,6 +451,7 @@ console.log("minted-" + count + ":" + process.argv.slice(2).join("|")); it("threads operator static argv from env bindings into binding rows", async () => { const companyId = await seedCompany(); + await enableDynamicSecrets(); const svc = secretService(db); const secret = await svc.create(companyId, { name: `dynamic-binding-${randomUUID()}`, @@ -403,7 +482,9 @@ console.log("minted-" + count + ":" + process.argv.slice(2).join("|")); .from(companySecretBindings) .where(eq(companySecretBindings.targetId, "agent-argv")); expect(bindings).toHaveLength(1); - expect(bindings[0]?.staticArgv).toEqual(["--installation", "12345"]); + expect(bindings[0]?.staticArgv).toEqual(["***REDACTED***", "***REDACTED***"]); + expect(JSON.stringify(bindings[0]?.staticArgvMaterial)).not.toContain("--installation"); + expect(JSON.stringify(bindings[0]?.staticArgvMaterial)).not.toContain("12345"); }); it("syncs top-level secret refs idempotently", async () => { diff --git a/server/src/routes/secrets.ts b/server/src/routes/secrets.ts index e005a5164bb..d0434af10c7 100644 --- a/server/src/routes/secrets.ts +++ b/server/src/routes/secrets.ts @@ -322,6 +322,7 @@ export function secretRoutes(db: Db) { throw forbidden("Dynamic secrets are not enabled for this instance."); } const result = await svc.testDynamicCommand({ + companyId, command: req.body.command, staticArgv: req.body.staticArgv, }); diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index d0fa68a08b5..6c095601080 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -34,11 +34,13 @@ import type { import { createSecretProviderConfigSchema, deriveProjectUrlKey, + dynamicSecretCommandSchema, envBindingSchema, isUuidLike, normalizeAgentUrlKey, secretProviderConfigPayloadSchema, secretProviderConfigDiscoveryPreviewSchema, + staticArgvSchema, updateSecretProviderConfigSchema, } from "@paperclipai/shared"; import { conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js"; @@ -53,12 +55,14 @@ import type { RemoteSecretListResult, SecretProviderHealthCheck, SecretProviderModule, + SecretProviderRuntimeContext, SecretProviderVaultRuntimeConfig, SecretProviderWriteContext, } from "../secrets/types.js"; import { isSecretProviderClientError } from "../secrets/types.js"; import { authorizationService } from "./authorization.js"; import { logActivity } from "./activity-log.js"; +import { instanceSettingsService } from "./instance-settings.js"; const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; const SENSITIVE_ENV_KEY_RE = @@ -70,6 +74,8 @@ const COMING_SOON_SECRET_PROVIDERS: ReadonlySet = new Set([ ]); const EMPTY_STATIC_ARGV: string[] = []; const DEFAULT_DYNAMIC_SECRET_COMMAND_TIMEOUT_MS = 30_000; +const REDACTED_DYNAMIC_SECRET_FIELD = "***REDACTED***"; +const DYNAMIC_SECRET_CONFIG_VERSION = 1; const dynamicSecretCache = new Map(); type DbTransaction = Parameters[0]>[0]; type SecretBindingDb = Pick; @@ -276,8 +282,21 @@ type SecretResolutionErrorCode = | "dynamic_secret_command_failed" | "dynamic_secret_command_timeout" | "dynamic_secret_command_empty" + | "dynamic_secrets_disabled" | "provider_error"; +type DynamicSecretConfigPayload = { + kind: "dynamic_command_config"; + version: 1; + dynamicCommand: DynamicSecretCommandConfig; +}; + +type DynamicSecretStaticArgvPayload = { + kind: "dynamic_binding_static_argv"; + version: 1; + staticArgv: string[]; +}; + function asRecord(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; return value as Record; @@ -313,6 +332,57 @@ function normalizeStaticArgv(value: unknown): string[] { return value; } +function dynamicCommandSummary(config: DynamicSecretCommandConfig): DynamicSecretCommandConfig { + return { + provider: config.provider, + command: REDACTED_DYNAMIC_SECRET_FIELD, + ttlSeconds: config.ttlSeconds, + }; +} + +function staticArgvSummary(staticArgv: string[]) { + return staticArgv.map(() => REDACTED_DYNAMIC_SECRET_FIELD); +} + +async function prepareEncryptedPayload( + value: DynamicSecretConfigPayload | DynamicSecretStaticArgvPayload, + context: SecretProviderWriteContext, +) { + return getSecretProvider("local_encrypted").createSecret({ + value: JSON.stringify(value), + externalRef: null, + providerConfig: null, + context, + }); +} + +async function resolveEncryptedPayload( + material: Record | null | undefined, + context: SecretProviderRuntimeContext, + parse: (value: unknown) => T, +): Promise { + if (!material) { + throw unprocessable("Encrypted dynamic secret material is missing", { + code: "dynamic_secret_command_failed", + }); + } + const raw = await getSecretProvider("local_encrypted").resolveVersion({ + material, + externalRef: null, + providerConfig: null, + context, + }); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw unprocessable("Encrypted dynamic secret material is invalid", { + code: "dynamic_secret_command_failed", + }); + } + return parse(parsed); +} + function dynamicSecretHttpError(code: SecretResolutionErrorCode, message: string) { return new HttpError(422, message, { code }); } @@ -432,6 +502,7 @@ function secretResolutionErrorCode(error: unknown): SecretResolutionErrorCode { case "dynamic_secret_command_failed": case "dynamic_secret_command_timeout": case "dynamic_secret_command_empty": + case "dynamic_secrets_disabled": case "provider_error": return details.code; } @@ -466,6 +537,7 @@ function assertSelectableProviderConfig(config: { export function secretService(db: Db) { const authorization = authorizationService(db); + const instanceSettings = instanceSettingsService(db); type NormalizeEnvOptions = { strictMode?: boolean; @@ -505,6 +577,15 @@ export function secretService(db: Db) { .then((rows) => rows[0] ?? null); } + async function assertDynamicSecretsEnabled() { + const experimental = await instanceSettings.getExperimental(); + if (experimental.enableDynamicSecrets !== true) { + throw new HttpError(403, "Dynamic secrets are not enabled for this instance.", { + code: "dynamic_secrets_disabled", + }); + } + } + async function getBinding(input: { companyId: string; secretId: string; @@ -561,6 +642,96 @@ export function secretService(db: Db) { return binding; } + async function resolveDynamicCommandConfig(secret: { + id: string; + companyId: string; + key: string; + latestVersion: number; + }): Promise { + const versionRow = await getSecretVersion(secret.id, secret.latestVersion); + const payload = await resolveEncryptedPayload( + versionRow?.material as Record | undefined, + { + companyId: secret.companyId, + secretId: secret.id, + secretKey: secret.key, + version: secret.latestVersion, + }, + (value) => { + const record = asRecord(value); + if (record?.kind !== "dynamic_command_config" || record.version !== DYNAMIC_SECRET_CONFIG_VERSION) { + throw unprocessable("Encrypted dynamic secret config is invalid", { + code: "dynamic_secret_command_failed", + }); + } + const parsed = dynamicSecretCommandSchema.safeParse(record.dynamicCommand); + if (!parsed.success) { + throw unprocessable("Encrypted dynamic secret config is invalid", { + code: "dynamic_secret_command_failed", + }); + } + return parsed.data; + }, + ); + return payload; + } + + async function resolveBindingStaticArgv(binding: SecretBindingRow): Promise { + if (!binding.staticArgvMaterial) { + return normalizeStaticArgv(binding.staticArgv); + } + return resolveEncryptedPayload( + binding.staticArgvMaterial as Record, + { + companyId: binding.companyId, + secretId: binding.secretId, + secretKey: "binding-static-argv", + version: 1, + }, + (value) => { + const record = asRecord(value); + if (record?.kind !== "dynamic_binding_static_argv" || record.version !== DYNAMIC_SECRET_CONFIG_VERSION) { + throw unprocessable("Encrypted dynamic secret binding argv is invalid", { + code: "dynamic_secret_command_failed", + }); + } + const parsed = staticArgvSchema.safeParse(record.staticArgv); + if (!parsed.success) { + throw unprocessable("Encrypted dynamic secret binding argv is invalid", { + code: "dynamic_secret_command_failed", + }); + } + return parsed.data; + }, + ); + } + + async function prepareStaticArgvForBinding(input: { + companyId: string; + secretId: string; + staticArgv: string[] | undefined; + }): Promise<{ summary: string[]; material: Record | null }> { + const staticArgv = normalizeStaticArgv(input.staticArgv ?? EMPTY_STATIC_ARGV); + if (staticArgv.length === 0) return { summary: EMPTY_STATIC_ARGV, material: null }; + const prepared = await prepareEncryptedPayload( + { + kind: "dynamic_binding_static_argv", + version: DYNAMIC_SECRET_CONFIG_VERSION, + staticArgv, + }, + { + companyId: input.companyId, + secretKey: `binding-static-argv-${input.secretId}`, + secretName: "Dynamic secret binding static argv", + version: 1, + }, + ); + return { + summary: staticArgvSummary(staticArgv), + material: prepared.material, + }; + } + async function recordAccessEvent(input: { companyId: string; secretId: string; @@ -753,7 +924,8 @@ export function secretService(db: Db) { } const binding = await assertBindingContext(companyId, secret.id, bindingContext); if (secret.managedMode === "dynamic_command") { - if (providerId !== "host_command" || !secret.dynamicCommand) { + await assertDynamicSecretsEnabled(); + if (providerId !== "host_command") { throw unprocessable("Dynamic secret is missing generator command config", { code: "dynamic_secret_command_failed", }); @@ -761,13 +933,19 @@ export function secretService(db: Db) { if (!binding) { throw unprocessable("Dynamic secret resolution requires a runtime binding", { code: "binding_missing" }); } + const dynamicCommand = await resolveDynamicCommandConfig({ + id: secret.id, + companyId: secret.companyId, + key: secret.key, + latestVersion: secret.latestVersion, + }); const resolution = await resolveDynamicSecretValue({ companyId, secret: { id: secret.id, key: secret.key, provider: providerId, - dynamicCommand: secret.dynamicCommand, + dynamicCommand, }, binding, configPath, @@ -854,7 +1032,7 @@ export function secretService(db: Db) { configPath: string | null; accessContext: SecretConsumerContext | undefined; }): Promise { - const staticArgv = normalizeStaticArgv(input.binding.staticArgv); + const staticArgv = await resolveBindingStaticArgv(input.binding); const cacheKey = dynamicSecretCacheKey({ bindingId: input.binding.id, staticArgv }); const now = Date.now(); const cached = dynamicSecretCache.get(cacheKey); @@ -1996,38 +2174,67 @@ export function secretService(db: Db) { throw unprocessable("Managed secrets require value"); } if (managedMode === "dynamic_command") { + await assertDynamicSecretsEnabled(); 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"); } + const dynamicCommand = input.dynamicCommand; 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({ + const prepared = await prepareEncryptedPayload( + { + kind: "dynamic_command_config", + version: DYNAMIC_SECRET_CONFIG_VERSION, + dynamicCommand, + }, + { 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, + secretKey: key, + secretName: input.name, + version: 1, + }, + ); + return db.transaction(async (tx) => { + const inserted = await tx + .insert(companySecrets) + .values({ + companyId, + key, + name: input.name, + provider: input.provider, + providerConfigId: input.providerConfigId ?? null, + status: "active", + managedMode, + externalRef: null, + dynamicCommand: dynamicCommandSummary(dynamicCommand), + providerMetadata: input.providerMetadata ?? null, + latestVersion: 1, + description: input.description ?? null, + createdByAgentId: actor?.agentId ?? null, + createdByUserId: actor?.userId ?? null, + }) + .returning() + .then((rows) => rows[0]); + await tx.insert(companySecretVersions).values({ + secretId: inserted.id, + version: 1, + material: prepared.material, + valueSha256: prepared.valueSha256, + fingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256, + providerVersionRef: prepared.providerVersionRef ?? null, + status: "current", createdByAgentId: actor?.agentId ?? null, createdByUserId: actor?.userId ?? null, - }) - .returning() - .then((rows) => rows[0]); + }); + return inserted; + }); } const provider = getSecretProvider(input.provider); const providerConfig = await getSelectableRuntimeProviderConfig({ @@ -2419,6 +2626,7 @@ export function secretService(db: Db) { // an unbound generator is not yet attached to any hard-isolation // environment; the runtime resolver labels the bound posture per Stage 5. testDynamicCommand: async (input: { + companyId: string; command: string; staticArgv?: string[]; }): Promise<{ @@ -2429,6 +2637,7 @@ export function secretService(db: Db) { message?: string; }> => { try { + await assertDynamicSecretsEnabled(); const value = await runDynamicSecretCommand({ command: input.command, staticArgv: normalizeStaticArgv(input.staticArgv ?? EMPTY_STATIC_ARGV), @@ -2455,7 +2664,15 @@ export function secretService(db: Db) { label?: string | null; staticArgv?: string[]; }) => { - await assertSecretInCompany(input.companyId, input.secretId); + const secret = await assertSecretInCompany(input.companyId, input.secretId); + if (secret.managedMode === "dynamic_command") { + await assertDynamicSecretsEnabled(); + } + const staticArgvStorage = await prepareStaticArgvForBinding({ + companyId: input.companyId, + secretId: input.secretId, + staticArgv: input.staticArgv, + }); const existing = await db .select() .from(companySecretBindings) @@ -2480,7 +2697,8 @@ export function secretService(db: Db) { versionSelector: String(input.versionSelector ?? "latest"), required: input.required ?? true, label: input.label ?? null, - staticArgv: input.staticArgv ?? EMPTY_STATIC_ARGV, + staticArgv: staticArgvStorage.summary, + staticArgvMaterial: staticArgvStorage.material, }) .returning() .then((rows) => rows[0]); @@ -2505,16 +2723,26 @@ export function secretService(db: Db) { required: boolean; label: string | null; staticArgv: string[]; + staticArgvMaterial: Record | null; }> = []; for (const ref of refs) { - await assertSecretInCompany(companyId, ref.secretId); + const secret = await assertSecretInCompany(companyId, ref.secretId); + if (secret.managedMode === "dynamic_command") { + await assertDynamicSecretsEnabled(); + } + const staticArgvStorage = await prepareStaticArgvForBinding({ + companyId, + secretId: ref.secretId, + staticArgv: ref.staticArgv, + }); normalizedRefs.push({ secretId: ref.secretId, configPath: ref.configPath, versionSelector: ref.versionSelector ?? "latest", required: ref.required ?? true, label: ref.label ?? null, - staticArgv: ref.staticArgv ?? EMPTY_STATIC_ARGV, + staticArgv: staticArgvStorage.summary, + staticArgvMaterial: staticArgvStorage.material, }); } @@ -2560,6 +2788,7 @@ export function secretService(db: Db) { required: ref.required, label: ref.label, staticArgv: ref.staticArgv, + staticArgvMaterial: ref.staticArgvMaterial, })), ); }); @@ -2592,6 +2821,7 @@ export function secretService(db: Db) { configPath: string; versionSelector: SecretVersionSelector; staticArgv: string[]; + staticArgvMaterial: Record | null; }> = []; const pathPrefix = target.pathPrefix ?? "env"; const bindingDb = options?.db ?? db; @@ -2600,12 +2830,21 @@ export function secretService(db: Db) { if (!parsed.success) continue; const binding = canonicalizeBinding(parsed.data as EnvBinding); if (binding.type !== "secret_ref") continue; - await assertSecretInCompany(companyId, binding.secretId, bindingDb); + const secret = await assertSecretInCompany(companyId, binding.secretId, bindingDb); + if (secret.managedMode === "dynamic_command") { + await assertDynamicSecretsEnabled(); + } + const staticArgvStorage = await prepareStaticArgvForBinding({ + companyId, + secretId: binding.secretId, + staticArgv: binding.staticArgv, + }); refs.push({ secretId: binding.secretId, configPath: `${pathPrefix}.${key}`, versionSelector: binding.version, - staticArgv: binding.staticArgv ?? EMPTY_STATIC_ARGV, + staticArgv: staticArgvStorage.summary, + staticArgvMaterial: staticArgvStorage.material, }); } @@ -2631,6 +2870,7 @@ export function secretService(db: Db) { versionSelector: String(ref.versionSelector), required: true, staticArgv: ref.staticArgv, + staticArgvMaterial: ref.staticArgvMaterial, })), ); };