feat: forward short-lived Privy access token per prompt to sandbox#13
Conversation
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) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis PR extends the chat system to support passing a Recoupable (Privy JWT) access token from client to tools. The token flows through the API, agent, and into tool execution environments as the Changes
Sequence DiagramsequenceDiagram
participant Client
participant ChatAPI as Chat API
participant Agent
participant ToolRuntime as Tool Runtime
participant Sandbox
Client->>Client: Fetch Privy<br/>access token
Client->>ChatAPI: POST /api/chat<br/>{prompt, recoupAccessToken}
ChatAPI->>Agent: runAgentWorkflow<br/>(agentOptions with token)
Agent->>Agent: Extract token from<br/>agentOptions
Agent->>ToolRuntime: prepareCall<br/>(experimental_context<br/>with recoupAccessToken)
ToolRuntime->>ToolRuntime: buildRecoupExecEnv()<br/>→ {RECOUP_ACCESS_TOKEN}
ToolRuntime->>Sandbox: exec(command,<br/>options: {env})
Sandbox->>Sandbox: mergeCommandEnv()<br/>to override process env
Sandbox->>Sandbox: Execute command<br/>with RECOUP_ACCESS_TOKEN
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
apps/web/app/api/chat/_lib/request.ts (1)
3-14:⚠️ Potential issue | 🟠 MajorValidate the new token field at the request boundary.
req.json()is cast directly toChatRequestBody, sorecoupAccessTokencan be a non-string or oversized value until a later layer fails. Add a Zod schema for the request body and deriveChatRequestBodyfrom it.Suggested shape
import type { WebAgentUIMessage } from "@/app/types"; +import { z } from "zod"; -export interface ChatRequestBody { - messages: WebAgentUIMessage[]; - sessionId?: string; - chatId?: string; +const chatRequestBodySchema = z.object({ + messages: z.custom<WebAgentUIMessage[]>(), + sessionId: z.string().optional(), + chatId: z.string().optional(), /** * Short-lived Recoupable access token (Privy JWT) for this prompt. @@ * the duration of this prompt only. */ - recoupAccessToken?: string; -} + recoupAccessToken: z.string().min(1).max(8192).optional(), +}); + +export type ChatRequestBody = z.infer<typeof chatRequestBodySchema>; @@ - const body = (await req.json()) as ChatRequestBody; + const body = chatRequestBodySchema.parse(await req.json()); return { ok: true, body }; - } catch { + } catch {As per coding guidelines, “Use Zod schemas for validation and derive types with
z.infer.”Also applies to: 37-42
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/app/api/chat/_lib/request.ts` around lines 3 - 14, The request body is cast unsafely from req.json() into ChatRequestBody so recoupAccessToken can be wrong type or too large; create a Zod schema (e.g., ChatRequestBodySchema) that validates messages, optional sessionId/chatId and recoupAccessToken as a bounded string (and any other constraints), use z.infer<typeof ChatRequestBodySchema> to derive the ChatRequestBody type, and parse/validate the incoming payload with ChatRequestBodySchema.parse or safeParse at the request boundary before using the result in this file (replace direct casts of req.json() and the usage sites that reference ChatRequestBody/recoupAccessToken).packages/agent/tools/bash.ts (1)
116-133:⚠️ Potential issue | 🟠 MajorDon’t inject prompt-scoped tokens into detached processes.
Detached commands are explicitly long-running, so
execDetached(..., { env: recoupEnv })can leaveRECOUP_ACCESS_TOKENin a background process environment after the prompt finishes. Keep the token limited to foreground per-call execs, or use a separate refresh/secret mechanism for long-running services.Limit token forwarding to non-detached exec
- const { commandId } = await sandbox.execDetached( - command, - workingDir, - recoupEnv ? { env: recoupEnv } : undefined, - ); + const { commandId } = await sandbox.execDetached(command, workingDir);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/agent/tools/bash.ts` around lines 116 - 133, Detached executions currently forward the prompt-scoped env (recoupEnv) into sandbox.execDetached which can leak RECOUP_ACCESS_TOKEN into long-running background processes; change the detached branch in the code that calls sandbox.execDetached so it does NOT include prompt-scoped tokens: sanitize recoupEnv before passing it to execDetached (remove RECOUP_ACCESS_TOKEN and other prompt-scoped secrets) or pass undefined for env for detached runs, and keep full recoupEnv only for immediate/foreground exec calls (e.g., the non-detached exec path). Also add a comment near sandbox.execDetached to document using a separate refresh/secret mechanism for long-running services.packages/agent/tools/fetch.ts (1)
57-96:⚠️ Potential issue | 🟠 MajorAttach the bearer header for trusted Recoupable URLs; env alone won’t authenticate
curl.
curldoes not automatically turnRECOUP_ACCESS_TOKENinto anAuthorizationheader, soweb_fetchstill makes unauthenticated Recoupable API calls unless the model manually supplies the header. Add the header only for the configured Recoupable API origin to avoid leaking the token to arbitrary URLs.Possible direction
- const recoupEnv = buildRecoupExecEnv(experimental_context); + const recoupEnv = buildRecoupExecEnv(experimental_context); + const recoupAccessToken = recoupEnv?.RECOUP_ACCESS_TOKEN; + const hasAuthorizationHeader = Object.keys(headers ?? {}).some( + (key) => key.toLowerCase() === "authorization", + ); const args: string[] = [ "curl", @@ if (headers) { for (const [key, value] of Object.entries(headers)) { args.push("-H", shellEscape(`${key}: ${value}`)); } } + + if ( + recoupAccessToken && + !hasAuthorizationHeader && + isTrustedRecoupableApiUrl(url) + ) { + args.push( + "-H", + shellEscape(`Authorization: Bearer ${recoupAccessToken}`), + ); + }Define
isTrustedRecoupableApiUrlagainst the project’s configured Recoupable API origin rather than a broad user-controlled host match.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/agent/tools/fetch.ts` around lines 57 - 96, The current implementation only sets RECOUP_ACCESS_TOKEN in the exec env (via buildRecoupExecEnv/recoupEnv) but does not add an Authorization header to curl; update fetch.ts to add an "Authorization: Bearer <token>" header into the curl args when and only when the request URL matches the configured Recoupable API origin (use a new or existing helper like isTrustedRecoupableApiUrl(config.recoupableApiOrigin, url) to compare origins), placing this header before you serialize other headers into args (so it’s included in the -H entries) and avoid adding the header for any non-matching URLs to prevent token leakage.
🧹 Nitpick comments (2)
packages/agent/tools/tools.test.ts (1)
457-462: Assert the second exec call before checking its options.
execCalls[1]?.options?.envis alsoundefinedwhen the command never executes, so the no-token branch can false-pass.Small test hardening
await bashTool().execute?.( { command: "ls" }, executionOptions(contextWithoutToken), ); + expect(execCalls).toHaveLength(2); expect(execCalls[1]?.options?.env).toBeUndefined();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/agent/tools/tools.test.ts` around lines 457 - 462, The test checks execCalls[1]?.options?.env which can be undefined even if the command never ran; before asserting its env, explicitly assert the second exec call exists (e.g. expect(execCalls.length).toBeGreaterThan(1) or expect(execCalls[1]).toBeDefined()) so the no-token branch cannot false-pass—update the test around the bashTool().execute invocation to first assert the presence of execCalls[1] and then check execCalls[1].options.env.packages/agent/tools/utils.ts (1)
130-153: LGTM — helpers cleanly gate on token presence.
buildRecoupExecEnvcorrectly returnsundefinedwhen no token exists, which lets callers (bash/fetch tools) passundefinedtosandbox.exec/execDetachedand avoid materializing an empty env override. Typing flows throughAgentContext.recoupAccessToken, keeping the helper dependency-free.One minor hardening consideration:
getRecoupAccessTokentrusts theAgentContextshape without runtime-checking thatrecoupAccessTokenis astring. If the token ever gets sourced from a less trusted boundary, atypeof context.recoupAccessToken === "string"guard here would prevent a non-string slipping intoprocess.envasRECOUP_ACCESS_TOKEN. Not blocking given current call sites.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/agent/tools/utils.ts` around lines 130 - 153, The helper getRecoupAccessToken should validate that recoupAccessToken is a string before returning it to prevent non-string values propagating into envs; update getRecoupAccessToken to check isAgentContext(experimental_context) and then verify typeof context.recoupAccessToken === "string" (returning that string) otherwise return undefined, which will keep buildRecoupExecEnv safe and still return undefined when no valid token exists.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/web/app/api/chat/route.ts`:
- Line 70: Extracting recoupAccessToken from the request body and forwarding it
into agentOptions/durable workflow is unsafe; call
verifyPrivyAccessToken(recoupAccessToken) (from
lib/privy/verify-access-token.ts) in the route handler to validate the token and
assert it is bound to the current userId before including it in any agentOptions
or workflow input, and if verification fails reject the request; if verification
succeeds, either pass a verified token handle (not the raw bearer) or replace
the raw token with an encrypted payload / server-side secret reference so the
durable workflow state never stores the raw bearer token.
---
Outside diff comments:
In `@apps/web/app/api/chat/_lib/request.ts`:
- Around line 3-14: The request body is cast unsafely from req.json() into
ChatRequestBody so recoupAccessToken can be wrong type or too large; create a
Zod schema (e.g., ChatRequestBodySchema) that validates messages, optional
sessionId/chatId and recoupAccessToken as a bounded string (and any other
constraints), use z.infer<typeof ChatRequestBodySchema> to derive the
ChatRequestBody type, and parse/validate the incoming payload with
ChatRequestBodySchema.parse or safeParse at the request boundary before using
the result in this file (replace direct casts of req.json() and the usage sites
that reference ChatRequestBody/recoupAccessToken).
In `@packages/agent/tools/bash.ts`:
- Around line 116-133: Detached executions currently forward the prompt-scoped
env (recoupEnv) into sandbox.execDetached which can leak RECOUP_ACCESS_TOKEN
into long-running background processes; change the detached branch in the code
that calls sandbox.execDetached so it does NOT include prompt-scoped tokens:
sanitize recoupEnv before passing it to execDetached (remove RECOUP_ACCESS_TOKEN
and other prompt-scoped secrets) or pass undefined for env for detached runs,
and keep full recoupEnv only for immediate/foreground exec calls (e.g., the
non-detached exec path). Also add a comment near sandbox.execDetached to
document using a separate refresh/secret mechanism for long-running services.
In `@packages/agent/tools/fetch.ts`:
- Around line 57-96: The current implementation only sets RECOUP_ACCESS_TOKEN in
the exec env (via buildRecoupExecEnv/recoupEnv) but does not add an
Authorization header to curl; update fetch.ts to add an "Authorization: Bearer
<token>" header into the curl args when and only when the request URL matches
the configured Recoupable API origin (use a new or existing helper like
isTrustedRecoupableApiUrl(config.recoupableApiOrigin, url) to compare origins),
placing this header before you serialize other headers into args (so it’s
included in the -H entries) and avoid adding the header for any non-matching
URLs to prevent token leakage.
---
Nitpick comments:
In `@packages/agent/tools/tools.test.ts`:
- Around line 457-462: The test checks execCalls[1]?.options?.env which can be
undefined even if the command never ran; before asserting its env, explicitly
assert the second exec call exists (e.g.
expect(execCalls.length).toBeGreaterThan(1) or
expect(execCalls[1]).toBeDefined()) so the no-token branch cannot
false-pass—update the test around the bashTool().execute invocation to first
assert the presence of execCalls[1] and then check execCalls[1].options.env.
In `@packages/agent/tools/utils.ts`:
- Around line 130-153: The helper getRecoupAccessToken should validate that
recoupAccessToken is a string before returning it to prevent non-string values
propagating into envs; update getRecoupAccessToken to check
isAgentContext(experimental_context) and then verify typeof
context.recoupAccessToken === "string" (returning that string) otherwise return
undefined, which will keep buildRecoupExecEnv safe and still return undefined
when no valid token exists.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f9d9b732-ce4b-48a2-a218-65a3e12ea4ec
📒 Files selected for processing (11)
apps/web/app/api/chat/_lib/request.tsapps/web/app/api/chat/route.tsapps/web/app/sessions/[sessionId]/chats/[chatId]/hooks/use-session-chat-runtime.tspackages/agent/open-harness-agent.tspackages/agent/tools/bash.tspackages/agent/tools/fetch.tspackages/agent/tools/tools.test.tspackages/agent/tools/utils.tspackages/agent/types.tspackages/sandbox/interface.tspackages/sandbox/vercel/sandbox.ts
| } | ||
|
|
||
| const { messages } = parsedBody.body; | ||
| const { messages, recoupAccessToken } = parsedBody.body; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Search for existing Privy/Recoup token verification or secret indirection helpers.
rg -n -C3 --type=ts \
'(verify.*(Privy|AccessToken|Jwt|JWT)|PrivyClient|@privy-io/server-auth|recoupAccessToken|secretRef|secret reference|workflow.*secret)' .Repository: recoupable/open-agents
Length of output: 10057
🌐 Web query:
Privy server-side access token verification TypeScript @privy-io/server-auth verifyAuthToken
💡 Result:
To verify Privy access tokens (also called auth tokens) server-side in TypeScript, use the official @privy-io/node package (the current replacement for the deprecated @privy-io/server-auth). Install the package: npm install @privy-io/node Initialize the PrivyClient: import { PrivyClient } from '@privy-io/node'; const privy = new PrivyClient({ appId: process.env.PRIVY_APP_ID!, appSecret: process.env.PRIVY_APP_SECRET! }); Verify the token using privy.utils.auth.verifyAccessToken({ access_token }) or verifyAuthToken (deprecated alias). It throws on invalid/expired tokens and returns claims like { appId, userId, issuer, issuedAt, expiration, sessionId } on success. Example: try { const verifiedClaims = await privy.utils.auth.verifyAccessToken({ access_token: accessToken // from Authorization: Bearer header }); // Use verifiedClaims.userId etc. } catch (error) { // Invalid token: deny access } To avoid fetching the verification key from Privy's API each time, set jwtVerificationKey from your Privy Dashboard (Configuration > App settings) in the PrivyClient options. Legacy @privy-io/server-auth used privy.verifyAuthToken(accessToken), but migrate to @privy-io/node as per docs. The token is an ES256 JWT; verify against your app's Ed25519 public key (as SPKI string).
Citations:
- 1: https://docs.privy.io/authentication/user-authentication/access-tokens
- 2: https://docs.privy.io/guide/server/authorization/verification
- 3: https://docs.privy.io/basics/nodeJS-node/setup
- 4: https://docs.privy.io/basics/nodeJS/installation
- 5: https://cdn.jsdelivr.net/npm/@privy-io/node@0.11.0/src/lib/auth.ts
- 6: https://docs.privy.io/recipes/trpc
🏁 Script executed:
cat -n apps/web/app/api/chat/route.ts | sed -n '60,250p'Repository: recoupable/open-agents
Length of output: 7572
🏁 Script executed:
rg -n -A20 "export.*function parseChatRequestBody|const parseChatRequestBody" apps/web/app/api/chat/_lib/request.tsRepository: recoupable/open-agents
Length of output: 657
🏁 Script executed:
rg -n -B5 "interface ChatRequestBody|type ChatRequestBody" apps/web/app/api/chat/_lib/request.tsRepository: recoupable/open-agents
Length of output: 161
🏁 Script executed:
sed -n '1,30p' apps/web/app/api/chat/_lib/request.tsRepository: recoupable/open-agents
Length of output: 774
🏁 Script executed:
rg -n "recoupAccessToken" apps/web/app/api/chat/_lib/ -A3 -B3Repository: recoupable/open-agents
Length of output: 531
Verify and bind the bearer token to userId before forwarding to the durable workflow.
Line 70 extracts recoupAccessToken from the request body, and line 239 forwards it to agentOptions without server-side verification or user binding. Call verifyPrivyAccessToken(recoupAccessToken) (available in lib/privy/verify-access-token.ts) to validate the token and confirm it belongs to the current userId. Only pass the token downstream after verification succeeds, or use an encrypted payload / server-side secret reference instead. Durable workflows persist their inputs as state; raw bearer tokens should not be stored there.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/web/app/api/chat/route.ts` at line 70, Extracting recoupAccessToken from
the request body and forwarding it into agentOptions/durable workflow is unsafe;
call verifyPrivyAccessToken(recoupAccessToken) (from
lib/privy/verify-access-token.ts) in the route handler to validate the token and
assert it is bound to the current userId before including it in any agentOptions
or workflow input, and if verification fails reject the request; if verification
succeeds, either pass a verified token handle (not the raw bearer) or replace
the raw token with an encrypted payload / server-side secret reference so the
durable workflow state never stores the raw bearer token.
There was a problem hiding this comment.
No issues found across 11 files
You're on the cubic free plan with 15 free PR reviews remaining this month. Upgrade for unlimited reviews.
| const getAccessTokenRef = useRef(getAccessToken); | ||
| useEffect(() => { | ||
| getAccessTokenRef.current = getAccessToken; | ||
| }, [getAccessToken]); |
There was a problem hiding this comment.
KISS principle
- actual: Complex useRef to store an outdated accessTokenRef.
- required: move call of getAccessToken into the transport useMemo.
| * Returns undefined when no token is present (e.g. callers that do not | ||
| * authenticate against the Recoupable API). | ||
| */ | ||
| export function getRecoupAccessToken( |
There was a problem hiding this comment.
SRP
- actual: getRecoupAccessToken defined in a shared packages/agent/tools/utils.ts file.
- required: new standalone file for the getRecoupAccessToken function def.
| * token when one is available, so outbound shell commands (curl, scripts) | ||
| * can authenticate without the token persisting on the sandbox. | ||
| */ | ||
| export function buildRecoupExecEnv( |
There was a problem hiding this comment.
SRP
- actual: buildRecoupExecEnv defined in a shared packages/agent/tools/utils.ts file.
- required: new standalone file for the buildRecoupExecEnv function def.
- 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) <noreply@anthropic.com>
…hed execs 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) <noreply@anthropic.com>
There was a problem hiding this comment.
3 issues found across 5 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/web/app/api/chat/_lib/request.ts">
<violation number="1" location="apps/web/app/api/chat/_lib/request.ts:5">
P2: `messages` validation only checks that the value is an array, not that each item has the required message shape. Validate message elements to prevent malformed payloads from reaching workflow logic.</violation>
</file>
<file name="packages/sandbox/vercel/sandbox.ts">
<violation number="1">
P2: Detached commands now ignore per-call env overrides, so prompt-scoped credentials/config cannot be forwarded to `execDetached` calls.</violation>
</file>
<file name="packages/agent/tools/tools.test.ts">
<violation number="1" location="packages/agent/tools/tools.test.ts:399">
P2: This test claims detached exec never gets the token, but it no longer asserts that `execDetached` was called without `env`, so token-leak regressions can slip through.</violation>
</file>
You're on the cubic free plan with 14 free PR reviews remaining this month. Upgrade for unlimited reviews.
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| chatId?: string; | ||
| } | ||
| const chatRequestBodySchema = z.object({ | ||
| messages: z.custom<WebAgentUIMessage[]>((val) => Array.isArray(val), { |
There was a problem hiding this comment.
P2: messages validation only checks that the value is an array, not that each item has the required message shape. Validate message elements to prevent malformed payloads from reaching workflow logic.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/app/api/chat/_lib/request.ts, line 5:
<comment>`messages` validation only checks that the value is an array, not that each item has the required message shape. Validate message elements to prevent malformed payloads from reaching workflow logic.</comment>
<file context>
@@ -1,17 +1,27 @@
- sessionId?: string;
- chatId?: string;
+const chatRequestBodySchema = z.object({
+ messages: z.custom<WebAgentUIMessage[]>((val) => Array.isArray(val), {
+ message: "messages must be an array",
+ }),
</file context>
| @@ -471,15 +471,23 @@ ${hostLine}${portLines}${runtimeEnvLine}`; | |||
| return runtimeEnv; | |||
There was a problem hiding this comment.
P2: Detached commands now ignore per-call env overrides, so prompt-scoped credentials/config cannot be forwarded to execDetached calls.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sandbox/vercel/sandbox.ts, line 1000:
<comment>Detached commands now ignore per-call env overrides, so prompt-scoped credentials/config cannot be forwarded to `execDetached` calls.</comment>
<file context>
@@ -993,12 +993,11 @@ ${hostLine}${portLines}${runtimeEnvLine}`;
cmd: "bash",
args: ["-c", `cd "${cwd}" && ${command}`],
- env: this.getCommandEnv(options?.env),
+ env: this.getCommandEnv(),
detached: true,
});
</file context>
| execDetached: async (command: string, cwd: string) => { | ||
| detachedCalls.push({ command, cwd }); | ||
| return { commandId: "cmd-detached" }; | ||
| }, |
There was a problem hiding this comment.
P2: This test claims detached exec never gets the token, but it no longer asserts that execDetached was called without env, so token-leak regressions can slip through.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/agent/tools/tools.test.ts, line 399:
<comment>This test claims detached exec never gets the token, but it no longer asserts that `execDetached` was called without `env`, so token-leak regressions can slip through.</comment>
<file context>
@@ -400,12 +396,8 @@ describe("tools execute behavior", () => {
- options?: { env?: Record<string, string> },
- ) => {
- detachedCalls.push({ command, cwd, options });
+ execDetached: async (command: string, cwd: string) => {
+ detachedCalls.push({ command, cwd });
return { commandId: "cmd-detached" };
</file context>
| execDetached: async (command: string, cwd: string) => { | |
| detachedCalls.push({ command, cwd }); | |
| return { commandId: "cmd-detached" }; | |
| }, | |
| execDetached: async ( | |
| ...args: [string, string, { env?: Record<string, string> }?] | |
| ) => { | |
| const [command, cwd, options] = args; | |
| expect(options?.env).toBeUndefined(); | |
| detachedCalls.push({ command, cwd }); | |
| return { commandId: "cmd-detached" }; | |
| }, |
Summary
Pass the user's Privy access token from the chat client into each
sandbox.exec/execDetachedcall's env asRECOUP_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.Why this shape (per design discussion)
Flow
use-session-chat-runtimecallsusePrivy().getAccessToken()intransport.body()per prompt (ref-stable so the transport memo doesn't churn)./api/chatacceptsrecoupAccessTokenon the body and threads it intoagentOptions.runAgentWorkflow→webAgent.stream({ options: agentOptions })→prepareCallsurfaces it onexperimental_context.bashTool/webFetchToolread it viabuildRecoupExecEnvand pass it as an ephemeral per-call env tosandbox.exec.RECOUP_ACCESS_TOKENfor that single command's env — it does not persist across commands, does not land in the sandbox-wide env, does not touch disk.Scope
Sandbox (
packages/sandbox/):interface.ts—Sandbox.exec/execDetachedgain an optional per-callenv: Record<string, string>.vercel/sandbox.ts— newmergeCommandEnvmerges per-call env on top ofgetCommandEnv()(per-call wins), used by bothexecandexecDetached.Agent (
packages/agent/):types.ts—AgentContext.recoupAccessToken?: string.open-harness-agent.ts—callOptionsSchemaacceptsrecoupAccessToken; forwarded ontoexperimental_context.tools/utils.ts— newgetRecoupAccessToken/buildRecoupExecEnvhelpers.tools/bash.ts,tools/fetch.ts— inject token into their exec calls only (grep/glob/etc. never see it).tools/tools.test.ts— new test asserts the token reaches the execenvfor bothexecandexecDetached, and is absent when no token is in context.Web (
apps/web/):app/api/chat/_lib/request.ts—ChatRequestBody.recoupAccessToken?: string.app/api/chat/route.ts— plumb from body intoagentOptions.app/sessions/[sessionId]/chats/[chatId]/hooks/use-session-chat-runtime.ts—usePrivy()+ ref-stabilizedgetAccessToken()intransport.body().Test plan
bun run check— cleanbun run typecheck— FULL TURBO, all packages cleanbun run --cwd apps/web db:check— migrations in synccurl -H "Authorization: Bearer $RECOUP_ACCESS_TOKEN" https://developers.recoupable.com/api/...— confirm a 200 and that the Recoupable API logs attribute the call to the current user.Caveats / follow-ups
verifyPrivyAccessTokencall on the/api/chatroute and matchsubagainst the session's userId.getAccessToken()auto-refreshes expired tokens; the rare "prompt kicked off with <35min of TTL and lasted >35min" case will 401 late. Easy to add later by inspecting the JWTexpbefore send.apps/web/app/api/pr/route.test.tsfailures onmainare unrelated to this PR.Builds on #6 (submodules), #7 (org selector), #9 (per-org base snapshots). The auth plumbing completes the sandbox → Recoup API path.
🤖 Generated with Claude Code
Summary by cubic
Forwards a short‑lived Privy access token per prompt from the chat client to the sandbox as RECOUP_ACCESS_TOKEN for foreground execs (bash/web_fetch), so tools can call the Recoupable API as the user without persisting credentials. Adds per-call env support to
sandbox.exec, threads the token through/api/chatand the agent context, and validates chat request bodies with zod.New Features
apps/web: fetches a freshusePrivy().getAccessToken()per prompt in transportbody();/api/chatacceptsrecoupAccessTokenand passes it in agent options.packages/agent:AgentContextand call options acceptrecoupAccessToken; new helpersget-recoup-access-tokenandbuild-recoup-exec-env;bash/web_fetchinjectRECOUP_ACCESS_TOKENonly for foregroundsandbox.exec(never forexecDetached).packages/sandbox:execaccepts an optional per-callenv;getCommandEnvmerges per-call env on top of the persistent env so the token never persists;execDetacheddoes not accept per-call env.Refactors
ChatRequestBodywithzod(400 on invalid input; token length bounded); simplified chat transport by callinggetAccessToken()directly inbody();utils.tsexportsisAgentContext.Written for commit c9b24c3. Summary will update on new commits.
Summary by CodeRabbit