diff --git a/apps/web/app/api/chat/_lib/request.ts b/apps/web/app/api/chat/_lib/request.ts index ce67d5b..2a4af5c 100644 --- a/apps/web/app/api/chat/_lib/request.ts +++ b/apps/web/app/api/chat/_lib/request.ts @@ -1,10 +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: z.string().min(1).max(8192).optional(), + context: z + .object({ + contextLimit: z.number(), + }) + .optional(), +}); + +export type ChatRequestBody = z.infer; type ParseChatRequestResult = | { @@ -30,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/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..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 @@ -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,28 @@ export function useSessionChatRuntime({ contextLimitRef.current = contextLimit; }, [contextLimit]); + // 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 transport = useMemo( () => new AbortableChatTransport({ api: "/api/chat", - body: () => { + body: async () => { const requestContextLimit = contextLimitRef.current; + const recoupAccessToken = await getAccessToken().catch((error) => { + console.error( + "[chat] failed to fetch Privy access token for prompt", + error, + ); + return null; + }); return { sessionId, chatId, + ...(recoupAccessToken ? { recoupAccessToken } : {}), ...(requestContextLimit !== null ? { context: { @@ -107,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/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..c3307ca 100644 --- a/packages/agent/tools/bash.ts +++ b/packages/agent/tools/bash.ts @@ -1,6 +1,7 @@ import { tool } from "ai"; import { z } from "zod"; import * as path from "path"; +import { buildRecoupExecEnv } from "./build-recoup-exec-env"; import { getSandbox } from "./utils"; const TIMEOUT_MS = 120_000; @@ -104,6 +105,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,6 +127,10 @@ EXAMPLES: } try { + // 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, @@ -144,6 +150,7 @@ EXAMPLES: const result = await sandbox.exec(command, workingDir, TIMEOUT_MS, { signal: abortSignal, + ...(recoupEnv ? { env: recoupEnv } : {}), }); return { 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 8b15f0d..85faa2a 100644 --- a/packages/agent/tools/fetch.ts +++ b/packages/agent/tools/fetch.ts @@ -1,5 +1,6 @@ import { tool } from "ai"; import { z } from "zod"; +import { buildRecoupExecEnv } from "./build-recoup-exec-env"; import { getSandbox, shellEscape } from "./utils"; const TIMEOUT_MS = 30_000; @@ -54,6 +55,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 +94,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/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/tools.test.ts b/packages/agent/tools/tools.test.ts index cb3a2be..4fa0313 100644 --- a/packages/agent/tools/tools.test.ts +++ b/packages/agent/tools/tools.test.ts @@ -371,6 +371,88 @@ describe("tools execute behavior", () => { }); }); + 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 }> = []; + + 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) => { + detachedCalls.push({ command, cwd }); + 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", + }); + + // 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); + + 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).toHaveLength(2); + 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..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 && 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..8c9062b 100644 --- a/packages/sandbox/interface.ts +++ b/packages/sandbox/interface.ts @@ -132,7 +132,7 @@ export interface Sandbox { command: string, cwd: string, timeoutMs: number, - options?: { signal?: AbortSignal }, + options?: { signal?: AbortSignal; env?: Record }, ): Promise; /** diff --git a/packages/sandbox/vercel/sandbox.ts b/packages/sandbox/vercel/sandbox.ts index b8c0d51..fb564ae 100644 --- a/packages/sandbox/vercel/sandbox.ts +++ b/packages/sandbox/vercel/sandbox.ts @@ -471,15 +471,23 @@ ${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, + ...perCallEnv, }; } @@ -923,7 +931,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 +942,7 @@ ${hostLine}${portLines}${runtimeEnvLine}`; const result = await this.session.runCommand({ cmd: "bash", args: ["-c", `cd "${cwd}" && ${command}`], - env: this.getCommandEnv(), + env: this.getCommandEnv(options?.env), signal, });