Skip to content

Commit d9b7a5b

Browse files
committed
Proxy Codex exec_command and update_plan onto Corbits tools
1 parent 0b042a8 commit d9b7a5b

7 files changed

Lines changed: 410 additions & 18 deletions

File tree

‎docs/IMPLEMENTATION.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ Sixteen packages under `src/agent/directors/<id>/` register in `DIRECTOR_REGISTR
160160
4. `directorProfiles()` is the spawn catalog (`default-agents.ts`) — closed set minus skywalker; plugin agent profiles still load and can override by id.
161161
5. Primary chat role is Skywalker: `buildChatRole()` → `createSkywalkerSystemPrompt()`. Product mutation tools are stripped from the primary toolset and from CORE/CATALOG ads (`PRIMARY_DENIED_PRODUCT_TOOLS`) — never-implement is structural for path tools. Residual: `run_shell` stays on primary; MCP tools loaded later are not re-stripped by that deny list; optional `writePaths` (when a profile sets it) only gate path-keyed product tools.
162162

163-
**Codex `apply_patch` proxy.** When the active provider is Codex (`isCodexProviderName`), `createAgentToolset` and `runSubAgent` mount an `apply_patch` stringTool from `createCodexToolProxies` that parses the Codex envelope and forwards each op through the posix `ToolRunner` (`write_file` / `delete_file` / `read_file`) so permission plugins still apply. Primary still denies `apply_patch` via `PRIMARY_DENIED_PRODUCT_TOOLS`; implement and docs leaf allowlists (`IMPLEMENT_TOOLS` / `DOCS_TOOLS`) include it so Codex workers keep the proxy after the capability filter. `CORE_TOOL_NAMES` does not list it.
163+
**Codex tool proxies.** When the active provider is Codex (`isCodexProviderName`), `createAgentToolset` and `runSubAgent` mount `apply_patch`, `shell`, and `update_plan` stringTools from `createCodexToolProxies`, all forwarding through the same posix `ToolRunner` seam (`runTool`) so permission plugins still apply. `apply_patch` parses the Codex envelope and forwards each op (`write_file` / `delete_file` / `read_file`). `shell` — the native Codex name is `shell`, not `exec_command`, per the pinned base-instructions text quoted in `codex-responses-adapter.ts`'s bridge message — normalizes Codex's `command` (string or `["bash","-lc",script]`-style argv array), `workdir`, and `timeout_ms` onto `run_shell`'s `{command, cwd?, timeout?}` and is gated by `allowShellFromCapabilities` (mirrors `allowDeleteFromCapabilities` against `run_shell`). `update_plan` maps Codex's `plan: [{step, status}]` onto `manage_tasks(action: "create")`; `pending`/`in_progress`/`completed` map to `todo`/`doing`/`done` — `manage_tasks`'s `cancelled` status has no Codex equivalent and is never produced by this proxy. Primary still denies `apply_patch` via `PRIMARY_DENIED_PRODUCT_TOOLS` (`shell` and `update_plan` are not product-mutation tools, same classification as `run_shell` and `manage_tasks`, so they are not stripped); implement and docs leaf allowlists (`IMPLEMENT_TOOLS` / `DOCS_TOOLS`) include `apply_patch` so Codex workers keep the proxy after the capability filter. `CORE_TOOL_NAMES` does not list it.
164164
6. Shipped directors omit `writePaths`. The optional field is still enforced in the permission gate via ALS identity (`identity-context.ts` + `write-path-policy.ts`) when a plugin/custom profile sets it.
165165
7. Spawn effort: pin > package `modelRole` default (`defaultEffortForDirector`; intern=low; plan/review/orchestrator=high; implement/explore/docs/test=medium) > orchestrator/worker binary > parent inheritance. Optional skills are listed in the identity header for awareness; workers do not mount `use_skill` (guidance is baked into package system prompts). Primary mounts `use_skill` for its own skill list.
166166

‎src/agent/codex-tool-mount.test.ts‎

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
11
/**
2-
* Mount coverage for Codex apply_patch proxies: primary deny, allowlists,
3-
* and implement-shaped capability filter retention.
2+
* Mount coverage for Codex tool proxies (apply_patch, shell, update_plan):
3+
* primary deny, allowlists, and implement-shaped capability filter retention.
4+
*
5+
* runSubAgent has no standalone toolset-factory export to import directly (the
6+
* mount is inline in runSubAgent's tool-assembly), so the subagent mount path
7+
* is covered here via the same allowDeleteFromCapabilities /
8+
* allowShellFromCapabilities calls runSubAgent makes against a leaf
9+
* capability filter, feeding createCodexToolProxies exactly as run.ts does.
410
*/
511
import { mkdtempSync } from "node:fs";
612
import { tmpdir } from "node:os";
713
import { join } from "node:path";
814
import { afterEach, describe, expect, test, spyOn } from "bun:test";
915
import * as posixModule from "@intx/tools-posix";
1016

11-
import { createCodexToolProxies } from "./codex-tool-proxies.js";
17+
import {
18+
allowDeleteFromCapabilities,
19+
allowShellFromCapabilities,
20+
createCodexToolProxies,
21+
} from "./codex-tool-proxies.js";
1222
import { IMPLEMENT_TOOLS, DOCS_TOOLS } from "./directors/tool-sets.js";
1323
import { CORE_TOOL_NAMES, PRIMARY_DENIED_PRODUCT_TOOLS } from "./tool-search.js";
1424

@@ -39,6 +49,8 @@ describe("Codex apply_patch mount", () => {
3949
});
4050
const names = toolset.dynamicRunner.currentDefinitions().map((d) => d.name);
4151
expect(names).not.toContain("apply_patch");
52+
expect(names).not.toContain("shell");
53+
expect(names).not.toContain("update_plan");
4254
await toolset.dispose();
4355
});
4456

@@ -65,6 +77,11 @@ describe("Codex apply_patch mount", () => {
6577
const names = toolset.dynamicRunner.currentDefinitions().map((d) => d.name);
6678
expect(names).not.toContain("apply_patch");
6779
expect(PRIMARY_DENIED_PRODUCT_TOOLS).toContain("apply_patch");
80+
// shell / update_plan are not product-mutation tools (same classification
81+
// as run_shell / manage_tasks), so PRIMARY_DENIED_PRODUCT_TOOLS does not
82+
// strip them — they stay mounted on primary, mirroring run_shell.
83+
expect(names).toContain("shell");
84+
expect(names).toContain("update_plan");
6885
await toolset.dispose();
6986
});
7087

@@ -79,14 +96,51 @@ describe("Codex apply_patch mount", () => {
7996
isCodex: true,
8097
runTool: async () => ({ content: "ok" }),
8198
});
82-
expect(proxies.map((t) => t.definition.name)).toEqual(["apply_patch"]);
99+
expect(proxies.map((t) => t.definition.name)).toEqual([
100+
"apply_patch",
101+
"shell",
102+
"update_plan",
103+
]);
83104

84105
const allow = new Set<string>(IMPLEMENT_TOOLS);
85106
const kept = proxies.filter((t) => allow.has(t.definition.name));
86-
expect(kept.map((t) => t.definition.name)).toContain("apply_patch");
107+
expect(kept.map((t) => t.definition.name)).toEqual(["apply_patch", "shell", "update_plan"]);
87108

88109
const docsAllow = new Set<string>(DOCS_TOOLS);
89110
const docsKept = proxies.filter((t) => docsAllow.has(t.definition.name));
90-
expect(docsKept.map((t) => t.definition.name)).toContain("apply_patch");
111+
expect(docsKept.map((t) => t.definition.name)).toEqual(["apply_patch", "update_plan"]);
112+
});
113+
114+
test("runSubAgent-shaped mount: docs capability filter denies shell, keeps update_plan", () => {
115+
// Mirrors run.ts: allowDelete / allowShell are derived from the leaf
116+
// capability filter before createCodexToolProxies runs. update_plan is
117+
// never gated by it (manage_tasks is unconditionally mounted for every
118+
// sub-agent), so it stays regardless of the allowlist shape.
119+
const docsCapabilities = { mode: "allow" as const, tools: DOCS_TOOLS };
120+
const proxies = createCodexToolProxies({
121+
isCodex: true,
122+
runTool: async () => ({ content: "ok" }),
123+
allowDelete: allowDeleteFromCapabilities(docsCapabilities),
124+
allowShell: allowShellFromCapabilities(docsCapabilities),
125+
});
126+
expect(proxies.map((t) => t.definition.name)).toEqual([
127+
"apply_patch",
128+
"shell",
129+
"update_plan",
130+
]);
131+
132+
const docsAllow = new Set<string>(DOCS_TOOLS);
133+
const docsKept = proxies.filter((t) => docsAllow.has(t.definition.name));
134+
expect(docsKept.map((t) => t.definition.name)).toEqual(["apply_patch", "update_plan"]);
135+
});
136+
137+
test("non-Codex runSubAgent-shaped mount produces no proxies at all", () => {
138+
const proxies = createCodexToolProxies({
139+
isCodex: false,
140+
runTool: async () => ({ content: "ok" }),
141+
allowDelete: allowDeleteFromCapabilities({ mode: "allow", tools: IMPLEMENT_TOOLS }),
142+
allowShell: allowShellFromCapabilities({ mode: "allow", tools: IMPLEMENT_TOOLS }),
143+
});
144+
expect(proxies).toEqual([]);
91145
});
92146
});

‎src/agent/codex-tool-proxies.test.ts‎

Lines changed: 140 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { AgentTool } from "@intx/agent";
44

55
import {
66
allowDeleteFromCapabilities,
7+
allowShellFromCapabilities,
78
createCodexToolProxies,
89
type CodexRunTool,
910
} from "./codex-tool-proxies.js";
@@ -38,6 +39,12 @@ function makeRecorder(initial: Record<string, string> = {}): {
3839
files.delete(path);
3940
return { content: `Deleted file: ${path}` };
4041
}
42+
if (name === "run_shell") {
43+
return { content: `ran: ${JSON.stringify(args)}` };
44+
}
45+
if (name === "manage_tasks") {
46+
return { content: "Tasks updated." };
47+
}
4148
return { content: `unknown tool: ${name}`, isError: true };
4249
};
4350
return { calls, files, runTool };
@@ -51,6 +58,11 @@ async function invokeApplyPatch(tools: AgentTool[], input: string) {
5158
);
5259
}
5360

61+
async function invokeTool(tools: AgentTool[], name: string, args: Record<string, unknown>) {
62+
const runner = createToolRunner(tools);
63+
return runner.run({ id: "call-1", name, arguments: args }, new AbortController().signal);
64+
}
65+
5466
describe("createCodexToolProxies", () => {
5567
test("returns [] when not Codex", () => {
5668
const tools = createCodexToolProxies({
@@ -60,14 +72,13 @@ describe("createCodexToolProxies", () => {
6072
expect(tools).toEqual([]);
6173
});
6274

63-
test("returns apply_patch stringTool when Codex", () => {
75+
test("returns apply_patch, shell, update_plan stringTools when Codex", () => {
6476
const tools = createCodexToolProxies({
6577
isCodex: true,
6678
runTool: async () => ({ content: "unused" }),
6779
});
68-
expect(tools).toHaveLength(1);
69-
expect(tools[0]!.definition.name).toBe("apply_patch");
70-
expect(tools[0]!.kind).toBe("string");
80+
expect(tools.map((t) => t.definition.name)).toEqual(["apply_patch", "shell", "update_plan"]);
81+
expect(tools.every((t) => t.kind === "string")).toBe(true);
7182
expect(tools[0]!.definition.inputSchema).toMatchObject({
7283
required: ["input"],
7384
});
@@ -312,6 +323,117 @@ print("Hello, world!")
312323
});
313324
});
314325

326+
describe("shell proxy", () => {
327+
test("string command forwards to run_shell", async () => {
328+
const { calls, runTool } = makeRecorder();
329+
const tools = createCodexToolProxies({ isCodex: true, runTool });
330+
const result = await invokeTool(tools, "shell", { command: "ls -la" });
331+
expect(result.isError).toBeFalsy();
332+
expect(calls).toEqual([{ name: "run_shell", args: { command: "ls -la" } }]);
333+
});
334+
335+
test("bash -lc argv triple unwraps to the script", async () => {
336+
const { calls, runTool } = makeRecorder();
337+
const tools = createCodexToolProxies({ isCodex: true, runTool });
338+
await invokeTool(tools, "shell", { command: ["bash", "-lc", "echo 'hi there'"] });
339+
expect(calls).toEqual([{ name: "run_shell", args: { command: "echo 'hi there'" } }]);
340+
});
341+
342+
test("other argv arrays are shell-quoted and joined", async () => {
343+
const { calls, runTool } = makeRecorder();
344+
const tools = createCodexToolProxies({ isCodex: true, runTool });
345+
await invokeTool(tools, "shell", { command: ["echo", "hello world"] });
346+
expect(calls).toEqual([{ name: "run_shell", args: { command: "echo 'hello world'" } }]);
347+
});
348+
349+
test("workdir and timeout_ms translate to cwd and timeout", async () => {
350+
const { calls, runTool } = makeRecorder();
351+
const tools = createCodexToolProxies({ isCodex: true, runTool });
352+
await invokeTool(tools, "shell", {
353+
command: "pwd",
354+
workdir: "/tmp/work",
355+
timeout_ms: 5000,
356+
});
357+
expect(calls).toEqual([
358+
{ name: "run_shell", args: { command: "pwd", cwd: "/tmp/work", timeout: 5000 } },
359+
]);
360+
});
361+
362+
test("missing command surfaces as tool error", async () => {
363+
const { calls, runTool } = makeRecorder();
364+
const tools = createCodexToolProxies({ isCodex: true, runTool });
365+
const result = await invokeTool(tools, "shell", {});
366+
expect(result.isError).toBe(true);
367+
expect(result.content).toMatch(/command/);
368+
expect(calls).toEqual([]);
369+
});
370+
371+
test("allowShell false refuses without calling run_shell", async () => {
372+
const { calls, runTool } = makeRecorder();
373+
const tools = createCodexToolProxies({ isCodex: true, runTool, allowShell: false });
374+
const result = await invokeTool(tools, "shell", { command: "ls" });
375+
expect(result.isError).toBe(true);
376+
expect(result.content).toMatch(/not allowed/);
377+
expect(calls).toEqual([]);
378+
});
379+
380+
test("run_shell isError propagates as tool error", async () => {
381+
const runTool: CodexRunTool = async () => ({ content: "boom", isError: true });
382+
const tools = createCodexToolProxies({ isCodex: true, runTool });
383+
const result = await invokeTool(tools, "shell", { command: "ls" });
384+
expect(result.isError).toBe(true);
385+
expect(result.content).toMatch(/boom/);
386+
});
387+
});
388+
389+
describe("update_plan proxy", () => {
390+
test("maps plan steps onto manage_tasks(action=create)", async () => {
391+
const { calls, runTool } = makeRecorder();
392+
const tools = createCodexToolProxies({ isCodex: true, runTool });
393+
const result = await invokeTool(tools, "update_plan", {
394+
explanation: "getting started",
395+
plan: [
396+
{ step: "Read the file", status: "completed" },
397+
{ step: "Write the fix", status: "in_progress" },
398+
{ step: "Run tests", status: "pending" },
399+
],
400+
});
401+
expect(result.isError).toBeFalsy();
402+
expect(calls).toEqual([
403+
{
404+
name: "manage_tasks",
405+
args: {
406+
action: "create",
407+
tasks: [
408+
{ id: "p1", title: "Read the file", status: "done" },
409+
{ id: "p2", title: "Write the fix", status: "doing" },
410+
{ id: "p3", title: "Run tests", status: "todo" },
411+
],
412+
},
413+
},
414+
]);
415+
});
416+
417+
test("malformed plan surfaces as tool error", async () => {
418+
const { calls, runTool } = makeRecorder();
419+
const tools = createCodexToolProxies({ isCodex: true, runTool });
420+
const result = await invokeTool(tools, "update_plan", {
421+
plan: [{ step: "no status here" }],
422+
});
423+
expect(result.isError).toBe(true);
424+
expect(result.content).toMatch(/plan/);
425+
expect(calls).toEqual([]);
426+
});
427+
428+
test("missing plan surfaces as tool error", async () => {
429+
const { calls, runTool } = makeRecorder();
430+
const tools = createCodexToolProxies({ isCodex: true, runTool });
431+
const result = await invokeTool(tools, "update_plan", {});
432+
expect(result.isError).toBe(true);
433+
expect(calls).toEqual([]);
434+
});
435+
});
436+
315437
describe("allowDeleteFromCapabilities", () => {
316438
test("docs allowlist (no delete_file) → false; implement → true", () => {
317439
expect(
@@ -329,3 +451,17 @@ describe("allowDeleteFromCapabilities", () => {
329451
).toBe(false);
330452
});
331453
});
454+
455+
describe("allowShellFromCapabilities", () => {
456+
test("docs allowlist (no run_shell) → false; implement → true", () => {
457+
expect(allowShellFromCapabilities({ mode: "allow", tools: DOCS_TOOLS })).toBe(false);
458+
expect(allowShellFromCapabilities({ mode: "allow", tools: IMPLEMENT_TOOLS })).toBe(true);
459+
expect(allowShellFromCapabilities(undefined)).toBe(true);
460+
expect(
461+
allowShellFromCapabilities({ mode: "exclude", tools: ["delete_file"] }),
462+
).toBe(true);
463+
expect(
464+
allowShellFromCapabilities({ mode: "exclude", tools: ["run_shell"] }),
465+
).toBe(false);
466+
});
467+
});

0 commit comments

Comments
 (0)