From 0a8c501297364475ada7fe77033344001d7bd2e3 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 21 May 2026 22:08:46 -0500 Subject: [PATCH] feat(chat-workflow): forward Privy JWT as RECOUP_ACCESS_TOKEN (Bundle A.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth and final open-agents → api cutover bundle. The chat UI sends a short-lived Privy JWT in the workflow request body as `recoupAccessToken`. Today api silently strips it via Zod's default `.strip()` mode and never plumbs it into the sandbox env, so the `recoup-api` skill's curl examples can't authenticate as the user. Production reproducer (today, before this fix): api prod: recoup-api skill loads. curl returns "RECOUP_ACCESS_TOKEN is not set" → 401. Agent: "you need to sign in." open-agents prod: recoup-api skill loads. curl returns HTTP 200 with the user's account_id. Plumbing (all three layers TDD red → green): - lib/chat/validateChatWorkflow.ts — accept `recoupAccessToken: z.string().min(1).max(8192).optional()` in the body schema. Open-agents-shape compatible. - lib/agent/tools/AgentContext.ts — add `recoupAccessToken?: string` field. Mirrors open-agents' `packages/agent/types.ts:29`. - lib/chat/handleChatWorkflowStream.ts — conditionally spread the token into `agentContext` when validator surfaced it. - lib/agent/tools/buildRecoupExecEnv.ts — inject `RECOUP_ACCESS_TOKEN` into the sandbox exec env when the field is set. The recoup-api skill's curl examples reference this env var. Security note: only forward the token when the caller sent it in the body (chat UI path). x-api-key callers don't set this field, so their long-lived `recoup_sk_…` key is never exfiltratable from the sandbox env. Maintained from the prior code comment. Tests: 5 new (3 buildRecoupExecEnv + 1 validator + 1 handler); plus 1 handler omit-when-undefined assertion. Full suite 3144/3144 pass; lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/agent/tools/AgentContext.ts | 26 ++++++++++---- .../__tests__/buildRecoupExecEnv.test.ts | 34 +++++++++++++++++++ lib/agent/tools/buildRecoupExecEnv.ts | 16 ++++++--- .../handleChatWorkflowStream.test.ts | 30 ++++++++++++++++ .../__tests__/validateChatWorkflow.test.ts | 13 +++++++ lib/chat/handleChatWorkflowStream.ts | 12 ++++--- lib/chat/validateChatWorkflow.ts | 8 +++++ 7 files changed, 124 insertions(+), 15 deletions(-) diff --git a/lib/agent/tools/AgentContext.ts b/lib/agent/tools/AgentContext.ts index 7cdcf24a4..e7c703af1 100644 --- a/lib/agent/tools/AgentContext.ts +++ b/lib/agent/tools/AgentContext.ts @@ -16,12 +16,12 @@ import type { SkillMetadata } from "@/lib/skills/skillTypes"; * the constructed model(s) before `experimental_context` is observed * by any tool. * - * Why no `recoupAccessToken` field? A short-lived per-prompt credential - * would let sandbox tools (`skill`, the eventual `recoup-api` skill) call - * back to recoup-api as the caller. We deliberately omit it here — the - * legacy api-key path is too long-lived to expose inside a sandbox where - * model-issued bash commands can read env. Proper short-lived token - * minting lands alongside the `skill` tool port. + * `recoupAccessToken` carries a short-lived Privy JWT (~1h TTL) so + * the `recoup-api` skill can authenticate curl-style calls back to + * recoup-api as the user. Set by `handleChatWorkflowStream` only when + * the caller authenticates via `Authorization: Bearer ` — + * a long-lived `recoup_sk_…` api key is deliberately NOT forwarded + * (model-issued bash commands could exfiltrate it via env). */ export type AgentContext = { /** @@ -41,6 +41,20 @@ export type AgentContext = { * Public information — no security risk in exposing. */ recoupOrgId?: string; + /** + * Short-lived Privy JWT (the user's session token from the chat + * UI's Privy login). Forwarded into the sandbox env as + * `RECOUP_ACCESS_TOKEN` so the `recoup-api` skill's curl examples + * can authenticate as the user. Mirrors open-agents' + * `AgentContext.recoupAccessToken` (`packages/agent/types.ts:29`). + * + * Only set when the chat-workflow caller authenticated via + * `Authorization: Bearer ` (or sent `recoupAccessToken` in the + * request body). x-api-key callers do NOT get the token forwarded — + * the long-lived `recoup_sk_…` key would be exfiltratable from the + * sandbox env by any model-issued bash command. + */ + recoupAccessToken?: string; /** * Skills discovered in the sandbox before workflow start (handler * calls `discoverSkills(sandbox, getSandboxSkillDirectories(sandbox))`). diff --git a/lib/agent/tools/__tests__/buildRecoupExecEnv.test.ts b/lib/agent/tools/__tests__/buildRecoupExecEnv.test.ts index 3422fd662..c17374ea1 100644 --- a/lib/agent/tools/__tests__/buildRecoupExecEnv.test.ts +++ b/lib/agent/tools/__tests__/buildRecoupExecEnv.test.ts @@ -28,4 +28,38 @@ describe("buildRecoupExecEnv", () => { expect(buildRecoupExecEnv({ recoupOrgId: "org-uuid" })).toBeUndefined(); expect(buildRecoupExecEnv({ sandbox: null, recoupOrgId: "org-uuid" })).toBeUndefined(); }); + + // Bundle A.4 contract: when the handler has plumbed a Privy JWT into + // AgentContext.recoupAccessToken, it MUST be surfaced as the + // `RECOUP_ACCESS_TOKEN` env var so the recoup-api skill's curl + // examples can authenticate. Currently failing on production — + // verified end-to-end against api prod: agent reports + // "RECOUP_ACCESS_TOKEN is not set" even when client sent it. + // Open-agents prod passes the equivalent test + // (TOKEN_SET length=413). This test will flip from red → green + // when A.4 lands. + it("injects RECOUP_ACCESS_TOKEN when present in context", () => { + const env = buildRecoupExecEnv({ + sandbox: baseSandbox, + recoupAccessToken: "eyJhbGciOiJFUzI1NiI.test.jwt", + }); + expect(env).toEqual({ RECOUP_ACCESS_TOKEN: "eyJhbGciOiJFUzI1NiI.test.jwt" }); + }); + + it("ignores empty-string recoupAccessToken", () => { + const env = buildRecoupExecEnv({ sandbox: baseSandbox, recoupAccessToken: "" }); + expect(env).toBeUndefined(); + }); + + it("injects BOTH RECOUP_ORG_ID and RECOUP_ACCESS_TOKEN when both are set", () => { + const env = buildRecoupExecEnv({ + sandbox: baseSandbox, + recoupOrgId: "org-uuid", + recoupAccessToken: "jwt.value", + }); + expect(env).toEqual({ + RECOUP_ORG_ID: "org-uuid", + RECOUP_ACCESS_TOKEN: "jwt.value", + }); + }); }); diff --git a/lib/agent/tools/buildRecoupExecEnv.ts b/lib/agent/tools/buildRecoupExecEnv.ts index 6eaf3015f..67d4849bf 100644 --- a/lib/agent/tools/buildRecoupExecEnv.ts +++ b/lib/agent/tools/buildRecoupExecEnv.ts @@ -5,11 +5,14 @@ import { isAgentContext } from "@/lib/agent/tools/isAgentContext"; * so outbound shell commands (curl, scripts, the `recoup-api` skill) can * scope requests correctly without any state persisting on the sandbox. * - * Currently injects only `RECOUP_ORG_ID` — a public identifier. Auth-token - * injection is deliberately NOT included here; a long-lived api key in the - * sandbox env would be readable by any model-issued bash command. Proper - * short-lived token minting will land alongside the `skill` tool port - * (when there's an actual consumer for it). + * Injects: + * - `RECOUP_ORG_ID` — public organization UUID. Always safe. + * - `RECOUP_ACCESS_TOKEN` — short-lived Privy JWT, when the handler + * plumbed one through `AgentContext.recoupAccessToken`. Used by the + * `recoup-api` skill's curl examples to authenticate as the user. + * Long-lived api keys are deliberately NOT forwarded — only the + * short-lived bearer token is, and only when the caller used + * bearer auth (the handler enforces that gating). * * Returns `undefined` when nothing is available to inject so callers can * cleanly spread a conditional `...(env ? { env } : {})` into exec opts. @@ -25,6 +28,9 @@ export function buildRecoupExecEnv( if (experimental_context.recoupOrgId) { env.RECOUP_ORG_ID = experimental_context.recoupOrgId; } + if (experimental_context.recoupAccessToken) { + env.RECOUP_ACCESS_TOKEN = experimental_context.recoupAccessToken; + } return Object.keys(env).length > 0 ? env : undefined; } diff --git a/lib/chat/__tests__/handleChatWorkflowStream.test.ts b/lib/chat/__tests__/handleChatWorkflowStream.test.ts index 702edb918..1af062c8b 100644 --- a/lib/chat/__tests__/handleChatWorkflowStream.test.ts +++ b/lib/chat/__tests__/handleChatWorkflowStream.test.ts @@ -288,6 +288,36 @@ describe("handleChatWorkflowStream", () => { const startArgs = vi.mocked(start).mock.calls[0]?.[1]?.[0] as { modelId: string }; expect(startArgs.modelId).toBe("anthropic/claude-haiku-4.5"); }); + + // Bundle A.4 — forward the Privy JWT from the validated body into + // AgentContext.recoupAccessToken so the sandbox env-builder can + // surface it as `RECOUP_ACCESS_TOKEN`. + it("forwards validated.recoupAccessToken into AgentContext.recoupAccessToken", async () => { + vi.mocked(validateChatWorkflow).mockResolvedValue({ + messages: [], + chatId: CHAT_ID, + sessionId: SESSION_ID, + accountId: ACCOUNT_ID, + orgId: null, + authToken: "test-key", + recoupAccessToken: "eyJ.privy.jwt", + }); + mockStartedRun(); + await handleChatWorkflowStream(makeRequest()); + const startArgs = vi.mocked(start).mock.calls[0]?.[1]?.[0] as { + agentContext: { recoupAccessToken?: string }; + }; + expect(startArgs.agentContext.recoupAccessToken).toBe("eyJ.privy.jwt"); + }); + + it("omits AgentContext.recoupAccessToken when validated body has no token", async () => { + mockStartedRun(); + await handleChatWorkflowStream(makeRequest()); + const startArgs = vi.mocked(start).mock.calls[0]?.[1]?.[0] as { + agentContext: { recoupAccessToken?: string }; + }; + expect(startArgs.agentContext.recoupAccessToken).toBeUndefined(); + }); }); describe("promote placeholder → run id", () => { diff --git a/lib/chat/__tests__/validateChatWorkflow.test.ts b/lib/chat/__tests__/validateChatWorkflow.test.ts index 8eb9457c2..25dd74d20 100644 --- a/lib/chat/__tests__/validateChatWorkflow.test.ts +++ b/lib/chat/__tests__/validateChatWorkflow.test.ts @@ -65,6 +65,19 @@ describe("validateChatWorkflow", () => { const result = await validateChatWorkflow(makeRequest({ ...validBody, messages: [] })); expect(result).not.toBeInstanceOf(NextResponse); }); + + // Bundle A.4 — open-agents' chat UI sends `recoupAccessToken` + // (the user's Privy JWT) in the request body. Today api silently + // strips it via Zod's default `.strip()` mode. After A.4 the + // schema must accept the field so the handler can forward it. + it("accepts and surfaces an optional recoupAccessToken from the body", async () => { + const result = await validateChatWorkflow( + makeRequest({ ...validBody, recoupAccessToken: "eyJ.test.jwt" }), + ); + expect(result).not.toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) return; + expect(result.recoupAccessToken).toBe("eyJ.test.jwt"); + }); }); describe("invalid body", () => { diff --git a/lib/chat/handleChatWorkflowStream.ts b/lib/chat/handleChatWorkflowStream.ts index 5a1c89603..20f048a5c 100644 --- a/lib/chat/handleChatWorkflowStream.ts +++ b/lib/chat/handleChatWorkflowStream.ts @@ -130,10 +130,14 @@ export async function handleChatWorkflowStream(request: NextRequest): Promise;