Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d20ac4e
feat(chat-workflow): POST /api/chat/workflow route stub (PR 2 of 5) (…
sweetmantech May 21, 2026
26e847f
Merge remote-tracking branch 'origin/main' into test
sweetmantech May 21, 2026
f9efbea
feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel W…
sweetmantech May 21, 2026
fbe5fb3
Merge remote-tracking branch 'origin/main' into test
sweetmantech May 21, 2026
dcddcbf
feat(chat-workflow): port bash sandbox tool + wire experimental_conte…
sweetmantech May 21, 2026
c16533d
Merge remote-tracking branch 'origin/main' into test
sweetmantech May 21, 2026
51fd649
feat(chat-workflow): port 7 leaf sandbox tools — read/write/edit/grep…
sweetmantech May 21, 2026
cb2e8a4
Merge remote-tracking branch 'origin/main' into test
sweetmantech May 21, 2026
5e1a386
feat(chat-workflow): port skill discovery + skillTool (PR 6, slim) (#…
sweetmantech May 21, 2026
f77e455
Merge remote-tracking branch 'origin/main' into test
sweetmantech May 21, 2026
b36aa58
feat(chat-workflow): port task + ask_user_question composite tools (P…
sweetmantech May 21, 2026
956c766
Merge remote-tracking branch 'origin/main' into test
sweetmantech May 21, 2026
bd67ac7
feat(chat-workflow): emit per-message cost/usage metadata (cutover Bu…
sweetmantech May 21, 2026
bfb4595
Merge remote-tracking branch 'origin/main' into test
sweetmantech May 21, 2026
386c4ee
feat(task-tool): live subagent progress + transcript (Cutover Bundle …
sweetmantech May 22, 2026
59eeab1
Merge remote-tracking branch 'origin/main' into test
sweetmantech May 22, 2026
f3b8954
feat(chat-workflow): thread real cwd + currentBranch into system prom…
sweetmantech May 22, 2026
274f0b6
Merge remote-tracking branch 'origin/main' into test
sweetmantech May 22, 2026
cbcabcc
feat(chat-workflow): Anthropic prompt cache control (Bundle A.6) (#599)
sweetmantech May 22, 2026
2ffd706
Merge remote-tracking branch 'origin/main' into test
sweetmantech May 22, 2026
af4a01e
feat(chat-workflow): forward Privy JWT as RECOUP_ACCESS_TOKEN (Bundle…
sweetmantech May 22, 2026
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
26 changes: 20 additions & 6 deletions lib/agent/tools/AgentContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ import type { SkillMetadata } from "@/lib/skills/skillTypes";
* the constructed model(s) before `experimental_context` is observed
* by any tool.
*
* Why no `recoupAccessToken` field? A short-lived per-prompt credential
* would let sandbox tools (`skill`, the eventual `recoup-api` skill) call
* back to recoup-api as the caller. We deliberately omit it here — the
* legacy api-key path is too long-lived to expose inside a sandbox where
* model-issued bash commands can read env. Proper short-lived token
* minting lands alongside the `skill` tool port.
* `recoupAccessToken` carries a short-lived Privy JWT (~1h TTL) so
* the `recoup-api` skill can authenticate curl-style calls back to
* recoup-api as the user. Set by `handleChatWorkflowStream` only when
* the caller authenticates via `Authorization: Bearer <privy-jwt>` —
* a long-lived `recoup_sk_…` api key is deliberately NOT forwarded
* (model-issued bash commands could exfiltrate it via env).
*/
export type AgentContext = {
/**
Expand All @@ -41,6 +41,20 @@ export type AgentContext = {
* Public information — no security risk in exposing.
*/
recoupOrgId?: string;
/**
* Short-lived Privy JWT (the user's session token from the chat
* UI's Privy login). Forwarded into the sandbox env as
* `RECOUP_ACCESS_TOKEN` so the `recoup-api` skill's curl examples
* can authenticate as the user. Mirrors open-agents'
* `AgentContext.recoupAccessToken` (`packages/agent/types.ts:29`).
*
* Only set when the chat-workflow caller authenticated via
* `Authorization: Bearer <jwt>` (or sent `recoupAccessToken` in the
* request body). x-api-key callers do NOT get the token forwarded —
* the long-lived `recoup_sk_…` key would be exfiltratable from the
* sandbox env by any model-issued bash command.
*/
recoupAccessToken?: string;
/**
* Skills discovered in the sandbox before workflow start (handler
* calls `discoverSkills(sandbox, getSandboxSkillDirectories(sandbox))`).
Expand Down
34 changes: 34 additions & 0 deletions lib/agent/tools/__tests__/buildRecoupExecEnv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,38 @@ describe("buildRecoupExecEnv", () => {
expect(buildRecoupExecEnv({ recoupOrgId: "org-uuid" })).toBeUndefined();
expect(buildRecoupExecEnv({ sandbox: null, recoupOrgId: "org-uuid" })).toBeUndefined();
});

// Bundle A.4 contract: when the handler has plumbed a Privy JWT into
// AgentContext.recoupAccessToken, it MUST be surfaced as the
// `RECOUP_ACCESS_TOKEN` env var so the recoup-api skill's curl
// examples can authenticate. Currently failing on production —
// verified end-to-end against api prod: agent reports
// "RECOUP_ACCESS_TOKEN is not set" even when client sent it.
// Open-agents prod passes the equivalent test
// (TOKEN_SET length=413). This test will flip from red → green
// when A.4 lands.
it("injects RECOUP_ACCESS_TOKEN when present in context", () => {
const env = buildRecoupExecEnv({
sandbox: baseSandbox,
recoupAccessToken: "eyJhbGciOiJFUzI1NiI.test.jwt",
});
expect(env).toEqual({ RECOUP_ACCESS_TOKEN: "eyJhbGciOiJFUzI1NiI.test.jwt" });
});

it("ignores empty-string recoupAccessToken", () => {
const env = buildRecoupExecEnv({ sandbox: baseSandbox, recoupAccessToken: "" });
expect(env).toBeUndefined();
});

it("injects BOTH RECOUP_ORG_ID and RECOUP_ACCESS_TOKEN when both are set", () => {
const env = buildRecoupExecEnv({
sandbox: baseSandbox,
recoupOrgId: "org-uuid",
recoupAccessToken: "jwt.value",
});
expect(env).toEqual({
RECOUP_ORG_ID: "org-uuid",
RECOUP_ACCESS_TOKEN: "jwt.value",
});
});
});
16 changes: 11 additions & 5 deletions lib/agent/tools/buildRecoupExecEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import { isAgentContext } from "@/lib/agent/tools/isAgentContext";
* so outbound shell commands (curl, scripts, the `recoup-api` skill) can
* scope requests correctly without any state persisting on the sandbox.
*
* Currently injects only `RECOUP_ORG_ID` — a public identifier. Auth-token
* injection is deliberately NOT included here; a long-lived api key in the
* sandbox env would be readable by any model-issued bash command. Proper
* short-lived token minting will land alongside the `skill` tool port
* (when there's an actual consumer for it).
* Injects:
* - `RECOUP_ORG_ID` — public organization UUID. Always safe.
* - `RECOUP_ACCESS_TOKEN` — short-lived Privy JWT, when the handler
* plumbed one through `AgentContext.recoupAccessToken`. Used by the
* `recoup-api` skill's curl examples to authenticate as the user.
* Long-lived api keys are deliberately NOT forwarded — only the
* short-lived bearer token is, and only when the caller used
* bearer auth (the handler enforces that gating).
*
* Returns `undefined` when nothing is available to inject so callers can
* cleanly spread a conditional `...(env ? { env } : {})` into exec opts.
Expand All @@ -25,6 +28,9 @@ export function buildRecoupExecEnv(
if (experimental_context.recoupOrgId) {
env.RECOUP_ORG_ID = experimental_context.recoupOrgId;
}
if (experimental_context.recoupAccessToken) {
env.RECOUP_ACCESS_TOKEN = experimental_context.recoupAccessToken;
}

return Object.keys(env).length > 0 ? env : undefined;
}
30 changes: 30 additions & 0 deletions lib/chat/__tests__/handleChatWorkflowStream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,36 @@ describe("handleChatWorkflowStream", () => {
const startArgs = vi.mocked(start).mock.calls[0]?.[1]?.[0] as { modelId: string };
expect(startArgs.modelId).toBe("anthropic/claude-haiku-4.5");
});

// Bundle A.4 — forward the Privy JWT from the validated body into
// AgentContext.recoupAccessToken so the sandbox env-builder can
// surface it as `RECOUP_ACCESS_TOKEN`.
it("forwards validated.recoupAccessToken into AgentContext.recoupAccessToken", async () => {
vi.mocked(validateChatWorkflow).mockResolvedValue({
messages: [],
chatId: CHAT_ID,
sessionId: SESSION_ID,
accountId: ACCOUNT_ID,
orgId: null,
authToken: "test-key",
recoupAccessToken: "eyJ.privy.jwt",
});
mockStartedRun();
await handleChatWorkflowStream(makeRequest());
const startArgs = vi.mocked(start).mock.calls[0]?.[1]?.[0] as {
agentContext: { recoupAccessToken?: string };
};
expect(startArgs.agentContext.recoupAccessToken).toBe("eyJ.privy.jwt");
});

it("omits AgentContext.recoupAccessToken when validated body has no token", async () => {
mockStartedRun();
await handleChatWorkflowStream(makeRequest());
const startArgs = vi.mocked(start).mock.calls[0]?.[1]?.[0] as {
agentContext: { recoupAccessToken?: string };
};
expect(startArgs.agentContext.recoupAccessToken).toBeUndefined();
});
});

describe("promote placeholder → run id", () => {
Expand Down
13 changes: 13 additions & 0 deletions lib/chat/__tests__/validateChatWorkflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ describe("validateChatWorkflow", () => {
const result = await validateChatWorkflow(makeRequest({ ...validBody, messages: [] }));
expect(result).not.toBeInstanceOf(NextResponse);
});

// Bundle A.4 — open-agents' chat UI sends `recoupAccessToken`
// (the user's Privy JWT) in the request body. Today api silently
// strips it via Zod's default `.strip()` mode. After A.4 the
// schema must accept the field so the handler can forward it.
it("accepts and surfaces an optional recoupAccessToken from the body", async () => {
const result = await validateChatWorkflow(
makeRequest({ ...validBody, recoupAccessToken: "eyJ.test.jwt" }),
);
expect(result).not.toBeInstanceOf(NextResponse);
if (result instanceof NextResponse) return;
expect(result.recoupAccessToken).toBe("eyJ.test.jwt");
});
});

describe("invalid body", () => {
Expand Down
12 changes: 8 additions & 4 deletions lib/chat/handleChatWorkflowStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,14 @@ export async function handleChatWorkflowStream(request: NextRequest): Promise<Re
},
recoupOrgId,
skills,
// No `recoupAccessToken`: handing the long-lived api key to bash
// would let any model-issued command exfiltrate it via env. Proper
// short-lived token minting lands alongside the `skill` tool port
// (when there's an actual consumer for it).
// Forward the short-lived Privy JWT from the chat UI when
// present. The `recoup-api` skill's curl examples authenticate
// against recoup-api with this as a Bearer header (via the
// `$RECOUP_ACCESS_TOKEN` env var injected by buildRecoupExecEnv).
// x-api-key auth callers don't send this field — the long-lived
// recoup_sk_ key is deliberately NOT forwarded (exfiltration
// risk from model-issued bash).
...(validated.recoupAccessToken ? { recoupAccessToken: validated.recoupAccessToken } : {}),
},
},
]);
Expand Down
8 changes: 8 additions & 0 deletions lib/chat/validateChatWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ export const chatWorkflowBodySchema = z.object({
contextLimit: z.number().int("contextLimit must be an integer"),
})
.optional(),
/**
* Short-lived Privy JWT from the chat UI. Forwarded by the handler
* into `AgentContext.recoupAccessToken` (Bundle A.4) so the
* `recoup-api` skill's curl examples can authenticate as the user.
* Open-agents-shape compatible — their chat UI sends this same
* field shape.
*/
recoupAccessToken: z.string().min(1).max(8192).optional(),
});

export type ChatWorkflowBody = z.infer<typeof chatWorkflowBodySchema>;
Expand Down
Loading