feat(agent-codex): mature Codex plugin to match Claude Code support

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 <branch>` 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 <noreply@anthropic.com>
This commit is contained in:
suraj-markup 2026-02-23 19:46:37 +05:30
parent 3c160881e5
commit e410079026
3 changed files with 954 additions and 21 deletions

View File

@ -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=<url> 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 ||

View File

@ -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> = {}): Session {
return {
@ -62,8 +93,10 @@ function makeLaunchConfig(overrides: Partial<AgentLaunchConfig> = {}): 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<string> {
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");
});
});
});

View File

@ -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 <key> <value>
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 <key> <value>
\`\`\`
`;
/**
* 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<void> {
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<void> {
// 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<ActivityDetection | null> {
// 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<void> {
await setupCodexWorkspace(workspacePath);
},
async postLaunchSetup(session: Session): Promise<void> {
if (!session.workspacePath) return;
await setupCodexWorkspace(session.workspacePath);
},
};
}