Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions apps/web/app/api/chat/_lib/request.ts
Original file line number Diff line number Diff line change
@@ -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<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

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<typeof chatRequestBodySchema>;

type ParseChatRequestResult =
| {
Expand All @@ -30,15 +47,28 @@ type RequireChatIdentifiersResult =
export async function parseChatRequestBody(
req: Request,
): Promise<ParseChatRequestResult> {
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(
Expand Down
3 changes: 2 additions & 1 deletion apps/web/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export async function POST(req: Request) {
return parsedBody.response;
}

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.


// 2. Require sessionId and chatId to ensure sandbox ownership verification
const chatIdentifiers = requireChatIdentifiers(parsedBody.body);
Expand Down Expand Up @@ -236,6 +236,7 @@ export async function POST(req: Request) {
: {}),
...(skills.length > 0 && { skills }),
customInstructions: assistantFileLinkPrompt,
...(recoupAccessToken ? { recoupAccessToken } : {}),
},
...(shouldAutoCommitPush &&
sessionRecord.repoOwner &&
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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: {
Expand All @@ -107,7 +121,7 @@ export function useSessionChatRuntime({
api: `/api/chat/${id}/stream`,
}),
}),
[sessionId, chatId],
[sessionId, chatId, getAccessToken],
);

const { instance: chatInstance, alreadyExisted } = useMemo(
Expand Down
9 changes: 9 additions & 0 deletions packages/agent/open-harness-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ const callOptionsSchema = z.object({
subagentModel: z.custom<OpenHarnessAgentModelInput>().optional(),
customInstructions: z.string().optional(),
skills: z.custom<SkillMetadata[]>().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<typeof callOptionsSchema>;
Expand Down Expand Up @@ -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,
Expand All @@ -137,6 +145,7 @@ export const openHarnessAgent = new ToolLoopAgent({
skills,
model: callModel,
subagentModel,
...(recoupAccessToken ? { recoupAccessToken } : {}),
},
};
},
Expand Down
7 changes: 7 additions & 0 deletions packages/agent/tools/bash.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -144,6 +150,7 @@ EXAMPLES:

const result = await sandbox.exec(command, workingDir, TIMEOUT_MS, {
signal: abortSignal,
...(recoupEnv ? { env: recoupEnv } : {}),
});

return {
Expand Down
17 changes: 17 additions & 0 deletions packages/agent/tools/build-recoup-exec-env.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
3 changes: 3 additions & 0 deletions packages/agent/tools/fetch.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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) {
Expand Down
15 changes: 15 additions & 0 deletions packages/agent/tools/get-recoup-access-token.ts
Original file line number Diff line number Diff line change
@@ -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;
}
82 changes: 82 additions & 0 deletions packages/agent/tools/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> };
}> = [];
const detachedCalls: Array<{ command: string; cwd: string }> = [];

const sandbox = {
workingDirectory: "/repo",
exec: async (
command: string,
cwd: string,
_timeoutMs: number,
options?: { signal?: AbortSignal; env?: Record<string, string> },
) => {
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" };
},
Comment on lines +399 to +402
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

};

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<string, string> },
) => {
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);
Expand Down
2 changes: 1 addition & 1 deletion packages/agent/tools/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
6 changes: 6 additions & 0 deletions packages/agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/sandbox/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export interface Sandbox {
command: string,
cwd: string,
timeoutMs: number,
options?: { signal?: AbortSignal },
options?: { signal?: AbortSignal; env?: Record<string, string> },
): Promise<ExecResult>;

/**
Expand Down
Loading