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); + }, }; }