Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .mcp.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"/ABSOLUTE/PATH/TO/codex-orchestrator/dist/src/mcp-server.js"
],
"env": {
"CODEX_ORCHESTRATOR_MODE": "mcp-server",
"CODEX_ORCHESTRATOR_MODE": "exec",
"CODEX_ORCHESTRATOR_MAX_RUNTIME_MS": "1800000"
}
}
Expand Down
57 changes: 37 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,10 @@ reports progress. See [The /massa command](#the-massa-command-for-claude-code).
# from a clean clone:
npm install # installs pinned deps
npm run build # compiles src/ + test/ to dist/
npm test # builds, then runs the full test suite (13 tests)
npm test # builds, then runs the full test suite
```

`npm test` output ends with `pass 13 / fail 0`.
`npm test` must finish with zero failures.

---

Expand Down Expand Up @@ -129,7 +129,7 @@ Start an autonomous loop; returns a `run_id` and runs in the background.
| `max_iterations` | int | default 10 |
| `verification_commands` | string[] | default `[]` |
| `sandbox` | enum | `read-only` \| `workspace-write` \| `danger-full-access`; default **`workspace-write`** |
| `approval_policy` | enum | `untrusted` \| `on-failure` \| `on-request` \| `never`; default **`never`** |
| `approval_policy` | enum | `on-request` \| `never`; default **`never`** |

### `codex_task`
Send one prompt to Codex; returns its output + thread/session id. Outgoing
Expand Down Expand Up @@ -187,7 +187,7 @@ Replace the absolute path. A copy is in [`.mcp.json.example`](.mcp.json.example)
"/ABSOLUTE/PATH/TO/codex-orchestrator/dist/src/mcp-server.js"
],
"env": {
"CODEX_ORCHESTRATOR_MODE": "mcp-server",
"CODEX_ORCHESTRATOR_MODE": "exec",
"CODEX_ORCHESTRATOR_MAX_RUNTIME_MS": "1800000"
}
}
Expand All @@ -199,7 +199,7 @@ Equivalent CLI registration:

```bash
claude mcp add codex-orchestrator \
-e CODEX_ORCHESTRATOR_MODE=mcp-server \
-e CODEX_ORCHESTRATOR_MODE=exec \
-e CODEX_ORCHESTRATOR_MAX_RUNTIME_MS=1800000 \
-- node /ABSOLUTE/PATH/TO/codex-orchestrator/dist/src/mcp-server.js
```
Expand Down Expand Up @@ -246,8 +246,8 @@ Selected via `CODEX_ORCHESTRATOR_MODE` (MCP server) or `--mode` / `--dry-run` (C
| Mode | Transport | Use |
|---|---|---|
| `mock` | none (scripted) | dry-run / tests; applies steps from `<project>/mock-plan.json` (or an inline plan) |
| `mcp-server` | `codex mcp-server` over stdio (MCP SDK) | **primary** real path |
| `exec` | `codex exec --json` per call | fallback when MCP transport is impractical |
| `exec` | `codex exec --json` per call | **default** real path for Codex CLI 0.155+ |
| `mcp-server` | `codex mcp-server` over stdio (MCP SDK) | explicit legacy mode for older CLIs that still provide this subcommand |

---

Expand Down Expand Up @@ -277,13 +277,17 @@ printenv OPENAI_API_KEY | codex login --with-api-key # API key via stdin

```bash
codex --help
codex mcp-server --help # primary transport
codex exec --help # fallback transport
codex exec --help # default worker transport
codex exec resume --help # continuation support
```

Only when explicitly selecting legacy `--mode mcp-server` on an older CLI,
check `codex mcp-server --help`. This subcommand was removed in Codex CLI 0.155;
it is separate from this package's own MCP server, which still exposes the five tools.

Config lives at `~/.codex/config.toml` (`CODEX_HOME` defaults to `~/.codex`).
The orchestrator passes `cwd`, `sandbox`, and `approval-policy` per call; you do
not need to pre-configure those.
The exec transport runs in the project directory and passes `sandbox_mode` and
`approval_policy` through per-call `-c` overrides; you do not need to pre-configure those.

---

Expand Down Expand Up @@ -438,8 +442,8 @@ Resulting `final-report.md` (iteration log):
```

**Running it for real (with Codex):** drop the `--dry-run` flag and ensure Codex
is installed/authenticated. The loop will use `codex mcp-server`, threading the
conversation across iterations via the returned `threadId`. To exercise the real
is installed/authenticated. The loop will use `codex exec --json`, threading the
conversation across iterations via `codex exec resume <thread_id>`. To exercise the real
`git diff` review on the sample, `git init` the target first:

```bash
Expand All @@ -453,27 +457,40 @@ node dist/src/cli.js run --project-dir ./examples/sample-project --verify "node

The Codex flag/tool surface drifts between versions. All version-dependent names
are centralized in [`config.ts`](src/config.ts) (`DEFAULT_CODEX_CLI`) so they can
be adjusted in one place. As built (verified against the current Codex CLI):
be adjusted in one place. The default exec transport targets Codex CLI 0.155+:

- **Launch MCP:** `codex mcp-server` (over stdio). *Not* `codex mcp serve`
- **Legacy MCP only:** `codex mcp-server` (over stdio), on older CLIs that still
provide it. *Not* `codex mcp serve`
(`codex mcp` is the separate "manage external servers" command).
- **MCP tools:** `codex` (start) and `codex-reply` (continue). The `codex` tool
- **Legacy MCP tools:** `codex` (start) and `codex-reply` (continue). The `codex` tool
takes **kebab-case** args (`prompt`, `cwd`, `sandbox`, `approval-policy`) and
**rejects unknown fields**; `codex-reply` takes **camelCase** `{ threadId, prompt }`.
- **Session id:** returned in `structuredContent.threadId`; reused as `threadId`.
- **exec fallback:** `codex exec --json "<prompt>"`; resume with
- **Legacy MCP session id:** returned in `structuredContent.threadId`; reused as `threadId`.
- **Default exec:** `codex exec --json -c 'sandbox_mode="workspace-write"' -c 'approval_policy="never"' "<prompt>"`; resume with
`codex exec resume <id>`. The JSONL stream is tagged by dotted `type`
(`thread.started` carries `thread_id`; the final answer is in an
`item.completed` whose `item.type === "agent_message"`, field `text`). A legacy
`EventMsg` fallback parser is included.
- **Sandbox values:** `read-only` | `workspace-write` | `danger-full-access`.
- **Approval values:** `untrusted` | `on-failure` | `on-request` | `never`.
- **Approval values:** `on-request` | `never`. Retired `untrusted` and
`on-failure` values are rejected before execution, including in legacy mode.
Codex's granular approval objects are not exposed by this package's string API.
- **Exec failures:** nonzero exits, signals, and timeouts fail the task even if
stdout contains a partial reply. CLI tasks exit nonzero with diagnostics; MCP
tasks return `status: "error"`. Loops retain the error as a gap and cannot
complete that iteration merely because existing verification checks pass.
- **Guarded:** `--dangerously-bypass-approvals-and-sandbox` / `--yolo` and
`--sandbox danger-full-access` are never emitted.

If a future Codex renames a tool or flag, edit `DEFAULT_CODEX_CLI` — no other
code changes are needed.

To check the supported string policies against a real CLI without credentials or
a model call, run `node scripts/check-codex-policies.mjs [path/to/codex]`.
It uses an empty temporary `CODEX_HOME`, a read-only sandbox, and an intentionally
missing output schema to stop accepted policies before task execution; retired
policies must fail CLI argument validation.

---

## Troubleshooting
Expand All @@ -484,7 +501,7 @@ code changes are needed.
server scans the *server's* cwd. Launch the MCP server from the project dir, or
use the CLI `status` command with `--project-dir`.
- **Real Codex run errors immediately** — run `codex login status` and
`codex mcp-server --help`; confirm the binary is on `PATH` (override with the
`codex exec --help`; confirm the binary is on `PATH` (override with the
`CODEX_BIN` env var).
- **Git diff shows "(not a git repository)"** — the diff-based guards/review need
a git repo; `git init` the target. The checklist + verification still work
Expand Down
36 changes: 36 additions & 0 deletions scripts/check-codex-policies.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Validate the real CLI argument parser, stopping before authentication or a model call.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const bin = process.argv[2] ?? process.env.CODEX_BIN ?? 'codex';
const home = mkdtempSync(join(tmpdir(), 'massa-policy-'));
try {
const env = { ...process.env, CODEX_HOME: home };
const version = spawnSync(bin, ['--version'], { env, encoding: 'utf8', timeout: 10000 });
assert.equal(version.status, 0, version.stderr || String(version.error));
console.log(version.stdout.trim());
for (const policy of ['never', 'on-request', 'untrusted', 'on-failure']) {
const result = spawnSync(bin, [
'--ask-for-approval', policy, 'exec', '--ignore-user-config', '--ephemeral',
'--sandbox', 'read-only', '--output-schema', join(home, 'missing.json'),
'--json', 'policy validation only',
], { env, cwd: home, input: '', encoding: 'utf8', timeout: 10000 });
assert.ifError(result.error);
if (policy === 'never' || policy === 'on-request') {
// The deliberately absent schema stops execution after argument parsing.
assert.equal(result.status, 1, result.stderr);
assert.match(result.stderr, /Failed to read output schema file/);
console.log(`${policy}: accepted; stopped at missing schema before task execution`);
} else {
assert.equal(result.status, 2, result.stderr);
assert.match(result.stderr, /invalid value/);
assert.match(result.stderr, /possible values: on-request, never/);
console.log(`${policy}: rejected by CLI policy parser`);
}
}
} finally {
rmSync(home, { recursive: true, force: true });
}
14 changes: 9 additions & 5 deletions src/codex-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
*
* MockCodexClient — no network, deterministic; applies a scripted plan of
* file writes/deletes so the full loop can be proven.
* McpCodexClient — PRIMARY path: drives `codex mcp-server` over stdio using
* McpCodexClient — LEGACY path: drives `codex mcp-server` over stdio using
* the official MCP SDK client.
* ExecCodexClient — FALLBACK only: shells out to `codex exec --json` and
* ExecCodexClient — DEFAULT: shells out to `codex exec --json` and
* parses the JSONL event stream.
*
* All version-dependent Codex names (subcommands, tool names, flags) come from
Expand All @@ -20,6 +20,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import {
DEFAULT_CODEX_CLI,
validateSafetyConfig,
type CodexCliConfig,
} from './config.js';
import { runCommand } from './verifier.js';
Expand Down Expand Up @@ -256,7 +257,7 @@ export class McpCodexClient implements CodexClient {
}

// =========================================================================
// exec --json (fallback)
// exec --json (default)
// =========================================================================

/**
Expand Down Expand Up @@ -332,6 +333,7 @@ export class ExecCodexClient implements CodexClient {

async runTask(input: CodexTaskInput): Promise<CodexTaskResult> {
const cwd = path.resolve(input.projectDir);
validateSafetyConfig(input.sandbox, input.approvalPolicy);
// First turn: codex exec --json -c sandbox_mode=... -c approval_policy=... "<prompt>"
// Continuation: codex exec resume <id> --json -c ... "<prompt>"
// `resume` MUST come immediately after `exec`. Sandbox and approval go in as
Expand All @@ -351,8 +353,10 @@ export class ExecCodexClient implements CodexClient {
const command = parts.join(' ');

const res = await runCommand(command, cwd, input.timeoutMs);
if (res.error && !res.stdout) {
throw new Error(`codex exec failed: ${res.error}`);
if (res.error || res.exitCode !== 0) {
const reason = res.error ?? `exit code ${res.exitCode ?? 'unknown'}`;
const diagnostics = (res.stderr || res.stdout).trim();
throw new Error(`codex exec failed: ${reason}${diagnostics ? `\n${diagnostics}` : ''}`);
}
const { text, threadId } = parseExecJsonl(res.stdout);
return {
Expand Down
9 changes: 7 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export interface CodexCliConfig {
mcpStartTool: string;
/** Tool name to CONTINUE a Codex conversation. */
mcpReplyTool: string;
/** Subcommand for one-shot non-interactive runs (exec fallback). */
/** Subcommand for one-shot non-interactive runs (default exec transport). */
execArgs: string[];
/** Flag that switches `codex exec` to JSONL event output. */
execJsonFlag: string;
Expand Down Expand Up @@ -88,11 +88,16 @@ export function validateSafetyConfig(
sandbox: SandboxMode,
approvalPolicy: ApprovalPolicy,
): void {
if (approvalPolicy !== 'on-request' && approvalPolicy !== 'never') {
throw new ConfigError(
`Unsupported approval_policy="${approvalPolicy}"; use "on-request" or "never" (Codex CLI 0.155+).`,
);
}
if (sandbox === DANGER_SANDBOX && approvalPolicy === 'never') {
throw new ConfigError(
`Refusing to run with sandbox="${DANGER_SANDBOX}" and approval_policy="never": ` +
`full filesystem access unattended is not allowed. Use sandbox="workspace-write" ` +
`or set approval_policy to "on-request"/"untrusted" and supervise the run.`,
`or set approval_policy to "on-request" and supervise the run.`,
);
}
}
5 changes: 5 additions & 0 deletions src/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,11 @@ export class LoopController {
verification,
diff,
);
// Existing passing checks cannot turn a failed worker call into completion.
if (taskError) {
reviewResult.complete = false;
reviewResult.remainingGaps.push(`Codex task failed: ${taskError}`);
}
this.state.checklist = checklist;
await this.store.writeChecklist(this.state.goalFile, checklist);
await this.store.appendEvent('review', iteration, 'review complete', reviewResult);
Expand Down
2 changes: 1 addition & 1 deletion src/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export function buildServer(): McpServer {
.enum(['read-only', 'workspace-write', 'danger-full-access'])
.default(DEFAULT_SANDBOX),
approval_policy: z
.enum(['untrusted', 'on-failure', 'on-request', 'never'])
.enum(['on-request', 'never'])
.default(DEFAULT_APPROVAL_POLICY),
},
},
Expand Down
4 changes: 2 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
/** Codex sandbox modes (exact strings accepted by the Codex CLI `--sandbox`). */
export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access';

/** Codex approval policies (exact strings accepted by `--ask-for-approval`). */
export type ApprovalPolicy = 'untrusted' | 'on-failure' | 'on-request' | 'never';
/** Supported string approval policies for Codex CLI 0.155+ and legacy MCP. */
export type ApprovalPolicy = 'on-request' | 'never';

/** Which transport the Codex client is using. */
export type CodexMode = 'mock' | 'mcp-server' | 'exec';
Expand Down
Loading
Loading