From e41007902616ceb4aa0db28febaf609977b87201 Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Mon, 23 Feb 2026 19:46:37 +0530 Subject: [PATCH 1/8] feat(agent-codex): mature Codex plugin to match Claude Code support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the Codex agent plugin to feature parity with Claude Code by fixing four key gaps and hardening the shell wrapper infrastructure. ## Changes **`packages/plugins/agent-codex/src/index.ts`** - Permission flag: `--approval-mode full-auto` → `--dangerously-bypass-approvals-and-sandbox` - Terminal-based activity detection with Codex-specific patterns: idle (bare prompts), waiting_input (approval prompts), active (spinners, esc to interrupt) - PATH-based shell wrappers (`~/.ao/bin/gh`, `git`, `ao-metadata-helper.sh`): - `gh` wrapper captures `pr/create` and `pr/merge` output to update PR metadata; all other commands use `exec` for transparent passthrough (no stderr merging) - `git` wrapper captures `checkout -b` / `switch -c` to update branch metadata - Metadata helper escapes sed metacharacters (`&`, `|`, `\`) in values - Uses `grep -Fxv` (fixed-string) instead of regex for PATH cleaning - Validates `AO_DATA_DIR`/`AO_SESSION` paths to prevent arbitrary file overwrites (rejects traversal, resolves symlinks, allowlists `~/.ao/` and `/tmp/`) - `getEnvironment()` prepends `~/.ao/bin` to PATH for wrapper interception - `setupWorkspaceHooks()` / `postLaunchSetup()` install wrappers atomically (temp file + rename) and append AGENTS.md section - `getActivityState()` returns `{ state: "exited" }` when dead, `null` when running **`packages/core/src/lifecycle-manager.ts`** - Branch-based PR fallback: when `metadata.pr` is missing, calls `scm.detectPR()` via `gh pr list --head ` to auto-discover PRs for agents without hook systems (Codex, Aider, OpenCode) ## Test coverage - 85 tests in `agent-codex` (up from 27), replicating Claude Code test patterns: detectActivity edge cases, isProcessRunning boundaries, setupWorkspaceHooks file I/O behavior, shell wrapper content verification, atomic write validation - 245 tests in core pass - All 22 packages typecheck clean Co-Authored-By: Claude Opus 4.6 --- packages/core/src/lifecycle-manager.ts | 23 +- .../plugins/agent-codex/src/index.test.ts | 666 +++++++++++++++++- packages/plugins/agent-codex/src/index.ts | 286 +++++++- 3 files changed, 954 insertions(+), 21 deletions(-) diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 0ab2df839..c4005c8f1 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -229,7 +229,26 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan } } - // 3. Check PR state if PR exists + // 3. Auto-detect PR by branch if metadata.pr is missing. + // This is critical for agents without auto-hook systems (Codex, Aider, + // OpenCode) that can't reliably write pr= to metadata on their own. + if (!session.pr && scm && session.branch) { + try { + const detectedPR = await scm.detectPR(session, project); + if (detectedPR) { + session.pr = detectedPR; + // Persist PR URL so subsequent polls don't need to re-query. + // Don't write status here — step 4 below will determine the + // correct status (merged, ci_failed, etc.) on this same cycle. + const sessionsDir = getSessionsDir(config.configPath, project.path); + updateMetadata(sessionsDir, session.id, { pr: detectedPR.url }); + } + } catch { + // SCM detection failed — will retry next poll + } + } + + // 4. Check PR state if PR exists if (session.pr && scm) { try { const prState = await scm.getPRState(session.pr); @@ -257,7 +276,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan } } - // 4. Default: if agent is active, it's working + // 5. Default: if agent is active, it's working if ( session.status === "spawning" || session.status === SESSION_STATUS.STUCK || diff --git a/packages/plugins/agent-codex/src/index.test.ts b/packages/plugins/agent-codex/src/index.test.ts index 71a7422d8..50c6c1ede 100644 --- a/packages/plugins/agent-codex/src/index.test.ts +++ b/packages/plugins/agent-codex/src/index.test.ts @@ -2,10 +2,22 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Session, RuntimeHandle, AgentLaunchConfig } from "@composio/ao-core"; // --------------------------------------------------------------------------- -// Hoisted mocks +// Hoisted mocks — available inside vi.mock factories // --------------------------------------------------------------------------- -const { mockExecFileAsync } = vi.hoisted(() => ({ +const { + mockExecFileAsync, + mockWriteFile, + mockMkdir, + mockReadFile, + mockRename, + mockHomedir, +} = vi.hoisted(() => ({ mockExecFileAsync: vi.fn(), + mockWriteFile: vi.fn().mockResolvedValue(undefined), + mockMkdir: vi.fn().mockResolvedValue(undefined), + mockReadFile: vi.fn(), + mockRename: vi.fn().mockResolvedValue(undefined), + mockHomedir: vi.fn(() => "/mock/home"), })); vi.mock("node:child_process", () => { @@ -15,10 +27,29 @@ vi.mock("node:child_process", () => { return { execFile: fn }; }); +vi.mock("node:fs/promises", () => ({ + writeFile: mockWriteFile, + mkdir: mockMkdir, + readFile: mockReadFile, + rename: mockRename, +})); + +vi.mock("node:crypto", () => ({ + randomBytes: () => ({ toString: () => "abc123" }), +})); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), +})); + +vi.mock("node:os", () => ({ + homedir: mockHomedir, +})); + import { create, manifest, default as defaultExport } from "./index.js"; // --------------------------------------------------------------------------- -// Helpers +// Test helpers // --------------------------------------------------------------------------- function makeSession(overrides: Partial = {}): Session { return { @@ -62,8 +93,10 @@ function makeLaunchConfig(overrides: Partial = {}): AgentLaun } function mockTmuxWithProcess(processName: string, found = true) { - mockExecFileAsync.mockImplementation((cmd: string) => { - if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" }); + mockExecFileAsync.mockImplementation((cmd: string, args: string[]) => { + if (cmd === "tmux" && args[0] === "list-panes") { + return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" }); + } if (cmd === "ps") { const line = found ? ` 789 ttys003 ${processName}` : " 789 ttys003 bash"; return Promise.resolve({ @@ -71,12 +104,13 @@ function mockTmuxWithProcess(processName: string, found = true) { stderr: "", }); } - return Promise.reject(new Error("unexpected")); + return Promise.reject(new Error(`Unexpected: ${cmd} ${args.join(" ")}`)); }); } beforeEach(() => { vi.clearAllMocks(); + mockHomedir.mockReturnValue("/mock/home"); }); // ========================================================================= @@ -114,9 +148,9 @@ describe("getLaunchCommand", () => { expect(agent.getLaunchCommand(makeLaunchConfig())).toBe("codex"); }); - it("includes --approval-mode full-auto when permissions=skip", () => { + it("includes --dangerously-bypass-approvals-and-sandbox when permissions=skip", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "skip" })); - expect(cmd).toContain("--approval-mode full-auto"); + expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox"); }); it("includes --model with shell-escaped value", () => { @@ -133,7 +167,7 @@ describe("getLaunchCommand", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ permissions: "skip", model: "o3", prompt: "Go" }), ); - expect(cmd).toBe("codex --approval-mode full-auto --model 'o3' -- 'Go'"); + expect(cmd).toBe("codex --dangerously-bypass-approvals-and-sandbox --model 'o3' -- 'Go'"); }); it("escapes single quotes in prompt (POSIX shell escaping)", () => { @@ -141,10 +175,38 @@ describe("getLaunchCommand", () => { expect(cmd).toContain("-- 'it'\\''s broken'"); }); + it("escapes dangerous characters in prompt", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ prompt: "$(rm -rf /); `evil`; $HOME" }), + ); + // Single-quoted strings prevent shell expansion + expect(cmd).toContain("-- '$(rm -rf /); `evil`; $HOME'"); + }); + + it("includes --system-prompt with file cat when systemPromptFile is set", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPromptFile: "/tmp/prompt.md" })); + expect(cmd).toContain("--system-prompt"); + expect(cmd).toContain("$(cat '/tmp/prompt.md')"); + }); + + it("prefers systemPromptFile over systemPrompt", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ systemPromptFile: "/tmp/prompt.md", systemPrompt: "Ignored" }), + ); + expect(cmd).toContain("$(cat '/tmp/prompt.md')"); + expect(cmd).not.toContain("'Ignored'"); + }); + + it("includes --system-prompt with inline text when systemPrompt is set", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPrompt: "Be helpful" })); + expect(cmd).toContain("--system-prompt 'Be helpful'"); + }); + it("omits optional flags when not provided", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig()); - expect(cmd).not.toContain("--approval-mode"); + expect(cmd).not.toContain("--dangerously-bypass-approvals-and-sandbox"); expect(cmd).not.toContain("--model"); + expect(cmd).not.toContain("--system-prompt"); }); }); @@ -169,6 +231,27 @@ describe("getEnvironment", () => { const env = agent.getEnvironment(makeLaunchConfig()); expect(env["AO_ISSUE_ID"]).toBeUndefined(); }); + + it("prepends ~/.ao/bin to PATH for shell wrappers", () => { + const env = agent.getEnvironment(makeLaunchConfig()); + expect(env["PATH"]).toMatch(/^.*\/\.ao\/bin:/); + }); + + it("PATH starts with the ao bin dir specifically", () => { + const env = agent.getEnvironment(makeLaunchConfig()); + expect(env["PATH"]?.startsWith("/mock/home/.ao/bin:")).toBe(true); + }); + + it("falls back to /usr/bin:/bin when process.env.PATH is undefined", () => { + const originalPath = process.env["PATH"]; + delete process.env["PATH"]; + try { + const env = agent.getEnvironment(makeLaunchConfig()); + expect(env["PATH"]).toContain("/usr/bin:/bin"); + } finally { + process.env["PATH"] = originalPath; + } + }); }); // ========================================================================= @@ -187,6 +270,11 @@ describe("isProcessRunning", () => { expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false); }); + it("returns false when tmux list-panes returns empty", async () => { + mockExecFileAsync.mockResolvedValue({ stdout: "", stderr: "" }); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false); + }); + it("returns true for process handle with alive PID", async () => { const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); expect(await agent.isProcessRunning(makeProcessHandle(123))).toBe(true); @@ -205,6 +293,8 @@ describe("isProcessRunning", () => { it("returns false for unknown runtime without PID", async () => { const handle: RuntimeHandle = { id: "x", runtimeName: "other", data: {} }; expect(await agent.isProcessRunning(handle)).toBe(false); + // Must NOT call external commands — could match wrong session + expect(mockExecFileAsync).not.toHaveBeenCalled(); }); it("returns false on tmux command failure", async () => { @@ -222,8 +312,8 @@ describe("isProcessRunning", () => { }); it("finds codex on any pane in multi-pane session", async () => { - mockExecFileAsync.mockImplementation((cmd: string) => { - if (cmd === "tmux") { + mockExecFileAsync.mockImplementation((cmd: string, args: string[]) => { + if (cmd === "tmux" && args[0] === "list-panes") { return Promise.resolve({ stdout: "/dev/ttys001\n/dev/ttys002\n", stderr: "" }); } if (cmd === "ps") { @@ -236,6 +326,33 @@ describe("isProcessRunning", () => { }); expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true); }); + + it("does not match similar process names like codex-something", async () => { + mockExecFileAsync.mockImplementation((cmd: string, args: string[]) => { + if (cmd === "tmux" && args[0] === "list-panes") { + return Promise.resolve({ stdout: "/dev/ttys001\n", stderr: "" }); + } + if (cmd === "ps") { + return Promise.resolve({ + stdout: " PID TT ARGS\n 100 ttys001 /usr/bin/codex-helper\n", + stderr: "", + }); + } + return Promise.reject(new Error("unexpected")); + }); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false); + }); + + it("handles string PID by converting to number", async () => { + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + expect(await agent.isProcessRunning(makeProcessHandle("456"))).toBe(true); + expect(killSpy).toHaveBeenCalledWith(456, 0); + killSpy.mockRestore(); + }); + + it("returns false for non-numeric PID", async () => { + expect(await agent.isProcessRunning(makeProcessHandle("not-a-pid"))).toBe(false); + }); }); // ========================================================================= @@ -244,6 +361,7 @@ describe("isProcessRunning", () => { describe("detectActivity", () => { const agent = create(); + // -- Idle states -- it("returns idle for empty terminal output", () => { expect(agent.detectActivity("")).toBe("idle"); }); @@ -252,9 +370,113 @@ describe("detectActivity", () => { expect(agent.detectActivity(" \n ")).toBe("idle"); }); - it("returns active for non-empty terminal output", () => { + it("returns idle when last line is a bare > prompt", () => { + expect(agent.detectActivity("some output\n> ")).toBe("idle"); + }); + + it("returns idle when last line is a bare $ prompt", () => { + expect(agent.detectActivity("some output\n$ ")).toBe("idle"); + }); + + it("returns idle when last line is a bare # prompt", () => { + expect(agent.detectActivity("some output\n# ")).toBe("idle"); + }); + + it("returns idle when prompt follows historical activity indicators", () => { + // Key regression test: historical active output in the buffer + // should NOT override an idle prompt on the last line. + expect(agent.detectActivity("✶ Reading files\nDone.\n> ")).toBe("idle"); + expect(agent.detectActivity("Working on task (esc to interrupt)\nFinished.\n$ ")).toBe("idle"); + }); + + // -- Waiting input states -- + it("returns waiting_input for approval required text", () => { + expect(agent.detectActivity("some output\napproval required\n")).toBe("waiting_input"); + }); + + it("returns waiting_input for (y)es / (n)o prompt", () => { + expect(agent.detectActivity("Do you want to continue?\n(y)es / (n)o\n")).toBe("waiting_input"); + }); + + it("returns waiting_input when permission prompt follows historical activity", () => { + // Permission prompt at the bottom should NOT be overridden by historical + // spinner/esc output higher in the buffer. + expect( + agent.detectActivity("✶ Writing files\nDone.\napproval required\n"), + ).toBe("waiting_input"); + expect( + agent.detectActivity("Working (esc to interrupt)\nFinished\n(y)es / (n)o\n"), + ).toBe("waiting_input"); + }); + + // -- Active states -- + it("returns active for non-empty terminal output with no special patterns", () => { expect(agent.detectActivity("codex is running some task\n")).toBe("active"); }); + + it("returns active when (esc to interrupt) is present", () => { + expect(agent.detectActivity("Working on task (esc to interrupt)\n")).toBe("active"); + }); + + it("returns active for spinner symbols with -ing words", () => { + expect(agent.detectActivity("✶ Reading files\n")).toBe("active"); + expect(agent.detectActivity("⏺ Writing to disk\n")).toBe("active"); + expect(agent.detectActivity("✽ Searching codebase\n")).toBe("active"); + expect(agent.detectActivity("⏳ Installing packages\n")).toBe("active"); + }); + + it("returns active (not idle) for spinner symbol without -ing word", () => { + // Spinner symbols alone without -ing words should still fall through to active + expect(agent.detectActivity("✶ done\n")).toBe("active"); + }); + + it("returns active for multi-line output with activity in the middle", () => { + expect(agent.detectActivity("Starting\n(esc to interrupt)\nstill going\n")).toBe("active"); + }); +}); + +// ========================================================================= +// getActivityState +// ========================================================================= +describe("getActivityState", () => { + const agent = create(); + + it("returns exited when no runtimeHandle", async () => { + const session = makeSession({ runtimeHandle: null }); + const result = await agent.getActivityState(session); + expect(result).toEqual({ state: "exited" }); + }); + + it("returns exited when process is not running", async () => { + mockExecFileAsync.mockRejectedValue(new Error("tmux not running")); + const session = makeSession({ runtimeHandle: makeTmuxHandle() }); + const result = await agent.getActivityState(session); + expect(result).toEqual({ state: "exited" }); + }); + + it("returns null (unknown) when process is running", async () => { + mockTmuxWithProcess("codex"); + const session = makeSession({ runtimeHandle: makeTmuxHandle() }); + expect(await agent.getActivityState(session)).toBeNull(); + }); + + it("returns exited when process handle has dead PID", async () => { + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => { + throw new Error("ESRCH"); + }); + const session = makeSession({ runtimeHandle: makeProcessHandle(999) }); + const result = await agent.getActivityState(session); + expect(result).toEqual({ state: "exited" }); + killSpy.mockRestore(); + }); + + it("does not include timestamp in exited state", async () => { + const session = makeSession({ runtimeHandle: null }); + const result = await agent.getActivityState(session); + // The Codex implementation returns { state: "exited" } without timestamp + expect(result).toEqual({ state: "exited" }); + expect(result?.timestamp).toBeUndefined(); + }); }); // ========================================================================= @@ -267,4 +489,422 @@ describe("getSessionInfo", () => { expect(await agent.getSessionInfo(makeSession())).toBeNull(); expect(await agent.getSessionInfo(makeSession({ workspacePath: "/some/path" }))).toBeNull(); }); + + it("returns null even with null workspacePath", async () => { + expect(await agent.getSessionInfo(makeSession({ workspacePath: null }))).toBeNull(); + }); +}); + +// ========================================================================= +// setupWorkspaceHooks — file writing behavior +// ========================================================================= +describe("setupWorkspaceHooks", () => { + const agent = create(); + + it("has setupWorkspaceHooks method", () => { + expect(typeof agent.setupWorkspaceHooks).toBe("function"); + }); + + it("creates ~/.ao/bin directory", async () => { + // Version marker doesn't exist — triggers full install + mockReadFile.mockRejectedValue(new Error("ENOENT")); + + await agent.setupWorkspaceHooks!("/workspace/test", { + dataDir: "/data", + sessionId: "sess-1", + }); + + expect(mockMkdir).toHaveBeenCalledWith("/mock/home/.ao/bin", { recursive: true }); + }); + + it("writes ao-metadata-helper.sh with executable permissions via atomic write", async () => { + mockReadFile.mockRejectedValue(new Error("ENOENT")); + + await agent.setupWorkspaceHooks!("/workspace/test", { + dataDir: "/data", + sessionId: "sess-1", + }); + + // Atomic write: writes to .tmp file first, then renames + const helperWriteCall = mockWriteFile.mock.calls.find( + (call: [string, string, object]) => + typeof call[0] === "string" && call[0].includes("ao-metadata-helper.sh.tmp."), + ); + expect(helperWriteCall).toBeDefined(); + expect(helperWriteCall![1]).toContain("update_ao_metadata()"); + expect(helperWriteCall![2]).toEqual({ encoding: "utf-8", mode: 0o755 }); + + // Then renamed to final path + const helperRenameCall = mockRename.mock.calls.find( + (call: string[]) => typeof call[1] === "string" && call[1].endsWith("ao-metadata-helper.sh"), + ); + expect(helperRenameCall).toBeDefined(); + }); + + it("writes gh and git wrappers atomically when version marker is missing", async () => { + mockReadFile.mockRejectedValue(new Error("ENOENT")); + + await agent.setupWorkspaceHooks!("/workspace/test", { + dataDir: "/data", + sessionId: "sess-1", + }); + + // gh wrapper: written to temp, then renamed + const ghWriteCall = mockWriteFile.mock.calls.find( + (call: [string, string, object]) => + typeof call[0] === "string" && call[0].includes("/gh.tmp."), + ); + expect(ghWriteCall).toBeDefined(); + expect(ghWriteCall![1]).toContain("ao gh wrapper"); + + const ghRenameCall = mockRename.mock.calls.find( + (call: string[]) => typeof call[1] === "string" && call[1].endsWith("/gh"), + ); + expect(ghRenameCall).toBeDefined(); + + // git wrapper: written to temp, then renamed + const gitWriteCall = mockWriteFile.mock.calls.find( + (call: [string, string, object]) => + typeof call[0] === "string" && call[0].includes("/git.tmp."), + ); + expect(gitWriteCall).toBeDefined(); + expect(gitWriteCall![1]).toContain("ao git wrapper"); + + const gitRenameCall = mockRename.mock.calls.find( + (call: string[]) => typeof call[1] === "string" && call[1].endsWith("/git"), + ); + expect(gitRenameCall).toBeDefined(); + }); + + it("sets executable permissions on gh and git wrappers via writeFile mode", async () => { + mockReadFile.mockRejectedValue(new Error("ENOENT")); + + await agent.setupWorkspaceHooks!("/workspace/test", { + dataDir: "/data", + sessionId: "sess-1", + }); + + const ghWriteCall = mockWriteFile.mock.calls.find( + (call: [string, string, object]) => + typeof call[0] === "string" && call[0].includes("/gh.tmp."), + ); + expect(ghWriteCall![2]).toEqual({ encoding: "utf-8", mode: 0o755 }); + + const gitWriteCall = mockWriteFile.mock.calls.find( + (call: [string, string, object]) => + typeof call[0] === "string" && call[0].includes("/git.tmp."), + ); + expect(gitWriteCall![2]).toEqual({ encoding: "utf-8", mode: 0o755 }); + }); + + it("skips wrapper writes when version marker matches", async () => { + // First call for version marker — matches current version + // Second call for AGENTS.md — file doesn't exist + mockReadFile.mockImplementation((path: string) => { + if (typeof path === "string" && path.endsWith(".ao-version")) { + return Promise.resolve("0.1.0"); + } + // AGENTS.md read attempt + return Promise.reject(new Error("ENOENT")); + }); + + await agent.setupWorkspaceHooks!("/workspace/test", { + dataDir: "/data", + sessionId: "sess-1", + }); + + // Should still write the metadata helper (always written) + const helperWriteCall = mockWriteFile.mock.calls.find( + (call: [string, string, object]) => + typeof call[0] === "string" && call[0].includes("ao-metadata-helper.sh.tmp."), + ); + expect(helperWriteCall).toBeDefined(); + + // But should NOT write gh/git wrappers (version matches) + const ghWriteCall = mockWriteFile.mock.calls.find( + (call: [string, string, object]) => + typeof call[0] === "string" && call[0].includes("/gh.tmp."), + ); + expect(ghWriteCall).toBeUndefined(); + }); + + it("writes version marker after installing wrappers", async () => { + mockReadFile.mockRejectedValue(new Error("ENOENT")); + + await agent.setupWorkspaceHooks!("/workspace/test", { + dataDir: "/data", + sessionId: "sess-1", + }); + + // Version marker is also atomically written + const versionWriteCall = mockWriteFile.mock.calls.find( + (call: [string, string, object]) => + typeof call[0] === "string" && call[0].includes(".ao-version.tmp."), + ); + expect(versionWriteCall).toBeDefined(); + expect(versionWriteCall![1]).toBe("0.1.0"); + + const versionRenameCall = mockRename.mock.calls.find( + (call: string[]) => typeof call[1] === "string" && call[1].endsWith(".ao-version"), + ); + expect(versionRenameCall).toBeDefined(); + }); + + it("appends ao section to AGENTS.md when not present", async () => { + // Version marker matches (skip wrapper install) + // AGENTS.md exists without ao section + mockReadFile.mockImplementation((path: string) => { + if (typeof path === "string" && path.endsWith(".ao-version")) { + return Promise.resolve("0.1.0"); + } + if (typeof path === "string" && path.endsWith("AGENTS.md")) { + return Promise.resolve("# Existing Content\n\nSome stuff here.\n"); + } + return Promise.reject(new Error("ENOENT")); + }); + + await agent.setupWorkspaceHooks!("/workspace/test", { + dataDir: "/data", + sessionId: "sess-1", + }); + + const agentsMdCall = mockWriteFile.mock.calls.find( + (call: string[]) => typeof call[0] === "string" && call[0].endsWith("AGENTS.md"), + ); + expect(agentsMdCall).toBeDefined(); + expect(agentsMdCall![1]).toContain("Agent Orchestrator (ao) Session"); + expect(agentsMdCall![1]).toContain("# Existing Content"); + }); + + it("creates AGENTS.md if it does not exist", async () => { + // Version marker matches, AGENTS.md doesn't exist + mockReadFile.mockImplementation((path: string) => { + if (typeof path === "string" && path.endsWith(".ao-version")) { + return Promise.resolve("0.1.0"); + } + return Promise.reject(new Error("ENOENT")); + }); + + await agent.setupWorkspaceHooks!("/workspace/test", { + dataDir: "/data", + sessionId: "sess-1", + }); + + const agentsMdCall = mockWriteFile.mock.calls.find( + (call: string[]) => typeof call[0] === "string" && call[0].endsWith("AGENTS.md"), + ); + expect(agentsMdCall).toBeDefined(); + expect(agentsMdCall![1]).toContain("Agent Orchestrator (ao) Session"); + }); + + it("uses atomic write (temp + rename) to prevent partial reads from concurrent sessions", async () => { + mockReadFile.mockRejectedValue(new Error("ENOENT")); + + await agent.setupWorkspaceHooks!("/workspace/test", { + dataDir: "/data", + sessionId: "sess-1", + }); + + // Every wrapper file should be written to a .tmp file first, then renamed + // This ensures concurrent readers never see a partially written file + const tmpWrites = mockWriteFile.mock.calls.filter( + (call: [string, string, object]) => + typeof call[0] === "string" && call[0].includes(".tmp."), + ); + const renames = mockRename.mock.calls; + + // We expect atomic writes for: helper, gh, git, version marker = 4 + expect(tmpWrites.length).toBe(4); + expect(renames.length).toBe(4); + + // Each rename should move a .tmp file to the final path + for (const [src, dst] of renames) { + expect(src).toContain(".tmp."); + expect(dst).not.toContain(".tmp."); + } + }); + + it("does not duplicate ao section in AGENTS.md if already present", async () => { + mockReadFile.mockImplementation((path: string) => { + if (typeof path === "string" && path.endsWith(".ao-version")) { + return Promise.resolve("0.1.0"); + } + if (typeof path === "string" && path.endsWith("AGENTS.md")) { + return Promise.resolve("# Existing\n\n## Agent Orchestrator (ao) Session\n\nAlready here.\n"); + } + return Promise.reject(new Error("ENOENT")); + }); + + await agent.setupWorkspaceHooks!("/workspace/test", { + dataDir: "/data", + sessionId: "sess-1", + }); + + const agentsMdCall = mockWriteFile.mock.calls.find( + (call: string[]) => typeof call[0] === "string" && call[0].endsWith("AGENTS.md"), + ); + // Should NOT write AGENTS.md since the section already exists + expect(agentsMdCall).toBeUndefined(); + }); +}); + +// ========================================================================= +// postLaunchSetup +// ========================================================================= +describe("postLaunchSetup", () => { + const agent = create(); + + it("has postLaunchSetup method", () => { + expect(typeof agent.postLaunchSetup).toBe("function"); + }); + + it("runs setup when session has workspacePath", async () => { + mockReadFile.mockRejectedValue(new Error("ENOENT")); + await agent.postLaunchSetup!(makeSession({ workspacePath: "/workspace/test" })); + expect(mockMkdir).toHaveBeenCalled(); + }); + + it("returns early when session has no workspacePath", async () => { + await agent.postLaunchSetup!(makeSession({ workspacePath: undefined })); + expect(mockMkdir).not.toHaveBeenCalled(); + }); +}); + +// ========================================================================= +// Shell wrapper content verification +// ========================================================================= +describe("shell wrapper content", () => { + const agent = create(); + + beforeEach(() => { + // Force wrapper installation by making version marker miss + mockReadFile.mockRejectedValue(new Error("ENOENT")); + }); + + async function getWrapperContent(name: string): Promise { + await agent.setupWorkspaceHooks!("/workspace/test", { + dataDir: "/data", + sessionId: "sess-1", + }); + + // With atomic writes, content is written to a .tmp. file + const call = mockWriteFile.mock.calls.find( + (c: [string, string, object]) => + typeof c[0] === "string" && c[0].includes(`/${name}.tmp.`), + ); + return call ? call[1] as string : ""; + } + + describe("metadata helper", () => { + it("contains update_ao_metadata function", async () => { + const content = await getWrapperContent("ao-metadata-helper.sh"); + expect(content).toContain("update_ao_metadata()"); + }); + + it("uses AO_DATA_DIR and AO_SESSION env vars", async () => { + const content = await getWrapperContent("ao-metadata-helper.sh"); + expect(content).toContain("AO_DATA_DIR"); + expect(content).toContain("AO_SESSION"); + }); + + it("escapes sed metacharacters in values", async () => { + const content = await getWrapperContent("ao-metadata-helper.sh"); + // Should contain the sed escaping logic for &, |, and \ + expect(content).toContain("escaped_value"); + expect(content).toMatch(/sed.*\\\\&/); + }); + + it("uses atomic temp file + mv pattern", async () => { + const content = await getWrapperContent("ao-metadata-helper.sh"); + expect(content).toContain("temp_file"); + expect(content).toContain("mv"); + }); + + it("validates session name has no path separators", async () => { + const content = await getWrapperContent("ao-metadata-helper.sh"); + // Rejects session names containing / or .. + expect(content).toContain("*/*"); + expect(content).toContain("*..*"); + }); + + it("validates ao_dir is an absolute path under expected locations", async () => { + const content = await getWrapperContent("ao-metadata-helper.sh"); + // Only allows paths under $HOME/.ao/, $HOME/.agent-orchestrator/, or /tmp/ + expect(content).toContain('$HOME"/.ao/*'); + expect(content).toContain('$HOME"/.agent-orchestrator/*'); + expect(content).toContain("/tmp/*"); + }); + + it("resolves symlinks and verifies file stays within ao_dir", async () => { + const content = await getWrapperContent("ao-metadata-helper.sh"); + expect(content).toContain("pwd -P"); + expect(content).toContain("real_ao_dir"); + expect(content).toContain("real_dir"); + }); + }); + + describe("gh wrapper", () => { + it("uses grep -Fxv for PATH cleaning (not regex grep)", async () => { + const content = await getWrapperContent("gh"); + expect(content).toContain("grep -Fxv"); + expect(content).not.toMatch(/grep -v "\^\$ao_bin_dir\$"/); + }); + + it("only captures output for pr/create and pr/merge", async () => { + const content = await getWrapperContent("gh"); + expect(content).toContain("pr/create|pr/merge"); + }); + + it("uses exec for non-PR commands (transparent passthrough)", async () => { + const content = await getWrapperContent("gh"); + expect(content).toContain('exec "$real_gh"'); + }); + + it("extracts PR URL from gh pr create output", async () => { + const content = await getWrapperContent("gh"); + expect(content).toContain("https://github"); + expect(content).toContain("update_ao_metadata pr"); + }); + + it("updates status to merged on gh pr merge", async () => { + const content = await getWrapperContent("gh"); + expect(content).toContain("update_ao_metadata status merged"); + }); + + it("cleans up temp file on exit", async () => { + const content = await getWrapperContent("gh"); + expect(content).toContain("trap"); + expect(content).toContain("rm -f"); + }); + }); + + describe("git wrapper", () => { + it("uses grep -Fxv for PATH cleaning (not regex grep)", async () => { + const content = await getWrapperContent("git"); + expect(content).toContain("grep -Fxv"); + expect(content).not.toMatch(/grep -v "\^\$ao_bin_dir\$"/); + }); + + it("captures branch name from checkout -b", async () => { + const content = await getWrapperContent("git"); + expect(content).toContain("checkout/-b"); + expect(content).toContain("update_ao_metadata branch"); + }); + + it("captures branch name from switch -c", async () => { + const content = await getWrapperContent("git"); + expect(content).toContain("switch/-c"); + }); + + it("only updates metadata on success (exit code 0)", async () => { + const content = await getWrapperContent("git"); + expect(content).toContain("exit_code -eq 0"); + }); + + it("sources the metadata helper", async () => { + const content = await getWrapperContent("git"); + expect(content).toContain("source"); + expect(content).toContain("ao-metadata-helper.sh"); + }); + }); }); diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index 0c6952052..8e02a94fe 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -3,17 +3,25 @@ import { type Agent, type AgentSessionInfo, type AgentLaunchConfig, - type ActivityDetection, type ActivityState, + type ActivityDetection, type PluginModule, type RuntimeHandle, type Session, + type WorkspaceHooksConfig, } from "@composio/ao-core"; import { execFile } from "node:child_process"; +import { writeFile, mkdir, readFile, rename } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { promisify } from "node:util"; +import { randomBytes } from "node:crypto"; const execFileAsync = promisify(execFile); +/** Shared bin directory for ao shell wrappers (prepended to PATH) */ +const AO_BIN_DIR = join(homedir(), ".ao", "bin"); + // ============================================================================= // Plugin Manifest // ============================================================================= @@ -25,6 +33,241 @@ export const manifest = { version: "0.1.0", }; +// ============================================================================= +// Shell Wrappers (automatic metadata updates — like Claude Code's PostToolUse) +// ============================================================================= + +/** + * Helper script sourced by both gh and git wrappers. + * Provides update_ao_metadata() for writing key=value to the session file. + */ +const AO_METADATA_HELPER = `#!/usr/bin/env bash +# ao-metadata-helper — shared by gh/git wrappers +# Provides: update_ao_metadata + +update_ao_metadata() { + local key="\$1" value="\$2" + local ao_dir="\${AO_DATA_DIR:-}" + local ao_session="\${AO_SESSION:-}" + + [[ -z "\$ao_dir" || -z "\$ao_session" ]] && return 0 + + # Validate: session name must not contain path separators or traversal + case "\$ao_session" in + */* | *..*) return 0 ;; + esac + + # Validate: ao_dir must be an absolute path under known ao directories or /tmp + case "\$ao_dir" in + "\$HOME"/.ao/* | "\$HOME"/.agent-orchestrator/* | /tmp/*) ;; + *) return 0 ;; + esac + + local metadata_file="\$ao_dir/\$ao_session" + + # Resolve and verify the file is still within ao_dir + local real_dir real_ao_dir + real_ao_dir="\$(cd "\$ao_dir" 2>/dev/null && pwd -P)" || return 0 + real_dir="\$(cd "\$(dirname "\$metadata_file")" 2>/dev/null && pwd -P)" || return 0 + [[ "\$real_dir" == "\$real_ao_dir"* ]] || return 0 + + [[ -f "\$metadata_file" ]] || return 0 + + local temp_file="\${metadata_file}.tmp.\$\$" + + # Strip newlines from value to prevent metadata line injection + local clean_value="\$(printf '%s' "\$value" | tr -d '\\n')" + + # Escape sed metacharacters in value (& expands to matched text, | breaks delimiter) + local escaped_value="\$(printf '%s' "\$clean_value" | sed 's/[&|\\\\]/\\\\&/g')" + + if grep -q "^\${key}=" "\$metadata_file" 2>/dev/null; then + sed "s|^\${key}=.*|\${key}=\${escaped_value}|" "\$metadata_file" > "\$temp_file" + else + cp "\$metadata_file" "\$temp_file" + printf '%s=%s\\n' "\$key" "\$clean_value" >> "\$temp_file" + fi + + mv "\$temp_file" "\$metadata_file" +} +`; + +/** + * gh wrapper — intercepts `gh pr create` and `gh pr merge` to auto-update + * session metadata. All other commands pass through transparently. + */ +const GH_WRAPPER = `#!/usr/bin/env bash +# ao gh wrapper — auto-updates session metadata on PR operations + +# Find real gh by removing our wrapper directory from PATH +ao_bin_dir="\$(cd "\$(dirname "\$0")" && pwd)" +clean_path="\$(echo "\$PATH" | tr ':' '\\n' | grep -Fxv "\$ao_bin_dir" | grep . | tr '\\n' ':')" +clean_path="\${clean_path%:}" +real_gh="\$(PATH="\$clean_path" command -v gh 2>/dev/null)" + +if [[ -z "\$real_gh" ]]; then + echo "ao-wrapper: gh not found in PATH" >&2 + exit 127 +fi + +# Source the metadata helper +source "\$ao_bin_dir/ao-metadata-helper.sh" 2>/dev/null || true + +# Only capture output for commands we need to parse (pr/create, pr/merge). +# All other commands pass through transparently without stream merging. +case "\$1/\$2" in + pr/create|pr/merge) + tmpout="\$(mktemp)" + trap 'rm -f "\$tmpout"' EXIT + + "\$real_gh" "\$@" 2>&1 | tee "\$tmpout" + exit_code=\${PIPESTATUS[0]} + + if [[ \$exit_code -eq 0 ]]; then + output="\$(cat "\$tmpout")" + case "\$1/\$2" in + pr/create) + pr_url="\$(echo "\$output" | grep -Eo 'https://github\\.com/[^/]+/[^/]+/pull/[0-9]+' | head -1)" + if [[ -n "\$pr_url" ]]; then + update_ao_metadata pr "\$pr_url" + update_ao_metadata status pr_open + fi + ;; + pr/merge) + update_ao_metadata status merged + ;; + esac + fi + + exit \$exit_code + ;; + *) + exec "\$real_gh" "\$@" + ;; +esac +`; + +/** + * git wrapper — intercepts branch creation commands to auto-update metadata. + * All other commands pass through transparently. + */ +const GIT_WRAPPER = `#!/usr/bin/env bash +# ao git wrapper — auto-updates session metadata on branch operations + +# Find real git by removing our wrapper directory from PATH +ao_bin_dir="\$(cd "\$(dirname "\$0")" && pwd)" +clean_path="\$(echo "\$PATH" | tr ':' '\\n' | grep -Fxv "\$ao_bin_dir" | grep . | tr '\\n' ':')" +clean_path="\${clean_path%:}" +real_git="\$(PATH="\$clean_path" command -v git 2>/dev/null)" + +if [[ -z "\$real_git" ]]; then + echo "ao-wrapper: git not found in PATH" >&2 + exit 127 +fi + +# Source the metadata helper +source "\$ao_bin_dir/ao-metadata-helper.sh" 2>/dev/null || true + +# Run real git +"\$real_git" "\$@" +exit_code=\$? + +# Only update metadata on success +if [[ \$exit_code -eq 0 ]]; then + case "\$1/\$2" in + checkout/-b) + update_ao_metadata branch "\$3" + ;; + switch/-c) + update_ao_metadata branch "\$3" + ;; + esac +fi + +exit \$exit_code +`; + +// ============================================================================= +// Workspace Setup +// ============================================================================= + +/** + * Section appended to AGENTS.md as a secondary signal. The PATH-based wrappers + * handle metadata updates automatically, but AGENTS.md reinforces the intent + * and helps if the wrappers are bypassed. + */ +const AO_AGENTS_MD_SECTION = ` +## Agent Orchestrator (ao) Session + +You are running inside an Agent Orchestrator managed workspace. +Session metadata is updated automatically via shell wrappers. + +If automatic updates fail, you can manually update metadata: +\`\`\`bash +~/.ao/bin/ao-metadata-helper.sh # sourced automatically +# Then call: update_ao_metadata +\`\`\` +`; + +/** + * Atomically write a file by writing to a temp file in the same directory, + * then renaming. This prevents concurrent sessions from reading partially + * written wrapper scripts. + */ +async function atomicWriteFile(filePath: string, content: string, mode: number): Promise { + const suffix = randomBytes(6).toString("hex"); + const tmpPath = `${filePath}.tmp.${suffix}`; + await writeFile(tmpPath, content, { encoding: "utf-8", mode }); + await rename(tmpPath, filePath); +} + +async function setupCodexWorkspace(workspacePath: string): Promise { + // 1. Write shared wrappers to ~/.ao/bin/ + await mkdir(AO_BIN_DIR, { recursive: true }); + + await atomicWriteFile( + join(AO_BIN_DIR, "ao-metadata-helper.sh"), + AO_METADATA_HELPER, + 0o755, + ); + + // Only write wrappers if they don't exist or are outdated (check marker) + const markerPath = join(AO_BIN_DIR, ".ao-version"); + const currentVersion = "0.1.0"; + let needsUpdate = true; + try { + const existing = await readFile(markerPath, "utf-8"); + if (existing.trim() === currentVersion) needsUpdate = false; + } catch { + // File doesn't exist — needs update + } + + if (needsUpdate) { + // Write wrappers atomically, then write the version marker last. + // If we crash between wrapper writes and marker write, the next + // invocation will redo the writes (safe: wrappers are idempotent). + await atomicWriteFile(join(AO_BIN_DIR, "gh"), GH_WRAPPER, 0o755); + await atomicWriteFile(join(AO_BIN_DIR, "git"), GIT_WRAPPER, 0o755); + await atomicWriteFile(markerPath, currentVersion, 0o644); + } + + // 2. Append ao section to AGENTS.md (create if missing, skip if already present) + const agentsMdPath = join(workspacePath, "AGENTS.md"); + let existing = ""; + try { + existing = await readFile(agentsMdPath, "utf-8"); + } catch { + // File doesn't exist yet + } + + if (!existing.includes("Agent Orchestrator (ao) Session")) { + const content = existing + ? existing.trimEnd() + "\n" + AO_AGENTS_MD_SECTION + : AO_AGENTS_MD_SECTION.trimStart(); + await writeFile(agentsMdPath, content, "utf-8"); + } +} + // ============================================================================= // Agent Implementation // ============================================================================= @@ -38,7 +281,7 @@ function createCodexAgent(): Agent { const parts: string[] = ["codex"]; if (config.permissions === "skip") { - parts.push("--approval-mode", "full-auto"); + parts.push("--dangerously-bypass-approvals-and-sandbox"); } if (config.model) { @@ -67,21 +310,43 @@ function createCodexAgent(): Agent { if (config.issueId) { env["AO_ISSUE_ID"] = config.issueId; } + + // Prepend ~/.ao/bin to PATH so our gh/git wrappers intercept commands. + // The wrappers strip this directory from PATH before calling the real + // binary, so there's no infinite recursion. + env["PATH"] = `${AO_BIN_DIR}:${process.env["PATH"] ?? "/usr/bin:/bin"}`; + return env; }, detectActivity(terminalOutput: string): ActivityState { if (!terminalOutput.trim()) return "idle"; - // Codex doesn't have rich terminal output patterns yet + + const lines = terminalOutput.trim().split("\n"); + const lastLine = lines[lines.length - 1]?.trim() ?? ""; + + // If Codex is showing its input prompt, it's idle + if (/^[>$#]\s*$/.test(lastLine)) return "idle"; + + // Check last few lines for approval prompts + const tail = lines.slice(-5).join("\n"); + if (/approval required/i.test(tail)) return "waiting_input"; + if (/\(y\)es.*\(n\)o/i.test(tail)) return "waiting_input"; + + // "(esc to interrupt)" appears while Codex is actively running a task + if (/\(esc to interrupt\)/i.test(terminalOutput)) return "active"; + + // Progress spinner symbols + words ending in "ing" = actively working + if (/[✶⏺✽⏳]/.test(terminalOutput) && /\w+ing\b/.test(terminalOutput)) return "active"; + return "active"; }, async getActivityState(session: Session, _readyThresholdMs?: number): Promise { // Check if process is running first - const exitedAt = new Date(); - if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt }; + if (!session.runtimeHandle) return { state: "exited" }; const running = await this.isProcessRunning(session.runtimeHandle); - if (!running) return { state: "exited", timestamp: exitedAt }; + if (!running) return { state: "exited" }; // NOTE: Codex stores rollout files in a global ~/.codex/sessions/ directory // without workspace-specific scoping. When multiple Codex sessions run in @@ -148,6 +413,15 @@ function createCodexAgent(): Agent { // Codex doesn't have JSONL session files for introspection yet return null; }, + + async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise { + await setupCodexWorkspace(workspacePath); + }, + + async postLaunchSetup(session: Session): Promise { + if (!session.workspacePath) return; + await setupCodexWorkspace(session.workspacePath); + }, }; } From 71ee3a21441847193d79b72088df1d5d80f3ce93 Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Mon, 23 Feb 2026 23:23:54 +0530 Subject: [PATCH 2/8] feat(cli): add --agent flag to override agent plugin at spawn time Allows starting sessions with a different agent without changing config: ao spawn ao --agent codex ao spawn ao --agent codex INT-1234 Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/spawn.ts | 7 +++++-- packages/core/src/session-manager.ts | 10 ++++++++++ packages/core/src/types.ts | 2 ++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index 52fdfed0d..f912450b5 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -11,6 +11,7 @@ async function spawnSession( projectId: string, issueId?: string, openTab?: boolean, + agent?: string, ): Promise { const spinner = ora("Creating session").start(); @@ -21,6 +22,7 @@ async function spawnSession( const session = await sm.spawn({ projectId, issueId, + agent, }); spinner.succeed(`Session ${chalk.green(session.id)} created`); @@ -58,7 +60,8 @@ export function registerSpawn(program: Command): void { .argument("", "Project ID from config") .argument("[issue]", "Issue identifier (e.g. INT-1234, #42) - must exist in tracker") .option("--open", "Open session in terminal tab") - .action(async (projectId: string, issueId: string | undefined, opts: { open?: boolean }) => { + .option("--agent ", "Override the agent plugin (e.g. codex, claude-code)") + .action(async (projectId: string, issueId: string | undefined, opts: { open?: boolean; agent?: string }) => { const config = loadConfig(); if (!config.projects[projectId]) { console.error( @@ -70,7 +73,7 @@ export function registerSpawn(program: Command): void { } try { - await spawnSession(config, projectId, issueId, opts.open); + await spawnSession(config, projectId, issueId, opts.open, opts.agent); } catch (err) { console.error(chalk.red(`✗ ${err}`)); process.exit(1); diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index c58a286cd..a86fa4837 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -322,6 +322,16 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { if (!plugins.runtime) { throw new Error(`Runtime plugin '${project.runtime ?? config.defaults.runtime}' not found`); } + + // Allow --agent override to swap the agent plugin for this session + if (spawnConfig.agent) { + const overrideAgent = registry.get("agent", spawnConfig.agent); + if (!overrideAgent) { + throw new Error(`Agent plugin '${spawnConfig.agent}' not found`); + } + plugins.agent = overrideAgent; + } + if (!plugins.agent) { throw new Error(`Agent plugin '${project.agent ?? config.defaults.agent}' not found`); } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6de795c8f..506ed96a8 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -176,6 +176,8 @@ export interface SessionSpawnConfig { issueId?: string; branch?: string; prompt?: string; + /** Override the agent plugin for this session (e.g. "codex", "claude-code") */ + agent?: string; } /** Config for creating an orchestrator session */ From b0965693c2c948461575ea967110a0140d86be42 Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Tue, 24 Feb 2026 00:32:51 +0530 Subject: [PATCH 3/8] fix(agent-codex): suppress no-useless-escape lint for bash template literals The \$ escapes in bash script template literals are intentional to prevent JS template interpolation. Added eslint-disable/enable block. Co-Authored-By: Claude Opus 4.6 --- packages/plugins/agent-codex/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index 8e02a94fe..37db61a88 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -41,6 +41,7 @@ export const manifest = { * Helper script sourced by both gh and git wrappers. * Provides update_ao_metadata() for writing key=value to the session file. */ +/* eslint-disable no-useless-escape -- \$ escapes are intentional: bash scripts in JS template literals */ const AO_METADATA_HELPER = `#!/usr/bin/env bash # ao-metadata-helper — shared by gh/git wrappers # Provides: update_ao_metadata @@ -208,6 +209,7 @@ If automatic updates fail, you can manually update metadata: # Then call: update_ao_metadata \`\`\` `; +/* eslint-enable no-useless-escape */ /** * Atomically write a file by writing to a temp file in the same directory, From 2bdc6bb59c25cbce90a84f6c540c45126ad87afa Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Tue, 24 Feb 2026 03:26:54 +0530 Subject: [PATCH 4/8] test: add tests for --agent override and codex plugin registration - Session manager: agent override uses correct agent, unknown agent throws, default used without override - CLI spawn: --agent flag passthrough with and without issue ID - Plugin registry: multiple agent plugins registered via loadBuiltins importFn Co-Authored-By: Claude Opus 4.6 --- packages/cli/__tests__/commands/spawn.test.ts | 56 ++++++++++++++++++ .../src/__tests__/plugin-registry.test.ts | 20 +++++++ .../src/__tests__/session-manager.test.ts | 58 +++++++++++++++++++ 3 files changed, 134 insertions(+) diff --git a/packages/cli/__tests__/commands/spawn.test.ts b/packages/cli/__tests__/commands/spawn.test.ts index 594f2de86..a422bb06c 100644 --- a/packages/cli/__tests__/commands/spawn.test.ts +++ b/packages/cli/__tests__/commands/spawn.test.ts @@ -234,6 +234,62 @@ describe("spawn command", () => { expect(output).toContain("8474d6f29887-app-7"); }); + it("passes --agent flag to sessionManager.spawn()", async () => { + const fakeSession: Session = { + id: "app-1", + projectId: "my-app", + status: "spawning", + activity: null, + branch: null, + issueId: null, + pr: null, + workspacePath: "/tmp/wt", + runtimeHandle: { id: "hash-app-1", runtimeName: "tmux", data: {} }, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + }; + + mockSessionManager.spawn.mockResolvedValue(fakeSession); + + await program.parseAsync(["node", "test", "spawn", "my-app", "--agent", "codex"]); + + expect(mockSessionManager.spawn).toHaveBeenCalledWith({ + projectId: "my-app", + issueId: undefined, + agent: "codex", + }); + }); + + it("passes --agent flag with issue ID", async () => { + const fakeSession: Session = { + id: "app-1", + projectId: "my-app", + status: "spawning", + activity: null, + branch: "feat/INT-42", + issueId: "INT-42", + pr: null, + workspacePath: "/tmp/wt", + runtimeHandle: { id: "hash-app-1", runtimeName: "tmux", data: {} }, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + }; + + mockSessionManager.spawn.mockResolvedValue(fakeSession); + + await program.parseAsync(["node", "test", "spawn", "my-app", "INT-42", "--agent", "codex"]); + + expect(mockSessionManager.spawn).toHaveBeenCalledWith({ + projectId: "my-app", + issueId: "INT-42", + agent: "codex", + }); + }); + it("rejects unknown project ID", async () => { await expect( program.parseAsync(["node", "test", "spawn", "nonexistent"]), diff --git a/packages/core/src/__tests__/plugin-registry.test.ts b/packages/core/src/__tests__/plugin-registry.test.ts index 0fc85c619..e0132c4eb 100644 --- a/packages/core/src/__tests__/plugin-registry.test.ts +++ b/packages/core/src/__tests__/plugin-registry.test.ts @@ -140,6 +140,26 @@ describe("loadBuiltins", () => { // In the test environment, most are not resolvable — should not throw. await expect(registry.loadBuiltins()).resolves.toBeUndefined(); }); + + it("registers multiple agent plugins from importFn", async () => { + const registry = createPluginRegistry(); + + const fakeClaudeCode = makePlugin("agent", "claude-code"); + const fakeCodex = makePlugin("agent", "codex"); + + await registry.loadBuiltins(undefined, async (pkg: string) => { + if (pkg === "@composio/ao-plugin-agent-claude-code") return fakeClaudeCode; + if (pkg === "@composio/ao-plugin-agent-codex") return fakeCodex; + throw new Error(`Not found: ${pkg}`); + }); + + const agents = registry.list("agent"); + expect(agents).toContainEqual(expect.objectContaining({ name: "claude-code", slot: "agent" })); + expect(agents).toContainEqual(expect.objectContaining({ name: "codex", slot: "agent" })); + + expect(registry.get("agent", "codex")).not.toBeNull(); + expect(registry.get("agent", "claude-code")).not.toBeNull(); + }); }); describe("extractPluginConfig (via register with config)", () => { diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index 96d56edff..424404bcf 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -227,6 +227,64 @@ describe("spawn", () => { await expect(sm.spawn({ projectId: "my-app" })).rejects.toThrow("not found"); }); + describe("agent override", () => { + let mockCodexAgent: Agent; + let registryWithMultipleAgents: PluginRegistry; + + beforeEach(() => { + mockCodexAgent = { + name: "codex", + processName: "codex", + getLaunchCommand: vi.fn().mockReturnValue("codex --start"), + getEnvironment: vi.fn().mockReturnValue({ CODEX_VAR: "1" }), + detectActivity: vi.fn().mockReturnValue("active"), + getActivityState: vi.fn().mockResolvedValue(null), + isProcessRunning: vi.fn().mockResolvedValue(true), + getSessionInfo: vi.fn().mockResolvedValue(null), + }; + + registryWithMultipleAgents = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string, name: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") { + if (name === "mock-agent") return mockAgent; + if (name === "codex") return mockCodexAgent; + return null; + } + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + }); + + it("uses overridden agent when spawnConfig.agent is provided", async () => { + const sm = createSessionManager({ config, registry: registryWithMultipleAgents }); + + await sm.spawn({ projectId: "my-app", agent: "codex" }); + + expect(mockCodexAgent.getLaunchCommand).toHaveBeenCalled(); + expect(mockAgent.getLaunchCommand).not.toHaveBeenCalled(); + }); + + it("throws when agent override plugin is not found", async () => { + const sm = createSessionManager({ config, registry: registryWithMultipleAgents }); + + await expect( + sm.spawn({ projectId: "my-app", agent: "nonexistent" }), + ).rejects.toThrow("Agent plugin 'nonexistent' not found"); + }); + + it("uses default agent when no override specified", async () => { + const sm = createSessionManager({ config, registry: registryWithMultipleAgents }); + + await sm.spawn({ projectId: "my-app" }); + + expect(mockAgent.getLaunchCommand).toHaveBeenCalled(); + expect(mockCodexAgent.getLaunchCommand).not.toHaveBeenCalled(); + }); + }); + it("validates issue exists when issueId provided", async () => { const mockTracker: Tracker = { name: "mock-tracker", From 46b0d1a541c0f768259252185ad970b6879ee506 Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Tue, 24 Feb 2026 03:47:43 +0530 Subject: [PATCH 5/8] fix: persist agent name in metadata so lifecycle resolves correct plugin When --agent override is used at spawn time, the agent name was not stored in session metadata. The lifecycle manager and session enrichment always resolved the agent from project config defaults, causing mismatched process detection (e.g. looking for "claude" instead of "codex") and incorrect "killed" status on the first poll cycle. - Add `agent` field to SessionMetadata type and writeMetadata - Store `plugins.agent.name` in metadata during spawn - Pass stored agent name to resolvePlugins in list/get/restore paths - Read `session.metadata["agent"]` in lifecycle manager's determineStatus - Add tests for metadata persistence with and without override Co-Authored-By: Claude Opus 4.6 --- .../src/__tests__/session-manager.test.ts | 20 +++++++++++++++++++ packages/core/src/lifecycle-manager.ts | 3 ++- packages/core/src/metadata.ts | 1 + packages/core/src/session-manager.ts | 11 +++++----- packages/core/src/types.ts | 1 + 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index 424404bcf..ae2d3c466 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -283,6 +283,26 @@ describe("spawn", () => { expect(mockAgent.getLaunchCommand).toHaveBeenCalled(); expect(mockCodexAgent.getLaunchCommand).not.toHaveBeenCalled(); }); + + it("persists agent name in metadata when override is used", async () => { + const sm = createSessionManager({ config, registry: registryWithMultipleAgents }); + + await sm.spawn({ projectId: "my-app", agent: "codex" }); + + const meta = readMetadataRaw(sessionsDir, "app-1"); + expect(meta).not.toBeNull(); + expect(meta!["agent"]).toBe("codex"); + }); + + it("persists default agent name in metadata when no override", async () => { + const sm = createSessionManager({ config, registry: registryWithMultipleAgents }); + + await sm.spawn({ projectId: "my-app" }); + + const meta = readMetadataRaw(sessionsDir, "app-1"); + expect(meta).not.toBeNull(); + expect(meta!["agent"]).toBe("mock-agent"); + }); }); it("validates issue exists when issueId provided", async () => { diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index c4005c8f1..d35d75303 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -183,7 +183,8 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan const project = config.projects[session.projectId]; if (!project) return session.status; - const agent = registry.get("agent", project.agent ?? config.defaults.agent); + const agentName = session.metadata["agent"] ?? project.agent ?? config.defaults.agent; + const agent = registry.get("agent", agentName); const scm = project.scm ? registry.get("scm", project.scm.plugin) : null; // 1. Check if runtime is alive diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index 67e5242f1..71fd263ec 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -139,6 +139,7 @@ export function writeMetadata( if (metadata.pr) data["pr"] = metadata.pr; if (metadata.summary) data["summary"] = metadata.summary; if (metadata.project) data["project"] = metadata.project; + if (metadata.agent) data["agent"] = metadata.agent; if (metadata.createdAt) data["createdAt"] = metadata.createdAt; if (metadata.runtimeHandle) data["runtimeHandle"] = metadata.runtimeHandle; if (metadata.dashboardPort !== undefined) diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index a86fa4837..9ccc2c007 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -210,9 +210,9 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { } /** Resolve which plugins to use for a project. */ - function resolvePlugins(project: ProjectConfig) { + function resolvePlugins(project: ProjectConfig, agentOverride?: string) { const runtime = registry.get("runtime", project.runtime ?? config.defaults.runtime); - const agent = registry.get("agent", project.agent ?? config.defaults.agent); + const agent = registry.get("agent", agentOverride ?? project.agent ?? config.defaults.agent); const workspace = registry.get( "workspace", project.workspace ?? config.defaults.workspace, @@ -525,6 +525,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { tmuxName, // Store tmux name for mapping issue: spawnConfig.issueId, project: spawnConfig.projectId, + agent: plugins.agent.name, // Persist agent name for lifecycle manager createdAt: new Date().toISOString(), runtimeHandle: JSON.stringify(handle), }); @@ -703,7 +704,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { const session = metadataToSession(sessionName, raw, createdAt, modifiedAt); - const plugins = resolvePlugins(project); + const plugins = resolvePlugins(project, raw["agent"]); // Cap per-session enrichment at 2s — subprocess calls (tmux/ps) can be // slow under load. If we time out, session keeps its metadata values. const enrichTimeout = new Promise((resolve) => setTimeout(resolve, 2_000)); @@ -737,7 +738,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { const session = metadataToSession(sessionId, raw, createdAt, modifiedAt); - const plugins = resolvePlugins(project); + const plugins = resolvePlugins(project, raw["agent"]); await ensureHandleAndEnrich(session, sessionId, project, plugins); return session; @@ -977,7 +978,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { // session (status "working", agent exited) would not be detected as terminal // and isRestorable would reject it. const session = metadataToSession(sessionId, raw); - const plugins = resolvePlugins(project); + const plugins = resolvePlugins(project, raw["agent"]); await enrichSessionWithRuntimeState(session, plugins, true); // 3. Validate restorability diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 506ed96a8..7860db3a0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -965,6 +965,7 @@ export interface SessionMetadata { pr?: string; summary?: string; project?: string; + agent?: string; // Agent plugin name (e.g. "codex", "claude-code") — persisted for lifecycle createdAt?: string; runtimeHandle?: string; restoredAt?: string; From 89fc3369d5e72924e0520b5dcc06cd43032f9ae3 Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Tue, 24 Feb 2026 03:54:52 +0530 Subject: [PATCH 6/8] fix: add agent field to readMetadata return mapping readMetadata() was not returning the `agent` field even though writeMetadata() persisted it. This meant typed consumers got undefined for session.agent while raw readers worked fine. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/__tests__/session-manager.test.ts | 10 ++++++++++ packages/core/src/metadata.ts | 1 + 2 files changed, 11 insertions(+) diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index ae2d3c466..5722ebaaa 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -303,6 +303,16 @@ describe("spawn", () => { expect(meta).not.toBeNull(); expect(meta!["agent"]).toBe("mock-agent"); }); + + it("readMetadata returns agent field (typed SessionMetadata)", async () => { + const sm = createSessionManager({ config, registry: registryWithMultipleAgents }); + + await sm.spawn({ projectId: "my-app", agent: "codex" }); + + const meta = readMetadata(sessionsDir, "app-1"); + expect(meta).not.toBeNull(); + expect(meta!.agent).toBe("codex"); + }); }); it("validates issue exists when issueId provided", async () => { diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index 71fd263ec..0e0fb1db5 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -97,6 +97,7 @@ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMeta pr: raw["pr"], summary: raw["summary"], project: raw["project"], + agent: raw["agent"], createdAt: raw["createdAt"], runtimeHandle: raw["runtimeHandle"], dashboardPort: raw["dashboardPort"] ? Number(raw["dashboardPort"]) : undefined, From 584ca1daa2db1db4e10e483ca1c68c9063e0b14c Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Tue, 24 Feb 2026 08:25:42 +0530 Subject: [PATCH 7/8] fix(agent-codex): use correct Codex CLI flags and remove dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace --dangerously-bypass-approvals-and-sandbox with --full-auto (keeps workspace sandbox, auto-approves — safer default for automation) - Replace nonexistent --system-prompt flag with Codex config overrides: -c developer_instructions=... for inline prompts -c model_instructions_file=... for file-based prompts - Remove redundant "active" pattern checks that duplicate the default return value (addresses BugBot dead-code finding) Co-Authored-By: Claude Opus 4.6 --- packages/plugins/agent-codex/src/index.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index 37db61a88..916d0f293 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -283,7 +283,7 @@ function createCodexAgent(): Agent { const parts: string[] = ["codex"]; if (config.permissions === "skip") { - parts.push("--dangerously-bypass-approvals-and-sandbox"); + parts.push("--full-auto"); } if (config.model) { @@ -291,9 +291,11 @@ function createCodexAgent(): Agent { } if (config.systemPromptFile) { - parts.push("--system-prompt", `"$(cat ${shellEscape(config.systemPromptFile)})"`); + // Codex reads developer instructions from a file via config override + parts.push("-c", `model_instructions_file=${shellEscape(config.systemPromptFile)}`); } else if (config.systemPrompt) { - parts.push("--system-prompt", shellEscape(config.systemPrompt)); + // Codex accepts inline developer instructions via config override + parts.push("-c", `developer_instructions=${shellEscape(config.systemPrompt)}`); } if (config.prompt) { @@ -335,12 +337,8 @@ function createCodexAgent(): Agent { if (/approval required/i.test(tail)) return "waiting_input"; if (/\(y\)es.*\(n\)o/i.test(tail)) return "waiting_input"; - // "(esc to interrupt)" appears while Codex is actively running a task - if (/\(esc to interrupt\)/i.test(terminalOutput)) return "active"; - - // Progress spinner symbols + words ending in "ing" = actively working - if (/[✶⏺✽⏳]/.test(terminalOutput) && /\w+ing\b/.test(terminalOutput)) return "active"; - + // Default to active — specific patterns (esc to interrupt, spinner + // symbols) all map to "active" so no need to check them individually. return "active"; }, From 0a5fea19e3692c3b76a2416a2245248494af2da7 Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Tue, 24 Feb 2026 08:56:03 +0530 Subject: [PATCH 8/8] test(agent-codex): update tests for correct Codex CLI flags Update test expectations to match the corrected Codex CLI flags: - --full-auto instead of --dangerously-bypass-approvals-and-sandbox - -c developer_instructions=... instead of --system-prompt (inline) - -c model_instructions_file=... instead of --system-prompt $(cat) (file) Co-Authored-By: Claude Opus 4.6 --- .../plugins/agent-codex/src/index.test.ts | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/plugins/agent-codex/src/index.test.ts b/packages/plugins/agent-codex/src/index.test.ts index 50c6c1ede..00dbeb119 100644 --- a/packages/plugins/agent-codex/src/index.test.ts +++ b/packages/plugins/agent-codex/src/index.test.ts @@ -148,9 +148,9 @@ describe("getLaunchCommand", () => { expect(agent.getLaunchCommand(makeLaunchConfig())).toBe("codex"); }); - it("includes --dangerously-bypass-approvals-and-sandbox when permissions=skip", () => { + it("includes --full-auto when permissions=skip", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "skip" })); - expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox"); + expect(cmd).toContain("--full-auto"); }); it("includes --model with shell-escaped value", () => { @@ -167,7 +167,7 @@ describe("getLaunchCommand", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ permissions: "skip", model: "o3", prompt: "Go" }), ); - expect(cmd).toBe("codex --dangerously-bypass-approvals-and-sandbox --model 'o3' -- 'Go'"); + expect(cmd).toBe("codex --full-auto --model 'o3' -- 'Go'"); }); it("escapes single quotes in prompt (POSIX shell escaping)", () => { @@ -183,30 +183,29 @@ describe("getLaunchCommand", () => { expect(cmd).toContain("-- '$(rm -rf /); `evil`; $HOME'"); }); - it("includes --system-prompt with file cat when systemPromptFile is set", () => { + it("includes -c model_instructions_file when systemPromptFile is set", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPromptFile: "/tmp/prompt.md" })); - expect(cmd).toContain("--system-prompt"); - expect(cmd).toContain("$(cat '/tmp/prompt.md')"); + expect(cmd).toContain("-c model_instructions_file='/tmp/prompt.md'"); }); it("prefers systemPromptFile over systemPrompt", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ systemPromptFile: "/tmp/prompt.md", systemPrompt: "Ignored" }), ); - expect(cmd).toContain("$(cat '/tmp/prompt.md')"); + expect(cmd).toContain("model_instructions_file='/tmp/prompt.md'"); expect(cmd).not.toContain("'Ignored'"); }); - it("includes --system-prompt with inline text when systemPrompt is set", () => { + it("includes -c developer_instructions when systemPrompt is set", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPrompt: "Be helpful" })); - expect(cmd).toContain("--system-prompt 'Be helpful'"); + expect(cmd).toContain("-c developer_instructions='Be helpful'"); }); it("omits optional flags when not provided", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig()); - expect(cmd).not.toContain("--dangerously-bypass-approvals-and-sandbox"); + expect(cmd).not.toContain("--full-auto"); expect(cmd).not.toContain("--model"); - expect(cmd).not.toContain("--system-prompt"); + expect(cmd).not.toContain("-c"); }); });