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;