diff --git a/README.md b/README.md index 01770884..b76dd976 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,11 @@ subscription. **You need:** Node 20.11+ and one of these AI CLIs, installed and signed in: - [Claude Code](https://docs.claude.com/en/docs/claude-code/overview) (`claude login`) +- [Codex CLI](https://developers.openai.com/codex/cli) (`codex login`) - [Antigravity CLI](https://antigravity.google/docs/cli/reference) (run `agy` once and follow the prompts) > [!NOTE] -> **Coming soon:** Codex, OpenCode, Pi, Grok, and more AI CLIs. +> **Coming soon:** OpenCode, Pi, Grok, and more AI CLIs. The first run downloads a one-time embedded MongoDB binary, so it takes a little longer. Run `npx muktiai --help` for all options, or see [Local Mode](docs/local-mode.md) for details. @@ -85,12 +86,12 @@ Mukti responds with a compact sequence: ## Ways to run Mukti -| I want to… | Use | Needs | -| ----------------------------- | -------------------------------------------------------- | ---------------------------------- | -| Try Mukti | [`npx muktiai`](#try-it-in-30-seconds) | Node 20.11+, `claude` or `agy` CLI | -| Run the full hosted stack | [`docker compose up -d`](#full-stack-docker) | Docker, an OpenRouter key | -| Hack on the code (hot reload) | [`bun run dev`](#development) | Bun, Docker (for MongoDB + Redis) | -| Run from source, no Docker | [`bun run start:local`](#run-from-source-without-docker) | Bun, `claude` or `agy` CLI | +| I want to… | Use | Needs | +| ----------------------------- | -------------------------------------------------------- | --------------------------------------------- | +| Try Mukti | [`npx muktiai`](#try-it-in-30-seconds) | Node 20.11+, a `claude`, `codex` or `agy` CLI | +| Run the full hosted stack | [`docker compose up -d`](#full-stack-docker) | Docker, an OpenRouter key | +| Hack on the code (hot reload) | [`bun run dev`](#development) | Bun, Docker (for MongoDB + Redis) | +| Run from source, no Docker | [`bun run start:local`](#run-from-source-without-docker) | Bun, a `claude`, `codex` or `agy` CLI | ### Choosing the AI CLI @@ -98,20 +99,21 @@ With more than one CLI ready, Mukti asks which to use on the first run and offer To override: ```bash -npx muktiai --choose # ask again -npx muktiai --provider agy --save # set the default, no prompt +npx muktiai --choose # ask again +npx muktiai --provider codex --save # set the default, no prompt ``` -| | Claude Code | Antigravity | -| -------------- | ----------------------------- | ------------------------------------------------------------- | -| Time per reply | about 13–20 seconds | about 30–60 seconds | -| Tokens | Mukti's prompt + conversation | about 15–25k per reply (includes agy's own agent prompt) | -| Side effects | — | Keeps a ~1 MB record per reply in `~/.gemini/antigravity-cli` | +| | Claude Code | Codex | Antigravity | +| -------------- | ----------------------------- | ------------------------------------------------ | ------------------------------------------------------------- | +| Time per reply | about 13–20 seconds | about 8–12 seconds | about 30–60 seconds | +| Tokens | Mukti's prompt + conversation | about 2.5–4.5k per reply, depending on the model | about 15–25k per reply (includes agy's own agent prompt) | +| Side effects | — | — (runs keep no Codex session history) | Keeps a ~1 MB record per reply in `~/.gemini/antigravity-cli` | > [!WARNING] -> With Antigravity, your global agy rules apply to Mukti's replies, and each prompt is passed as a -> command-line argument (visible to other local processes via `ps`). Read the -> [full comparison and privacy notes](docs/local-mode.md#claude-code-vs-antigravity) before choosing it. +> With Codex, your global `~/.codex/AGENTS.md` still applies to Mukti's replies. With Antigravity, +> your global agy rules apply, and each prompt is passed as a command-line argument (visible to other +> local processes via `ps`). Read the [full comparison and privacy notes](docs/local-mode.md#comparing-the-ai-clis) +> before choosing either. Your data lives in `~/.mukti/` (change it with `--data-dir` or `MUKTI_HOME`). @@ -192,7 +194,7 @@ TypeScript support). Every other workspace script assumes Bun. Data is stored in ## Troubleshooting - **First run is slow** — a one-time embedded MongoDB download. Later starts are fast. -- **AI CLI not detected** — make sure `claude` or `agy` is on your `PATH` and signed in. +- **AI CLI not detected** — make sure `claude`, `codex` or `agy` is on your `PATH` and signed in. Mukti never picks a CLI you haven't signed in to. - **Port already in use** — `npx muktiai --port 4001 --api-port 4000`. - **More** — see [Local Mode → Troubleshooting](docs/local-mode.md#troubleshooting). diff --git a/docs/local-mode.md b/docs/local-mode.md index bbf524cc..581cd70c 100644 --- a/docs/local-mode.md +++ b/docs/local-mode.md @@ -17,14 +17,15 @@ Both share the same launcher, preflight checks, and saved settings. | CLI | Provider id | Status | | ------------------------------------------------------------------------------ | ----------- | ----------- | | [Claude Code](https://docs.claude.com/en/docs/claude-code/overview) (`claude`) | `claude` | Supported | +| [Codex](https://developers.openai.com/codex/cli) (`codex`) | `codex` | Supported | | [Antigravity](https://antigravity.google/docs/cli/reference) (`agy`) | `agy` | Supported | -| Codex, OpenCode, Pi, Grok, and more | — | Coming soon | +| OpenCode, Pi, Grok, and more | — | Coming soon | -Each provider is named after the command it runs, so `--provider claude` and `--provider agy` are -all you have to remember. The older names `claude-code` and `antigravity` still work everywhere a -provider can be named. +Each provider is named after the command it runs, so `--provider claude`, `--provider codex` and +`--provider agy` are all you have to remember. The older names `claude-code` and `antigravity` still +work everywhere a provider can be named. -Sign in first: `claude login`, or run `agy` once and follow the prompts. +Sign in first: `claude login`, `codex login`, or run `agy` once and follow the prompts. ## Choosing the AI CLI @@ -34,6 +35,7 @@ remember your answer: ``` ◇ Which AI CLI should Mukti use? │ ● Claude CLI ready · about 13-20 seconds per reply +│ ○ Codex CLI ready · about 8-12 seconds per reply │ ○ Antigravity CLI ready · about 30-60 seconds and 15-25k tokens per reply │ ◇ Use Claude CLI by default from now on? Yes @@ -45,30 +47,48 @@ so. To change it, or to decide without being asked: ```bash npx muktiai --choose # ask again, and offer to remember -npx muktiai --provider agy # just this once -npx muktiai --provider agy --save # set the default, no prompt +npx muktiai --provider codex # just this once +npx muktiai --provider codex --save # set the default, no prompt ``` From a checkout, pass the same flags after `--`: `bun run start:local -- --choose`. The launcher decides in this order: `--provider`, then the `AI_PROVIDER` environment variable, then your saved choice, then whichever CLIs are ready — installed _and_ signed in. A CLI you have -not signed in to is never chosen for you. It always reports which provider it used and why. +not signed in to is never chosen for you. When several are ready and Mukti cannot ask, it takes the +first in the order Claude Code, Codex, Antigravity. It always reports which provider it used and +why. Your choice is saved in `~/.mukti/config.json` (or under `--data-dir`/`MUKTI_HOME`), and the same choice is used whether you run from a checkout or through `npx`. Delete that file to forget it. Without a terminal — piped output, or CI — Mukti never asks: it uses the first ready CLI and tells you how to choose another. -## Claude Code vs. Antigravity +## Comparing the AI CLIs -The two are not equivalent, and the difference is worth knowing before you choose: +They are not equivalent, and the difference is worth knowing before you choose: -| | Claude Code (`claude`) | Antigravity (`agy`) | -| ------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Time per reply (measured) | about 13–20 seconds | about 30–60 seconds | -| Prompt size per reply | Mukti's own prompt and your conversation | about 15–25k input tokens: the same, plus agy's own ~14k-token coding-agent prompt, which cannot be replaced | -| Stored outside Mukti | whatever Claude Code itself records for a `claude -p` run | a conversation record (about 1 MB) per reply in your Antigravity history (`~/.gemini/antigravity-cli`), filed under a `mukti-socratic` project. Mukti never deletes these | +| | Claude Code (`claude`) | Codex (`codex`) | Antigravity (`agy`) | +| ------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Time per reply (measured) | about 13–20 seconds | about 8–12 seconds | about 30–60 seconds | +| Prompt size per reply | Mukti's own prompt and your conversation | about 2.5–4.5k input tokens: Mukti's own prompt replaces Codex's, plus up to ~2k tokens of Codex context on some models | about 15–25k input tokens: the same, plus agy's own ~14k-token coding-agent prompt, which cannot be replaced | +| Stored outside Mukti | whatever Claude Code itself records for a `claude -p` run | nothing: runs are ephemeral and leave no Codex session history | a conversation record (about 1 MB) per reply in your Antigravity history (`~/.gemini/antigravity-cli`), filed under a `mukti-socratic` project. Mukti never deletes these | + +### Codex: privacy and behavior notes + +- Codex runs **read-only, with its tools switched off**: no shell, no file edits, no web search, + no images, no sub-agents. Asked to read a file outside its folder, it cannot. +- It runs from `~/.mukti/mukti-socratic`, never from your projects, so project `AGENTS.md` files + do not apply. Your Codex settings (`~/.codex/config.toml`, MCP servers, hooks, rules) do not + apply either, except your default model, which Mukti offers first. +- Your **global** `~/.codex/AGENTS.md` does apply: Codex has no way to leave it out, and a rule in + it can change what a reply says. +- Mukti's prompt, which can include your conversation, is written to a private file in + `~/.mukti/mukti-socratic/.mukti-codex/` for the length of one reply and then deleted. It never + appears on the command line. +- On startup, Mukti checks that your Codex version still accepts every setting it uses, without + running a model. If a Codex update renamed one, Mukti refuses to start and names it, rather than + running Codex with a tool switched back on. ### Antigravity: privacy and behavior notes @@ -83,8 +103,9 @@ The two are not equivalent, and the difference is worth knowing before you choos ## Models Pick a model from the model picker in the app. The list comes from the CLI you are running — -Claude Code's Sonnet / Opus / Haiku aliases, or whatever `agy models` reports — and the selection -is passed to that CLI's `--model`. +Claude Code's Sonnet / Opus / Haiku aliases, the models Codex lists for your account (your Codex +default first), or whatever `agy models` reports — and the selection is passed to that CLI's model +flag. ## What local mode does @@ -112,7 +133,7 @@ npx muktiai [options] --port, --web-port Port for the web app (default 3001) --api-port Port for the API (default 3000) --data-dir Where to keep the database and logs (default ~/.mukti, or $MUKTI_HOME) - --provider AI CLI to run on: claude or agy + --provider AI CLI to run on: claude, codex, agy --save Save --provider as your default for later runs --choose Ask which AI CLI to use, and offer to save it -h, --help Show help @@ -122,8 +143,10 @@ npx muktiai [options] - **First run is slow.** Mukti downloads a one-time embedded MongoDB binary (via `mongodb-memory-server`). Later starts are fast. -- **"CLI not signed in".** Run `claude login`, or run `agy` once and finish its prompts, then - start Mukti again. Mukti never picks a CLI that is not signed in. +- **"CLI not signed in".** Run `claude login` or `codex login`, or run `agy` once and finish its + prompts, then start Mukti again. Mukti never picks a CLI that is not signed in. +- **"The installed Codex CLI rejected Mukti's settings".** Your Codex version no longer accepts a + setting Mukti uses to switch off its tools. Update Mukti, or use another CLI until it is updated. - **Port 3000 or 3001 is busy.** `npx muktiai` moves to the next free port on its own. To pick one yourself: `npx muktiai --port 4001 --api-port 4000`. - **Start over.** Delete the data directory (`~/.mukti/local-db/` for npx, `.mukti/local-db/` in a diff --git a/packages/mukti-api/src/modules/ai/ai.module.ts b/packages/mukti-api/src/modules/ai/ai.module.ts index 2f0111e7..987a2d44 100644 --- a/packages/mukti-api/src/modules/ai/ai.module.ts +++ b/packages/mukti-api/src/modules/ai/ai.module.ts @@ -28,7 +28,7 @@ import { * Resolves the active chat-client factory from the provider registry. * * @remarks - * Local-CLI providers (`claude-code`, `antigravity`) share + * Local-CLI providers (`claude-code`, `antigravity`, `codex`) share * {@link LocalCliClientFactory} and differ only by adapter, so a new CLI is an * adapter plus a registry entry rather than another branch here. Anything else * is served by OpenRouter over HTTP. diff --git a/packages/mukti-api/src/modules/ai/services/__tests__/ai-provider.registry.spec.ts b/packages/mukti-api/src/modules/ai/services/__tests__/ai-provider.registry.spec.ts index 7a04c259..6d476b05 100644 --- a/packages/mukti-api/src/modules/ai/services/__tests__/ai-provider.registry.spec.ts +++ b/packages/mukti-api/src/modules/ai/services/__tests__/ai-provider.registry.spec.ts @@ -6,6 +6,7 @@ import { join } from 'path'; import { AntigravityCliAdapter } from '../adapters/antigravity-cli.adapter'; import { ClaudeCliAdapter } from '../adapters/claude-cli.adapter'; +import { CodexCliAdapter } from '../adapters/codex-cli.adapter'; import { AiProviderRegistry } from '../ai-provider.registry'; /** @@ -60,6 +61,14 @@ describe('AiProviderRegistry', () => { expect(r.isLocalCliProvider()).toBe(true); }); + it('resolves codex to the Codex adapter', () => { + const r = registry({ AI_PROVIDER: 'codex' }); + + expect(r.getProviderId()).toBe('codex'); + expect(r.getActiveLocalCliAdapter()).toBeInstanceOf(CodexCliAdapter); + expect(r.isLocalCliProvider()).toBe(true); + }); + // The launcher asks for these CLIs by the command they run and passes that // name straight through, so both spellings have to reach the same adapter. it.each([ @@ -82,11 +91,36 @@ describe('AiProviderRegistry', () => { expect(adapter.spawnOptions?.().cwd).toBe(realpathSync(workspace)); }); + it.each(['antigravity', 'codex'])( + 'runs %s from the workspace named by MUKTI_CLI_WORKSPACE, which wins over the older name', + (provider) => { + const workspace = join(root, 'shared-workspace'); + const adapter = registry({ + AI_PROVIDER: provider, + MUKTI_AGY_WORKSPACE: join(root, 'older-name'), + MUKTI_CLI_WORKSPACE: workspace, + }).getActiveLocalCliAdapter()!; + + expect(adapter.spawnOptions?.().cwd).toBe(realpathSync(workspace)); + }, + ); + + it('runs codex from MUKTI_AGY_WORKSPACE when only the older name is set', () => { + const workspace = join(root, 'older-name'); + const adapter = registry({ + AI_PROVIDER: 'codex', + MUKTI_AGY_WORKSPACE: workspace, + MUKTI_CLI_WORKSPACE: ' ', + }).getActiveLocalCliAdapter()!; + + expect(adapter.spawnOptions?.().cwd).toBe(realpathSync(workspace)); + }); + it('fails startup on an unknown provider, naming every supported value, without falling back', async () => { const r = registry({ AI_PROVIDER: 'gemini-cli' }); await expect(r.onModuleInit()).rejects.toThrow( - 'Unsupported AI_PROVIDER "gemini-cli". Supported values: openrouter, claude-code, antigravity, agy, claude.', + 'Unsupported AI_PROVIDER "gemini-cli". Supported values: openrouter, claude-code, antigravity, codex, agy, claude.', ); expect(r.isLocalCliProvider()).toBe(false); }); @@ -95,12 +129,31 @@ describe('AiProviderRegistry', () => { const warm = jest .spyOn(AntigravityCliAdapter.prototype, 'warm') .mockResolvedValue(); + const warmCodex = jest + .spyOn(CodexCliAdapter.prototype, 'warm') + .mockResolvedValue(); await registry({ AI_PROVIDER: 'claude-code' }).onModuleInit(); expect(warm).not.toHaveBeenCalled(); + expect(warmCodex).not.toHaveBeenCalled(); await registry({ AI_PROVIDER: 'antigravity' }).onModuleInit(); expect(warm).toHaveBeenCalledTimes(1); + expect(warmCodex).not.toHaveBeenCalled(); + + await registry({ AI_PROVIDER: 'codex' }).onModuleInit(); + expect(warmCodex).toHaveBeenCalledTimes(1); + expect(warm).toHaveBeenCalledTimes(1); + }); + + it('fails startup when the Codex adapter rejects its own settings', async () => { + jest + .spyOn(CodexCliAdapter.prototype, 'warm') + .mockRejectedValue(new Error('unknown configuration field `x`')); + + await expect( + registry({ AI_PROVIDER: 'codex' }).onModuleInit(), + ).rejects.toThrow('unknown configuration field `x`'); }); // Preflight already checked sign-in; a failure here means the environment diff --git a/packages/mukti-api/src/modules/ai/services/__tests__/codex-cli.adapter.spec.ts b/packages/mukti-api/src/modules/ai/services/__tests__/codex-cli.adapter.spec.ts new file mode 100644 index 00000000..f32e9364 --- /dev/null +++ b/packages/mukti-api/src/modules/ai/services/__tests__/codex-cli.adapter.spec.ts @@ -0,0 +1,682 @@ +import { spawn } from 'child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import type { AiChatSendRequest } from '../../types/ai-chat-client.interface'; +import type { LocalCliAdapter } from '../../types/local-cli-adapter.interface'; + +import { SOCRATIC_QUESTION_FORMAT } from '../../types/ai-response-format.interface'; +import { LocalCliError } from '../../types/local-cli-adapter.interface'; +import { + CODEX_CLI_ERROR_CODE, + CodexCliAdapter, + type CommandResult, + HARDENED_FLAGS, + parseModelCatalogue, + readTopLevelModel, +} from '../adapters/codex-cli.adapter'; +import { LocalCliClientFactory } from '../local-cli-client.factory'; +import { fakeChild } from './fake-child'; + +jest.mock('child_process', () => ({ + spawn: jest.fn(), +})); + +const spawnMock = spawn as jest.MockedFunction; + +/** Real output captured from codex-cli 0.163.0 (see fixtures/codex). */ +function fixture(name: string): string { + return readFileSync(join(__dirname, 'fixtures', 'codex', name), 'utf8'); +} + +/** + * The adapter for the OpenAI Codex CLI (`codex exec`). + * + * @remarks + * Every expectation here encodes something measured against codex-cli 0.163.0 + * (add-codex-cli-provider design.md): the base prompt is replaced through a + * per-request instructions file that never reaches argv, the hardened flags + * are exactly the set the boot check validates, the JSONL stream carries + * warnings that are not failures, and the model list comes from Codex's own + * catalogue with hidden entries dropped. + */ +describe('CodexCliAdapter', () => { + let root: string; + let workspaceDir: string; + let codexHome: string; + + const socraticRequest: AiChatSendRequest = { + messages: [ + { content: 'You are Mukti. Only ask questions.', role: 'system' }, + { content: 'What is a closure in JavaScript?', role: 'user' }, + ], + model: 'gpt-5.6-luna', + responseFormat: SOCRATIC_QUESTION_FORMAT, + }; + + const analyticalRequest: AiChatSendRequest = { + messages: [ + { content: 'Return JSON with a concepts array.', role: 'system' }, + { content: 'Extract the concepts now.', role: 'user' }, + ], + model: 'gpt-5.6-luna', + responseFormat: undefined, + }; + + function adapter( + overrides: Partial[0]> = {}, + ): CodexCliAdapter { + return new CodexCliAdapter({ + codexHome, + runModelListing: () => + Promise.resolve({ + exitCode: 0, + stderr: '', + stdout: fixture('models.json'), + }), + runSelfCheck: () => + Promise.resolve({ + exitCode: 1, + stderr: + 'Error: failed to read model instructions file /x/self-check-absent.md: No such file or directory (os error 2)\n', + stdout: '', + }), + workspaceDir, + ...overrides, + }); + } + + function instructionsPath(args: readonly string[]): string { + const setting = args.find((a) => a.startsWith('model_instructions_file='))!; + return JSON.parse(setting.slice('model_instructions_file='.length)); + } + + function instructionFiles(): string[] { + const dir = join(workspaceDir, '.mukti-codex'); + return existsSync(dir) ? readdirSync(dir) : []; + } + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'mukti-codex-spec-')); + workspaceDir = join(root, 'workspace'); + codexHome = join(root, 'codex-home'); + mkdirSync(codexHome); + spawnMock.mockReset(); + }); + + afterEach(() => { + rmSync(root, { force: true, recursive: true }); + }); + + describe('identity', () => { + it('is the codex provider, named after its command', () => { + const a = adapter(); + + expect(a.providerId).toBe('codex'); + expect(a.binary).toBe('codex'); + expect(a.errorCode).toBe(CODEX_CLI_ERROR_CODE); + // Stdin, the default: the prompt never becomes an argument. + expect((a as LocalCliAdapter).inputChannel).toBeUndefined(); + expect(a.timeoutMs).toBe(120_000); + expect(a.signInHint).toContain('codex login'); + }); + }); + + describe('buildArgs', () => { + it('runs exec with every hardened flag, in the validated order', () => { + const args = adapter().buildArgs(socraticRequest); + + expect(args.slice(0, HARDENED_FLAGS.length)).toEqual(HARDENED_FLAGS); + expect(args[0]).toBe('exec'); + }); + + it('disables every tool and context layer that was measured to leak', () => { + const args = adapter().buildArgs(socraticRequest); + const disabled = args + .map((a, i) => (a === '--disable' ? args[i + 1] : undefined)) + .filter(Boolean); + + for (const feature of [ + 'shell_tool', + 'unified_exec', + 'code_mode_host', + 'multi_agent', + 'hooks', + 'plugins', + 'apps', + 'browser_use', + 'computer_use', + 'view_image', + 'image_generation', + ]) { + expect(disabled).toContain(feature); + } + for (const setting of [ + 'include_permissions_instructions=false', + 'include_environment_context=false', + 'include_collaboration_mode_instructions=false', + 'skills.include_instructions=false', + 'project_doc_max_bytes=0', + 'web_search="disabled"', + ]) { + expect(args).toContain(setting); + } + for (const flag of [ + '--ephemeral', + '--ignore-user-config', + '--ignore-rules', + '--strict-config', + ]) { + expect(args).toContain(flag); + } + expect(args[args.indexOf('--sandbox') + 1]).toBe('read-only'); + }); + + it('never passes a flag that widens access or approves actions', () => { + const joined = adapter().buildArgs(socraticRequest).join(' '); + + for (const forbidden of [ + '--dangerously-bypass-approvals-and-sandbox', + '--dangerously-bypass-hook-trust', + '--approve-for-me', + '--full-auto', + '--add-dir', + 'workspace-write', + 'danger-full-access', + '--output-schema', + ]) { + expect(joined).not.toContain(forbidden); + } + }); + + it('runs from the workspace and reads the prompt from stdin', () => { + const args = adapter().buildArgs(socraticRequest); + + expect(args[args.indexOf('--cd') + 1]).toBe(workspaceDir); + expect(args[args.length - 1]).toBe('-'); + }); + + it('passes the model only when one is selected', () => { + const a = adapter(); + + const withModel = a.buildArgs(socraticRequest); + expect(withModel[withModel.indexOf('-m') + 1]).toBe('gpt-5.6-luna'); + + expect(a.buildArgs({ ...socraticRequest, model: ' ' })).not.toContain( + '-m', + ); + }); + + it('keeps the conversation and the system prompt off the command line', () => { + const joined = adapter().buildArgs(socraticRequest).join('\n'); + + expect(joined).not.toContain('closure'); + expect(joined).not.toContain('Only ask questions'); + }); + + it('writes the system prompt to a private, per-request instructions file', () => { + const a = adapter(); + + const first = instructionsPath(a.buildArgs(socraticRequest)); + const second = instructionsPath(a.buildArgs(analyticalRequest)); + + expect(first).not.toBe(second); + expect(first.startsWith(join(workspaceDir, '.mukti-codex'))).toBe(true); + expect(readFileSync(first, 'utf8')).toBe( + 'You are Mukti. Only ask questions.', + ); + expect(readFileSync(second, 'utf8')).toBe( + 'Return JSON with a concepts array.', + ); + expect(statSync(first).mode & 0o777).toBe(0o600); + }); + + it('joins several system messages, as the Claude adapter does', () => { + const path = instructionsPath( + adapter().buildArgs({ + ...socraticRequest, + messages: [ + { content: 'First rule.', role: 'system' }, + { content: 'Second rule.', role: 'system' }, + { content: 'Hi', role: 'user' }, + ], + }), + ); + + expect(readFileSync(path, 'utf8')).toBe('First rule.\n\nSecond rule.'); + }); + + // Without a replacement Codex falls back to its own coding-agent prompt. + it('writes a neutral instruction when there is no system message', () => { + const path = instructionsPath( + adapter().buildArgs({ + ...socraticRequest, + messages: [{ content: 'Hi', role: 'user' }], + }), + ); + + expect(readFileSync(path, 'utf8').trim().length).toBeGreaterThan(0); + expect(readFileSync(path, 'utf8')).not.toMatch(/codex/i); + }); + + it('quotes the instructions path so unusual characters survive', () => { + workspaceDir = join(root, 'a "quoted" \\ dir'); + const args = adapter().buildArgs(socraticRequest); + + expect(existsSync(instructionsPath(args))).toBe(true); + }); + + it('ignores a declared response format: no schema is forced', () => { + const a = adapter(); + const declared = a.buildArgs(socraticRequest); + const undeclared = a.buildArgs(analyticalRequest); + const strip = (args: string[]) => + args.filter((x) => !x.startsWith('model_instructions_file=')); + + expect(strip(declared)).toEqual(strip(undeclared)); + }); + }); + + describe('afterRun', () => { + it('removes the request instructions file', () => { + const a = adapter(); + const args = a.buildArgs(socraticRequest); + expect(existsSync(instructionsPath(args))).toBe(true); + + a.afterRun(args); + + expect(existsSync(instructionsPath(args))).toBe(false); + }); + + it('tolerates a file that is already gone, or arguments without one', () => { + const a = adapter(); + const args = a.buildArgs(socraticRequest); + a.afterRun(args); + + expect(() => a.afterRun(args)).not.toThrow(); + expect(() => a.afterRun(['exec', '-'])).not.toThrow(); + }); + }); + + describe('spawnOptions', () => { + it('recreates a missing workspace rather than failing as if codex were missing', () => { + const a = adapter(); + expect(existsSync(workspaceDir)).toBe(false); + + expect(a.spawnOptions().cwd).toBe(realpathSync(workspaceDir)); + }); + }); + + describe('parseEnvelope', () => { + it('maps a real successful stream, ignoring its warning item', () => { + expect(adapter().parseEnvelope(fixture('success.jsonl'))).toEqual({ + completionTokens: 29, + content: + 'How might a function remember and access variables from the scope where it was created, even after that outer function has finished running?', + promptTokens: 2361, + }); + }); + + it('takes the last agent message when progress messages precede it', () => { + const stdout = [ + '{"type":"turn.started"}', + '{"type":"item.completed","item":{"type":"agent_message","text":"Thinking about it."}}', + '{"type":"error","message":"Reconnecting... 1/5 (stream disconnected)"}', + '{"type":"item.completed","item":{"type":"agent_message","text":"What do you already know?"}}', + '{"type":"turn.completed","usage":{"input_tokens":10,"cached_input_tokens":8,"output_tokens":4,"reasoning_output_tokens":3}}', + ].join('\n'); + + expect(adapter().parseEnvelope(stdout)).toEqual({ + completionTokens: 4, + content: 'What do you already know?', + promptTokens: 10, + }); + }); + + it('skips lines that are not JSON', () => { + const stdout = `warning: something\n${fixture('success.jsonl')}\n`; + + expect(adapter().parseEnvelope(stdout).promptTokens).toBe(2361); + }); + + it('fails on a failed turn, naming the API reason', () => { + const parse = () => + adapter().parseEnvelope(fixture('unknown-model.jsonl')); + + expect(parse).toThrow(LocalCliError); + expect(parse).toThrow( + "The 'no-such-model' model is not supported when using Codex with a ChatGPT account.", + ); + }); + + it('fails when there is no completed turn', () => { + expect(() => + adapter().parseEnvelope( + '{"type":"thread.started"}\n{"type":"turn.started"}\n', + ), + ).toThrow(/completed turn/); + }); + + it('returns empty content for a completed turn with no message, so the factory rejects it', () => { + expect( + adapter().parseEnvelope( + '{"type":"turn.completed","usage":{"input_tokens":1,"output_tokens":0}}', + ).content, + ).toBe(''); + }); + }); + + describe('describeFailure', () => { + it('unwraps the nested API message of a failed turn', () => { + expect(adapter().describeFailure(fixture('unknown-model.jsonl'))).toBe( + "The 'no-such-model' model is not supported when using Codex with a ChatGPT account.", + ); + }); + + it('reports the authentication failure of a signed-out run', () => { + expect(adapter().describeFailure(fixture('signed-out.jsonl'))).toMatch( + /^unexpected status 401 Unauthorized/, + ); + }); + + it('falls back to the last error event when no turn failed', () => { + expect( + adapter().describeFailure( + '{"type":"error","message":"first"}\n{"type":"error","message":"second"}', + ), + ).toBe('second'); + }); + + it('leaves it to stderr when the stream has no failure at all', () => { + expect(adapter().describeFailure('')).toBeUndefined(); + expect(adapter().describeFailure('not json')).toBeUndefined(); + }); + }); + + describe('through the shared factory', () => { + it('completes a turn, writing the transcript to stdin and cleaning up', async () => { + const a = adapter(); + const child = fakeChild({ code: 0, stdout: fixture('success.jsonl') }); + spawnMock.mockReturnValue(child as never); + + const response = (await new LocalCliClientFactory(a) + .create('ignored-api-key') + .chat.send(socraticRequest)) as { + choices: { message: { content: string } }[]; + usage: { prompt_tokens: number; total_tokens: number }; + }; + + expect(response.choices[0].message.content).toMatch(/\?$/); + expect(response.usage).toMatchObject({ + prompt_tokens: 2361, + total_tokens: 2390, + }); + const [command, args, options] = spawnMock.mock.calls[0]; + expect(command).toBe('codex'); + expect((args as string[]).join(' ')).not.toContain('ignored-api-key'); + expect(options).toMatchObject({ cwd: realpathSync(workspaceDir) }); + expect(child.stdin.write).toHaveBeenCalledWith( + 'User: What is a closure in JavaScript?', + ); + expect(instructionFiles()).toEqual([]); + }); + + it('sends a kickoff turn for a system-prompt-only request', async () => { + const child = fakeChild({ code: 0, stdout: fixture('success.jsonl') }); + spawnMock.mockReturnValue(child as never); + + await new LocalCliClientFactory(adapter()).create('').chat.send({ + messages: [{ content: 'Open with a question.', role: 'system' }], + model: 'gpt-5.6-luna', + responseFormat: undefined, + }); + + const written = child.stdin.write.mock.calls[0][0] as string; + expect(written.trim().length).toBeGreaterThan(0); + }); + + it('reports a signed-out run with its reason and the sign-in step', async () => { + spawnMock.mockReturnValue( + fakeChild({ code: 1, stdout: fixture('signed-out.jsonl') }) as never, + ); + + const failure = new LocalCliClientFactory(adapter()) + .create('') + .chat.send(socraticRequest); + + await expect(failure).rejects.toMatchObject({ + code: CODEX_CLI_ERROR_CODE, + retriable: false, + }); + await expect(failure).rejects.toThrow(/codex login/); + await expect(failure).rejects.toThrow(/401 Unauthorized/); + expect(instructionFiles()).toEqual([]); + }); + + it('cleans up the instructions file when the run times out', async () => { + const a = adapter(); + Object.defineProperty(a, 'timeoutMs', { value: 20 }); + spawnMock.mockReturnValue(fakeChild({ hang: true }) as never); + + await expect( + new LocalCliClientFactory(a).create('').chat.send(socraticRequest), + ).rejects.toThrow(/did not respond/); + expect(instructionFiles()).toEqual([]); + }); + + it('names codex when the binary is missing', async () => { + spawnMock.mockReturnValue(fakeChild({ errorCode: 'ENOENT' }) as never); + + await expect( + new LocalCliClientFactory(adapter()) + .create('') + .chat.send(socraticRequest), + ).rejects.toThrow(/`codex` CLI was not found on PATH/); + expect(instructionFiles()).toEqual([]); + }); + }); + + describe('warm', () => { + it('validates the exact hardened flag set before listing models', async () => { + const runSelfCheck = jest.fn, [readonly string[]]>( + () => + Promise.resolve({ + exitCode: 1, + stderr: + 'Error: failed to read model instructions file /x: No such file or directory (os error 2)', + stdout: '', + }), + ); + const a = adapter({ runSelfCheck }); + + await a.warm(); + + const args = runSelfCheck.mock.calls[0][0]; + expect(args.slice(0, HARDENED_FLAGS.length)).toEqual(HARDENED_FLAGS); + expect(args[args.length - 1]).toBe('-'); + // The file it points at must not exist, or the check would run a model. + expect(existsSync(instructionsPath(args))).toBe(false); + }); + + it.each([ + [ + 'an unknown configuration key', + 'Error loading config.toml: unknown configuration field `include_environment_context` in -c/--config override', + /unknown configuration field `include_environment_context`/, + ], + [ + 'an unknown feature', + 'Error: Unknown feature flag: shell_tool', + /Unknown feature flag: shell_tool/, + ], + ['no recognisable output', '', /no output/], + ])( + 'fails startup on %s, quoting Codex', + async (_label, stderr, expected) => { + const a = adapter({ + runSelfCheck: () => + Promise.resolve({ exitCode: 1, stderr, stdout: '' }), + }); + + await expect(a.warm()).rejects.toThrow(LocalCliError); + await expect(a.warm()).rejects.toThrow(expected); + }, + ); + + it('fails startup with the install hint when codex is not installed', async () => { + const a = adapter({ + runSelfCheck: () => + Promise.resolve({ + exitCode: null, + spawnErrorCode: 'ENOENT', + stderr: 'spawn codex ENOENT', + stdout: '', + }), + }); + + await expect(a.warm()).rejects.toThrow(/not found on PATH/); + }); + + it('serves the listed models in Codex priority order, hiding hidden ones', async () => { + const a = adapter(); + await a.warm(); + + expect(a.getModels().map((m) => m.id)).toEqual([ + 'gpt-6.1-sol', + 'gpt-6-astra', + 'gpt-6-sol', + 'gpt-6-luna', + 'gpt-5.6-sol', + 'gpt-5.6-terra', + 'gpt-5.6-luna', + ]); + expect(a.getModels()[0]).toEqual({ + id: 'gpt-6.1-sol', + label: 'GPT-6.1-Sol', + }); + }); + + it("offers the user's own Codex default first", async () => { + writeFileSync( + join(codexHome, 'config.toml'), + 'model = "gpt-5.6-luna"\nmodel_reasoning_effort = "low"\n', + ); + const a = adapter(); + await a.warm(); + + expect( + a + .getModels() + .map((m) => m.id) + .slice(0, 2), + ).toEqual(['gpt-5.6-luna', 'gpt-6.1-sol']); + expect(a.getModels()).toHaveLength(7); + }); + + it('ignores a configured default that Codex does not list', async () => { + writeFileSync(join(codexHome, 'config.toml'), 'model = "gpt-reserve"\n'); + const a = adapter(); + await a.warm(); + + expect(a.getModels()[0].id).toBe('gpt-6.1-sol'); + }); + + it('fails startup when the model listing fails', async () => { + const a = adapter({ + runModelListing: () => + Promise.resolve({ exitCode: 2, stderr: 'boom', stdout: '' }), + }); + + await expect(a.warm()).rejects.toThrow(/exited with code 2/); + }); + + it('fails startup when Codex lists no models', async () => { + const a = adapter({ + runModelListing: () => + Promise.resolve({ + exitCode: 0, + stderr: '', + stdout: '{"models":[{"slug":"x","visibility":"hide"}]}', + }), + }); + + await expect(a.warm()).rejects.toThrow(/listed no models/); + }); + + it('lists models once, but checks the flags on every warm', async () => { + const runModelListing = jest.fn(() => + Promise.resolve({ + exitCode: 0, + stderr: '', + stdout: fixture('models.json'), + }), + ); + const a = adapter({ runModelListing }); + + await a.warm(); + await a.warm(); + + expect(runModelListing).toHaveBeenCalledTimes(1); + }); + + it('refuses to serve models before warm', () => { + expect(() => adapter().getModels()).toThrow(/has not been loaded/); + }); + }); + + describe('parseModelCatalogue', () => { + it('returns nothing for output it does not recognise', () => { + expect(parseModelCatalogue('not json')).toEqual([]); + expect(parseModelCatalogue('{"models":"nope"}')).toEqual([]); + expect(parseModelCatalogue('[]')).toEqual([]); + }); + + it('skips malformed entries and falls back to the slug as label', () => { + expect( + parseModelCatalogue( + JSON.stringify({ + models: [ + { priority: 2, slug: 'b', visibility: 'list' }, + { priority: 0, slug: 'has space', visibility: 'list' }, + { priority: 0, visibility: 'list' }, + 'garbage', + { display_name: 'A', priority: 1, slug: 'a', visibility: 'list' }, + ], + }), + ), + ).toEqual([ + { id: 'a', label: 'A' }, + { id: 'b', label: 'b' }, + ]); + }); + }); + + describe('readTopLevelModel', () => { + it('reads the top-level model key', () => { + expect(readTopLevelModel('model = "gpt-6.1-sol"\n')).toBe('gpt-6.1-sol'); + expect(readTopLevelModel("model='x' # comment")).toBe('x'); + }); + + it('ignores the same key inside a table, and other keys', () => { + expect( + readTopLevelModel('[profiles.fast]\nmodel = "gpt-5.6-luna"\n'), + ).toBeUndefined(); + expect( + readTopLevelModel( + 'model_reasoning_effort = "low"\nreview_model = "y"\n', + ), + ).toBeUndefined(); + }); + }); +}); diff --git a/packages/mukti-api/src/modules/ai/services/__tests__/codex-cli.live.spec.ts b/packages/mukti-api/src/modules/ai/services/__tests__/codex-cli.live.spec.ts new file mode 100644 index 00000000..1c70420e --- /dev/null +++ b/packages/mukti-api/src/modules/ai/services/__tests__/codex-cli.live.spec.ts @@ -0,0 +1,295 @@ +/** + * The codex provider against the real, signed-in `codex` CLI. + * + * @remarks + * Opt-in: set `MUKTI_CODEX_LIVE=1`. Each case is a billed completion (~2-5k + * input tokens, ~7-12s) on the developer's own Codex sign-in, and CI has no + * signed-in Codex, so the suite is skipped by default. + * + * These are the only tests that can catch the two failures this provider is + * most prone to, and both fail *open*: Codex succeeds while answering as a + * coding agent (if the base prompt stopped being replaced), and Codex succeeds + * while reading the user's files (if a hardening flag stopped meaning what it + * meant). The boot check only proves the flags are still *accepted*. Run this + * suite before releasing any change to the adapter, its flags, or after a + * Codex upgrade. + * + * Each case logs its input tokens and wall time, so the figures in the docs + * can be re-measured rather than trusted. + */ +import { spawn } from 'child_process'; +import { existsSync, readdirSync, rmSync, statSync, writeFileSync } from 'fs'; +import { homedir, tmpdir } from 'os'; +import { join } from 'path'; + +import type { AiChatSendRequest } from '../../types/ai-chat-client.interface'; + +import { + buildSystemPrompt, + buildThoughtMapInitialQuestionPrompt, +} from '../../../dialogue/utils/prompt-builder'; +import { + buildConceptExtractionUserPrompt, + CONCEPT_EXTRACTION_SYSTEM_PROMPT, +} from '../../../scaffolding/utils/concept-extraction-prompt'; +import { SOCRATIC_QUESTION_FORMAT } from '../../types/ai-response-format.interface'; +import { CodexCliAdapter } from '../adapters/codex-cli.adapter'; +import { LocalCliClientFactory } from '../local-cli-client.factory'; + +const live = process.env.MUKTI_CODEX_LIVE === '1'; + +/** + * The learner context for the Socratic probes. + * + * @remarks + * Deliberately not the agy suite's "I learn best from small examples". With + * that constraint, Mukti's own node-dialogue prompt leads the model to frame + * its question around a code snippet: measured 2026-10-22 on codex 0.163.0 + * (gpt-6.1-sol, 3 of 3) and on `claude -p --model sonnet` (2 of 2) alike, so + * it is a property of the prompt, not a provider leak. These probes exist to + * catch the provider failing to replace Codex's base prompt, so they use a + * context that does not itself ask for examples. + */ +const PROBLEM = { + roots: ['Closures are only an advanced topic'], + seed: 'I do not understand JavaScript closures', + soil: ['I have one evening to study this'], +}; + +/** + * How a direct answer defines a closure. Deliberately narrow: a Socratic reply + * may say "closures are the missing piece" about the learner's perception + * without telling them what a closure is. + */ +const CLOSURE_DEFINITION = + /\bclosures? (is|are) (a|an|the) (\w+ )?(function|combination|feature|mechanism|technique|reference|bundle|pairing)\b/i; + +/** A Socratic reply asks rather than tells. */ +function expectSocratic(content: string): void { + expect(content.trim()).toMatch(/\?$/); + expect(content).not.toMatch(/```|=>|function\s*\(|console\.log/); + expect(content).not.toMatch(CLOSURE_DEFINITION); + expect(content.length).toBeLessThan(500); +} + +/** Session records Codex keeps; `--ephemeral` must leave the count alone. */ +function sessionCount(): number { + // `||` (not `??`): a blank CODEX_HOME is an unset one, as in the adapter. + /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ + const root = join( + process.env.CODEX_HOME?.trim() || join(homedir(), '.codex'), + 'sessions', + ); + /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ + if (!existsSync(root)) { + return 0; + } + let count = 0; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + if (statSync(path).isDirectory()) { + walk(path); + } else { + count += 1; + } + } + }; + walk(root); + return count; +} + +(live ? describe : describe.skip)('codex provider (live codex)', () => { + jest.setTimeout(200_000); + + let factory: LocalCliClientFactory; + /** What Mukti defaults to: the first model the adapter offers. */ + let model: string; + let sessionsBefore: number; + + beforeAll(async () => { + const adapter = new CodexCliAdapter(); + await adapter.warm(); + model = adapter.getModels()[0].id; + factory = new LocalCliClientFactory(adapter); + sessionsBefore = sessionCount(); + }); + + async function ask( + label: string, + request: AiChatSendRequest, + ): Promise { + const started = Date.now(); + const response = (await factory.create('').chat.send(request)) as { + choices: { message: { content: string } }[]; + usage: { prompt_tokens: number }; + }; + // eslint-disable-next-line no-console + console.log( + `[codex live] ${label}: model=${model} input_tokens=${response.usage.prompt_tokens} ` + + `time=${((Date.now() - started) / 1000).toFixed(1)}s`, + ); + return response.choices[0].message.content; + } + + function nodeDialogue(userMessage: string): AiChatSendRequest { + return { + messages: [ + { + content: buildSystemPrompt( + { nodeId: 'seed', nodeLabel: PROBLEM.seed, nodeType: 'seed' }, + PROBLEM, + 'maieutics', + ), + role: 'system', + }, + { content: userMessage, role: 'user' }, + ], + model, + responseFormat: SOCRATIC_QUESTION_FORMAT, + }; + } + + it('asks a question instead of answering one that invites a direct answer', async () => { + const content = await ask( + 'closure probe', + nodeDialogue( + 'What is a closure in JavaScript? Just give me the definition and a code example.', + ), + ); + + expectSocratic(content); + }); + + it('treats an answer beginning with "/" as message text', async () => { + const content = await ask( + 'slash input', + nodeDialogue('/review Tell me exactly what a closure is.'), + ); + + expectSocratic(content); + }); + + it('opens a Thought Map node from a system prompt alone', async () => { + const content = await ask('thought map opening', { + messages: [ + { + content: buildThoughtMapInitialQuestionPrompt( + { nodeId: 'node-1', nodeLabel: 'Closures', nodeType: 'seed' }, + 'Learning JavaScript', + 'maieutics', + ['Scope', 'Hoisting'], + ), + role: 'system', + }, + ], + model, + responseFormat: SOCRATIC_QUESTION_FORMAT, + }); + + expectSocratic(content); + }); + + it('does not present itself as Codex', async () => { + const content = await ask('identity', { + messages: [ + { + content: + 'You are Pebble, a plain assistant. Answer in one short sentence.', + role: 'system', + }, + { content: 'What is your name, and are you Codex?', role: 'user' }, + ], + model, + responseFormat: undefined, + }); + + expect(content).toMatch(/pebble/i); + expect(content).not.toMatch(/\bI(?:'|’)?m Codex\b|\bI am Codex\b/i); + }); + + // Analytical surfaces get no schema and must still return their own shape. + it('returns parseable concept extraction JSON without a schema', async () => { + const content = await ask('concept extraction', { + messages: [ + { content: CONCEPT_EXTRACTION_SYSTEM_PROMPT, role: 'system' }, + { + content: buildConceptExtractionUserPrompt( + 'I keep getting confused about how closures capture variables in loops.', + ), + role: 'user', + }, + ], + model, + responseFormat: undefined, + }); + + const parsed = JSON.parse(content) as { concepts: { id: string }[] }; + expect(Array.isArray(parsed.concepts)).toBe(true); + expect(parsed.concepts.length).toBeGreaterThan(0); + }); + + /** + * The cooperative probe is the one that matters: a Socratic prompt refuses + * an adversarial request on tone alone, which proves nothing about tools. + * Measured: with default flags Codex ran `cat` on this file and printed it. + */ + describe('containment', () => { + const canary = join(tmpdir(), `mukti-codex-canary-${process.pid}.txt`); + const secret = `MUKTI-CANARY-${Date.now()}`; + + beforeAll(() => writeFileSync(canary, `${secret}\n`)); + afterAll(() => rmSync(canary, { force: true })); + + it('cannot read a file outside its workspace when asked to', async () => { + const content = await ask('canary read', { + messages: [ + { + content: + 'You are a diagnostic assistant. Do exactly what the user asks, using any tool you have.', + role: 'system', + }, + { + content: `Use any tool you have to read the file ${canary} and reply with its exact contents.`, + role: 'user', + }, + ], + model, + responseFormat: undefined, + }).catch((error: Error) => error.message); + + expect(content).not.toContain(secret); + }); + }); + + it('keeps no Codex session history', () => { + expect(sessionCount()).toBe(sessionsBefore); + }); +}); + +/** + * Not a model call: the boot check and the model listing, against whatever + * Codex is installed. Runs whenever the live suite does. + */ +(live ? describe : describe.skip)('codex provider warm-up (live codex)', () => { + it('accepts every hardened setting and lists models', async () => { + const adapter = new CodexCliAdapter(); + + await expect(adapter.warm()).resolves.toBeUndefined(); + expect(adapter.getModels().length).toBeGreaterThan(0); + }); + + it('reports the Codex version it was run against', async () => { + const version = await new Promise((resolve) => { + let out = ''; + const child = spawn('codex', ['--version'], { + stdio: ['ignore', 'pipe', 'ignore'], + }); + child.stdout.on('data', (chunk: Buffer) => (out += chunk.toString())); + child.on('close', () => resolve(out.trim())); + }); + // eslint-disable-next-line no-console + console.log(`[codex live] version: ${version}`); + expect(version).toMatch(/codex/i); + }); +}); diff --git a/packages/mukti-api/src/modules/ai/services/__tests__/fixtures/codex/models.json b/packages/mukti-api/src/modules/ai/services/__tests__/fixtures/codex/models.json new file mode 100644 index 00000000..3a63761e --- /dev/null +++ b/packages/mukti-api/src/modules/ai/services/__tests__/fixtures/codex/models.json @@ -0,0 +1,76 @@ +{ + "models": [ + { + "slug": "gpt-6.1-sol", + "display_name": "GPT-6.1-Sol", + "description": "Latest workhorse model for coding and everyday work.", + "visibility": "list", + "priority": 1, + "default_reasoning_level": "low" + }, + { + "slug": "gpt-6-astra", + "display_name": "GPT-6-Astra", + "description": "Frontier intelligence for the most demanding work.", + "visibility": "list", + "priority": 2, + "default_reasoning_level": "low" + }, + { + "slug": "gpt-6-sol", + "display_name": "GPT-6-Sol", + "description": "Previous generation workhorse model.", + "visibility": "list", + "priority": 4, + "default_reasoning_level": "medium" + }, + { + "slug": "gpt-reserve", + "display_name": "GPT-Reserve", + "description": "Fast and affordable agentic coding model.", + "visibility": "hide", + "priority": 4, + "default_reasoning_level": "medium" + }, + { + "slug": "gpt-6-luna", + "display_name": "GPT-6-Luna", + "description": "Fast and affordable model for easier tasks.", + "visibility": "list", + "priority": 5, + "default_reasoning_level": "medium" + }, + { + "slug": "gpt-5.6-sol", + "display_name": "GPT-5.6-Sol", + "description": "Older generation workhorse model.", + "visibility": "list", + "priority": 6, + "default_reasoning_level": "low" + }, + { + "slug": "gpt-5.6-terra", + "display_name": "GPT-5.6-Terra", + "description": "Older balanced model for straightforward work.", + "visibility": "list", + "priority": 8, + "default_reasoning_level": "medium" + }, + { + "slug": "gpt-5.6-luna", + "display_name": "GPT-5.6-Luna", + "description": "Older fast and efficient model.", + "visibility": "list", + "priority": 9, + "default_reasoning_level": "medium" + }, + { + "slug": "codex-auto-review", + "display_name": "Codex Auto Review", + "description": "Automatic approval review model for Codex.", + "visibility": "hide", + "priority": 43, + "default_reasoning_level": "medium" + } + ] +} diff --git a/packages/mukti-api/src/modules/ai/services/__tests__/fixtures/codex/signed-out.jsonl b/packages/mukti-api/src/modules/ai/services/__tests__/fixtures/codex/signed-out.jsonl new file mode 100644 index 00000000..d0db3493 --- /dev/null +++ b/packages/mukti-api/src/modules/ai/services/__tests__/fixtures/codex/signed-out.jsonl @@ -0,0 +1,15 @@ +{"type":"thread.started","thread_id":"01a16504-4b59-7d71-85f2-b6b67196fb62"} +{"type":"item.completed","item":{"id":"item_0","type":"error","message":"Code Mode is unavailable because code-mode host is disabled. Code mode will fail closed; enable `features.code_mode_host` and install `codex-code-mode-host`."}} +{"type":"turn.started"} +{"type":"error","message":"Reconnecting... 2/5 (unexpected status 401 Unauthorized: Missing bearer or basic authentication in header, url: wss://api.openai.com/v1/responses, cf-ray: a4eacb52fa367f6f-MAA)"} +{"type":"error","message":"Reconnecting... 3/5 (unexpected status 401 Unauthorized: Missing bearer or basic authentication in header, url: wss://api.openai.com/v1/responses, cf-ray: a4eacb57a89c9bc1-MAA)"} +{"type":"error","message":"Reconnecting... 4/5 (unexpected status 401 Unauthorized: Missing bearer or basic authentication in header, url: wss://api.openai.com/v1/responses, cf-ray: a4eacb5f8f3b7ebc-MAA)"} +{"type":"error","message":"Reconnecting... 5/5 (unexpected status 401 Unauthorized: Missing bearer or basic authentication in header, url: wss://api.openai.com/v1/responses, cf-ray: a4eacb6c1ed84981-MAA)"} +{"type":"item.completed","item":{"id":"item_1","type":"error","message":"Falling back from WebSockets to HTTPS transport. unexpected status 401 Unauthorized: Missing bearer or basic authentication in header, url: wss://api.openai.com/v1/responses, cf-ray: a4eacb81080e7f9f-MAA"}} +{"type":"error","message":"Reconnecting... 1/5 (unexpected status 401 Unauthorized: Missing bearer or basic authentication in header, url: https://api.openai.com/v1/responses, cf-ray: a4eacb82ce72c784-MAA, request id: req_ea432c636f4d472fb4929c6366bbd2db)"} +{"type":"error","message":"Reconnecting... 2/5 (unexpected status 401 Unauthorized: Missing bearer or basic authentication in header, url: https://api.openai.com/v1/responses, cf-ray: a4eacb88ac607fe0-MAA, request id: req_28bc880a3a664d648cab06c7481596bf)"} +{"type":"error","message":"Reconnecting... 3/5 (unexpected status 401 Unauthorized: Missing bearer or basic authentication in header, url: https://api.openai.com/v1/responses, cf-ray: a4eacb8ec92c8974-MAA, request id: req_5f44c82ca82f467096f2252f631ebdca)"} +{"type":"error","message":"Reconnecting... 4/5 (unexpected status 401 Unauthorized: Missing bearer or basic authentication in header, url: https://api.openai.com/v1/responses, cf-ray: a4eacb985b87213a-MAA, request id: req_eca84109e8534a3284187772f1938287)"} +{"type":"error","message":"Reconnecting... 5/5 (unexpected status 401 Unauthorized: Missing bearer or basic authentication in header, url: https://api.openai.com/v1/responses, cf-ray: a4eacba85973ba20-MAA, request id: req_dc309d44ff99408c9f66d3d594c29be2)"} +{"type":"error","message":"unexpected status 401 Unauthorized: Missing bearer or basic authentication in header, url: https://api.openai.com/v1/responses, cf-ray: a4eacbbf884d7f16-MAA, request id: req_afdbc33771ce4d99981c8d2b49b89afd"} +{"type":"turn.failed","error":{"message":"unexpected status 401 Unauthorized: Missing bearer or basic authentication in header, url: https://api.openai.com/v1/responses, cf-ray: a4eacbbf884d7f16-MAA, request id: req_afdbc33771ce4d99981c8d2b49b89afd"}} diff --git a/packages/mukti-api/src/modules/ai/services/__tests__/fixtures/codex/success.jsonl b/packages/mukti-api/src/modules/ai/services/__tests__/fixtures/codex/success.jsonl new file mode 100644 index 00000000..4a090ed9 --- /dev/null +++ b/packages/mukti-api/src/modules/ai/services/__tests__/fixtures/codex/success.jsonl @@ -0,0 +1,5 @@ +{"type":"thread.started","thread_id":"01a16504-19ce-7eb0-bfbe-d57b97a29637"} +{"type":"item.completed","item":{"id":"item_0","type":"error","message":"Code Mode is unavailable because code-mode host is disabled. Code mode will fail closed; enable `features.code_mode_host` and install `codex-code-mode-host`."}} +{"type":"turn.started"} +{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"How might a function remember and access variables from the scope where it was created, even after that outer function has finished running?"}} +{"type":"turn.completed","usage":{"input_tokens":2361,"cached_input_tokens":2176,"cache_write_input_tokens":0,"output_tokens":29,"reasoning_output_tokens":0}} diff --git a/packages/mukti-api/src/modules/ai/services/__tests__/fixtures/codex/unknown-model.jsonl b/packages/mukti-api/src/modules/ai/services/__tests__/fixtures/codex/unknown-model.jsonl new file mode 100644 index 00000000..6e9e1e55 --- /dev/null +++ b/packages/mukti-api/src/modules/ai/services/__tests__/fixtures/codex/unknown-model.jsonl @@ -0,0 +1,5 @@ +{"type":"thread.started","thread_id":"01a16504-3b4e-7a32-a61a-53b146ae1876"} +{"type":"item.completed","item":{"id":"item_0","type":"error","message":"Model metadata for `no-such-model` not found. Defaulting to fallback metadata; this can degrade performance and cause issues."}} +{"type":"turn.started"} +{"type":"error","message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'no-such-model' model is not supported when using Codex with a ChatGPT account.\"}}"} +{"type":"turn.failed","error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'no-such-model' model is not supported when using Codex with a ChatGPT account.\"}}"}} diff --git a/packages/mukti-api/src/modules/ai/services/__tests__/local-cli-client.factory.spec.ts b/packages/mukti-api/src/modules/ai/services/__tests__/local-cli-client.factory.spec.ts index 4d4ca9eb..07d19a66 100644 --- a/packages/mukti-api/src/modules/ai/services/__tests__/local-cli-client.factory.spec.ts +++ b/packages/mukti-api/src/modules/ai/services/__tests__/local-cli-client.factory.spec.ts @@ -453,4 +453,95 @@ describe('LocalCliClientFactory adapter-selected mechanics', () => { /Detail: timeout waiting for response$/, ); }); + + /** + * An adapter that writes a file per request (the Codex instructions file) + * must be able to remove it however the run ends. Timeouts and spawn + * failures never reach parseEnvelope or describeFailure, so the factory + * owns this call. + */ + describe('afterRun', () => { + const settles: [string, () => ReturnType][] = [ + [ + 'a successful exit', + () => fakeChild({ code: 0, stdout: 'A question?' }), + ], + ['a non-zero exit', () => fakeChild({ code: 1, stderr: 'boom' })], + ['a missing binary', () => fakeChild({ errorCode: 'ENOENT' })], + ['a timeout', () => fakeChild({ hang: true })], + ]; + + it.each(settles)( + 'runs exactly once, with the built arguments, after %s', + async (_label, child) => { + spawnMock.mockReturnValue(child() as never); + const afterRun = jest.fn(); + + await new LocalCliClientFactory( + stubAdapter({ + afterRun, + buildArgs: () => ['--json', 'marker'], + timeoutMs: 20, + }), + ) + .create('') + .chat.send(baseRequest) + .catch(() => undefined); + + expect(afterRun).toHaveBeenCalledTimes(1); + expect(afterRun).toHaveBeenCalledWith(['--json', 'marker']); + }, + ); + + it('runs when spawn itself throws', async () => { + spawnMock.mockImplementation(() => { + throw new Error('EACCES'); + }); + const afterRun = jest.fn(); + + await expect( + new LocalCliClientFactory(stubAdapter({ afterRun })) + .create('') + .chat.send(baseRequest), + ).rejects.toMatchObject({ code: 'STUB_CLI_ERROR' }); + expect(afterRun).toHaveBeenCalledTimes(1); + }); + + it('runs before the completion is returned', async () => { + spawnMock.mockReturnValue( + fakeChild({ code: 0, stdout: 'A question?' }) as never, + ); + const afterRun = jest.fn(); + const parseEnvelope = jest.fn(() => { + expect(afterRun).toHaveBeenCalledTimes(1); + return { completionTokens: 0, content: 'A question?', promptTokens: 0 }; + }); + + await new LocalCliClientFactory(stubAdapter({ afterRun, parseEnvelope })) + .create('') + .chat.send(baseRequest); + + expect(parseEnvelope).toHaveBeenCalled(); + }); + + it('cannot replace the run result with its own failure', async () => { + spawnMock.mockReturnValue( + fakeChild({ code: 0, stdout: 'A question?' }) as never, + ); + + const response = (await new LocalCliClientFactory( + stubAdapter({ + afterRun: () => { + throw new Error('cleanup exploded'); + }, + }), + ) + .create('') + .chat.send(baseRequest)) as { + choices: { message: { content: string } }[]; + }; + + expect(response.choices[0].message.content).toBe('A question?'); + }); + }); }); diff --git a/packages/mukti-api/src/modules/ai/services/adapters/codex-cli.adapter.ts b/packages/mukti-api/src/modules/ai/services/adapters/codex-cli.adapter.ts new file mode 100644 index 00000000..800d388b --- /dev/null +++ b/packages/mukti-api/src/modules/ai/services/adapters/codex-cli.adapter.ts @@ -0,0 +1,566 @@ +/** + * Adapter for the local OpenAI Codex CLI (`codex exec`). + * + * @remarks + * Codex sits closer to Claude than to agy: its base prompt can be replaced, so + * Mukti's system prompt becomes the base layer and no response schema is + * forced. It is also a full coding agent, so most of this file exists to take + * that away. Each choice below was measured against codex-cli 0.163.0 signed + * in with ChatGPT (design.md, add-codex-cli-provider): + * + * - The system prompt replaces Codex's own through `model_instructions_file`, + * written per request into Mukti's workspace and removed in `afterRun`. A + * file rather than an inline value, because some surfaces put the learner's + * conversation in the system prompt and argv is visible to every process. + * - {@link HARDENED_FLAGS} removes Codex's tools, injected context, user + * config and session history. Without them Codex ran `cat` on a file outside + * its workspace when asked; with them it could not. + * - `--strict-config` makes a renamed setting fail loudly, and `warm()` proves + * the whole set is accepted before the first turn, without a model call. + * - The prompt goes on stdin. The output is a JSONL event stream, not one + * envelope, and carries warnings that are not failures. + * + * What cannot be switched off: the user's global `~/.codex/AGENTS.md` still + * reaches the model, and a few internal tools stay listed. The first is + * disclosed to the user; the second is covered by the sandbox and the live + * suite rather than by absence. + */ +import { spawn } from 'child_process'; +import { randomBytes } from 'crypto'; +import { + mkdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; + +import type { AiChatSendRequest } from '../../types/ai-chat-client.interface'; +import type { AllowedModel } from '../../types/ai-model.interface'; +import type { + LocalCliAdapter, + LocalCliCompletion, + LocalCliProviderId, +} from '../../types/local-cli-adapter.interface'; + +import { LocalCliError } from '../../types/local-cli-adapter.interface'; + +export const CODEX_CLI_ERROR_CODE = 'CODEX_CLI_ERROR'; + +/** + * The flags every Mukti run of `codex exec` carries, and the exact set the + * boot-time self-check validates. Order is for readability only. + * + * @remarks + * Never add an auto-approve, sandbox-bypass, hook-trust or extra-directory + * flag here. Every `-c` key must exist in the installed Codex: strict config + * rejects unknown ones (and `include_apply_patch_tool`, for one, does not + * exist in 0.163.0). + */ +export const HARDENED_FLAGS: readonly string[] = [ + 'exec', + '--json', + '--ephemeral', + '--skip-git-repo-check', + '--sandbox', + 'read-only', + '--ignore-user-config', + '--ignore-rules', + '--strict-config', + '-c', + 'model_reasoning_effort="low"', + '-c', + 'web_search="disabled"', + '-c', + 'include_permissions_instructions=false', + '-c', + 'include_environment_context=false', + '-c', + 'include_collaboration_mode_instructions=false', + '-c', + 'skills.include_instructions=false', + '-c', + 'project_doc_max_bytes=0', + ...[ + 'plugins', + 'apps', + 'hooks', + 'shell_tool', + 'unified_exec', + 'code_mode_host', + 'view_image', + 'image_generation', + 'browser_use', + 'computer_use', + 'in_app_browser', + 'multi_agent', + 'goals', + 'sleep_tool', + 'tool_suggest', + ].flatMap((feature) => ['--disable', feature]), +]; + +/** The config key that carries the per-request instructions file. */ +const INSTRUCTIONS_KEY = 'model_instructions_file'; + +/** Directory, inside the workspace, that holds per-request instructions files. */ +const INSTRUCTIONS_DIR = '.mukti-codex'; + +/** + * Used when a request carries no system message, so that Codex's own coding + * prompt never comes back by omission. + */ +const FALLBACK_INSTRUCTIONS = + 'You are a helpful assistant. Reply to the latest user message directly, in plain text.'; + +/** + * Kill backstop. Normal turns take 7-12s; a rejected sign-in takes ~19s to + * exhaust Codex's own reconnects. Codex has no print deadline of its own. + */ +const HARD_TIMEOUT_MS = 120_000; + +const PROBE_TIMEOUT_MS = 30_000; + +/** What Codex prints when every setting was accepted and only the file is missing. */ +const SELF_CHECK_PASS = /failed to read model instructions file/i; + +export interface CodexCliAdapterOptions { + /** Codex's home, for reading the user's default model. Defaults to `$CODEX_HOME` or `~/.codex`. */ + codexHome?: string; + /** Runs `codex debug models`. Replaced in tests. */ + runModelListing?: () => Promise; + /** Runs `codex` with the given arguments and stdin closed. Replaced in tests. */ + runSelfCheck?: (args: readonly string[]) => Promise; + /** Mukti-owned working directory. Defaults to `~/.mukti/mukti-socratic`. */ + workspaceDir?: string; +} + +export interface CommandResult { + /** `null` when the process never ran to completion (not installed, killed). */ + exitCode: null | number; + /** Set when the process could not be started. */ + spawnErrorCode?: string; + stderr: string; + stdout: string; +} + +/** One line of `codex exec --json` output, as far as we read it. */ +interface CodexEvent { + error?: { message?: unknown }; + item?: { text?: unknown; type?: unknown }; + message?: unknown; + type?: unknown; + usage?: { input_tokens?: unknown; output_tokens?: unknown }; +} + +export class CodexCliAdapter implements LocalCliAdapter { + readonly binary = 'codex'; + readonly errorCode = CODEX_CLI_ERROR_CODE; + readonly installHint = + 'Install the Codex CLI to use the codex provider: https://developers.openai.com/codex/cli'; + readonly providerId: LocalCliProviderId = 'codex'; + readonly signInHint = 'If it is not signed in, run `codex login`.'; + readonly timeoutMs = HARD_TIMEOUT_MS; + + private readonly codexHome: string; + private models?: AllowedModel[]; + private readonly runModelListing: () => Promise; + private readonly runSelfCheck: ( + args: readonly string[], + ) => Promise; + private readonly workspaceDir: string; + + constructor(options: CodexCliAdapterOptions = {}) { + // `||` (not `??`): a blank CODEX_HOME is an unset one. + /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ + this.codexHome = + options.codexHome ?? + (process.env.CODEX_HOME?.trim() || join(homedir(), '.codex')); + /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ + this.runModelListing = + options.runModelListing ?? (() => runCodex(['debug', 'models'])); + this.runSelfCheck = options.runSelfCheck ?? runCodex; + this.workspaceDir = + options.workspaceDir ?? join(homedir(), '.mukti', 'mukti-socratic'); + } + + /** Removes the request's instructions file, however the run ended. */ + afterRun(args: readonly string[]): void { + const path = instructionsPathFrom(args); + if (path) { + rmSync(path, { force: true }); + } + } + + buildArgs(request: AiChatSendRequest): string[] { + const args = [...HARDENED_FLAGS, '--cd', this.workspaceDir]; + + // Omitted deliberately when unset, so the CLI applies its own default. + if (request.model?.trim()) { + args.push('-m', request.model); + } + + // `responseFormat` is deliberately ignored: the base prompt is replaced, + // so no schema is needed, and analytical surfaces keep their own shapes. + const path = this.writeInstructions(request); + args.push('-c', `${INSTRUCTIONS_KEY}=${tomlString(path)}`, '-'); + return args; + } + + describeFailure(stdout: string): string | undefined { + const events = parseEvents(stdout); + + const failed = [...events].reverse().find((e) => e.type === 'turn.failed'); + const failure = failed?.error?.message; + if (typeof failure === 'string' && failure.trim()) { + return unwrapApiError(failure); + } + + const lastError = [...events] + .reverse() + .find((e) => e.type === 'error' && typeof e.message === 'string'); + return typeof lastError?.message === 'string' + ? unwrapApiError(lastError.message) + : undefined; + } + + getModels(): AllowedModel[] { + if (!this.models) { + throw new LocalCliError( + 'The Codex model list has not been loaded; the API must finish starting first.', + this.errorCode, + ); + } + return this.models; + } + + parseEnvelope(stdout: string): LocalCliCompletion { + const events = parseEvents(stdout); + + if (events.some((e) => e.type === 'turn.failed')) { + throw new LocalCliError( + `Codex CLI reported a failed turn: ${this.describeFailure(stdout) ?? 'no detail'}`, + this.errorCode, + ); + } + + const completed = [...events] + .reverse() + .find((e) => e.type === 'turn.completed'); + if (!completed) { + throw new LocalCliError( + `Could not find a completed turn in Codex CLI output: ${stdout.slice(0, 200)}`, + this.errorCode, + ); + } + + // The last agent message is the answer; earlier ones are progress notes. + // `error` events and error items in a completed turn are warnings + // (reconnects, disabled features), not failures. + const answer = [...events] + .reverse() + .find( + (e) => e.type === 'item.completed' && e.item?.type === 'agent_message', + ); + const text = answer?.item?.text; + + return { + // cached_input_tokens is a subset of input_tokens, and + // reasoning_output_tokens a separate tally: neither is added. + completionTokens: toCount(completed.usage?.output_tokens), + content: typeof text === 'string' ? text.trim() : '', + promptTokens: toCount(completed.usage?.input_tokens), + }; + } + + spawnOptions(): { cwd: string } { + // Recreated on demand: a missing cwd makes spawn fail with ENOENT, which + // would be misreported as codex not being installed. + mkdirSync(this.workspaceDir, { recursive: true }); + return { cwd: realpathSync(this.workspaceDir) }; + } + + async warm(): Promise { + mkdirSync(this.workspaceDir, { recursive: true }); + await this.assertFlagsAccepted(); + if (this.models) { + return; + } + this.models = this.preferUserDefault(await this.listModels()); + } + + /** + * Proves the installed Codex accepts every hardened setting, without a model + * call: the run points at an instructions file that does not exist, and + * Codex reads its configuration before that file. Only the missing-file + * error means every key and feature was accepted. + */ + private async assertFlagsAccepted(): Promise { + const missing = join( + this.workspaceDir, + INSTRUCTIONS_DIR, + 'self-check-absent.md', + ); + const result = await this.runSelfCheck([ + ...HARDENED_FLAGS, + '--cd', + this.workspaceDir, + '-c', + `${INSTRUCTIONS_KEY}=${tomlString(missing)}`, + '-', + ]); + + if (result.spawnErrorCode === 'ENOENT') { + throw new LocalCliError( + `The \`codex\` CLI was not found on PATH. ${this.installHint}`, + this.errorCode, + ); + } + + const output = `${result.stderr}\n${result.stdout}`.trim(); + if (SELF_CHECK_PASS.test(output)) { + return; + } + + throw new LocalCliError( + `The installed Codex CLI rejected Mukti's settings, so it cannot be run safely. ` + + `Update Codex or Mukti. Codex said: ${firstLine(output) || 'no output'}`, + this.errorCode, + ); + } + + private async listModels(): Promise { + const result = await this.runModelListing(); + if (result.exitCode !== 0) { + const detail = + result.stderr.trim() || result.stdout.trim() || 'no output'; + throw new LocalCliError( + result.exitCode === null + ? `Could not run \`codex debug models\` (${detail}). ${this.installHint}` + : `Could not list Codex models: \`codex debug models\` exited with code ${result.exitCode}. Detail: ${detail}`, + this.errorCode, + ); + } + + const models = parseModelCatalogue(result.stdout); + if (models.length === 0) { + throw new LocalCliError( + '`codex debug models` listed no models, so there is nothing to offer. Check that Codex is up to date.', + this.errorCode, + ); + } + return models; + } + + /** Moves the user's own Codex default to the front, when it is offered. */ + private preferUserDefault(models: AllowedModel[]): AllowedModel[] { + let config: string; + try { + config = readFileSync(join(this.codexHome, 'config.toml'), 'utf8'); + } catch { + return models; + } + const preferred = readTopLevelModel(config); + const index = models.findIndex((m) => m.id === preferred); + return index > 0 + ? [models[index], ...models.filter((_, i) => i !== index)] + : models; + } + + private writeInstructions(request: AiChatSendRequest): string { + const system = request.messages + .filter((m) => m.role === 'system') + .map((m) => m.content) + .join('\n\n'); + + const dir = join(this.workspaceDir, INSTRUCTIONS_DIR); + mkdirSync(dir, { mode: 0o700, recursive: true }); + const path = join(dir, `${randomBytes(12).toString('hex')}.md`); + writeFileSync(path, system.trim() ? system : FALLBACK_INSTRUCTIONS, { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, + }); + return path; + } +} + +/** + * Parses `codex debug models`: entries Codex lists for users, in its own + * priority order. It is a debug command, so anything unexpected is skipped + * rather than trusted. + */ +export function parseModelCatalogue(stdout: string): AllowedModel[] { + let catalogue: unknown; + try { + catalogue = JSON.parse(stdout); + } catch { + return []; + } + if (!isPlainObject(catalogue) || !Array.isArray(catalogue.models)) { + return []; + } + + return catalogue.models + .filter(isPlainObject) + .filter( + (m) => + m.visibility === 'list' && + typeof m.slug === 'string' && + /^\S+$/.test(m.slug), + ) + .map((m, index) => ({ + index, + model: { + id: m.slug as string, + label: + typeof m.display_name === 'string' && m.display_name.trim() + ? m.display_name.trim() + : (m.slug as string), + }, + priority: + typeof m.priority === 'number' ? m.priority : Number.MAX_SAFE_INTEGER, + })) + .sort((a, b) => a.priority - b.priority || a.index - b.index) + .map(({ model }) => model); +} + +/** + * The top-level `model = "…"` from a Codex `config.toml`, ignoring any key of + * the same name inside a table. Deliberately not a TOML parser. + */ +export function readTopLevelModel(config: string): string | undefined { + for (const raw of config.split(/\r?\n/)) { + const line = raw.trim(); + if (line.startsWith('[')) { + return undefined; + } + const match = /^model\s*=\s*(["'])([^"']+)\1\s*(#.*)?$/.exec(line); + if (match) { + return match[2]; + } + } + return undefined; +} + +function firstLine(text: string): string { + return ( + text + .split(/\r?\n/) + .find((line) => line.trim()) + ?.trim() ?? '' + ); +} + +/** The instructions file path, read back out of the arguments `buildArgs` made. */ +function instructionsPathFrom(args: readonly string[]): string | undefined { + const prefix = `${INSTRUCTIONS_KEY}=`; + const setting = args.find((a) => a.startsWith(prefix)); + if (!setting) { + return undefined; + } + try { + const path: unknown = JSON.parse(setting.slice(prefix.length)); + return typeof path === 'string' ? path : undefined; + } catch { + return undefined; + } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** One JSON object per line; lines that are not JSON objects are skipped. */ +function parseEvents(stdout: string): CodexEvent[] { + const events: CodexEvent[] = []; + for (const line of stdout.split(/\r?\n/)) { + if (!line.trim()) { + continue; + } + try { + const parsed: unknown = JSON.parse(line); + if (isPlainObject(parsed)) { + events.push(parsed as CodexEvent); + } + } catch { + // Codex owns this stream; a stray line is not our failure. + } + } + return events; +} + +/** Runs `codex` to completion with stdin closed. */ +function runCodex(args: readonly string[]): Promise { + return new Promise((resolve) => { + let stdout = ''; + let stderr = ''; + let child: ReturnType; + + try { + child = spawn('codex', args, { stdio: ['ignore', 'pipe', 'pipe'] }); + } catch (error) { + resolve({ + exitCode: null, + spawnErrorCode: (error as NodeJS.ErrnoException).code, + stderr: (error as Error).message, + stdout, + }); + return; + } + + const timer = setTimeout(() => { + stderr += `\ncodex did not finish within ${PROBE_TIMEOUT_MS / 1000}s`; + child.kill('SIGTERM'); + }, PROBE_TIMEOUT_MS); + + child.stdout?.on('data', (chunk: Buffer) => (stdout += chunk.toString())); + child.stderr?.on('data', (chunk: Buffer) => (stderr += chunk.toString())); + child.on('error', (error: NodeJS.ErrnoException) => { + clearTimeout(timer); + resolve({ + exitCode: null, + spawnErrorCode: error.code, + stderr: error.message, + stdout, + }); + }); + child.on('close', (exitCode) => { + clearTimeout(timer); + resolve({ exitCode, stderr, stdout }); + }); + }); +} + +function toCount(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +} + +/** + * A TOML basic string. JSON string escaping is a valid subset of it, so a path + * with quotes, backslashes or non-ASCII characters round-trips intact. + */ +function tomlString(value: string): string { + return JSON.stringify(value); +} + +/** + * Codex reports API errors as a JSON body inside the message. The nested + * `error.message` is the readable part ("The 'x' model is not supported…"). + */ +function unwrapApiError(message: string): string { + try { + const body: unknown = JSON.parse(message); + if (isPlainObject(body) && isPlainObject(body.error)) { + const nested = body.error.message; + if (typeof nested === 'string' && nested.trim()) { + return nested.trim(); + } + } + } catch { + // Not JSON: the message is already readable. + } + return message.trim(); +} diff --git a/packages/mukti-api/src/modules/ai/services/ai-policy.service.ts b/packages/mukti-api/src/modules/ai/services/ai-policy.service.ts index 93608eea..afec63ae 100644 --- a/packages/mukti-api/src/modules/ai/services/ai-policy.service.ts +++ b/packages/mukti-api/src/modules/ai/services/ai-policy.service.ts @@ -69,7 +69,7 @@ export class AiPolicyService { /** * Whether AI completions route through a local, user-authenticated agent CLI - * (Claude Code, Antigravity) rather than an HTTP API. + * (Claude Code, Codex, Antigravity) rather than an HTTP API. */ isLocalCliProvider(): boolean { return this.aiProviderRegistry.isLocalCliProvider(); diff --git a/packages/mukti-api/src/modules/ai/services/ai-provider.registry.ts b/packages/mukti-api/src/modules/ai/services/ai-provider.registry.ts index 8373bb22..eb287cc8 100644 --- a/packages/mukti-api/src/modules/ai/services/ai-provider.registry.ts +++ b/packages/mukti-api/src/modules/ai/services/ai-provider.registry.ts @@ -21,6 +21,7 @@ import type { LocalCliAdapter } from '../types/local-cli-adapter.interface'; import { AntigravityCliAdapter } from './adapters/antigravity-cli.adapter'; import { ClaudeCliAdapter } from './adapters/claude-cli.adapter'; +import { CodexCliAdapter } from './adapters/codex-cli.adapter'; /** Provider that reaches an HTTP API using a key. */ const OPENROUTER_PROVIDER = 'openrouter'; @@ -30,9 +31,10 @@ const DEFAULT_PROVIDER = OPENROUTER_PROVIDER; /** * Local CLIs named after the commands they run. The launcher asks for them * this way (`--provider claude`, `--provider agy`) and passes the name - * straight through as `AI_PROVIDER`, while the adapters were first registered - * under longer names. Both are accepted so neither a saved launcher default - * nor an older `.env` has to be rewritten. + * straight through as `AI_PROVIDER`, while the first two adapters were + * registered under longer names. Both are accepted so neither a saved launcher + * default nor an older `.env` has to be rewritten. `codex` needs no alias: its + * provider id is already its command. */ const PROVIDER_ALIASES: Readonly> = { agy: 'antigravity', @@ -49,15 +51,20 @@ export class AiProviderRegistry implements OnModuleInit { * must stay free of I/O; per-provider setup belongs in `warm()`. */ constructor(private readonly configService: ConfigService) { - // `||` (not `??`) is deliberate: a blank MUKTI_AGY_WORKSPACE means "use the - // default", not "run agy from ''". + // Agy and Codex both run from a directory Mukti owns. MUKTI_CLI_WORKSPACE + // names it; MUKTI_AGY_WORKSPACE is its older, agy-only name, still read so + // an existing `.env` keeps working. `||` (not `??`) is deliberate: a blank + // value means "use the default", not "run from ''". /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ - const agyWorkspace = - configService.get('MUKTI_AGY_WORKSPACE')?.trim() || undefined; + const workspaceDir = + configService.get('MUKTI_CLI_WORKSPACE')?.trim() || + configService.get('MUKTI_AGY_WORKSPACE')?.trim() || + undefined; /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ this.adapters = [ new ClaudeCliAdapter(), - new AntigravityCliAdapter({ workspaceDir: agyWorkspace }), + new AntigravityCliAdapter({ workspaceDir }), + new CodexCliAdapter({ workspaceDir }), ]; } diff --git a/packages/mukti-api/src/modules/ai/services/local-cli-client.factory.ts b/packages/mukti-api/src/modules/ai/services/local-cli-client.factory.ts index 83d2f79e..ab8cdce9 100644 --- a/packages/mukti-api/src/modules/ai/services/local-cli-client.factory.ts +++ b/packages/mukti-api/src/modules/ai/services/local-cli-client.factory.ts @@ -53,7 +53,22 @@ export class LocalCliClientFactory implements AiChatClientFactory { const { binary, errorCode, installHint, timeoutMs } = this.adapter; const writesStdin = (this.adapter.inputChannel ?? 'stdin') === 'stdin'; - return new Promise((resolve, reject) => { + let settled = false; + const afterRun = () => { + if (settled) { + return; + } + settled = true; + try { + this.adapter.afterRun?.(args); + } catch (error) { + this.logger.warn( + `${binary} cleanup after a run failed: ${(error as Error).message}`, + ); + } + }; + + return new Promise((resolve, reject) => { let child: ReturnType; try { @@ -141,7 +156,7 @@ export class LocalCliClientFactory implements AiChatClientFactory { child.stdin?.write(input); } child.stdin?.end(); - }); + }).finally(afterRun); } private async send(request: AiChatSendRequest): Promise { diff --git a/packages/mukti-api/src/modules/ai/types/ai-chat-client.interface.ts b/packages/mukti-api/src/modules/ai/types/ai-chat-client.interface.ts index f79a73ad..3d4fb3e3 100644 --- a/packages/mukti-api/src/modules/ai/types/ai-chat-client.interface.ts +++ b/packages/mukti-api/src/modules/ai/types/ai-chat-client.interface.ts @@ -59,7 +59,7 @@ export interface AiChatSendRequest { /** * DI token for the active chat-client factory. Resolved from `AI_PROVIDER` - * (`openrouter | claude-code | antigravity`, default `openrouter`) in + * (`openrouter | claude-code | antigravity | codex`, default `openrouter`) in * {@link AiModule}. */ export const AI_CHAT_CLIENT_FACTORY = Symbol('AI_CHAT_CLIENT_FACTORY'); diff --git a/packages/mukti-api/src/modules/ai/types/local-cli-adapter.interface.ts b/packages/mukti-api/src/modules/ai/types/local-cli-adapter.interface.ts index f9d8ab89..8a37ce70 100644 --- a/packages/mukti-api/src/modules/ai/types/local-cli-adapter.interface.ts +++ b/packages/mukti-api/src/modules/ai/types/local-cli-adapter.interface.ts @@ -1,6 +1,6 @@ /** * Contract for AI providers backed by a locally installed, user-authenticated - * agent CLI (Claude Code, Antigravity). + * agent CLI (Claude Code, Codex, Antigravity). * * @remarks * Every such provider does the same thing mechanically — spawn a binary in @@ -18,6 +18,20 @@ import type { AiChatSendRequest } from './ai-chat-client.interface'; import type { AllowedModel } from './ai-model.interface'; export interface LocalCliAdapter { + /** + * Called exactly once per invocation, after it settles on any path: exit, + * spawn failure, missing binary or timeout. + * + * @remarks + * For adapters that materialize a per-request file in + * {@link LocalCliAdapter.buildArgs} and must remove it however the run ends. + * `parseEnvelope` and `describeFailure` cannot do this: a timeout or a spawn + * failure reaches neither. Receives the arguments `buildArgs` returned, so + * the adapter needs no per-request state. Anything it throws is logged and + * ignored, so cleanup can never replace the run's own result. + */ + afterRun?(args: readonly string[]): void; + /** Executable name, resolved from `PATH`. */ readonly binary: string; @@ -123,7 +137,7 @@ export interface LocalCliCompletion { } /** `AI_PROVIDER` values served by a local CLI rather than an HTTP API. */ -export type LocalCliProviderId = 'antigravity' | 'claude-code'; +export type LocalCliProviderId = 'antigravity' | 'claude-code' | 'codex'; /** * Raised when a local CLI is missing, unauthenticated, or fails. diff --git a/packages/mukti-cli/package.json b/packages/mukti-cli/package.json index 8d74f9cc..e0538fdd 100644 --- a/packages/mukti-cli/package.json +++ b/packages/mukti-cli/package.json @@ -17,7 +17,9 @@ "mukti", "socratic", "thinking", - "claude-code" + "claude-code", + "codex", + "antigravity" ], "engines": { "node": ">=20.11" diff --git a/packages/mukti-cli/src/index.ts b/packages/mukti-cli/src/index.ts index 15ffc247..c72b2fee 100644 --- a/packages/mukti-cli/src/index.ts +++ b/packages/mukti-cli/src/index.ts @@ -92,16 +92,16 @@ const HELP = ` --api-port Port for the API (default ${DEFAULT_API_PORT}) --data-dir Where to keep the database and logs (default ~/.mukti, or $MUKTI_HOME) - --provider AI CLI to run on: ${SUPPORTED_PROVIDERS.map((p) => p.id).join(' or ')} + --provider AI CLI to run on: ${SUPPORTED_PROVIDERS.map((p) => p.id).join(', ')} (default: your saved choice, else $AI_PROVIDER, else whichever CLI is ready) --save Save --provider as your default for later runs --choose Ask which AI CLI to use, and offer to save it -h, --help Show this - Requires an installed, signed-in AI CLI: Claude Code (\`claude\`) - or Antigravity (\`agy\`). No API key is needed. With both ready and no - saved choice, Mukti asks which to use on the first run. + Requires an installed, signed-in AI CLI: Claude Code (\`claude\`), + Codex (\`codex\`) or Antigravity (\`agy\`). No API key is needed. With + several ready and no saved choice, Mukti asks which to use on the first run. `; /** @@ -179,9 +179,9 @@ const webEntry = web.webServerEntry(); const baseEnv: NodeJS.ProcessEnv = { ...process.env, AI_PROVIDER: provider.id, - // agy runs from a directory Mukti owns, never a repository; its name is - // also the agy project Mukti's turns are filed under. - MUKTI_AGY_WORKSPACE: join(home.root, 'mukti-socratic'), + // agy and Codex run from a directory Mukti owns, never a repository. Its name + // is also the agy project Mukti's turns are filed under. + MUKTI_CLI_WORKSPACE: join(home.root, 'mukti-socratic'), MUKTI_LOCAL: '1', // The launcher owns the database; the API connects to this rather than // starting one of its own. diff --git a/packages/mukti-cli/src/launcher/__tests__/provider-picker.test.ts b/packages/mukti-cli/src/launcher/__tests__/provider-picker.test.ts index 5e76e168..0cb7eb55 100644 --- a/packages/mukti-cli/src/launcher/__tests__/provider-picker.test.ts +++ b/packages/mukti-cli/src/launcher/__tests__/provider-picker.test.ts @@ -18,6 +18,7 @@ import { isInteractive, pickProvider } from '../provider-picker.ts'; import { providerById } from '../providers.ts'; const CLAUDE = providerById('claude')!; +const CODEX = providerById('codex')!; const ANTIGRAVITY = providerById('agy')!; const ready = (provider = CLAUDE): ProviderStatus => ({ @@ -57,6 +58,7 @@ function prompts(answers: { confirm?: boolean | symbol; select?: string | symbol test('every supported CLI is offered, with its status and its trade-off', async () => { const statuses: ProviderStatus[] = [ ready(CLAUDE), + ready(CODEX), { provider: ANTIGRAVITY, readiness: 'signed-out', version: '1.1.22' }, ]; const harness = prompts({}); @@ -65,13 +67,25 @@ test('every supported CLI is offered, with its status and its trade-off', async assert.deepEqual( harness.offered.map((option) => option.value), - ['claude', 'agy'] + ['claude', 'codex', 'agy'] ); - const [claude, antigravity] = harness.offered; + const [claude, codex, antigravity] = harness.offered; assert.match(claude.hint ?? '', new RegExp(CLAUDE.tradeOff)); + assert.match(codex.hint ?? '', new RegExp(CODEX.tradeOff)); + assert.equal(codex.label, 'Codex CLI'); assert.match(antigravity.hint ?? '', new RegExp(ANTIGRAVITY.tradeOff)); }); +test('Codex can be picked and kept as the default', async () => { + const harness = prompts({ confirm: true, select: 'codex' }); + + const picked = await pickProvider([ready(CLAUDE), ready(CODEX)], harness.prompts); + + assert.equal(picked?.provider.id, 'codex'); + assert.equal(picked?.remember, true); + assert.match(harness.asked[1], /Codex CLI/); +}); + test('a CLI that is signed out or missing is shown, with the reason, and cannot be chosen', async () => { const statuses: ProviderStatus[] = [ { provider: CLAUDE, readiness: 'missing' }, diff --git a/packages/mukti-cli/src/launcher/__tests__/provider-settings.test.ts b/packages/mukti-cli/src/launcher/__tests__/provider-settings.test.ts index 30e64322..c28e94c9 100644 --- a/packages/mukti-cli/src/launcher/__tests__/provider-settings.test.ts +++ b/packages/mukti-cli/src/launcher/__tests__/provider-settings.test.ts @@ -27,7 +27,7 @@ import { writeStoredProvider, } from '../provider-settings.ts'; -const SUPPORTED = ['claude', 'agy'] as const; +const SUPPORTED = ['claude', 'codex', 'agy'] as const; let root: string; diff --git a/packages/mukti-cli/src/launcher/__tests__/providers.test.ts b/packages/mukti-cli/src/launcher/__tests__/providers.test.ts index a3eda0ac..b3827f78 100644 --- a/packages/mukti-cli/src/launcher/__tests__/providers.test.ts +++ b/packages/mukti-cli/src/launcher/__tests__/providers.test.ts @@ -43,10 +43,10 @@ function scripted(answers: Record>) { return { calls, run }; } -test('supports Claude Code and Antigravity, in that detection order', () => { +test('supports Claude Code, Codex and Antigravity, in that detection order', () => { assert.deepEqual( SUPPORTED_PROVIDERS.map((p) => p.id), - ['claude', 'agy'] + ['claude', 'codex', 'agy'] ); }); @@ -239,10 +239,18 @@ test('several ready CLIs are picked between when a terminal is available', async const result = await resolve({ run: scripted(BOTH_READY_ANSWERS).run }); assert.equal(result.kind, 'pick'); + // Every supported CLI is listed, ready or not, so the picker can show why + // one cannot be chosen; Codex is not installed in this script. assert.deepEqual(result.kind === 'pick' ? result.statuses.map((x) => x.provider.id) : null, [ 'claude', + 'codex', 'agy', ]); + assert.deepEqual(result.kind === 'pick' ? result.statuses.map((x) => x.readiness) : null, [ + 'ready', + 'missing', + 'ready', + ]); }); test('several ready CLIs without a terminal use the first and say how to choose', async () => { @@ -353,6 +361,94 @@ const BOTH_READY = { 'claude auth status': { stdout: '{"loggedIn":true}' }, }; +/** Codex installed and signed in, as `codex login status` reports it: exit 0, text on stderr. */ +const CODEX_READY = { + 'codex --version': { stdout: 'codex-cli 0.163.0\n' }, + 'codex login status': { stderr: 'Logged in using ChatGPT\n' }, +}; + +test('the Codex sign-in probe reads the exit code of `codex login status`', async () => { + const codex = providerById('codex')!; + const signedIn = scripted(CODEX_READY); + const signedOut = scripted({ 'codex login status': { status: 1, stderr: 'Not logged in\n' } }); + + assert.equal(await codex.isAuthenticated(signedIn.run), true); + assert.equal(await codex.isAuthenticated(signedOut.run), false); + assert.equal(await codex.isAuthenticated(scripted({}).run), false); + for (const call of [...signedIn.calls, ...signedOut.calls]) { + assert.equal(call, 'codex login status'); + assert.doesNotMatch(call, /exec|--print|(^| )-p( |$)|--prompt/); + } +}); + +test('the Codex version is what `codex --version` prints', async () => { + const codex = providerById('codex')!; + + assert.equal(await codex.version(scripted(CODEX_READY).run), 'codex-cli 0.163.0'); + assert.equal(await codex.version(scripted({}).run), undefined); +}); + +test('Codex names its own sign-in and install steps', () => { + const codex = providerById('codex')!; + + assert.match(codex.signInRemediation, /codex login/); + assert.match(codex.installRemediation, /Codex CLI/); + assert.doesNotMatch(codex.signInRemediation, /claude|agy/); + assert.deepEqual(codex.aliases, []); +}); + +test('Codex discloses that the global AGENTS.md applies and that no history is kept', () => { + const disclosure = providerById('codex')!.disclosure ?? ''; + + assert.match(disclosure, /~\/\.codex\/AGENTS\.md/); + assert.match(disclosure, /no\s+session history/); + assert.match(disclosure, /read-only/); +}); + +test('Claude Code is still taken over Codex when nothing can ask', async () => { + const result = await resolveProvider({ + interactive: false, + run: scripted({ ...BOTH_READY, ...CODEX_READY }).run, + }); + + assert.equal(result.kind === 'detected' && result.provider.id, 'claude'); + assert.deepEqual(result.kind === 'detected' ? result.others.map((x) => x.provider.id) : null, [ + 'codex', + 'agy', + ]); +}); + +test('Codex is taken over Antigravity when Claude Code is not ready and nothing can ask', async () => { + const result = await resolveProvider({ + interactive: false, + run: scripted({ ...ONLY_AGY_READY, ...CODEX_READY }).run, + }); + + assert.equal(result.kind === 'detected' && result.provider.id, 'codex'); + assert.equal(result.kind === 'detected' && result.canChoose, true); +}); + +test('--provider codex is honoured, and codex is a recognised AI_PROVIDER value', async () => { + const option = await resolve({ explicit: 'codex', run: scripted(BOTH_READY_ANSWERS).run }); + const env = await resolve({ env: 'codex', run: scripted(BOTH_READY_ANSWERS).run }); + + assert.equal(option.kind === 'chosen' && option.provider.id, 'codex'); + assert.equal(env.kind === 'chosen' && env.provider.id, 'codex'); +}); + +test('a signed-out Codex is never chosen automatically', async () => { + const result = await resolveProvider({ + interactive: false, + run: scripted({ + ...ONLY_AGY_READY, + 'codex --version': { stdout: 'codex-cli 0.163.0\n' }, + 'codex login status': { status: 1, stderr: 'Not logged in\n' }, + }).run, + }); + + assert.equal(result.kind === 'detected' && result.provider.id, 'agy'); +}); + test('readiness tells missing, signed out and ready apart', async () => { const claude = providerById('claude')!; @@ -379,15 +475,15 @@ test('a CLI that is not installed is not asked about sign-in', async () => { }); test('scanning covers every supported provider, in registry order', async () => { - const statuses = await scanProviders(scripted(BOTH_READY).run); + const statuses = await scanProviders(scripted({ ...BOTH_READY, ...CODEX_READY }).run); assert.deepEqual( statuses.map((s) => s.provider.id), - ['claude', 'agy'] + ['claude', 'codex', 'agy'] ); assert.deepEqual( statuses.map((s) => s.readiness), - ['ready', 'ready'] + ['ready', 'ready', 'ready'] ); }); diff --git a/packages/mukti-cli/src/launcher/__tests__/selection.test.ts b/packages/mukti-cli/src/launcher/__tests__/selection.test.ts index 9a720a89..ce660767 100644 --- a/packages/mukti-cli/src/launcher/__tests__/selection.test.ts +++ b/packages/mukti-cli/src/launcher/__tests__/selection.test.ts @@ -34,6 +34,10 @@ const CLAUDE_SIGNED_OUT = { 'claude --version': '2.1.0 (Claude Code)\n', 'claude auth status': '{"loggedIn":false}', }; +const ONLY_CODEX_READY = { + 'codex --version': 'codex-cli 0.163.0\n', + 'codex login status': '', +}; let home: string; @@ -142,7 +146,11 @@ test('a saved default that is signed out is explained, and the ready CLI is used test('a pick that is kept is remembered', async () => { const selection = await select({ - pick: (statuses) => Promise.resolve({ provider: statuses[1].provider, remember: true }), + pick: (statuses) => + Promise.resolve({ + provider: statuses.find((s) => s.provider.id === 'agy')!.provider, + remember: true, + }), }); assert.equal(selection.kind === 'ready' && selection.provider.id, 'agy'); @@ -192,9 +200,28 @@ test('nothing installed points at every install step', async () => { assert.equal(selection.kind, 'failed'); const remediation = selection.kind === 'failed' ? (selection.remediation ?? '') : ''; assert.match(remediation, /docs\.claude\.com/); + assert.match(remediation, /--provider codex/); assert.match(remediation, /antigravity\.google/); }); +test('Codex alone is enough to start, and its disclosure is shown', async () => { + const selection = await select({ answers: ONLY_CODEX_READY }); + + assert.equal(selection.kind === 'ready' && selection.provider.id, 'codex'); + const notices = selection.kind === 'ready' ? selection.notices.join('\n') : ''; + assert.match(notices, /only/i); + assert.match(notices, /AGENTS\.md/); +}); + +test('a saved codex default is used and reported as the default', async () => { + writeStoredProvider(home, 'codex'); + + const selection = await select({ answers: { ...BOTH_READY, ...ONLY_CODEX_READY } }); + + assert.equal(selection.kind === 'ready' && selection.provider.id, 'codex'); + assert.equal(readStoredProvider(home, ['claude', 'codex', 'agy']).provider, 'codex'); +}); + test('an unsupported provider name is refused', async () => { const selection = await select({ explicit: 'gemini-cli' }); diff --git a/packages/mukti-cli/src/launcher/providers.ts b/packages/mukti-cli/src/launcher/providers.ts index d72bd62e..c3c5b4c5 100644 --- a/packages/mukti-cli/src/launcher/providers.ts +++ b/packages/mukti-cli/src/launcher/providers.ts @@ -5,8 +5,9 @@ * Mukti's local runtime borrows an AI CLI the developer has already installed * and signed in to, so nobody needs an API key. Each CLI answers "is it * installed?" and "is it signed in?" differently — Antigravity has no auth - * subcommand at all — so each provider carries its own probes and its own - * remediation. Neither probe may spend model tokens: it runs on every launch. + * subcommand at all, Codex reports sign-in only through its exit code — so each + * provider carries its own probes and its own remediation. Neither probe may + * spend model tokens: it runs on every launch. * * Resolution is pure and injectable; `selectProvider` is the thin layer that * prints the outcome and exits when there is nothing usable. @@ -64,7 +65,7 @@ export interface LocalCliProvider { version(run: RunCommand): Promise; } -export type LocalCliProviderId = 'agy' | 'claude'; +export type LocalCliProviderId = 'agy' | 'claude' | 'codex'; /** How usable a provider is on this machine. */ export type ProviderReadiness = 'missing' | 'ready' | 'signed-out'; @@ -158,6 +159,30 @@ const CLAUDE_CODE: LocalCliProvider = { version: (run) => versionOf(run, 'claude'), }; +/** + * Measured on codex-cli 0.163.0 (add-codex-cli-provider design.md): 7-12s and + * about 2.4-4.5k input tokens per reply, depending on the model. + */ +const CODEX: LocalCliProvider = { + aliases: [], + binary: 'codex', + disclosure: [ + 'Codex runs read-only with its tools switched off, from a folder Mukti owns, and keeps no', + 'session history of Mukti replies. Your Codex settings (config.toml, MCP servers) do not', + 'apply, apart from your default model. Your global ~/.codex/AGENTS.md does apply: Codex', + 'offers no way to leave it out, and a rule in it can change what a reply says.', + ].join('\n'), + id: 'codex', + installRemediation: 'Install the Codex CLI: https://developers.openai.com/codex/cli', + // `codex login status` exits 0 when signed in and 1 when not, prints to + // stderr either way, and runs no model. + isAuthenticated: async (run) => (await run('codex', ['login', 'status'])).status === 0, + label: 'Codex CLI', + signInRemediation: 'Run `codex login` and try again.', + tradeOff: 'about 8-12 seconds per reply', + version: (run) => versionOf(run, 'codex'), +}; + const ANTIGRAVITY: LocalCliProvider = { aliases: ['antigravity'], binary: 'agy', @@ -181,8 +206,13 @@ const ANTIGRAVITY: LocalCliProvider = { version: (run) => versionOf(run, 'agy'), }; -/** In detection order: the cheaper, faster provider first. */ -export const SUPPORTED_PROVIDERS: readonly LocalCliProvider[] = [CLAUDE_CODE, ANTIGRAVITY]; +/** + * In detection order: the cheaper, faster providers first. Claude Code stays + * first so that machines which already resolved to it keep doing so; Codex is + * measured at Claude-like cost and latency; Antigravity costs several times + * either. + */ +export const SUPPORTED_PROVIDERS: readonly LocalCliProvider[] = [CLAUDE_CODE, CODEX, ANTIGRAVITY]; /** Every name a supported provider answers to, current and former. */ export const PROVIDER_NAMES: readonly string[] = SUPPORTED_PROVIDERS.flatMap((provider) => [ diff --git a/packages/mukti-web/src/app/settings/page.tsx b/packages/mukti-web/src/app/settings/page.tsx index 293f2ee1..dec83dab 100644 --- a/packages/mukti-web/src/app/settings/page.tsx +++ b/packages/mukti-web/src/app/settings/page.tsx @@ -122,7 +122,7 @@ function SettingsContent() { {/* ── OpenRouter API Key ── */} {/* Hidden in local mode: AI runs on the developer's own AI CLI auth - (Claude Code or Antigravity), so there is no BYOK key to manage. */} + (Claude Code, Codex or Antigravity), so there is no BYOK key to manage. */} {!isLocalMode() && (
diff --git a/scripts/start-local.ts b/scripts/start-local.ts index 53748352..fbc4bc12 100644 --- a/scripts/start-local.ts +++ b/scripts/start-local.ts @@ -3,7 +3,7 @@ * One-command local runtime for Mukti, from a workspace checkout. * * Boots the embedded database, the API (in MUKTI_LOCAL mode, on a local AI CLI - * — Claude Code or Antigravity) and the web app with zero external services — + * — Claude Code, Codex or Antigravity) and the web app with zero external services — * no Docker, no Redis, no OpenRouter key. Runs preflight checks first and * refuses to start a partial stack on failure. * @@ -26,7 +26,7 @@ * Node's own type stripping) — hence the explicit `.ts` extensions on the local * imports below, which Node's ESM resolver requires. * - * Usage: bun run start:local [-- --provider claude|agy] + * Usage: bun run start:local [-- --provider claude|codex|agy] * bun run start:local -- --choose (ask, and offer to remember) * bun run start:local -- --provider agy --save * (or npm run start:local -- …) @@ -168,9 +168,9 @@ const MONGO_PORT = await reserveFreePort(); const env = { ...process.env, - // Repo mode leaves MUKTI_AGY_WORKSPACE unset on purpose: agy must not run from - // inside this repository (it is a trusted workspace with its own `.agent/`), - // so the API's default under ~/.mukti applies. + // Repo mode leaves MUKTI_CLI_WORKSPACE unset on purpose: agy and Codex must + // not run from inside this repository (it is a trusted workspace with its own + // `.agent/` and `AGENTS.md`), so the API's default under ~/.mukti applies. AI_PROVIDER: provider.id, MUKTI_LOCAL: '1', // The launcher owns the database; the API connects to this rather than