Skip to content

feat: forward short-lived Privy access token per prompt to sandbox#13

Merged
sweetmantech merged 3 commits into
mainfrom
feat/per-prompt-recoup-access-token
Apr 23, 2026
Merged

feat: forward short-lived Privy access token per prompt to sandbox#13
sweetmantech merged 3 commits into
mainfrom
feat/per-prompt-recoup-access-token

Conversation

@sweetmantech
Copy link
Copy Markdown
Contributor

@sweetmantech sweetmantech commented Apr 23, 2026

Summary

Pass the user's Privy access token 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.

Why this shape (per design discussion)

  • Privy access tokens are already short-lived (~1h TTL) and auto-expire — we get that property for free instead of building it ourselves.
  • p95 prompt duration is <30min, p99 <1h, so a fresh token attached per-prompt will always outlive the prompt that uses it.
  • Beats a custom sandbox-scoped Recoup API key: no new DB schema, no mint/revoke endpoints, no orphan cleanup workflow, no hash storage.
  • Attribution stays correct: every outbound Recoupable API call maps to "user X's prompt Y."
  • If the user signs out, in-flight prompts start 401'ing — which is the behavior we want.

Flow

  1. Frontend use-session-chat-runtime calls usePrivy().getAccessToken() in transport.body() per prompt (ref-stable so the transport memo doesn't churn).
  2. /api/chat accepts recoupAccessToken on the body and threads it into agentOptions.
  3. runAgentWorkflowwebAgent.stream({ options: agentOptions })prepareCall surfaces it on experimental_context.
  4. bashTool / webFetchTool read it via buildRecoupExecEnv and pass it as an ephemeral per-call env to sandbox.exec.
  5. The sandbox process only sees RECOUP_ACCESS_TOKEN for 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.tsSandbox.exec / execDetached gain an optional per-call env: Record<string, string>.
  • vercel/sandbox.ts — new mergeCommandEnv merges per-call env on top of getCommandEnv() (per-call wins), used by both exec and execDetached.

Agent (packages/agent/):

  • types.tsAgentContext.recoupAccessToken?: string.
  • open-harness-agent.tscallOptionsSchema accepts recoupAccessToken; forwarded onto experimental_context.
  • tools/utils.ts — new getRecoupAccessToken / buildRecoupExecEnv helpers.
  • 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 exec env for both exec and execDetached, and is absent when no token is in context.

Web (apps/web/):

  • app/api/chat/_lib/request.tsChatRequestBody.recoupAccessToken?: string.
  • app/api/chat/route.ts — plumb from body into agentOptions.
  • app/sessions/[sessionId]/chats/[chatId]/hooks/use-session-chat-runtime.tsusePrivy() + ref-stabilized getAccessToken() in transport.body().

Test plan

  • bun run check — clean
  • bun run typecheck — FULL TURBO, all packages clean
  • New bash env-forwarding test passes
  • bun run --cwd apps/web db:check — migrations in sync
  • Preview verify: on a deployed preview, send a chat prompt that has the agent run curl -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.
  • Preview verify: confirm idle sandboxes (no active prompt) do not hold a token — check the sandbox env between prompts.

Caveats / follow-ups

  • Server-side token validation is intentionally skipped — the Recoupable API is the validation boundary and will reject invalid/expired tokens. If we want belt-and-suspenders, add a verifyPrivyAccessToken call on the /api/chat route and match sub against the session's userId.
  • TTL pre-check on the client is not included in this cut. Privy's 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 JWT exp before send.
  • Pre-existing apps/web/app/api/pr/route.test.ts failures on main are 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/chat and the agent context, and validates chat request bodies with zod.

  • New Features

    • apps/web: fetches a fresh usePrivy().getAccessToken() per prompt in transport body(); /api/chat accepts recoupAccessToken and passes it in agent options.
    • packages/agent: AgentContext and call options accept recoupAccessToken; new helpers get-recoup-access-token and build-recoup-exec-env; bash/web_fetch inject RECOUP_ACCESS_TOKEN only for foreground sandbox.exec (never for execDetached).
    • packages/sandbox: exec accepts an optional per-call env; getCommandEnv merges per-call env on top of the persistent env so the token never persists; execDetached does not accept per-call env.
  • Refactors

    • Validate ChatRequestBody with zod (400 on invalid input; token length bounded); simplified chat transport by calling getAccessToken() directly in body(); utils.ts exports isAgentContext.

Written for commit c9b24c3. Summary will update on new commits.

Summary by CodeRabbit

  • New Features
    • Chat API now supports optional authentication tokens that are automatically propagated to agent tool execution.
    • Tools can now access authentication context during command and API execution, enabling secure context-aware operations.
    • Sandbox command execution now supports per-invocation environment configuration for enhanced credential management.

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>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 23, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
open-agents Ready Ready Preview Apr 23, 2026 5:40pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 23, 2026

📝 Walkthrough

Walkthrough

This 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 RECOUP_ACCESS_TOKEN environment variable, enabling authenticated outbound requests from tool processes.

Changes

Cohort / File(s) Summary
Request/Response Flow
apps/web/app/api/chat/_lib/request.ts, apps/web/app/api/chat/route.ts
Added optional recoupAccessToken field to ChatRequestBody interface; chat POST handler reads and forwards token to agent workflow options.
Client-side Token Integration
apps/web/app/sessions/.../use-session-chat-runtime.ts
Asynchronously fetches Privy access token on each send using a stable ref, conditionally includes recoupAccessToken in /api/chat requests; token-fetch errors are caught and logged without blocking.
Agent Core
packages/agent/open-harness-agent.ts, packages/agent/types.ts
Agent call options schema and AgentContext interface updated to carry optional recoupAccessToken; token is extracted during prepareCall and injected into experimental_context.
Tool Environment Utilities
packages/agent/tools/utils.ts
Introduced two utility functions: getRecoupAccessToken() extracts token from context; buildRecoupExecEnv() converts token into { RECOUP_ACCESS_TOKEN: string } environment override.
Tool Execution
packages/agent/tools/bash.ts, packages/agent/tools/fetch.ts
Tools extract environment from context via utilities and conditionally inject into sandbox exec calls; both detached and non-detached modes supported.
Sandbox Interface & Implementation
packages/sandbox/interface.ts, packages/sandbox/vercel/sandbox.ts
Sandbox.exec() and execDetached() methods now accept optional options parameter with env map; VercelSandbox adds private environment-merging helper to support per-invocation env overrides.
Testing
packages/agent/tools/tools.test.ts
New unit test validates bashTool.execute propagates recoupAccessToken to process environment for both detached and non-detached execution; verifies absent token does not pass env override.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A token hops through chat so bright,
From client's paws to agent's sight,
Through tools it flows with grace,
Environment finds its place,
Recoup authenticates just right!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately captures the main feature: forwarding a short-lived Privy access token per prompt to the sandbox, which aligns with the primary objective of the entire changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/per-prompt-recoup-access-token

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟠 Major

Validate the new token field at the request boundary.

req.json() is cast directly to ChatRequestBody, so recoupAccessToken can be a non-string or oversized value until a later layer fails. Add a Zod schema for the request body and derive ChatRequestBody from 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 | 🟠 Major

Don’t inject prompt-scoped tokens into detached processes.

Detached commands are explicitly long-running, so execDetached(..., { env: recoupEnv }) can leave RECOUP_ACCESS_TOKEN in 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 | 🟠 Major

Attach the bearer header for trusted Recoupable URLs; env alone won’t authenticate curl.

curl does not automatically turn RECOUP_ACCESS_TOKEN into an Authorization header, so web_fetch still 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 isTrustedRecoupableApiUrl against 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?.env is also undefined when 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.

buildRecoupExecEnv correctly returns undefined when no token exists, which lets callers (bash/fetch tools) pass undefined to sandbox.exec/execDetached and avoid materializing an empty env override. Typing flows through AgentContext.recoupAccessToken, keeping the helper dependency-free.

One minor hardening consideration: getRecoupAccessToken trusts the AgentContext shape without runtime-checking that recoupAccessToken is a string. If the token ever gets sourced from a less trusted boundary, a typeof context.recoupAccessToken === "string" guard here would prevent a non-string slipping into process.env as RECOUP_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

📥 Commits

Reviewing files that changed from the base of the PR and between 77f264e and 21e14a3.

📒 Files selected for processing (11)
  • apps/web/app/api/chat/_lib/request.ts
  • apps/web/app/api/chat/route.ts
  • apps/web/app/sessions/[sessionId]/chats/[chatId]/hooks/use-session-chat-runtime.ts
  • packages/agent/open-harness-agent.ts
  • packages/agent/tools/bash.ts
  • packages/agent/tools/fetch.ts
  • packages/agent/tools/tools.test.ts
  • packages/agent/tools/utils.ts
  • packages/agent/types.ts
  • packages/sandbox/interface.ts
  • packages/sandbox/vercel/sandbox.ts

}

