From 21e14a35e283e17bc9f47fbe053ff530b258f88f Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 23 Apr 2026 11:59:29 -0500 Subject: [PATCH 1/3] feat: forward short-lived Privy access token per prompt to sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threads the user's Privy JWT from the chat client into each `sandbox.exec` / `execDetached` call's env as `RECOUP_ACCESS_TOKEN`, so shell and web-fetch tools can authenticate against the Recoupable API as the user without the token persisting on the sandbox between prompts or turning into a long-lived credential. - packages/sandbox: `exec`/`execDetached` gain an optional per-call `env`, merged on top of the persistent sandbox env via a new `mergeCommandEnv` helper. Per-call entries win. - packages/agent: `AgentContext` and `callOptionsSchema` accept an optional `recoupAccessToken`; the agent forwards it via `experimental_context`. New `buildRecoupExecEnv` helper surfaces it to tools. - bash / web_fetch tools inject `RECOUP_ACCESS_TOKEN` only for their single exec invocation — grep/glob/etc. never see it. - /api/chat accepts `recoupAccessToken` on the body and plumbs into `agentOptions`; the session chat transport fetches a fresh token via `usePrivy().getAccessToken()` per prompt (ref-stable so the transport memo doesn't churn). Since prompts almost always finish well inside the Privy token TTL (~1h vs. p95 prompt <30min), the token naturally dies between prompts and a fresh one rides in on the next send — no custom keys, no DB schema, no cleanup workflow required. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/app/api/chat/_lib/request.ts | 7 ++ apps/web/app/api/chat/route.ts | 3 +- .../hooks/use-session-chat-runtime.ts | 24 ++++- packages/agent/open-harness-agent.ts | 9 ++ packages/agent/tools/bash.ts | 10 +- packages/agent/tools/fetch.ts | 4 +- packages/agent/tools/tools.test.ts | 91 +++++++++++++++++++ packages/agent/tools/utils.ts | 30 ++++++ packages/agent/types.ts | 6 ++ packages/sandbox/interface.ts | 8 +- packages/sandbox/vercel/sandbox.ts | 27 +++++- 11 files changed, 209 insertions(+), 10 deletions(-) diff --git a/apps/web/app/api/chat/_lib/request.ts b/apps/web/app/api/chat/_lib/request.ts index ce67d5b..922d907 100644 --- a/apps/web/app/api/chat/_lib/request.ts +++ b/apps/web/app/api/chat/_lib/request.ts @@ -4,6 +4,13 @@ export interface ChatRequestBody { messages: WebAgentUIMessage[]; sessionId?: string; chatId?: string; + /** + * Short-lived Recoupable access token (Privy JWT) for this prompt. + * Forwarded into the agent's `experimental_context` so tools making + * outbound calls to the Recoupable API authenticate as the user for + * the duration of this prompt only. + */ + recoupAccessToken?: string; } type ParseChatRequestResult = diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts index 701facb..5d3a236 100644 --- a/apps/web/app/api/chat/route.ts +++ b/apps/web/app/api/chat/route.ts @@ -67,7 +67,7 @@ export async function POST(req: Request) { return parsedBody.response; } - const { messages } = parsedBody.body; + const { messages, recoupAccessToken } = parsedBody.body; // 2. Require sessionId and chatId to ensure sandbox ownership verification const chatIdentifiers = requireChatIdentifiers(parsedBody.body); @@ -236,6 +236,7 @@ export async function POST(req: Request) { : {}), ...(skills.length > 0 && { skills }), customInstructions: assistantFileLinkPrompt, + ...(recoupAccessToken ? { recoupAccessToken } : {}), }, ...(shouldAutoCommitPush && sessionRecord.repoOwner && diff --git a/apps/web/app/sessions/[sessionId]/chats/[chatId]/hooks/use-session-chat-runtime.ts b/apps/web/app/sessions/[sessionId]/chats/[chatId]/hooks/use-session-chat-runtime.ts index 9403238..81dea1b 100644 --- a/apps/web/app/sessions/[sessionId]/chats/[chatId]/hooks/use-session-chat-runtime.ts +++ b/apps/web/app/sessions/[sessionId]/chats/[chatId]/hooks/use-session-chat-runtime.ts @@ -1,6 +1,7 @@ "use client"; import { type UseChatHelpers, useChat } from "@ai-sdk/react"; +import { usePrivy } from "@privy-io/react-auth"; import { isToolUIPart } from "ai"; import { useCallback, useEffect, useMemo, useRef } from "react"; import type { WebAgentUIMessage } from "@/app/types"; @@ -85,15 +86,36 @@ export function useSessionChatRuntime({ contextLimitRef.current = contextLimit; }, [contextLimit]); + // Keep the Privy access-token fetcher in a ref so the transport memo + // stays stable across renders while still reading the latest token on + // each send. A fresh token is attached per-prompt so the sandbox's + // outbound Recoupable API calls authenticate with a short-lived + // credential scoped to that prompt. + const { getAccessToken } = usePrivy(); + const getAccessTokenRef = useRef(getAccessToken); + useEffect(() => { + getAccessTokenRef.current = getAccessToken; + }, [getAccessToken]); + const transport = useMemo( () => new AbortableChatTransport({ api: "/api/chat", - body: () => { + body: async () => { const requestContextLimit = contextLimitRef.current; + let recoupAccessToken: string | null = null; + try { + recoupAccessToken = await getAccessTokenRef.current(); + } catch (error) { + console.error( + "[chat] failed to fetch Privy access token for prompt", + error, + ); + } return { sessionId, chatId, + ...(recoupAccessToken ? { recoupAccessToken } : {}), ...(requestContextLimit !== null ? { context: { diff --git a/packages/agent/open-harness-agent.ts b/packages/agent/open-harness-agent.ts index f66ce02..1ad86d9 100644 --- a/packages/agent/open-harness-agent.ts +++ b/packages/agent/open-harness-agent.ts @@ -44,6 +44,13 @@ const callOptionsSchema = z.object({ subagentModel: z.custom().optional(), customInstructions: z.string().optional(), skills: z.custom().optional(), + /** + * Short-lived Recoupable API access token forwarded by the caller on a + * per-prompt basis. Surfaced to tools via `experimental_context` so + * outbound calls to the Recoupable API can authenticate as the user + * without persisting a long-lived credential in the sandbox env. + */ + recoupAccessToken: z.string().optional(), }); export type OpenHarnessAgentCallOptions = z.infer; @@ -114,6 +121,7 @@ export const openHarnessAgent = new ToolLoopAgent({ const customInstructions = options.customInstructions; const sandbox = options.sandbox; const skills = options.skills ?? []; + const recoupAccessToken = options.recoupAccessToken; const instructions = buildSystemPrompt({ cwd: sandbox.workingDirectory, @@ -137,6 +145,7 @@ export const openHarnessAgent = new ToolLoopAgent({ skills, model: callModel, subagentModel, + ...(recoupAccessToken ? { recoupAccessToken } : {}), }, }; }, diff --git a/packages/agent/tools/bash.ts b/packages/agent/tools/bash.ts index 07fad02..1e62e41 100644 --- a/packages/agent/tools/bash.ts +++ b/packages/agent/tools/bash.ts @@ -1,7 +1,7 @@ import { tool } from "ai"; import { z } from "zod"; import * as path from "path"; -import { getSandbox } from "./utils"; +import { buildRecoupExecEnv, getSandbox } from "./utils"; const TIMEOUT_MS = 120_000; @@ -104,6 +104,7 @@ EXAMPLES: ) => { const sandbox = await getSandbox(experimental_context, "bash"); const workingDirectory = sandbox.workingDirectory; + const recoupEnv = buildRecoupExecEnv(experimental_context); // Resolve the working directory const workingDir = cwd @@ -125,7 +126,11 @@ EXAMPLES: } try { - const { commandId } = await sandbox.execDetached(command, workingDir); + const { commandId } = await sandbox.execDetached( + command, + workingDir, + recoupEnv ? { env: recoupEnv } : undefined, + ); return { success: true, exitCode: null, @@ -144,6 +149,7 @@ EXAMPLES: const result = await sandbox.exec(command, workingDir, TIMEOUT_MS, { signal: abortSignal, + ...(recoupEnv ? { env: recoupEnv } : {}), }); return { diff --git a/packages/agent/tools/fetch.ts b/packages/agent/tools/fetch.ts index 8b15f0d..3aa2503 100644 --- a/packages/agent/tools/fetch.ts +++ b/packages/agent/tools/fetch.ts @@ -1,6 +1,6 @@ import { tool } from "ai"; import { z } from "zod"; -import { getSandbox, shellEscape } from "./utils"; +import { buildRecoupExecEnv, getSandbox, shellEscape } from "./utils"; const TIMEOUT_MS = 30_000; export const MAX_BODY_LENGTH = 10_000; @@ -54,6 +54,7 @@ EXAMPLES: ) => { const sandbox = await getSandbox(experimental_context, "web_fetch"); const workingDirectory = sandbox.workingDirectory; + const recoupEnv = buildRecoupExecEnv(experimental_context); const args: string[] = [ "curl", @@ -92,6 +93,7 @@ EXAMPLES: try { const result = await sandbox.exec(command, workingDirectory, TIMEOUT_MS, { signal: abortSignal, + ...(recoupEnv ? { env: recoupEnv } : {}), }); if (result.exitCode !== 0 && result.exitCode !== 23) { diff --git a/packages/agent/tools/tools.test.ts b/packages/agent/tools/tools.test.ts index cb3a2be..396fb52 100644 --- a/packages/agent/tools/tools.test.ts +++ b/packages/agent/tools/tools.test.ts @@ -371,6 +371,97 @@ describe("tools execute behavior", () => { }); }); + test("bashTool forwards recoupAccessToken as RECOUP_ACCESS_TOKEN in exec env", async () => { + const execCalls: Array<{ + command: string; + cwd: string; + options?: { signal?: AbortSignal; env?: Record }; + }> = []; + const detachedCalls: Array<{ + command: string; + cwd: string; + options?: { env?: Record }; + }> = []; + + const sandbox = { + workingDirectory: "/repo", + exec: async ( + command: string, + cwd: string, + _timeoutMs: number, + options?: { signal?: AbortSignal; env?: Record }, + ) => { + execCalls.push({ command, cwd, options }); + return { + success: true, + exitCode: 0, + stdout: "", + stderr: "", + truncated: false, + }; + }, + execDetached: async ( + command: string, + cwd: string, + options?: { env?: Record }, + ) => { + detachedCalls.push({ command, cwd, options }); + return { commandId: "cmd-detached" }; + }, + }; + + const context = { + ...createContext(sandbox), + recoupAccessToken: "privy-jwt-abc", + }; + + await bashTool().execute?.( + { command: "curl https://developers.recoupable.com/ping" }, + executionOptions(context), + ); + + expect(execCalls).toHaveLength(1); + expect(execCalls[0]?.options?.env).toEqual({ + RECOUP_ACCESS_TOKEN: "privy-jwt-abc", + }); + + await bashTool().execute?.( + { command: "npm run dev", detached: true }, + executionOptions(context), + ); + + expect(detachedCalls).toHaveLength(1); + expect(detachedCalls[0]?.options?.env).toEqual({ + RECOUP_ACCESS_TOKEN: "privy-jwt-abc", + }); + + const contextWithoutToken = createContext({ + workingDirectory: "/repo", + exec: async ( + command: string, + cwd: string, + _timeoutMs: number, + options?: { signal?: AbortSignal; env?: Record }, + ) => { + execCalls.push({ command, cwd, options }); + return { + success: true, + exitCode: 0, + stdout: "", + stderr: "", + truncated: false, + }; + }, + }); + + await bashTool().execute?.( + { command: "ls" }, + executionOptions(contextWithoutToken), + ); + + expect(execCalls[1]?.options?.env).toBeUndefined(); + }); + test("commandNeedsApproval flags only rm -rf commands", () => { expect(commandNeedsApproval("ls -la")).toBe(false); expect(commandNeedsApproval("git status --short")).toBe(false); diff --git a/packages/agent/tools/utils.ts b/packages/agent/tools/utils.ts index a6d511e..b8f5389 100644 --- a/packages/agent/tools/utils.ts +++ b/packages/agent/tools/utils.ts @@ -122,6 +122,36 @@ export function getSandboxContext( }; } +/** + * Read the per-prompt Recoupable access token from agent context. + * Returns undefined when no token is present (e.g. callers that do not + * authenticate against the Recoupable API). + */ +export function getRecoupAccessToken( + experimental_context: unknown, +): string | undefined { + const context = isAgentContext(experimental_context) + ? experimental_context + : undefined; + return context?.recoupAccessToken; +} + +/** + * Build a per-invocation env override carrying the Recoupable access + * token when one is available, so outbound shell commands (curl, scripts) + * can authenticate without the token persisting on the sandbox. + */ +export function buildRecoupExecEnv( + experimental_context: unknown, +): { RECOUP_ACCESS_TOKEN: string } | undefined { + const token = getRecoupAccessToken(experimental_context); + if (!token) { + return undefined; + } + + return { RECOUP_ACCESS_TOKEN: token }; +} + /** * Get model from experimental context with null safety. * Throws a descriptive error if model is not initialized. diff --git a/packages/agent/types.ts b/packages/agent/types.ts index 6ce53ab..2af799d 100644 --- a/packages/agent/types.ts +++ b/packages/agent/types.ts @@ -21,6 +21,12 @@ export interface AgentContext { skills?: SkillMetadata[]; model: LanguageModel; subagentModel?: LanguageModel; + /** + * Short-lived Recoupable API access token (Privy JWT) scoped to the + * current prompt. Tools that call out to the Recoupable API should + * forward this as a Bearer token instead of holding a long-lived key. + */ + recoupAccessToken?: string; } export interface SandboxExecutionContext { diff --git a/packages/sandbox/interface.ts b/packages/sandbox/interface.ts index 0bfed68..4f018f1 100644 --- a/packages/sandbox/interface.ts +++ b/packages/sandbox/interface.ts @@ -132,13 +132,17 @@ export interface Sandbox { command: string, cwd: string, timeoutMs: number, - options?: { signal?: AbortSignal }, + options?: { signal?: AbortSignal; env?: Record }, ): Promise; /** * Execute a shell command in detached mode (returns immediately). */ - execDetached?(command: string, cwd: string): Promise<{ commandId: string }>; + execDetached?( + command: string, + cwd: string, + options?: { env?: Record }, + ): Promise<{ commandId: string }>; /** * Get the public URL for an exposed port. diff --git a/packages/sandbox/vercel/sandbox.ts b/packages/sandbox/vercel/sandbox.ts index b8c0d51..beedd50 100644 --- a/packages/sandbox/vercel/sandbox.ts +++ b/packages/sandbox/vercel/sandbox.ts @@ -483,6 +483,26 @@ ${hostLine}${portLines}${runtimeEnvLine}`; }; } + /** + * Merge per-invocation env on top of the persistent sandbox env. The + * per-invocation entries win so callers can pass short-lived credentials + * (e.g. access tokens) scoped to a single command without polluting the + * sandbox-wide env. + */ + private mergeCommandEnv( + perCallEnv?: Record, + ): Record | undefined { + const base = this.getCommandEnv(); + if (!perCallEnv || Object.keys(perCallEnv).length === 0) { + return base; + } + + return { + ...base, + ...perCallEnv, + }; + } + /** * Create a new Vercel Sandbox instance. * If `baseSnapshotId` is provided, sandbox bootstraps from that snapshot first. @@ -923,7 +943,7 @@ ${hostLine}${portLines}${runtimeEnvLine}`; command: string, cwd: string, timeoutMs: number, - options?: { signal?: AbortSignal }, + options?: { signal?: AbortSignal; env?: Record }, ): Promise { try { const timeoutSignal = AbortSignal.timeout(timeoutMs); @@ -934,7 +954,7 @@ ${hostLine}${portLines}${runtimeEnvLine}`; const result = await this.session.runCommand({ cmd: "bash", args: ["-c", `cd "${cwd}" && ${command}`], - env: this.getCommandEnv(), + env: this.mergeCommandEnv(options?.env), signal, }); @@ -985,11 +1005,12 @@ ${hostLine}${portLines}${runtimeEnvLine}`; async execDetached( command: string, cwd: string, + options?: { env?: Record }, ): Promise<{ commandId: string }> { const result = await this.session.runCommand({ cmd: "bash", args: ["-c", `cd "${cwd}" && ${command}`], - env: this.getCommandEnv(), + env: this.mergeCommandEnv(options?.env), detached: true, }); From 5e30145503615c6f546c2808a4575ede54fae437 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 23 Apr 2026 12:19:46 -0500 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20address=20PR=20#13=20review=20?= =?UTF-8?q?=E2=80=94=20simplify=20per=20KISS/SRP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the getAccessToken useRef/useEffect dance in the chat transport; call getAccessToken() directly in body() and add it to the memo deps. - Extract getRecoupAccessToken and buildRecoupExecEnv to their own files (SRP); utils.ts exports isAgentContext for reuse. - Remove the mergeCommandEnv helper; fold the per-call env merge into getCommandEnv directly so both exec and execDetached share one path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hooks/use-session-chat-runtime.ts | 22 ++++-------- packages/agent/tools/bash.ts | 3 +- packages/agent/tools/build-recoup-exec-env.ts | 17 ++++++++++ packages/agent/tools/fetch.ts | 3 +- .../agent/tools/get-recoup-access-token.ts | 15 ++++++++ packages/agent/tools/utils.ts | 32 +---------------- packages/sandbox/vercel/sandbox.ts | 34 ++++++------------- 7 files changed, 55 insertions(+), 71 deletions(-) create mode 100644 packages/agent/tools/build-recoup-exec-env.ts create mode 100644 packages/agent/tools/get-recoup-access-token.ts diff --git a/apps/web/app/sessions/[sessionId]/chats/[chatId]/hooks/use-session-chat-runtime.ts b/apps/web/app/sessions/[sessionId]/chats/[chatId]/hooks/use-session-chat-runtime.ts index 81dea1b..24fac6b 100644 --- a/apps/web/app/sessions/[sessionId]/chats/[chatId]/hooks/use-session-chat-runtime.ts +++ b/apps/web/app/sessions/[sessionId]/chats/[chatId]/hooks/use-session-chat-runtime.ts @@ -86,16 +86,10 @@ export function useSessionChatRuntime({ contextLimitRef.current = contextLimit; }, [contextLimit]); - // Keep the Privy access-token fetcher in a ref so the transport memo - // stays stable across renders while still reading the latest token on - // each send. A fresh token is attached per-prompt so the sandbox's - // outbound Recoupable API calls authenticate with a short-lived - // credential scoped to that prompt. + // Attach a fresh Privy access token to each prompt so outbound calls + // from the sandbox to the Recoupable API authenticate as the user with + // a short-lived credential that dies with the prompt. const { getAccessToken } = usePrivy(); - const getAccessTokenRef = useRef(getAccessToken); - useEffect(() => { - getAccessTokenRef.current = getAccessToken; - }, [getAccessToken]); const transport = useMemo( () => @@ -103,15 +97,13 @@ export function useSessionChatRuntime({ api: "/api/chat", body: async () => { const requestContextLimit = contextLimitRef.current; - let recoupAccessToken: string | null = null; - try { - recoupAccessToken = await getAccessTokenRef.current(); - } catch (error) { + const recoupAccessToken = await getAccessToken().catch((error) => { console.error( "[chat] failed to fetch Privy access token for prompt", error, ); - } + return null; + }); return { sessionId, chatId, @@ -129,7 +121,7 @@ export function useSessionChatRuntime({ api: `/api/chat/${id}/stream`, }), }), - [sessionId, chatId], + [sessionId, chatId, getAccessToken], ); const { instance: chatInstance, alreadyExisted } = useMemo( diff --git a/packages/agent/tools/bash.ts b/packages/agent/tools/bash.ts index 1e62e41..3515494 100644 --- a/packages/agent/tools/bash.ts +++ b/packages/agent/tools/bash.ts @@ -1,7 +1,8 @@ import { tool } from "ai"; import { z } from "zod"; import * as path from "path"; -import { buildRecoupExecEnv, getSandbox } from "./utils"; +import { buildRecoupExecEnv } from "./build-recoup-exec-env"; +import { getSandbox } from "./utils"; const TIMEOUT_MS = 120_000; diff --git a/packages/agent/tools/build-recoup-exec-env.ts b/packages/agent/tools/build-recoup-exec-env.ts new file mode 100644 index 0000000..3a3fd23 --- /dev/null +++ b/packages/agent/tools/build-recoup-exec-env.ts @@ -0,0 +1,17 @@ +import { getRecoupAccessToken } from "./get-recoup-access-token"; + +/** + * Build a per-invocation env override carrying the Recoupable access + * token when one is available, so outbound shell commands (curl, scripts) + * can authenticate without the token persisting on the sandbox. + */ +export function buildRecoupExecEnv( + experimental_context: unknown, +): { RECOUP_ACCESS_TOKEN: string } | undefined { + const token = getRecoupAccessToken(experimental_context); + if (!token) { + return undefined; + } + + return { RECOUP_ACCESS_TOKEN: token }; +} diff --git a/packages/agent/tools/fetch.ts b/packages/agent/tools/fetch.ts index 3aa2503..85faa2a 100644 --- a/packages/agent/tools/fetch.ts +++ b/packages/agent/tools/fetch.ts @@ -1,6 +1,7 @@ import { tool } from "ai"; import { z } from "zod"; -import { buildRecoupExecEnv, getSandbox, shellEscape } from "./utils"; +import { buildRecoupExecEnv } from "./build-recoup-exec-env"; +import { getSandbox, shellEscape } from "./utils"; const TIMEOUT_MS = 30_000; export const MAX_BODY_LENGTH = 10_000; diff --git a/packages/agent/tools/get-recoup-access-token.ts b/packages/agent/tools/get-recoup-access-token.ts new file mode 100644 index 0000000..43e7d8f --- /dev/null +++ b/packages/agent/tools/get-recoup-access-token.ts @@ -0,0 +1,15 @@ +import { isAgentContext } from "./utils"; + +/** + * Read the per-prompt Recoupable access token from agent context. + * Returns undefined when no token is present (e.g. callers that do not + * authenticate against the Recoupable API). + */ +export function getRecoupAccessToken( + experimental_context: unknown, +): string | undefined { + const context = isAgentContext(experimental_context) + ? experimental_context + : undefined; + return context?.recoupAccessToken; +} diff --git a/packages/agent/tools/utils.ts b/packages/agent/tools/utils.ts index b8f5389..3ff31f2 100644 --- a/packages/agent/tools/utils.ts +++ b/packages/agent/tools/utils.ts @@ -3,7 +3,7 @@ import type { LanguageModel, ModelMessage } from "ai"; import * as path from "path"; import type { AgentContext } from "../types"; -function isAgentContext(value: unknown): value is AgentContext { +export function isAgentContext(value: unknown): value is AgentContext { return ( typeof value === "object" && value !== null && @@ -122,36 +122,6 @@ export function getSandboxContext( }; } -/** - * Read the per-prompt Recoupable access token from agent context. - * Returns undefined when no token is present (e.g. callers that do not - * authenticate against the Recoupable API). - */ -export function getRecoupAccessToken( - experimental_context: unknown, -): string | undefined { - const context = isAgentContext(experimental_context) - ? experimental_context - : undefined; - return context?.recoupAccessToken; -} - -/** - * Build a per-invocation env override carrying the Recoupable access - * token when one is available, so outbound shell commands (curl, scripts) - * can authenticate without the token persisting on the sandbox. - */ -export function buildRecoupExecEnv( - experimental_context: unknown, -): { RECOUP_ACCESS_TOKEN: string } | undefined { - const token = getRecoupAccessToken(experimental_context); - if (!token) { - return undefined; - } - - return { RECOUP_ACCESS_TOKEN: token }; -} - /** * Get model from experimental context with null safety. * Throws a descriptive error if model is not initialized. diff --git a/packages/sandbox/vercel/sandbox.ts b/packages/sandbox/vercel/sandbox.ts index beedd50..4e8ea9a 100644 --- a/packages/sandbox/vercel/sandbox.ts +++ b/packages/sandbox/vercel/sandbox.ts @@ -471,34 +471,22 @@ ${hostLine}${portLines}${runtimeEnvLine}`; return runtimeEnv; } - private getCommandEnv(): Record | undefined { + private getCommandEnv( + perCallEnv?: Record, + ): Record | undefined { const runtimePreviewEnv = this.getRuntimePreviewEnv(); - if (!this.env && Object.keys(runtimePreviewEnv).length === 0) { + const hasPerCall = perCallEnv && Object.keys(perCallEnv).length > 0; + if ( + !this.env && + Object.keys(runtimePreviewEnv).length === 0 && + !hasPerCall + ) { return undefined; } return { ...this.env, ...runtimePreviewEnv, - }; - } - - /** - * Merge per-invocation env on top of the persistent sandbox env. The - * per-invocation entries win so callers can pass short-lived credentials - * (e.g. access tokens) scoped to a single command without polluting the - * sandbox-wide env. - */ - private mergeCommandEnv( - perCallEnv?: Record, - ): Record | undefined { - const base = this.getCommandEnv(); - if (!perCallEnv || Object.keys(perCallEnv).length === 0) { - return base; - } - - return { - ...base, ...perCallEnv, }; } @@ -954,7 +942,7 @@ ${hostLine}${portLines}${runtimeEnvLine}`; const result = await this.session.runCommand({ cmd: "bash", args: ["-c", `cd "${cwd}" && ${command}`], - env: this.mergeCommandEnv(options?.env), + env: this.getCommandEnv(options?.env), signal, }); @@ -1010,7 +998,7 @@ ${hostLine}${portLines}${runtimeEnvLine}`; const result = await this.session.runCommand({ cmd: "bash", args: ["-c", `cd "${cwd}" && ${command}`], - env: this.mergeCommandEnv(options?.env), + env: this.getCommandEnv(options?.env), detached: true, }); From c9b24c3d7f9d19dccdea28bd88103ff5431eb760 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 23 Apr 2026 12:38:43 -0500 Subject: [PATCH 3/3] refactor: validate chat request body with zod; strip token from detached execs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two "outside diff" CodeRabbit findings on PR #13. - apps/web/app/api/chat/_lib/request.ts — cast-to-type replaced with a zod schema; ChatRequestBody derived via z.infer. recoupAccessToken is bounded to 1..8192 chars so a pathological payload can't ride the body through into agentOptions. Schema validation returns 400 with issues on malformed input; preserves the existing invalid-JSON 400. - packages/agent/tools/bash.ts — stop injecting RECOUP_ACCESS_TOKEN into execDetached. Detached processes outlive the prompt that spawned them, so a prompt-scoped token leaking into a long-running server's env breaks the "token dies with the prompt" design. Foreground exec still receives it. - packages/sandbox: since no caller passes env to execDetached anymore, drop the `env?` option from both the Sandbox interface and the Vercel impl (YAGNI). `exec` keeps its env option. - packages/agent/tools/tools.test.ts — test updated to assert the token is forwarded to foreground exec and NOT to execDetached. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/app/api/chat/_lib/request.ts | 39 +++++++++++++++++++++------ packages/agent/tools/bash.ts | 10 +++---- packages/agent/tools/tools.test.ts | 21 +++++---------- packages/sandbox/interface.ts | 6 +---- packages/sandbox/vercel/sandbox.ts | 3 +-- 5 files changed, 44 insertions(+), 35 deletions(-) diff --git a/apps/web/app/api/chat/_lib/request.ts b/apps/web/app/api/chat/_lib/request.ts index 922d907..2a4af5c 100644 --- a/apps/web/app/api/chat/_lib/request.ts +++ b/apps/web/app/api/chat/_lib/request.ts @@ -1,17 +1,27 @@ +import { z } from "zod"; import type { WebAgentUIMessage } from "@/app/types"; -export interface ChatRequestBody { - messages: WebAgentUIMessage[]; - sessionId?: string; - chatId?: string; +const chatRequestBodySchema = z.object({ + messages: z.custom((val) => Array.isArray(val), { + message: "messages must be an array", + }), + sessionId: z.string().optional(), + chatId: z.string().optional(), /** * Short-lived Recoupable access token (Privy JWT) for this prompt. * Forwarded into the agent's `experimental_context` so tools making * outbound calls to the Recoupable API authenticate as the user for * the duration of this prompt only. */ - recoupAccessToken?: string; -} + recoupAccessToken: z.string().min(1).max(8192).optional(), + context: z + .object({ + contextLimit: z.number(), + }) + .optional(), +}); + +export type ChatRequestBody = z.infer; type ParseChatRequestResult = | { @@ -37,15 +47,28 @@ type RequireChatIdentifiersResult = export async function parseChatRequestBody( req: Request, ): Promise { + let rawBody: unknown; try { - const body = (await req.json()) as ChatRequestBody; - return { ok: true, body }; + rawBody = await req.json(); } catch { return { ok: false, response: Response.json({ error: "Invalid JSON body" }, { status: 400 }), }; } + + const parsed = chatRequestBodySchema.safeParse(rawBody); + if (!parsed.success) { + return { + ok: false, + response: Response.json( + { error: "Invalid request body", issues: parsed.error.issues }, + { status: 400 }, + ), + }; + } + + return { ok: true, body: parsed.data }; } export function requireChatIdentifiers( diff --git a/packages/agent/tools/bash.ts b/packages/agent/tools/bash.ts index 3515494..c3307ca 100644 --- a/packages/agent/tools/bash.ts +++ b/packages/agent/tools/bash.ts @@ -127,11 +127,11 @@ EXAMPLES: } try { - const { commandId } = await sandbox.execDetached( - command, - workingDir, - recoupEnv ? { env: recoupEnv } : undefined, - ); + // Detached processes outlive the prompt, so we deliberately do + // not inject RECOUP_ACCESS_TOKEN here — the token is scoped to + // foreground execs whose lifetime tracks the prompt. Long- + // running services must authenticate via their own mechanism. + const { commandId } = await sandbox.execDetached(command, workingDir); return { success: true, exitCode: null, diff --git a/packages/agent/tools/tools.test.ts b/packages/agent/tools/tools.test.ts index 396fb52..4fa0313 100644 --- a/packages/agent/tools/tools.test.ts +++ b/packages/agent/tools/tools.test.ts @@ -371,17 +371,13 @@ describe("tools execute behavior", () => { }); }); - test("bashTool forwards recoupAccessToken as RECOUP_ACCESS_TOKEN in exec env", async () => { + test("bashTool forwards recoupAccessToken to foreground exec only, never detached", async () => { const execCalls: Array<{ command: string; cwd: string; options?: { signal?: AbortSignal; env?: Record }; }> = []; - const detachedCalls: Array<{ - command: string; - cwd: string; - options?: { env?: Record }; - }> = []; + const detachedCalls: Array<{ command: string; cwd: string }> = []; const sandbox = { workingDirectory: "/repo", @@ -400,12 +396,8 @@ describe("tools execute behavior", () => { truncated: false, }; }, - execDetached: async ( - command: string, - cwd: string, - options?: { env?: Record }, - ) => { - detachedCalls.push({ command, cwd, options }); + execDetached: async (command: string, cwd: string) => { + detachedCalls.push({ command, cwd }); return { commandId: "cmd-detached" }; }, }; @@ -425,15 +417,13 @@ describe("tools execute behavior", () => { RECOUP_ACCESS_TOKEN: "privy-jwt-abc", }); + // Detached processes outlive the prompt — token must NOT be injected. await bashTool().execute?.( { command: "npm run dev", detached: true }, executionOptions(context), ); expect(detachedCalls).toHaveLength(1); - expect(detachedCalls[0]?.options?.env).toEqual({ - RECOUP_ACCESS_TOKEN: "privy-jwt-abc", - }); const contextWithoutToken = createContext({ workingDirectory: "/repo", @@ -459,6 +449,7 @@ describe("tools execute behavior", () => { executionOptions(contextWithoutToken), ); + expect(execCalls).toHaveLength(2); expect(execCalls[1]?.options?.env).toBeUndefined(); }); diff --git a/packages/sandbox/interface.ts b/packages/sandbox/interface.ts index 4f018f1..8c9062b 100644 --- a/packages/sandbox/interface.ts +++ b/packages/sandbox/interface.ts @@ -138,11 +138,7 @@ export interface Sandbox { /** * Execute a shell command in detached mode (returns immediately). */ - execDetached?( - command: string, - cwd: string, - options?: { env?: Record }, - ): Promise<{ commandId: string }>; + execDetached?(command: string, cwd: string): Promise<{ commandId: string }>; /** * Get the public URL for an exposed port. diff --git a/packages/sandbox/vercel/sandbox.ts b/packages/sandbox/vercel/sandbox.ts index 4e8ea9a..fb564ae 100644 --- a/packages/sandbox/vercel/sandbox.ts +++ b/packages/sandbox/vercel/sandbox.ts @@ -993,12 +993,11 @@ ${hostLine}${portLines}${runtimeEnvLine}`; async execDetached( command: string, cwd: string, - options?: { env?: Record }, ): Promise<{ commandId: string }> { const result = await this.session.runCommand({ cmd: "bash", args: ["-c", `cd "${cwd}" && ${command}`], - env: this.getCommandEnv(options?.env), + env: this.getCommandEnv(), detached: true, });