diff --git a/server/src/__tests__/secret-proposals-routes.test.ts b/server/src/__tests__/secret-proposals-routes.test.ts index 250132f6a46..1310dac8c9a 100644 --- a/server/src/__tests__/secret-proposals-routes.test.ts +++ b/server/src/__tests__/secret-proposals-routes.test.ts @@ -817,6 +817,35 @@ describeEmbeddedPostgres("secret proposal routes", () => { expect(history.filter(row => row.action === "secret.proposal.approved")).toHaveLength(2); }); + it.each([ + ["an empty string", ""], + ["an empty plain value", { type: "plain", value: "" }], + ])("fills a target key left as %s instead of refusing the approved binding", async (_label, blank) => { + const fixture = await seedRun(); + const agentApp = createAgentApp(fixture); + const boardApp = createBoardApp(fixture); + const secret = await request(agentApp).post("/api/agents/me/secret-proposals").send({ + kind: "secret", name: "dev/blank/token", value: "blank-fixture", justification: "Needed by task", + }); + const binding = await request(agentApp).post("/api/agents/me/secret-proposals").send({ + kind: "binding", secretProposalId: secret.body.id, configPath: "env.BLANK_TOKEN", justification: "Use saved secret", + }); + await db.update(agents).set({ adapterConfig: { env: { BLANK_TOKEN: blank, KEEP: "kept" } } }) + .where(eq(agents.id, fixture.agentId)); + + const approved = await request(boardApp) + .post(`/api/companies/${fixture.companyId}/secret-proposals/${binding.body.id}/approve`).send({ cascade: true }); + + expect(approved.status).toBe(200); + const [saved] = await db.select().from(companySecrets); + const env = ((await db.select().from(agents))[0].adapterConfig as { env: Record }).env; + expect(env.BLANK_TOKEN).toMatchObject({ type: "secret_ref", secretId: saved.id, version: "latest" }); + expect(JSON.stringify(env.KEEP)).toContain("kept"); + expect(await db.select().from(companySecretBindings)).toEqual([ + expect.objectContaining({ secretId: saved.id, targetId: fixture.agentId, configPath: "env.BLANK_TOKEN" }), + ]); + }); + it("approves a binding without cascade after its secret proposal was approved separately", async () => { const fixture = await seedRun(); const agentApp = createAgentApp(fixture); diff --git a/server/src/services/secret-proposals.ts b/server/src/services/secret-proposals.ts index 7de0ad8302e..5291561ef79 100644 --- a/server/src/services/secret-proposals.ts +++ b/server/src/services/secret-proposals.ts @@ -667,6 +667,12 @@ export function createSecretProposalsService(db: Db) { return updated; } + function isBlankConfigValue(value: unknown) { + if (value === undefined || value === "") return true; + const record = asRecord(value); + return record.type === "plain" && record.value === ""; + } + async function applyBindingApproval( txDb: Db, proposal: Proposal, @@ -701,13 +707,13 @@ export function createSecretProposalsService(db: Db) { if (namespace === "env") { const env = { ...asRecord(adapterConfig.env) }; const existing = env[key]; - if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(binding)) { + if (!isBlankConfigValue(existing) && JSON.stringify(existing) !== JSON.stringify(binding)) { throw conflict(`Agent config path already exists: ${proposal.configPath}`); } adapterConfig.env = { ...env, [key]: binding }; } else { const existing = adapterConfig[proposal.configPath]; - if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(binding)) { + if (!isBlankConfigValue(existing) && JSON.stringify(existing) !== JSON.stringify(binding)) { throw conflict(`Agent config path already exists: ${proposal.configPath}`); } adapterConfig[proposal.configPath] = binding;