From a079ba3bc59ce994afe591dfc2cc1eb744a21f57 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 19:04:16 +0000 Subject: [PATCH 1/5] test(server): reproduce wrong-project secretRef reported as used or missing (#27776) These tests reproduce #27776: consuming a secretRef from a different project fails with ref_unavailable, whose message tells the agent to ask again from the same thread, which cannot work. The test now expects a distinct ref_wrong_project reason that names the project mismatch, and that the wrong-project attempt does not spend the ref. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/secrets/SecretRequests.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/secrets/SecretRequests.test.ts b/apps/server/src/secrets/SecretRequests.test.ts index 8962e5d4103d0..13f911dfeb376 100644 --- a/apps/server/src/secrets/SecretRequests.test.ts +++ b/apps/server/src/secrets/SecretRequests.test.ts @@ -228,7 +228,9 @@ it.effect("a ref only works in the project it was entered for", () => const elsewhere = yield* service .consume({ ref, projectId: ProjectId.make("project-other") }) .pipe(Effect.flip); - assert.include(elsewhere.message, "does not exist"); + assert.equal(elsewhere.reason, "ref_wrong_project"); + assert.notInclude(elsewhere.message, "already used or does not exist"); + assert.match(elsewhere.message, /another project/); assert.equal(yield* service.consume({ ref, projectId }), "ghp_secret"); }), ), From c0031fef1de3dac6ebc6dc6098fbd49bd7e9ca0e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 19:11:50 +0000 Subject: [PATCH 2/5] fix(server): a secretRef from another project says so instead of "already used" A secretRef only works in the project of the thread that requested it, but using it from another project failed with ref_unavailable, which tells the agent the ref was used or missing and to ask again. From the same thread that mints another wrong-project ref and the agent loops, re-asking the user. readRef now reports ref_wrong_project for a project mismatch, with a message that names the other project and how to recover, still before the expiry check so a wrong-project caller never spends or deletes the value. The secretRef descriptions on request_secret and webhook signatures note the project scope. Fixes pingdotgg/t3code#27776 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/provider/agentContextBudget.json | 2 +- apps/server/src/secrets/SecretRequests.ts | 6 ++++-- packages/contracts/src/orchestratorMcp.ts | 3 ++- packages/contracts/src/scheduledTask.ts | 3 ++- packages/contracts/src/secretRequest.ts | 2 ++ 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 1711e148d4ca4..7bb85db9fd01e 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5135, "codexAdditionalContextBytes": 6865, - "mcpToolListJsonBytes": 46007 + "mcpToolListJsonBytes": 46042 } diff --git a/apps/server/src/secrets/SecretRequests.ts b/apps/server/src/secrets/SecretRequests.ts index 9e3a7c033c220..3879f0efa506c 100644 --- a/apps/server/src/secrets/SecretRequests.ts +++ b/apps/server/src/secrets/SecretRequests.ts @@ -209,8 +209,10 @@ const make = Effect.gen(function* () { const decoded = Option.flatMap(stored, (bytes) => decodeStored(new TextDecoder().decode(bytes)), ); - if (Option.isNone(decoded) || decoded.value.projectId !== input.projectId) { - return yield* new SecretRequestError({ reason: "ref_unavailable" }); + if (Option.isNone(decoded)) return yield* new SecretRequestError({ reason: "ref_unavailable" }); + // Before the expiry check, so a caller from another project never deletes the value. + if (decoded.value.projectId !== input.projectId) { + return yield* new SecretRequestError({ reason: "ref_wrong_project" }); } if ((yield* Clock.currentTimeMillis) - decoded.value.savedAt > SECRET_REF_TTL_MS) { yield* removeLogged(storeName(input.ref)); diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 1474a5d6e87b6..89ba5e9cfd4cb 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -750,7 +750,8 @@ export const OrchestratorMcpRequestSecretResult = Schema.Union([ Schema.Struct({ status: Schema.Literal("saved"), secretRef: SecretRef.annotate({ - description: "One-use. Pass to a tool that accepts a secretRef. You never see the value.", + description: + "One-use, only in this thread's project. Pass to a tool that accepts a secretRef. You never see the value.", }), }), Schema.Struct({ diff --git a/packages/contracts/src/scheduledTask.ts b/packages/contracts/src/scheduledTask.ts index b4dffacd74c04..1c2073b1129a7 100644 --- a/packages/contracts/src/scheduledTask.ts +++ b/packages/contracts/src/scheduledTask.ts @@ -147,7 +147,8 @@ const ScheduledTaskUpsertWebhookSchedule = Schema.Struct({ description: "Default: stored secret.", }), secretRef: Schema.optional(SecretRef).annotate({ - description: "From `request_secret`. Instead of `secret`. Spent by this save.", + description: + "From `request_secret` in a thread of this task's project. Instead of `secret`. Spent by this save.", }), }), ), diff --git a/packages/contracts/src/secretRequest.ts b/packages/contracts/src/secretRequest.ts index 2506d064b533c..9666c85b272c4 100644 --- a/packages/contracts/src/secretRequest.ts +++ b/packages/contracts/src/secretRequest.ts @@ -24,6 +24,8 @@ const SECRET_REQUEST_FAILURE_MESSAGES = { read_failed: "Could not read the secret.", ref_unavailable: "That secretRef was already used or does not exist. Ask the user again with request_secret.", + ref_wrong_project: + "That secretRef was entered in another project. Ask with request_secret from a thread in the project that uses it.", ref_expired: "That secretRef expired. Ask the user again with request_secret.", consume_failed: "Could not use that secretRef. Try again.", not_a_file_request: From 24b6c9f6d19a2ea461c8ba57a2e924f1fc29b790 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 19:22:34 +0000 Subject: [PATCH 3/5] fix(server): request_secret's description says a ref only works in its project Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/orchestrator/tools.ts | 2 +- apps/server/src/provider/agentContextBudget.json | 2 +- packages/contracts/src/orchestratorMcp.ts | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 413ab984211f8..0cf7b17363228 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -197,7 +197,7 @@ const DeleteScheduledTaskTool = Tool.make("delete_scheduled_task", { const RequestSecretTool = Tool.make("request_secret", { description: - 'Ask the user to enter a secret in a private card and wait. You never see the value. Returns a one-use `secretRef` for a tool that accepts it, or with `delivery: "file"` a private file path to read inside a shell command. Asked how to provide a secret: call this now, do not explain instead.', + 'Ask the user to enter a secret in a private card and wait. You never see the value. Returns a one-use `secretRef`, valid only in this thread\'s project, for a tool that accepts it, or with `delivery: "file"` a private file path to read inside a shell command. Asked how to provide a secret: call this now, do not explain instead.', parameters: OrchestratorMcpRequestSecretInput, success: OrchestratorMcpRequestSecretResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 7bb85db9fd01e..491e772d6efa2 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5135, "codexAdditionalContextBytes": 6865, - "mcpToolListJsonBytes": 46042 + "mcpToolListJsonBytes": 46080 } diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 89ba5e9cfd4cb..1474a5d6e87b6 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -750,8 +750,7 @@ export const OrchestratorMcpRequestSecretResult = Schema.Union([ Schema.Struct({ status: Schema.Literal("saved"), secretRef: SecretRef.annotate({ - description: - "One-use, only in this thread's project. Pass to a tool that accepts a secretRef. You never see the value.", + description: "One-use. Pass to a tool that accepts a secretRef. You never see the value.", }), }), Schema.Struct({ From a5720df3a05c3231af82c23f3cf24e5a7413666d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 19:30:21 +0000 Subject: [PATCH 4/5] fix(server): leave tool descriptions unchanged; the error carries the project rule Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/orchestrator/tools.ts | 2 +- apps/server/src/provider/agentContextBudget.json | 2 +- packages/contracts/src/scheduledTask.ts | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 0cf7b17363228..413ab984211f8 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -197,7 +197,7 @@ const DeleteScheduledTaskTool = Tool.make("delete_scheduled_task", { const RequestSecretTool = Tool.make("request_secret", { description: - 'Ask the user to enter a secret in a private card and wait. You never see the value. Returns a one-use `secretRef`, valid only in this thread\'s project, for a tool that accepts it, or with `delivery: "file"` a private file path to read inside a shell command. Asked how to provide a secret: call this now, do not explain instead.', + 'Ask the user to enter a secret in a private card and wait. You never see the value. Returns a one-use `secretRef` for a tool that accepts it, or with `delivery: "file"` a private file path to read inside a shell command. Asked how to provide a secret: call this now, do not explain instead.', parameters: OrchestratorMcpRequestSecretInput, success: OrchestratorMcpRequestSecretResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 491e772d6efa2..1711e148d4ca4 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5135, "codexAdditionalContextBytes": 6865, - "mcpToolListJsonBytes": 46080 + "mcpToolListJsonBytes": 46007 } diff --git a/packages/contracts/src/scheduledTask.ts b/packages/contracts/src/scheduledTask.ts index 1c2073b1129a7..b4dffacd74c04 100644 --- a/packages/contracts/src/scheduledTask.ts +++ b/packages/contracts/src/scheduledTask.ts @@ -147,8 +147,7 @@ const ScheduledTaskUpsertWebhookSchedule = Schema.Struct({ description: "Default: stored secret.", }), secretRef: Schema.optional(SecretRef).annotate({ - description: - "From `request_secret` in a thread of this task's project. Instead of `secret`. Spent by this save.", + description: "From `request_secret`. Instead of `secret`. Spent by this save.", }), }), ), From 610bed6cad6144cc6d0c20d4502d58b32204770d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 00:10:52 +0000 Subject: [PATCH 5/5] test(server): assert the wrong-project reason, not its copy --- apps/server/src/secrets/SecretRequests.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/server/src/secrets/SecretRequests.test.ts b/apps/server/src/secrets/SecretRequests.test.ts index feefbf2b79187..a8db8eb747127 100644 --- a/apps/server/src/secrets/SecretRequests.test.ts +++ b/apps/server/src/secrets/SecretRequests.test.ts @@ -341,9 +341,7 @@ it.effect("a ref only works in the project it was entered for", () => const elsewhere = yield* service .consume({ ref, projectId: ProjectId.make("project-other") }) .pipe(Effect.flip); - assert.equal(elsewhere.reason, "ref_wrong_project"); - assert.notInclude(elsewhere.message, "already used or does not exist"); - assert.match(elsewhere.message, /another project/); + assert.equal(elsewhere.reason, "ref_wrong_project"); assert.equal(yield* service.consume({ ref, projectId }), "ghp_secret"); }), ),