const { messages } = parsedBody.body;
const { messages, recoupAccessToken } = parsedBody.body;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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:


🏁 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.ts

Repository: recoupable/open-agents

Length of output: 657


🏁 Script executed:

rg -n -B5 "interface ChatRequestBody|type ChatRequestBody" apps/web/app/api/chat/_lib/request.ts

Repository: recoupable/open-agents

Length of output: 161


🏁 Script executed:

sed -n '1,30p' apps/web/app/api/chat/_lib/request.ts

Repository: recoupable/open-agents

Length of output: 774


🏁 Script executed:

rg -n "recoupAccessToken" apps/web/app/api/chat/_lib/ -A3 -B3

Repository: 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.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +95 to +98
const getAccessTokenRef = useRef(getAccessToken);
useEffect(() => {
getAccessTokenRef.current = getAccessToken;
}, [getAccessToken]);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KISS principle

  • actual: Complex useRef to store an outdated accessTokenRef.
  • required: move call of getAccessToken into the transport useMemo.

Comment thread packages/agent/tools/utils.ts Outdated
* Returns undefined when no token is present (e.g. callers that do not
* authenticate against the Recoupable API).
*/
export function getRecoupAccessToken(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SRP

  • actual: getRecoupAccessToken defined in a shared packages/agent/tools/utils.ts file.
  • required: new standalone file for the getRecoupAccessToken function def.

Comment thread packages/agent/tools/utils.ts Outdated
* token when one is available, so outbound shell commands (curl, scripts)
* can authenticate without the token persisting on the sandbox.
*/
export function buildRecoupExecEnv(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SRP

  • actual: buildRecoupExecEnv defined in a shared packages/agent/tools/utils.ts file.
  • required: new standalone file for the buildRecoupExecEnv function def.

Comment thread packages/sandbox/vercel/sandbox.ts Outdated
- 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>
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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), {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with Cubic

@@ -471,15 +471,23 @@ ${hostLine}${portLines}${runtimeEnvLine}`;
return runtimeEnv;
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with Cubic

Comment on lines +399 to +402
execDetached: async (command: string, cwd: string) => {
detachedCalls.push({ command, cwd });
return { commandId: "cmd-detached" };
},
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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" };
},
Fix with Cubic

@sweetmantech sweetmantech merged commit b170596 into main Apr 23, 2026
3 checks passed
@sweetmantech sweetmantech deleted the feat/per-prompt-recoup-access-token branch April 23, 2026 18:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant