diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3a7e4cb..63ece79 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ceauto", - "version": "0.16.0", + "version": "0.17.0", "description": "Autonomous CEO Agent — multi-agent orchestration, episodic memory, executive decision frameworks", "author": "CEAuto", "mcpServer": { diff --git a/CHANGELOG.md b/CHANGELOG.md index 64f03e7..9131027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## [0.17.0] - 2026-07-20 +### Added +- **`codex` executor** — run a task through the OpenAI Codex CLI non-interactively + (`codex exec`, prompt on stdin), the sibling of `claude-code`. Default **OFF** + (`executors.codex.enabled`), config-bound command/args/cwd, and behind the same + budget / approval / veto / `comms/STOP` gates as every other runtime. Sandbox + policy is passed through `args` (e.g. `--sandbox workspace-write`); no bypass flag + is ever injected. +- **Run with no API key.** Both CLI runtimes (`claude -p`, `codex exec`) bill your + Claude / ChatGPT **subscription** instead of per-token API credits, so an + autonomous loop can do real work on a plan you already pay for. They cost $0 in the + ledger — keyed on the **provider** (a constant inside each executor), never on the + binary name, so renaming a binary can't make a real API-backed model free. Their + **tokens are still metered** against the token caps. +- Both CLI executors now **strip `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` from the child + environment**. The daemon usually has those set for the `llm` executor, and an + inherited key could silently flip the CLI to per-token API billing — spend the $0 + ledger price would then hide from the global USD cap. Scrubbing makes + "subscription-billed" true by construction rather than assumed. `passApiKeys: true` + opts back in deliberately (then set real prices for the model it reports). + +### Verified +- Standalone operation end-to-end with **no MCP client and no API key**: the daemon + (`node bin/ceauto-daemon.js --once`) drained a seeded task to `done` in an isolated + `CEAUTO_WORKSPACE`, and the offline demo completed the full boot → delegate → cycle + → metrics walkthrough. +- Live subscription-backed run: the runner completed a real task through `codex exec` + (ChatGPT login, no `OPENAI_API_KEY`), recording `provider=codex` with `usd=0`. + ## [0.16.0] - 2026-07-16 ### Added - **Pursue goals autonomously (`lib/strategist.js`).** When the heartbeat's ready diff --git a/CLAUDE.md b/CLAUDE.md index cc1a388..0508fe1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,7 +92,10 @@ per-task veto (`comms/vetos.md`) and from a budget pause. - **Executors** (`executors.*`): each agent's work runs through one runtime — `llm` (default), `mcp-tool` (call any MCP server as an agent), `shell` (allowlisted), `webhook`/`http-webhook` (POST to an allowlisted host, redirects - refused), `claude-code` (headless `claude -p`, **default-off**), `composite` + refused), `claude-code` (headless `claude -p`, **default-off**), `codex` + (headless `codex exec`, **default-off**) — both CLI runtimes bill your Claude / + ChatGPT **subscription** instead of per-token API credits, so the loop can run + real coding work with no API key — `composite` (an ordered `chain`/`map` of other executors, max 10 steps, no nesting). Budget, approval, and veto wrap every runtime identically. - **Sources** (`sources.*`, all default-off): file-watch (chokidar), an inbound diff --git a/README.md b/README.md index 687eacb..c9d1c80 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CEAuto -![version](https://img.shields.io/badge/version-0.16.0-blue) +![version](https://img.shields.io/badge/version-0.17.0-blue) ![node](https://img.shields.io/badge/node-18%2B-339933?logo=node.js&logoColor=white) ![MCP](https://img.shields.io/badge/MCP-compatible-blueviolet) ![platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey) @@ -424,11 +424,20 @@ decomposes the task into a subtask DAG instead of doing it directly. ### How work runs Every task goes through one **executor**: `llm` (default), `mcp-tool` (call any MCP -server as an agent), `shell`, `webhook`, `claude-code`, or `composite` (an ordered -chain or map of other executors). Budget, approval, and veto gates wrap all of them -the same way. **Reactive sources** (file-watch, an inbound webhook, `@role` -mentions) feed the queue from outside the heartbeat. Sources and the higher-risk -executors are off by default. +server as an agent), `shell`, `webhook`, `claude-code` (headless `claude -p`), `codex` +(headless `codex exec`), or `composite` (an ordered chain or map of other executors). +Budget, approval, and veto gates wrap all of them the same way. **Reactive sources** +(file-watch, an inbound webhook, `@role` mentions) feed the queue from outside the +heartbeat. Sources and the higher-risk executors are off by default. + +**No API key required.** The `claude-code` and `codex` executors drive your installed +CLI, which bills your Claude / ChatGPT **subscription** rather than per-token API +credits — so an autonomous loop can do real work on a plan you already pay for. They +cost $0 in the ledger (keyed on the provider, so a renamed binary can't buy a free +ride) while their tokens are still metered against the token caps. The executors also +strip `ANTHROPIC_API_KEY`/`OPENAI_API_KEY` from the child environment, so an inherited +key can't silently flip the CLI to per-token API billing — set `passApiKeys: true` if +you deliberately want an API-backed CLI. **Governance.** Strategic decisions, sensitive actions, and budget overages gate on human approval. Drop in `config/policy.yaml` for rule-based gating and @@ -511,7 +520,7 @@ CEAuto/ ├── memory/context.md # your company/project context (fill this in) ├── strategy/goals.md # current strategic goals (fill this in) ├── workflows/ # YAML workflow definitions -├── test/ # unit + integration + e2e (232 tests, mock provider) +├── test/ # unit + integration + e2e (240 tests, mock provider) ├── install.sh # installer (Linux/macOS/Git Bash) └── install.bat # installer (Windows) ``` diff --git a/config/providers.yaml b/config/providers.yaml index f6be138..3ab530b 100644 --- a/config/providers.yaml +++ b/config/providers.yaml @@ -49,6 +49,11 @@ providers: # Model pricing in USD per 1K tokens. `default` is used for unknown models. pricing: default: { input: 0.003, output: 0.015 } + # NOTE: the subscription-billed CLI runtimes (`claude -p`, `codex exec`) are NOT + # priced here. They are $0 by PROVIDER in lib/budget.js — keying on the binary name + # would let a rename silently make a real API-backed model free. Their tokens are + # still metered. If you deliberately run either CLI API-backed (`passApiKeys: true`), + # add a price entry for the model name it reports. claude-sonnet-4-6: { input: 0.003, output: 0.015 } claude-opus-4-8: { input: 0.015, output: 0.075 } claude-haiku-4-5-20251001: { input: 0.001, output: 0.005 } diff --git a/config/settings.yaml b/config/settings.yaml index 4e4cfa8..bbc6ad4 100644 --- a/config/settings.yaml +++ b/config/settings.yaml @@ -2,7 +2,7 @@ ## General name: "Autonomous CEO Agent" -version: "0.16.0" +version: "0.17.0" framework: "MCP-compatible" ## Autonomy @@ -68,6 +68,19 @@ executors: enabled: false # default OFF — claude-code can edit files; enable deliberately command: claude # the Claude Code CLI binary # outputFormat: json # parse { result, usage } from `claude -p --output-format json` + # Codex CLI (`codex exec`, prompt on stdin) — the OpenAI-plan sibling of claude_code. + # Both bill your SUBSCRIPTION rather than per-token API credits, so the loop can run + # real coding work with no API key. Default OFF (it can run commands / edit files). + codex: + enabled: false # default OFF — enable deliberately + command: codex # the Codex CLI binary + # args: [exec, --skip-git-repo-check, --color, never] # the default + # args: [exec, --skip-git-repo-check, --color, never, --sandbox, workspace-write] # let it write + # cwd: F:/path/to/a/sandbox # give it a scratch working root, not your main repo + # passApiKeys: false # keys are stripped from the child env so billing stays on your + # # PLAN; set true only if you deliberately run the CLI API-backed + # CAVEAT: your own ~/.codex/config.toml can widen sandbox/approvals globally — the + # "no bypass flag injected" guarantee only covers the argv CEAuto builds. ## Reactive sources (Pillar 4, G1) — feed the queue from outside the cron tick. ## Master switch + per-source switch, all default-off (new inbound surface). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9bb8ab4..e612ad8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -45,8 +45,12 @@ Shipped: `llm`, `mcp-tool`, `shell` (Phase A); **`webhook`/`http-webhook` and `claude-code` (G1)** — both gated (host allowlist / default-off) and sharing a hardened child-process runner (`lib/executors/spawn-capture.js`); **`composite`** (`lib/executors/composite.js`) — an ordered chain/map of other executors, bounded -to 10 steps with no nesting; the runner scales the budget gate by step count. -**All runtimes done.** +to 10 steps with no nesting; the runner scales the budget gate by step count; and +**`codex`** (v0.17.0) — the OpenAI-plan sibling of `claude-code`. Both CLI runtimes +bill the operator's **subscription** rather than per-token API credits (priced $0 by +provider, API keys stripped from the child env), so the loop can run real coding work +with no API key at all. **All runtimes done** — this closes the "agent runtimes" gap +in the table above outright. ``` // lib/executors/index.js @@ -61,7 +65,8 @@ Built-in executors (each its own `lib/executors/*.js`): - `shell` — run a sandboxed command, capture stdout/exit; usage = wall/CPU proxy. - `http-webhook` — POST the task envelope to a URL, await a structured reply (lets an external worker/n8n/Zapier be an agent). -- `claude-code` — spawn a Claude Code subagent for coding tasks. +- `claude-code` — spawn a headless Claude Code subagent (`claude -p`) for coding tasks. +- `codex` — spawn a headless OpenAI Codex subagent (`codex exec`) for coding tasks. - `composite` — ordered fan to several executors (map/reduce). Agent definition (`agents.yaml` / `org.yaml`) picks `executor` + `params`. The diff --git a/lib/budget.js b/lib/budget.js index 998c9aa..9b6b896 100644 --- a/lib/budget.js +++ b/lib/budget.js @@ -54,13 +54,23 @@ function resetConfig() { CONFIG = null; } -// Runtimes that don't spend our LLM API dollars — a local command (shell), an -// external worker (webhook), or another MCP server (mcp). Pricing their proxy -// token counts at pricing.default would accrue phantom USD against the global USD -// cap, so their spend is $0. NOTE: claude-code and composite are deliberately -// EXCLUDED — both can drive real LLM spend and must stay under the USD cap -// (composite is priced conservatively via pricing.default). -const NON_LLM_PROVIDERS = new Set(['shell', 'webhook', 'mcp']); +// Runtimes that cost $0 of LLM API spend. Pricing their proxy token counts at +// pricing.default would accrue phantom USD against the global USD cap. +// +// no LLM at all: a local command (shell), an external worker (webhook), or +// another MCP server (mcp). +// +// subscription-billed CLIs: `claude -p` and `codex exec` bill the operator's +// Claude / ChatGPT PLAN, not per-token API credits. This is keyed on the PROVIDER +// (a hardcoded constant in each executor), never on the model/binary name, which +// is operator-config and could be renamed into or out of a price. The executors +// also strip ANTHROPIC_API_KEY/OPENAI_API_KEY from the child env, so the CLI +// CANNOT fall back to API billing — that is what makes $0 true rather than +// assumed. Opting in via `passApiKeys: true` means you should set real prices. +// +// `composite` stays EXCLUDED — it can chain a real `llm` step, so it is priced +// conservatively via pricing.default. Tokens are still metered for everything here. +const NON_LLM_PROVIDERS = new Set(['shell', 'webhook', 'mcp', 'claude-code', 'codex']); function priceFor(model, inputTokens, outputTokens) { const { pricing } = getConfig(); diff --git a/lib/executors/claude-code.js b/lib/executors/claude-code.js index 6b062e7..95d2f42 100644 --- a/lib/executors/claude-code.js +++ b/lib/executors/claude-code.js @@ -14,6 +14,12 @@ const { runChild, basename } = require('./spawn-capture'); const { estimateTokens } = require('../llm-adapter'); +// Stripped from the child env so `claude -p` bills the SUBSCRIPTION, not the API. +// The daemon usually has these set for the `llm` executor; an inherited key would +// silently flip billing to per-token API spend that the $0 ledger price hides from +// the USD cap. `passApiKeys: true` opts back in (then set real prices). +const PROVIDER_KEYS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY']; + async function claudeCode(ctx, deps = {}) { const cfg = deps.claudeCode || {}; if (!cfg.enabled) { @@ -29,12 +35,15 @@ async function claudeCode(ctx, deps = {}) { const prompt = [ctx.agentSpec, ctx.context, ctx.task].filter(Boolean).join('\n\n'); + const passApiKeys = params.passApiKeys ?? cfg.passApiKeys ?? false; + const { stdout } = await runChild({ command, args, input: prompt, cwd, timeoutMs, + unsetEnv: passApiKeys ? [] : PROVIDER_KEYS, label: `claude-code executor: "${basename(command)}"`, }); diff --git a/lib/executors/codex.js b/lib/executors/codex.js new file mode 100644 index 0000000..c6d8178 --- /dev/null +++ b/lib/executors/codex.js @@ -0,0 +1,96 @@ +/** + * codex executor — run a task by spawning the OpenAI Codex CLI non-interactively + * (`codex exec`, prompt on stdin). The sibling of the claude-code executor. + * + * Why both: `claude -p` and `codex exec` bill the operator's SUBSCRIPTION (Claude + * plan / ChatGPT plan) rather than per-token API credits, so an autonomous loop can + * run real coding work without an API key at all. Pick whichever plan you have. + * + * High blast radius (Codex can run commands and edit files), so it is DEFAULT-OFF: + * it only runs when config enables it (deps.codex.enabled), and like every executor + * it still sits behind the runner's budget / approval / veto / STOP gates. + * + * `codex exec` reads the prompt from stdin when no PROMPT argument is given, which + * is exactly how claude-code feeds it — same shape, same hardened child runner. + * + * params: { command?='codex', args?=['exec', ...], cwd?, timeoutMs?, outputFormat? } + * Sandboxing is Codex's own concern: pass `--sandbox workspace-write` (or + * read-only / danger-full-access) through `args` to choose the policy. We do NOT + * inject a bypass flag — opt into wider access deliberately. + * outputFormat:'json' parses a { result|text, usage } object from stdout. Kept for + * parity with claude-code; note no current `codex exec` flag emits that shape + * (`--json` is JSONL events), so today it harmlessly falls back to raw text. + */ + +const { runChild, basename } = require('./spawn-capture'); +const { estimateTokens } = require('../llm-adapter'); + +// --skip-git-repo-check: the workspace may not be a git repo. +// --color never: keep captured stdout clean of ANSI escapes. +const DEFAULT_ARGS = ['exec', '--skip-git-repo-check', '--color', 'never']; + +// Provider API keys are stripped from the child's environment by default. The +// daemon usually has ANTHROPIC_API_KEY / OPENAI_API_KEY set for the `llm` executor, +// and an inherited key can silently flip the CLI from SUBSCRIPTION billing to +// per-token API billing — spend the $0 ledger price would then hide from the USD +// cap. Scrubbing them makes "subscription-billed" true by construction. +// Set `passApiKeys: true` only if you deliberately want an API-backed CLI (and then +// give it real per-1K prices in providers.yaml). +const PROVIDER_KEYS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY']; + +async function codex(ctx, deps = {}) { + const cfg = deps.codex || {}; + if (!cfg.enabled) { + throw new Error('codex executor is disabled — set executors.codex.enabled in config/settings.yaml'); + } + + const params = ctx.params || {}; + const command = params.command || cfg.command || 'codex'; + const args = params.args || cfg.args || DEFAULT_ARGS; + const cwd = params.cwd || cfg.cwd; + const timeoutMs = params.timeoutMs || cfg.timeoutMs || 180000; + const outputFormat = params.outputFormat || cfg.outputFormat || null; + + const prompt = [ctx.agentSpec, ctx.context, ctx.task].filter(Boolean).join('\n\n'); + + const passApiKeys = params.passApiKeys ?? cfg.passApiKeys ?? false; + + const { stdout } = await runChild({ + command, + args, + input: prompt, + cwd, + timeoutMs, + unsetEnv: passApiKeys ? [] : PROVIDER_KEYS, + label: `codex executor: "${basename(command)}"`, + }); + + let text = stdout; + let usage = null; + if (outputFormat === 'json') { + try { + const parsed = JSON.parse(stdout); + text = parsed.result ?? parsed.text ?? stdout; + if (parsed.usage) { + usage = { + input_tokens: parsed.usage.input_tokens || 0, + output_tokens: parsed.usage.output_tokens || 0, + }; + } + } catch { + // not JSON after all — fall back to the raw text + estimate + } + } + + return { + text, + usage: { + input_tokens: usage?.input_tokens ?? estimateTokens(prompt), + output_tokens: usage?.output_tokens ?? estimateTokens(text), + model: basename(command), + provider: 'codex', + }, + }; +} + +module.exports = codex; diff --git a/lib/executors/index.js b/lib/executors/index.js index 4fc7dca..405a784 100644 --- a/lib/executors/index.js +++ b/lib/executors/index.js @@ -14,6 +14,7 @@ const shell = require('./shell'); const mcpTool = require('./mcp-tool'); const webhook = require('./webhook'); const claudeCode = require('./claude-code'); +const codex = require('./codex'); const composite = require('./composite'); const REGISTRY = { @@ -23,6 +24,7 @@ const REGISTRY = { webhook, 'http-webhook': webhook, 'claude-code': claudeCode, + codex, composite, }; diff --git a/lib/executors/spawn-capture.js b/lib/executors/spawn-capture.js index 234591b..b1b876e 100644 --- a/lib/executors/spawn-capture.js +++ b/lib/executors/spawn-capture.js @@ -17,13 +17,21 @@ function basename(cmd) { return path.basename(String(cmd || '')); } -function runChild({ command, args = [], input, cwd, env, timeoutMs = 60000, maxOutput = MAX_OUTPUT_DEFAULT, label } = {}) { +function runChild({ command, args = [], input, cwd, env, unsetEnv = [], timeoutMs = 60000, maxOutput = MAX_OUTPUT_DEFAULT, label } = {}) { const name = label || basename(command); + // `unsetEnv` REMOVES inherited vars (the spread can only add). Used to strip + // provider API keys before spawning a subscription-billed CLI. + const buildEnv = () => { + if (!env && !unsetEnv.length) return process.env; + const merged = { ...process.env, ...(env || {}) }; + for (const key of unsetEnv) delete merged[key]; + return merged; + }; return new Promise((resolve, reject) => { const child = spawn(command, args, { shell: false, cwd: cwd || undefined, - env: env ? { ...process.env, ...env } : process.env, + env: buildEnv(), }); let out = ''; let err = ''; diff --git a/lib/runner.js b/lib/runner.js index 9a9920e..28dc894 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -279,7 +279,8 @@ async function runTask(taskId, deps = {}) { const webhookAllowlist = (execCfg.webhook && execCfg.webhook.allowlist) || []; const mcpAllowlist = (execCfg['mcp-tool'] && execCfg['mcp-tool'].allowlist) || []; const claudeCode = execCfg.claude_code || {}; - const execDeps = { dispatch, shellAllowlist, webhookAllowlist, mcpAllowlist, claudeCode }; + const codex = execCfg.codex || {}; + const execDeps = { dispatch, shellAllowlist, webhookAllowlist, mcpAllowlist, claudeCode, codex }; // Learned dispatch policy (Pillar 6), opt-in: route this agent to the cheapest // model that has historically cleared the bar. null (off / not enough signal) diff --git a/package.json b/package.json index e8c4574..1c73a9e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ceauto", - "version": "0.16.0", + "version": "0.17.0", "description": "Autonomous CEO Agent — LLM-agnostic MCP server with multi-agent orchestration", "main": "server.js", "type": "commonjs", diff --git a/server.js b/server.js index 1791824..2185029 100644 --- a/server.js +++ b/server.js @@ -630,7 +630,7 @@ async function main() { memory.init(path.join(WORKSPACE, 'db', 'memory.sqlite')); const server = new Server( - { name: 'ceauto', version: '0.16.0' }, + { name: 'ceauto', version: '0.17.0' }, { capabilities: { tools: {} } } ); diff --git a/test/unit/executors-codex.test.js b/test/unit/executors-codex.test.js new file mode 100644 index 0000000..332b09b --- /dev/null +++ b/test/unit/executors-codex.test.js @@ -0,0 +1,88 @@ +const executors = require('../../lib/executors'); + +const NODE = process.execPath; +// stand-in for `codex exec`: echoes back whatever arrived on stdin +const ECHO = 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>process.stdout.write("CDX:"+d))'; +const JSON_OUT = 'process.stdout.write(JSON.stringify({result:"shipped",usage:{input_tokens:7,output_tokens:9}}))'; + +describe('codex executor', () => { + it('is disabled by default (high blast radius)', async () => { + await expect( + executors.execute('codex', { agent: 'coder', task: 't', context: '', params: { command: NODE, args: ['-e', ECHO] } }, {}) + ).rejects.toThrow(/disabled/); + }); + + it('runs the configured command with the prompt on stdin when enabled', async () => { + const res = await executors.execute( + 'codex', + { agent: 'coder', agentSpec: 'SPEC', task: 'build feature', context: 'CTX', params: { command: NODE, args: ['-e', ECHO] } }, + { codex: { enabled: true } } + ); + expect(res.text).toContain('SPEC'); + expect(res.text).toContain('CTX'); + expect(res.text).toContain('build feature'); + expect(res.usage.provider).toBe('codex'); + }); + + it('parses { result, usage } from JSON output', async () => { + const res = await executors.execute( + 'codex', + { agent: 'coder', task: 't', context: '', params: { command: NODE, args: ['-e', JSON_OUT], outputFormat: 'json' } }, + { codex: { enabled: true } } + ); + expect(res.text).toBe('shipped'); + expect(res.usage.input_tokens).toBe(7); + expect(res.usage.output_tokens).toBe(9); + }); + + it('is reachable through the runner deps wiring (config-bound, not task-set)', async () => { + // deps.codex comes from settings.executors.codex — a task cannot enable it + await expect( + executors.execute('codex', { agent: 'coder', task: 't', params: {} }, { codex: {} }) + ).rejects.toThrow(/disabled/); + }); + + // The production path: command/args come from settings (deps.codex), not params. + it('uses cfg.command / cfg.args from settings when params omit them', async () => { + const res = await executors.execute( + 'codex', + { agent: 'coder', task: 'from settings', context: '', params: {} }, + { codex: { enabled: true, command: NODE, args: ['-e', ECHO] } } + ); + expect(res.text).toContain('from settings'); + expect(res.usage.provider).toBe('codex'); + }); + + // $0 pricing is only honest if the CLI can't fall back to API billing. + it('strips provider API keys from the child env (subscription billing by construction)', async () => { + const prev = process.env.ANTHROPIC_API_KEY; + process.env.ANTHROPIC_API_KEY = 'sk-should-not-leak'; + try { + const DUMP = 'process.stdout.write(JSON.stringify({a:process.env.ANTHROPIC_API_KEY||null,o:process.env.OPENAI_API_KEY||null}))'; + const res = await executors.execute( + 'codex', + { agent: 'coder', task: 't', context: '', params: { command: NODE, args: ['-e', DUMP] } }, + { codex: { enabled: true } } + ); + expect(JSON.parse(res.text)).toEqual({ a: null, o: null }); + } finally { + if (prev === undefined) delete process.env.ANTHROPIC_API_KEY; else process.env.ANTHROPIC_API_KEY = prev; + } + }); + + it('passApiKeys:true opts back in to inheriting the key', async () => { + const prev = process.env.ANTHROPIC_API_KEY; + process.env.ANTHROPIC_API_KEY = 'sk-deliberate'; + try { + const DUMP = 'process.stdout.write(String(process.env.ANTHROPIC_API_KEY||"none"))'; + const res = await executors.execute( + 'codex', + { agent: 'coder', task: 't', context: '', params: { command: NODE, args: ['-e', DUMP], passApiKeys: true } }, + { codex: { enabled: true } } + ); + expect(res.text).toBe('sk-deliberate'); + } finally { + if (prev === undefined) delete process.env.ANTHROPIC_API_KEY; else process.env.ANTHROPIC_API_KEY = prev; + } + }); +}); diff --git a/test/unit/review-fixes.test.js b/test/unit/review-fixes.test.js index 2293f62..3fe890c 100644 --- a/test/unit/review-fixes.test.js +++ b/test/unit/review-fixes.test.js @@ -87,6 +87,21 @@ describe('review fixes', () => { expect(byTask['T-llm']).toBeGreaterThan(0); }); + // Subscription-billed CLI runtimes cost $0 of API spend — keyed on PROVIDER so a + // renamed binary can't make a real API-backed model free. + it('claude-code / codex ledger rows cost $0; a renamed binary does not get a free ride', () => { + budget.record({ agent: 'coder', task_id: 'T-cc', provider: 'claude-code', model: 'claude', input_tokens: 1000, output_tokens: 1000 }); + budget.record({ agent: 'coder', task_id: 'T-cx', provider: 'codex', model: 'codex', input_tokens: 1000, output_tokens: 1000 }); + // same binary NAME but a real API provider → still priced + budget.record({ agent: 'coder', task_id: 'T-api', provider: 'anthropic', model: 'claude', input_tokens: 1000, output_tokens: 1000 }); + const byTask = Object.fromEntries( + memory.getDb().prepare('SELECT task_id, usd FROM budget_ledger').all().map(r => [r.task_id, r.usd]) + ); + expect(byTask['T-cc']).toBe(0); + expect(byTask['T-cx']).toBe(0); + expect(byTask['T-api']).toBeGreaterThan(0); + }); + // S3 — a caller-supplied id that would escape reports/tasks/ is rejected. it('S3: tasks.create rejects an unsafe id', () => { expect(() => tasks.create({ id: '../../etc/evil', title: 'x' })).toThrow(/unsafe task id/); diff --git a/tools/index.json b/tools/index.json index 04e94d9..b496a9b 100644 --- a/tools/index.json +++ b/tools/index.json @@ -1,6 +1,6 @@ { "name": "ceo-tools", - "version": "0.16.0", + "version": "0.17.0", "description": "MCP tools for CEAuto Autonomous CEO Agent", "tools": [ {