From 81985da1dfd1b3bbd2faae98bf568c96bdbac0a8 Mon Sep 17 00:00:00 2001 From: Chris Dukes Date: Fri, 13 Feb 2026 22:34:42 -0600 Subject: [PATCH 01/11] Recreate launch doctor changes --- .codex-synaptic/memory.db | Bin 638976 -> 651264 bytes AGENTS.md | 7 + README.md | 8 +- docs/guides/codex-macos-workflows.md | 47 +++- docs/guides/quick-start.md | 28 +- src/cli/doctor.ts | 260 ++++++++++++++++++ src/cli/index.ts | 254 +++++++++--------- src/cli/launch.ts | 385 +++++++++++++++++++++++++++ src/env/service-manager.ts | 82 ++++++ tests/cli/doctor.test.ts | 134 ++++++++++ tests/cli/launch.test.ts | 138 ++++++++++ tests/e2e/cli-smoke.test.ts | 12 + tests/env/service-manager.test.ts | 9 + 13 files changed, 1221 insertions(+), 143 deletions(-) create mode 100644 src/cli/doctor.ts create mode 100644 src/cli/launch.ts create mode 100644 tests/cli/doctor.test.ts create mode 100644 tests/cli/launch.test.ts diff --git a/.codex-synaptic/memory.db b/.codex-synaptic/memory.db index 83089459849034d0efd6c2320d61bf8962d178e8..8c8284600de9fcfbe3234f32eeace056ba85fc91 100644 GIT binary patch delta 2771 zcmbuBdu&uy9>?#@+|JBB%G&$Upt6c@_9=RRk$VlkbrOIR3;F)k3dCAzX~w-&ZR zLXdW_8ClktwudmrAcA2HL1PNTR&>=>A`KB@KwV|)Bj6Gii4icQF|2X-oHKpVZnA%D zGWq7-^PBt0_nhB3zu)QXoY#4NUiXZWJ2g#vzNhN0(4B{y4lIh-ksD$_d!8e6RhZo9 z$yOKsX#R#o#n_%MT)4S78X{rw5~4%$e|zarfz)4)+@&wl=j+w_EWJ`Mr59)){R7=h zPtj-TBU(vB3x}~%q!cOzN)4s)g4^&81OoV+d=Jgop2=lGxlA~hiR3bRE>n`rM01%~ zE)(z17<$v(g!Z9E37LWCBM;sURv2sI>tl~ab0xj{-pEVgW1#`BUV8pjs-uhW&(KwiyawW{343iPz4oY{R2ovsn zzF}(_Z+rX~QQNVY$M~`nx8h8RGyfaTlsF)9%3F{W-molHeSB*rn zzw3(Sn?b1N)xLfsW5fV`Bt(rr6=L;Q|L?uY-G%{O)AU39-wjMYv~xGTKt43^htuui zA#$33o?a{;b{1}LyvNXKpVq*CyCU5|Pidv?54SeAwl8dMXpOwPDlrdlQ8}s58-S03ciTHz~}G>_%t5CZ{yeTQTz%%h+n{a@NV3V zcj7L*4R6L9aSQ%6Zo;eZeRw&(2j7ipnK6W)QIX)3OT3_ z)uK6QCYpj02p}E)0KbFZ!mr^acpeVIkKp_8Bs>n^fc>x!9)btpUib{`g-^j8?1Woj zJ8XsP;99r_-VZZyDQtj?VE`Fi0Bc|soDR!ZLjvP4488|L;2O9BE`l$>Iq(5E4F$d_KzFCOkb8SPLdbehtsN-LF4QCgw2Txpro+mx0nO(>;_$d5zA+f?nv(uo6P z;y`R{fNx(>y`;Fi#|DerJ*x0T>Hp{e;*`Bs_Lcl+=(5s(D!s%*>B`UrUY4#d8&o5I zS9(tAC;aE>S-d5^Ds-H`Bc3j%=T7Takv}UvstVh5II^Xpe? zM+&`@vc=D>^Brq^c@kY?I3gPJC5J`shaNG;?<0$Y;ms$ LCMH8%+GONEex9!5 delta 188 zcmV;t07L(P;3t59Cy*NjD*ylhoRJ_u0V}a!q%R094*CE9005km3@}=efhDtxFli2x zuwMqV`aT8$4hjGO0saIv01x~R_Yd5&4nXJw4iDiEun&n3cn`}Dnh$4@fiahs+6lR{ z?nDR+3JL@R009C60{{tvP=-(ehEM{APy>cg1cp!rhEN8EPzQ!k2)0lO1Yj5n28I9+ q4h|a)DGW9XLJCg_TnK6hcLs*BfocW>fo+5bmthJ4CbzJ*2~Y{*g*ER0 diff --git a/AGENTS.md b/AGENTS.md index ac5cfd7..02ad9b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,13 @@ The Codex-Synaptic system enhances OpenAI's Codex with advanced multi-agent capa - **Autoscaler behaviour:** With the background daemon disabled, idle worker retirement requests cannot execute. Expect scale-down warnings in logs and manually right-size replicas after experiments. See `docs/runbooks/autoscaler-daemon-coordination.md` for operational guidance. - **Repository hygiene:** Active development is running from the local `codex-synaptic-clone` directory, but upstream pushes must target `github.com/clduab11/codex-synaptic`. Align the folder/remote names before release packaging so automation recipes resolve assets correctly. See `docs/runbooks/workspace-rename-guide.md` for the step-by-step procedure. +## Startup Gate (Codex For macOS) + +- Run `codex-synaptic launch --json` before repository work whenever the user asks to launch or verify readiness first. +- Treat launch as a hard gate: if `ok` is `false` (or the command exits non-zero), stop and only return remediation commands. +- Proceed with repository changes only when launch returns `ok=true` and `nextAction="continue"`. +- Default launch gate profiles are `mcp-filesystem`, `mcp-playwright`, and `mcp-desktop-commander`. + ## Core Agent Types ### 1. Worker Agents diff --git a/README.md b/README.md index a5bfbda..d929438 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,10 @@ npm install npm run build # readiness +node dist/cli/index.js launch --json +node dist/cli/index.js launch --strict --json node dist/cli/index.js doctor -node dist/cli/index.js doctor --strict +node dist/cli/index.js doctor --strict --json # daemon lifecycle node dist/cli/index.js background start @@ -76,6 +78,7 @@ node dist/cli/index.js tui --local --interval 1000 # MCP profiles and registration node dist/cli/index.js env plan mcp-filesystem mcp-playwright mcp-desktop-commander +node dist/cli/index.js env docker-login mcp-filesystem mcp-playwright mcp-desktop-commander node dist/cli/index.js env up mcp-filesystem mcp-playwright mcp-desktop-commander node dist/cli/index.js env status mcp-filesystem mcp-playwright mcp-desktop-commander node dist/cli/index.js env codex-register mcp-filesystem mcp-playwright mcp-desktop-commander --replace @@ -87,6 +90,9 @@ node dist/cli/index.js env codex-register mcp-filesystem mcp-playwright mcp-desk # Local mode codex -C /absolute/path/to/codex-synaptic +# first-launch gate in this repo +codex-synaptic launch --json + # Worktree mode git worktree add ../codex-synaptic-worktree -b codex/macos-ops codex -C ../codex-synaptic-worktree diff --git a/docs/guides/codex-macos-workflows.md b/docs/guides/codex-macos-workflows.md index 527a2d4..f8f9b56 100644 --- a/docs/guides/codex-macos-workflows.md +++ b/docs/guides/codex-macos-workflows.md @@ -1,6 +1,6 @@ # Codex macOS Workflows (Local, Worktree, Cloud + MCP) -Last reviewed: 2026-02-13 +Last reviewed: 2026-02-14 Audience: contributors using Codex app/CLI on macOS (Apple Silicon) with Codex-Synaptic. ## Source Of Truth @@ -17,7 +17,7 @@ This guide is aligned with: - `https://developers.openai.com/codex/cli/features/` - `https://developers.openai.com/codex/security/` -## Bootstrap And Doctor (Run First) +## Bootstrap And Launch Gate (Run First) ```bash cd /absolute/path/to/codex-synaptic @@ -29,11 +29,40 @@ codex --help codex mcp --help codex mcp add --help -# one-shot readiness checks (auth + mcp + repo cli) -node dist/cli/index.js doctor +# one-command bootstrap + strict readiness gate +node dist/cli/index.js launch --json -# enforce failure in CI/automation -node dist/cli/index.js doctor --strict --json +# explicit strict form for CI/automation +node dist/cli/index.js launch --strict --json +``` + +Launch defaults: + +- Detached runtime authority (`background start`) that remains running after success. +- Required MCP gate set: `mcp-filesystem`, `mcp-playwright`, `mcp-desktop-commander`. +- Hard-stop behavior in strict mode: first failing gate exits non-zero with remediation commands. + +Typical first Codex app prompt in this repo: + +```text +Launch codex-synaptic and determine health/status prior to beginning repository work. +``` + +### Launch Failure Remediation Examples + +```bash +# Codex auth missing +codex login + +# Docker registry auth for MCP images +node dist/cli/index.js env docker-login mcp-filesystem mcp-playwright mcp-desktop-commander + +# MCP runtime or registration drift +node dist/cli/index.js env up mcp-filesystem mcp-playwright mcp-desktop-commander +node dist/cli/index.js env codex-register mcp-filesystem mcp-playwright mcp-desktop-commander --replace + +# Re-run hard gate +node dist/cli/index.js launch --strict --json ``` ## Runtime Model (Deterministic) @@ -130,6 +159,9 @@ codex cloud apply # inspect profiles and codex registration targets node dist/cli/index.js env plan mcp-filesystem mcp-playwright mcp-desktop-commander +# authenticate required Docker registries (for private GHCR images) +node dist/cli/index.js env docker-login mcp-filesystem mcp-playwright mcp-desktop-commander + # safest default: filesystem read-only node dist/cli/index.js env up mcp-filesystem mcp-playwright mcp-desktop-commander @@ -147,6 +179,7 @@ codex mcp list --json Expected indicators: - `env status` returns `running: yes` and `healthy: yes` for active profiles. +- `launch --json` returns `ok: true` and `nextAction: "continue"`. - `doctor` reports MCP profile checks passing and registration present. ## Sandbox And Approval Recommendations @@ -169,7 +202,7 @@ codex --sandbox read-only --ask-for-approval on-request ```bash # 1) refresh build + readiness npm run build -node dist/cli/index.js doctor --strict +node dist/cli/index.js launch --strict --json # 2) run focused work codex exec "Implement one bounded fix with tests" diff --git a/docs/guides/quick-start.md b/docs/guides/quick-start.md index 0e09554..75cee8a 100644 --- a/docs/guides/quick-start.md +++ b/docs/guides/quick-start.md @@ -1,6 +1,6 @@ # Quick Start (Codex-Synaptic + Codex macOS) -Last reviewed: 2026-02-10 +Last reviewed: 2026-02-14 ## 1. Install and build @@ -9,27 +9,33 @@ npm install npm run build ``` -## 2. Verify CLI health +## 2. Run launch gate ```bash -npm run cli -- system status +npm run cli -- launch --strict --json ``` -Expected output in a cold shell: +Expected success indicators: ```text -System not started. Run `codex-synaptic system start` first. +ok: true +nextAction: continue ``` -Expected output after startup: +If launch fails, stop repository work and run the remediation commands returned in the report. +For Docker registry-denied errors, run: ```bash -npm run cli -- system start +npm run cli -- env docker-login mcp-filesystem mcp-playwright mcp-desktop-commander ``` -This command prints a telemetry snapshot and then exits cleanly in one-shot mode. +## 3. Optional direct runtime inspection + +```bash +npm run cli -- system status +``` -## 3. Run a minimal local workflow +## 4. Run a minimal local workflow ```bash npm run cli -- reasoning plan "Stabilize codex-synaptic release readiness" --require-consensus --json @@ -37,13 +43,13 @@ npm run cli -- openai usage --json npm run cli -- hive-mind spawn "Verify macOS readiness smoke flow" --codex --dry-run ``` -## 4. Use Codex passthrough +## 5. Use Codex passthrough ```bash codex-synaptic --codex --dry-run "Inspect current readiness blockers and propose bounded fixes" ``` -## 5. Run verification gates +## 6. Run verification gates ```bash npm run lint diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts new file mode 100644 index 0000000..27ad23b --- /dev/null +++ b/src/cli/doctor.ts @@ -0,0 +1,260 @@ +import { spawnSync, type SpawnSyncReturns } from 'child_process'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { serviceManager, type ServiceStatus } from '../env/service-manager.js'; + +export const DEFAULT_MCP_PROFILES = [ + 'mcp-filesystem', + 'mcp-playwright', + 'mcp-desktop-commander' +] as const; + +export interface DoctorCheck { + id: string; + ok: boolean; + details: string; + remediation?: string; + metadata?: Record; +} + +export interface DoctorSummary { + passed: number; + failed: number; + total: number; +} + +export interface DoctorReport { + ok: boolean; + summary: DoctorSummary; + checks: DoctorCheck[]; +} + +export interface DoctorOptions { + cwd?: string; + mcpProfiles?: string[]; + skipCodexAuth?: boolean; +} + +export interface DoctorDependencies { + fileExists?: (path: string) => boolean; + spawnCommand?: ( + command: string, + args: string[], + options: { cwd: string; encoding: BufferEncoding } + ) => Pick, 'status' | 'stdout' | 'stderr'>; + getServiceStatus?: (name: string) => Promise; + getCodexRegistration?: (name: string) => { codexName: string; url: string } | null; +} + +function parseCodexMcpNames(payload: unknown): string[] { + if (Array.isArray(payload)) { + return payload + .map((entry) => { + if (!entry || typeof entry !== 'object') { + return undefined; + } + return String((entry as { name?: string }).name ?? ''); + }) + .filter(Boolean) as string[]; + } + + if (payload && typeof payload === 'object') { + const candidateArrays = [ + (payload as { servers?: unknown }).servers, + (payload as { items?: unknown }).items, + (payload as { mcpServers?: unknown }).mcpServers + ]; + + for (const candidate of candidateArrays) { + if (!Array.isArray(candidate)) { + continue; + } + + return candidate + .map((entry) => { + if (!entry || typeof entry !== 'object') { + return undefined; + } + return String((entry as { name?: string }).name ?? ''); + }) + .filter(Boolean) as string[]; + } + } + + throw new Error('Unsupported JSON format returned by `codex mcp list --json`.'); +} + +export function parseProfileList(input: string | string[] | undefined, fallback = [...DEFAULT_MCP_PROFILES]): string[] { + if (Array.isArray(input)) { + const normalized = input + .map((item) => item.trim()) + .filter(Boolean); + return normalized.length ? normalized : [...fallback]; + } + + if (typeof input === 'string') { + const normalized = input + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + return normalized.length ? normalized : [...fallback]; + } + + return [...fallback]; +} + +export function collectDoctorRemediations(report: DoctorReport): string[] { + const unique = new Set(); + + for (const check of report.checks) { + if (check.ok || !check.remediation) { + continue; + } + + const commands = check.remediation + .split('&&') + .map((item) => item.trim()) + .filter(Boolean); + + for (const command of commands) { + unique.add(command); + } + } + + return Array.from(unique); +} + +export async function runDoctor(options: DoctorOptions = {}, deps: DoctorDependencies = {}): Promise { + const cwd = options.cwd ?? process.cwd(); + const profileNames = parseProfileList(options.mcpProfiles); + const fileExists = deps.fileExists ?? existsSync; + const spawnCommand = deps.spawnCommand + ?? ((command, args, spawnOptions) => spawnSync(command, args, spawnOptions)); + const getServiceStatus = deps.getServiceStatus ?? ((name: string) => serviceManager.status(name)); + const getCodexRegistration = deps.getCodexRegistration + ?? ((name: string) => serviceManager.codexRegistration(name)); + + const checks: DoctorCheck[] = []; + + const distCliPath = join(cwd, 'dist', 'cli', 'index.js'); + const distExists = fileExists(distCliPath); + checks.push({ + id: 'repo.cli_build_artifact', + ok: distExists, + details: distExists ? `Found ${distCliPath}` : `Missing ${distCliPath}`, + remediation: distExists ? undefined : 'Run `npm run build`.' + }); + + if (distExists) { + const cliHelp = spawnCommand('node', [distCliPath, '--help'], { + cwd, + encoding: 'utf8' + }); + + checks.push({ + id: 'repo.cli_exec', + ok: cliHelp.status === 0, + details: cliHelp.status === 0 + ? 'CLI help command succeeded.' + : (cliHelp.stderr?.trim() || 'CLI help command failed.'), + remediation: cliHelp.status === 0 + ? undefined + : 'Run `npm run build` and re-run `node dist/cli/index.js --help`.' + }); + } + + if (!options.skipCodexAuth) { + const loginStatus = spawnCommand('codex', ['login', 'status'], { + cwd, + encoding: 'utf8' + }); + + const stdout = loginStatus.stdout?.trim() || ''; + const ok = loginStatus.status === 0 && !/not logged in/i.test(stdout); + + checks.push({ + id: 'codex.auth', + ok, + details: stdout || loginStatus.stderr?.trim() || 'No output', + remediation: ok ? undefined : 'Run `codex login` then re-run `codex login status`.' + }); + } + + const codexMcpList = spawnCommand('codex', ['mcp', 'list', '--json'], { + cwd, + encoding: 'utf8' + }); + + let codexMcpNames = new Set(); + if (codexMcpList.status === 0) { + try { + const parsed = JSON.parse(codexMcpList.stdout || '[]') as unknown; + const names = parseCodexMcpNames(parsed); + codexMcpNames = new Set(names); + checks.push({ + id: 'codex.mcp_list', + ok: true, + details: `Loaded ${codexMcpNames.size} Codex MCP registration(s).` + }); + } catch (error) { + checks.push({ + id: 'codex.mcp_list', + ok: false, + details: `Failed to parse codex mcp list output: ${(error as Error).message}`, + remediation: 'Run `codex mcp list --json` and inspect output.' + }); + } + } else { + checks.push({ + id: 'codex.mcp_list', + ok: false, + details: codexMcpList.stderr?.trim() || 'codex mcp list failed', + remediation: 'Verify Codex CLI install and MCP support (`codex mcp --help`).' + }); + } + + for (const profileName of profileNames) { + const status = await getServiceStatus(profileName); + const registration = getCodexRegistration(profileName); + const registered = registration ? codexMcpNames.has(registration.codexName) : true; + const healthy = status.healthy !== false; + const ok = status.running && healthy && registered; + + let details = `running=${status.running} healthy=${status.healthy === null ? 'n/a' : status.healthy} registered=${registered}`; + if (status.diagnostics.length) { + details += ` diagnostics=${status.diagnostics.join(' | ')}`; + } + + const remediationParts: string[] = []; + if (!status.running || !healthy) { + remediationParts.push(`codex-synaptic env up ${profileName}`); + } + if (registration && !registered) { + remediationParts.push(`codex-synaptic env codex-register ${profileName}`); + } + + checks.push({ + id: `mcp.${profileName}`, + ok, + details, + remediation: remediationParts.length ? remediationParts.join(' && ') : undefined, + metadata: { + codexName: registration?.codexName, + url: registration?.url + } + }); + } + + const passed = checks.filter((check) => check.ok).length; + const failed = checks.length - passed; + + return { + ok: failed === 0, + summary: { + passed, + failed, + total: checks.length + }, + checks + }; +} diff --git a/src/cli/index.ts b/src/cli/index.ts index ca61b80..5bd2555 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -74,6 +74,12 @@ import { buildPolicyInput, type QuotaOptions } from './tenant-quota-helpers.js'; +import { + DEFAULT_MCP_PROFILES, + parseProfileList, + runDoctor +} from './doctor.js'; +import { collectLaunchRemediations, runLaunch } from './launch.js'; function loadEnvFile(filePath: string): boolean { if (!existsSync(filePath)) { @@ -4435,6 +4441,34 @@ ${name}`)); }); }); +envCmd + .command('docker-login') + .description('Authenticate Docker registries required by one or more service profiles') + .argument('[names...]', 'Service profile names (defaults to launch gate profiles)') + .option('--dry-run', 'Print docker login commands without executing them') + .action(handleCommand('env.docker-login', async (names: string[] = [], options) => { + const targets = names.length ? names : [...DEFAULT_MCP_PROFILES]; + const registries = serviceManager.registriesForProfiles(targets); + + if (!registries.length) { + console.log(chalk.gray(`No registry authentication required for profiles: ${targets.join(', ')}`)); + return; + } + + if (options.dryRun) { + console.log(chalk.blue('Docker registry login commands (dry-run):')); + registries.forEach((registry) => { + console.log(chalk.gray(` docker login ${registry}`)); + }); + return; + } + + for (const registry of registries) { + serviceManager.dockerLogin(registry); + console.log(chalk.green(`✅ Docker auth completed for ${registry}`)); + } + })); + envCmd .command('codex-register') .description('Register MCP HTTP profiles in Codex CLI MCP config') @@ -4475,6 +4509,91 @@ envCmd } })); +const launchCmd = decorateCommandHelp( + program + .command('launch') + .description('Start detached runtime and hard-gate readiness before repository work'), + { + title: 'Launch Gate', + subtitle: 'Boot daemon + MCP dependencies and fail-fast if the repo is not work-ready.', + context: [ + 'Launch is the single-command bootstrap for Codex for macOS first prompts.', + 'In strict mode, launch stops on the first failing gate and exits non-zero.' + ], + skills: [ + 'Guarantee daemon, MCP profile, and doctor readiness before edits start.', + 'Emit machine-readable launch reports for automation and handoffs.' + ], + actions: [ + { command: 'codex-synaptic launch --json', description: 'Run the full bootstrap gate and emit structured output.' }, + { command: 'codex-synaptic launch --no-strict --json', description: 'Collect gate results without immediate fail-fast exit.' } + ], + docs: [ + { label: 'docs/guides/codex-macos-workflows.md', description: 'Single-command first-launch flow for Codex for macOS.' } + ] + } +); + +launchCmd + .option('--json', 'Output launch report as JSON') + .option('--strict', 'Exit with an error when any launch gate fails', true) + .option('--no-strict', 'Report failing gates without exiting non-zero') + .option('--skip-codex-auth', 'Skip codex login status check') + .option( + '--mcp-profiles ', + 'Comma-separated MCP service profiles to verify', + DEFAULT_MCP_PROFILES.join(',') + ) + .action(handleCommand('launch', async (options) => { + const strict = options.strict !== false; + const profileNames = parseProfileList(options.mcpProfiles, [...DEFAULT_MCP_PROFILES]); + const report = await runLaunch({ + cwd: process.cwd(), + strict, + skipCodexAuth: Boolean(options.skipCodexAuth), + mcpProfiles: profileNames + }); + + if (options.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log(chalk.blue('🚀 Codex-Synaptic Launch')); + console.log(chalk.gray(` Status: ${report.ok ? 'ready' : 'blocked'}`)); + console.log(chalk.gray(` Next action: ${report.nextAction}`)); + + report.steps.forEach((step) => { + const marker = step.ok ? chalk.green('✓') : chalk.red('✗'); + console.log(`${marker} ${step.id}: ${step.details}`); + if (!step.ok && step.remediation) { + console.log(chalk.yellow(` remediation: ${step.remediation}`)); + } + }); + + if (report.doctor.summary.total > 0) { + console.log(chalk.gray(` Doctor summary: passed=${report.doctor.summary.passed} failed=${report.doctor.summary.failed}`)); + } else { + console.log(chalk.gray(' Doctor summary: skipped (launch exited before strict doctor run).')); + } + + if (report.ok) { + console.log(chalk.green('✅ Launch gate passed. Safe to begin repository work.')); + } else { + console.log(chalk.red('🛑 Launch gate failed. Stop repository work until remediations pass.')); + const remediation = collectLaunchRemediations(report); + if (remediation.length) { + console.log(chalk.yellow(' Suggested commands:')); + remediation.forEach((command) => { + console.log(chalk.yellow(` - ${command}`)); + }); + } + } + } + + if (strict && !report.ok) { + throw new Error('Launch failed one or more readiness gates.'); + } + })); + const doctorCmd = decorateCommandHelp( program .command('doctor') @@ -4510,137 +4629,24 @@ doctorCmd .option( '--mcp-profiles ', 'Comma-separated MCP service profiles to verify', - 'mcp-filesystem,mcp-playwright,mcp-desktop-commander' + DEFAULT_MCP_PROFILES.join(',') ) .action(handleCommand('doctor', async (options) => { - const profileNames = String(options.mcpProfiles) - .split(',') - .map((item) => item.trim()) - .filter(Boolean); - - const checks: Array<{ - id: string; - ok: boolean; - details: string; - remediation?: string; - metadata?: Record; - }> = []; - - const distCliPath = join(process.cwd(), 'dist', 'cli', 'index.js'); - const distExists = existsSync(distCliPath); - checks.push({ - id: 'repo.cli_build_artifact', - ok: distExists, - details: distExists ? `Found ${distCliPath}` : `Missing ${distCliPath}`, - remediation: distExists ? undefined : 'Run `npm run build`.' - }); - - if (distExists) { - const cliHelp = spawnSync('node', [distCliPath, '--help'], { - cwd: process.cwd(), - encoding: 'utf8' - }); - checks.push({ - id: 'repo.cli_exec', - ok: cliHelp.status === 0, - details: cliHelp.status === 0 ? 'CLI help command succeeded.' : (cliHelp.stderr?.trim() || 'CLI help command failed.'), - remediation: cliHelp.status === 0 ? undefined : 'Run `npm run build` and re-run `node dist/cli/index.js --help`.' - }); - } - - if (!options.skipCodexAuth) { - const loginStatus = spawnSync('codex', ['login', 'status'], { - cwd: process.cwd(), - encoding: 'utf8' - }); - const stdout = loginStatus.stdout?.trim() || ''; - const ok = loginStatus.status === 0 && !/not logged in/i.test(stdout); - checks.push({ - id: 'codex.auth', - ok, - details: stdout || loginStatus.stderr?.trim() || 'No output', - remediation: ok ? undefined : 'Run `codex login` then re-run `codex login status`.' - }); - } - - let codexMcpNames = new Set(); - const codexMcpList = spawnSync('codex', ['mcp', 'list', '--json'], { + const profileNames = parseProfileList(options.mcpProfiles, [...DEFAULT_MCP_PROFILES]); + const report = await runDoctor({ cwd: process.cwd(), - encoding: 'utf8' + mcpProfiles: profileNames, + skipCodexAuth: Boolean(options.skipCodexAuth) }); - if (codexMcpList.status === 0) { - try { - const parsed = JSON.parse(codexMcpList.stdout || '[]') as Array<{ name?: string }>; - codexMcpNames = new Set(parsed.map((entry) => String(entry.name)).filter(Boolean)); - checks.push({ - id: 'codex.mcp_list', - ok: true, - details: `Loaded ${codexMcpNames.size} Codex MCP registration(s).` - }); - } catch (error) { - checks.push({ - id: 'codex.mcp_list', - ok: false, - details: `Failed to parse codex mcp list output: ${(error as Error).message}`, - remediation: 'Run `codex mcp list --json` and inspect output.' - }); - } - } else { - checks.push({ - id: 'codex.mcp_list', - ok: false, - details: codexMcpList.stderr?.trim() || 'codex mcp list failed', - remediation: 'Verify Codex CLI install and MCP support (`codex mcp --help`).' - }); - } - - for (const profileName of profileNames) { - const status = await serviceManager.status(profileName); - const registration = serviceManager.codexRegistration(profileName); - const registered = registration ? codexMcpNames.has(registration.codexName) : true; - const healthy = status.healthy !== false; - const ok = status.running && healthy && registered; - - let details = `running=${status.running} healthy=${status.healthy === null ? 'n/a' : status.healthy} registered=${registered}`; - if (status.diagnostics.length) { - details += ` diagnostics=${status.diagnostics.join(' | ')}`; - } - - const remediationParts: string[] = []; - if (!status.running || !healthy) { - remediationParts.push(`codex-synaptic env up ${profileName}`); - } - if (registration && !registered) { - remediationParts.push(`codex-synaptic env codex-register ${profileName}`); - } - - checks.push({ - id: `mcp.${profileName}`, - ok, - details, - remediation: remediationParts.length ? remediationParts.join(' && ') : undefined, - metadata: { - codexName: registration?.codexName, - url: registration?.url - } - }); - } - - const passed = checks.filter((check) => check.ok).length; - const failed = checks.length - passed; if (options.json) { - console.log(JSON.stringify({ - ok: failed === 0, - summary: { passed, failed, total: checks.length }, - checks - }, null, 2)); + console.log(JSON.stringify(report, null, 2)); } else { console.log(chalk.blue('🩺 Codex-Synaptic Doctor')); - console.log(chalk.gray(` Passed: ${passed}`)); - console.log(chalk.gray(` Failed: ${failed}`)); + console.log(chalk.gray(` Passed: ${report.summary.passed}`)); + console.log(chalk.gray(` Failed: ${report.summary.failed}`)); - checks.forEach((check) => { + report.checks.forEach((check) => { const marker = check.ok ? chalk.green('✓') : chalk.red('✗'); console.log(`${marker} ${check.id}: ${check.details}`); if (!check.ok && check.remediation) { @@ -4649,8 +4655,8 @@ doctorCmd }); } - if (options.strict && failed > 0) { - throw new Error(`Doctor found ${failed} failing check(s).`); + if (options.strict && !report.ok) { + throw new Error(`Doctor found ${report.summary.failed} failing check(s).`); } })); diff --git a/src/cli/launch.ts b/src/cli/launch.ts new file mode 100644 index 0000000..230fdd1 --- /dev/null +++ b/src/cli/launch.ts @@ -0,0 +1,385 @@ +import { spawnSync, type SpawnSyncReturns } from 'child_process'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { + getBackgroundStatus, + startBackgroundSystem, + type BackgroundStatus +} from './daemon-manager.js'; +import { serviceManager, type EnsureServiceOptions } from '../env/service-manager.js'; +import { + collectDoctorRemediations, + DEFAULT_MCP_PROFILES, + runDoctor, + type DoctorDependencies, + type DoctorOptions, + type DoctorReport +} from './doctor.js'; + +export interface LaunchStep { + id: string; + ok: boolean; + details: string; + remediation?: string; + metadata?: Record; +} + +export interface LaunchReport { + ok: boolean; + steps: LaunchStep[]; + doctor: DoctorReport; + nextAction: 'continue' | 'stop'; +} + +export interface LaunchOptions { + cwd?: string; + strict?: boolean; + skipCodexAuth?: boolean; + mcpProfiles?: string[]; +} + +export interface LaunchDependencies extends DoctorDependencies { + startBackground?: () => Promise; + getBackgroundStatus?: () => BackgroundStatus; + ensureService?: (name: string, options?: EnsureServiceOptions) => Promise; + runDoctor?: (options: DoctorOptions, deps?: DoctorDependencies) => Promise; +} + +const EMPTY_DOCTOR_REPORT: DoctorReport = { + ok: false, + summary: { + passed: 0, + failed: 0, + total: 0 + }, + checks: [] +}; + +function normalizeSpawn( + deps: LaunchDependencies +): ( + command: string, + args: string[], + options: { cwd: string; encoding: BufferEncoding } + ) => Pick, 'status' | 'stdout' | 'stderr'> { + return deps.spawnCommand + ?? ((command, args, spawnOptions) => spawnSync(command, args, spawnOptions)); +} + +function buildLaunchReport(steps: LaunchStep[], doctorReport: DoctorReport): LaunchReport { + const ok = steps.every((step) => step.ok) && doctorReport.ok; + return { + ok, + steps, + doctor: doctorReport, + nextAction: ok ? 'continue' : 'stop' + }; +} + +function collectLaunchRemediationsFromStep(step: LaunchStep): string[] { + if (!step.remediation) { + return []; + } + + return step.remediation + .split('&&') + .map((item) => item.trim()) + .filter(Boolean); +} + +function buildMcpBootstrapRemediation(profileNames: string[]): string { + const commands: string[] = []; + + commands.push(`codex-synaptic env docker-login ${profileNames.join(' ')}`); + commands.push(`codex-synaptic env up ${profileNames.join(' ')}`); + commands.push(`codex-synaptic env codex-register ${profileNames.join(' ')} --replace`); + + return commands.join(' && '); +} + +export function collectLaunchRemediations(report: LaunchReport): string[] { + const unique = new Set(); + + for (const step of report.steps) { + if (step.ok) { + continue; + } + const commands = collectLaunchRemediationsFromStep(step); + for (const command of commands) { + unique.add(command); + } + } + + for (const command of collectDoctorRemediations(report.doctor)) { + unique.add(command); + } + + return Array.from(unique); +} + +export async function runLaunch(options: LaunchOptions = {}, deps: LaunchDependencies = {}): Promise { + const cwd = options.cwd ?? process.cwd(); + const strict = options.strict !== false; + const profileNames = options.mcpProfiles?.length + ? [...options.mcpProfiles] + : [...DEFAULT_MCP_PROFILES]; + + const fileExists = deps.fileExists ?? existsSync; + const spawnCommand = normalizeSpawn(deps); + const startBackground = deps.startBackground ?? (() => startBackgroundSystem()); + const readBackgroundStatus = deps.getBackgroundStatus ?? (() => getBackgroundStatus()); + const ensureService = deps.ensureService + ?? ((name: string, ensureOptions?: EnsureServiceOptions) => serviceManager.ensureService(name, ensureOptions)); + const executeDoctor = deps.runDoctor ?? runDoctor; + + const steps: LaunchStep[] = []; + let doctorReport = EMPTY_DOCTOR_REPORT; + + const appendStep = (step: LaunchStep): LaunchReport | null => { + steps.push(step); + if (strict && !step.ok) { + return buildLaunchReport(steps, doctorReport); + } + return null; + }; + + const distCliPath = join(cwd, 'dist', 'cli', 'index.js'); + const distExists = fileExists(distCliPath); + + const preflightStep: LaunchStep = distExists + ? (() => { + const cliHelp = spawnCommand('node', [distCliPath, '--help'], { + cwd, + encoding: 'utf8' + }); + + return { + id: 'repo.preflight', + ok: cliHelp.status === 0, + details: cliHelp.status === 0 + ? `Found ${distCliPath}; CLI executable check passed.` + : `CLI executable check failed: ${cliHelp.stderr?.trim() || 'unknown error'}`, + remediation: cliHelp.status === 0 + ? undefined + : 'Run `npm run build` and then `node dist/cli/index.js --help`.' + }; + })() + : { + id: 'repo.preflight', + ok: false, + details: `Missing ${distCliPath}`, + remediation: 'Run `npm run build`.' + }; + + { + const stop = appendStep(preflightStep); + if (stop) { + return stop; + } + } + + const codexAuthStep: LaunchStep = options.skipCodexAuth + ? { + id: 'codex.auth', + ok: true, + details: 'Skipped codex auth check (--skip-codex-auth).' + } + : (() => { + const loginStatus = spawnCommand('codex', ['login', 'status'], { + cwd, + encoding: 'utf8' + }); + const stdout = loginStatus.stdout?.trim() || ''; + const ok = loginStatus.status === 0 && !/not logged in/i.test(stdout); + return { + id: 'codex.auth', + ok, + details: stdout || loginStatus.stderr?.trim() || 'No output', + remediation: ok ? undefined : 'Run `codex login` then re-run `codex login status`.' + }; + })(); + + { + const stop = appendStep(codexAuthStep); + if (stop) { + return stop; + } + } + + let daemonStep: LaunchStep; + const existingDaemon = readBackgroundStatus(); + if (existingDaemon.running) { + daemonStep = { + id: 'runtime.daemon', + ok: true, + details: `Background daemon already running (pid ${existingDaemon.pid ?? 'unknown'}).` + }; + } else { + try { + const started = await startBackground(); + daemonStep = { + id: 'runtime.daemon', + ok: started.running, + details: started.running + ? `Background daemon started (pid ${started.pid ?? 'unknown'}).` + : 'Background daemon did not report running state.', + remediation: started.running + ? undefined + : 'Run `codex-synaptic background start` and inspect logs with `codex-synaptic background logs --tail 100`.' + }; + } catch (error) { + daemonStep = { + id: 'runtime.daemon', + ok: false, + details: `Failed to start background daemon: ${(error as Error).message}`, + remediation: 'Run `codex-synaptic background start` and inspect logs with `codex-synaptic background logs --tail 100`.' + }; + } + } + + { + const stop = appendStep(daemonStep); + if (stop) { + return stop; + } + } + + let mcpUpStep: LaunchStep; + if (!profileNames.length) { + mcpUpStep = { + id: 'mcp.up', + ok: true, + details: 'No MCP profiles requested for launch gating.' + }; + } else { + try { + for (const profileName of profileNames) { + await ensureService(profileName, { waitForHealth: true }); + } + mcpUpStep = { + id: 'mcp.up', + ok: true, + details: `Started ${profileNames.length} MCP profile(s): ${profileNames.join(', ')}` + }; + } catch (error) { + mcpUpStep = { + id: 'mcp.up', + ok: false, + details: `Failed to start required MCP profile(s): ${(error as Error).message}`, + remediation: buildMcpBootstrapRemediation(profileNames) + }; + } + } + + { + const stop = appendStep(mcpUpStep); + if (stop) { + return stop; + } + } + + let codexRegisterStep: LaunchStep; + if (!profileNames.length) { + codexRegisterStep = { + id: 'mcp.codex_register', + ok: true, + details: 'No MCP profiles requested for Codex registration.' + }; + } else { + try { + const registeredNames: string[] = []; + for (const profileName of profileNames) { + const registration = (deps.getCodexRegistration ?? serviceManager.codexRegistration.bind(serviceManager))(profileName); + if (!registration) { + continue; + } + + const remove = spawnCommand('codex', ['mcp', 'remove', registration.codexName], { + cwd, + encoding: 'utf8' + }); + + if (remove.status !== 0 && process.env.CODEX_DEBUG === '1') { + const removeMessage = remove.stderr?.trim() || remove.stdout?.trim() || 'unknown remove failure'; + process.stderr.write( + `[launch] codex mcp remove ${registration.codexName} returned non-zero: ${removeMessage}\n` + ); + } + + const add = spawnCommand('codex', ['mcp', 'add', registration.codexName, '--url', registration.url], { + cwd, + encoding: 'utf8' + }); + + if (add.status !== 0) { + const stderr = add.stderr?.trim() || ''; + if (/already exists/i.test(stderr)) { + registeredNames.push(registration.codexName); + continue; + } + + throw new Error( + `codex mcp add failed for ${registration.codexName}: ${stderr || add.stdout?.trim() || 'unknown error'}` + ); + } + + registeredNames.push(registration.codexName); + } + + codexRegisterStep = { + id: 'mcp.codex_register', + ok: true, + details: registeredNames.length + ? `Ensured Codex MCP registration for ${registeredNames.join(', ')}` + : 'Selected MCP profiles do not expose Codex registration metadata.' + }; + } catch (error) { + codexRegisterStep = { + id: 'mcp.codex_register', + ok: false, + details: `Failed to register MCP profile(s) with Codex: ${(error as Error).message}`, + remediation: `codex-synaptic env codex-register ${profileNames.join(' ')} --replace` + }; + } + } + + { + const stop = appendStep(codexRegisterStep); + if (stop) { + return stop; + } + } + + doctorReport = await executeDoctor( + { + cwd, + mcpProfiles: profileNames, + skipCodexAuth: Boolean(options.skipCodexAuth) + }, + { + fileExists, + spawnCommand, + getServiceStatus: deps.getServiceStatus, + getCodexRegistration: deps.getCodexRegistration + } + ); + + const doctorRemediations = collectDoctorRemediations(doctorReport); + const doctorStep: LaunchStep = { + id: 'doctor.strict', + ok: doctorReport.ok, + details: doctorReport.ok + ? `Doctor passed (${doctorReport.summary.passed}/${doctorReport.summary.total}).` + : `Doctor reported ${doctorReport.summary.failed} failing check(s).`, + remediation: doctorRemediations.length ? doctorRemediations.join(' && ') : undefined + }; + + { + const stop = appendStep(doctorStep); + if (stop) { + return stop; + } + } + + return buildLaunchReport(steps, doctorReport); +} diff --git a/src/env/service-manager.ts b/src/env/service-manager.ts index d9014a3..0e1c348 100644 --- a/src/env/service-manager.ts +++ b/src/env/service-manager.ts @@ -10,6 +10,8 @@ export interface ServiceProfile { composeFile: string; services?: string[]; port?: number; + dockerImages?: string[]; + dockerRegistries?: string[]; requiredEnv?: string[]; codexName?: string; healthcheck?: { @@ -73,6 +75,7 @@ const PROFILES: Record = { composeFile: 'docker/mcp/docker-compose.github.yml', services: ['mcp-github'], port: 7010, + dockerImages: ['ghcr.io/context-labs/github-mcp:latest'], requiredEnv: ['GITHUB_TOKEN'], codexName: 'github' }, @@ -81,6 +84,7 @@ const PROFILES: Record = { composeFile: 'docker/mcp/docker-compose.context7.yml', services: ['mcp-context7'], port: 7020, + dockerImages: ['ghcr.io/context-labs/context7-mcp:latest'], requiredEnv: ['CONTEXT7_API_KEY'], codexName: 'context7' }, @@ -89,6 +93,7 @@ const PROFILES: Record = { composeFile: 'docker/mcp/docker-compose.playwright.yml', services: ['mcp-playwright'], port: 7030, + dockerImages: ['ghcr.io/context-labs/playwright-mcp:latest'], codexName: 'playwright-local' }, 'mcp-filesystem': { @@ -96,6 +101,7 @@ const PROFILES: Record = { composeFile: 'docker/mcp/docker-compose.filesystem.yml', services: ['mcp-filesystem'], port: 7040, + dockerImages: ['ghcr.io/context-labs/filesystem-mcp:latest'], codexName: 'filesystem-local' }, 'mcp-desktop-commander': { @@ -103,6 +109,7 @@ const PROFILES: Record = { composeFile: 'docker/mcp/docker-compose.desktop-commander.yml', services: ['mcp-desktop-commander'], port: 7070, + dockerImages: ['ghcr.io/wonderwhy-er/desktop-commander:latest'], codexName: 'desktop-commander' }, 'mcp-tavily': { @@ -110,6 +117,7 @@ const PROFILES: Record = { composeFile: 'docker/mcp/docker-compose.tavily.yml', services: ['mcp-tavily'], port: 7050, + dockerImages: ['ghcr.io/context-labs/tavily-mcp:latest'], requiredEnv: ['TAVILY_API_KEY'], codexName: 'tavily' }, @@ -118,6 +126,7 @@ const PROFILES: Record = { composeFile: 'docker/mcp/docker-compose.firecrawl.yml', services: ['mcp-firecrawl'], port: 7060, + dockerImages: ['ghcr.io/firecrawl/firecrawl-mcp:latest'], requiredEnv: ['FIRECRAWL_API_KEY'], codexName: 'firecrawl' } @@ -269,6 +278,79 @@ class ServiceManager { }; } + dockerImagesForProfiles(names: string[]): string[] { + const images = new Set(); + + for (const name of names) { + const profile = this.getProfile(name); + for (const image of profile.dockerImages ?? []) { + const normalized = image.trim(); + if (normalized) { + images.add(normalized); + } + } + } + + return Array.from(images); + } + + registriesForProfiles(names: string[]): string[] { + const registries = new Set(); + + for (const name of names) { + const profile = this.getProfile(name); + + for (const registry of profile.dockerRegistries ?? []) { + const normalized = registry.trim(); + if (normalized) { + registries.add(normalized); + } + } + + for (const image of profile.dockerImages ?? []) { + const registry = this.registryForImage(image); + if (registry) { + registries.add(registry); + } + } + } + + return Array.from(registries); + } + + dockerLogin(registry: string): void { + const normalized = registry.trim(); + if (!normalized) { + throw new Error('Docker registry is required for docker login.'); + } + const cmd = `docker login ${normalized}`; + this.logger.info('env', 'Authenticating Docker registry', { registry: normalized }); + execSync(cmd, { stdio: 'inherit' }); + } + + private registryForImage(image: string): string | null { + const normalized = image.trim(); + if (!normalized) { + return null; + } + + const firstSegment = normalized.split('/')[0] ?? ''; + if (!firstSegment) { + return null; + } + + // Registry host is explicit only when the first segment contains host-like syntax. + if ( + firstSegment.includes('.') + || firstSegment.includes(':') + || firstSegment === 'localhost' + ) { + return firstSegment; + } + + return null; + } + private async probeService(profile: ServiceProfile): Promise { if (profile.healthcheck?.url) { return this.probeHttp(profile.healthcheck.url, 2000); diff --git a/tests/cli/doctor.test.ts b/tests/cli/doctor.test.ts new file mode 100644 index 0000000..33a8a90 --- /dev/null +++ b/tests/cli/doctor.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { runDoctor, type DoctorDependencies } from '../../src/cli/doctor'; +import type { ServiceStatus } from '../../src/env/service-manager'; + +function serviceStatus(overrides: Partial = {}): ServiceStatus { + return { + name: 'mcp-filesystem', + running: true, + healthy: true, + raw: 'ok', + diagnostics: [], + checkedAt: '2026-02-14T00:00:00.000Z', + ...overrides + }; +} + +describe('runDoctor', () => { + it('fails when the dist CLI artifact is missing', async () => { + const deps: DoctorDependencies = { + fileExists: () => false, + spawnCommand: (command, args) => { + if (command === 'codex' && args.join(' ') === 'mcp list --json') { + return { + status: 0, + stdout: '[{"name":"filesystem-local"}]', + stderr: '' + }; + } + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); + }, + getServiceStatus: async () => serviceStatus(), + getCodexRegistration: () => ({ codexName: 'filesystem-local', url: 'http://localhost:7040' }) + }; + + const report = await runDoctor( + { + cwd: '/tmp/codex-synaptic', + skipCodexAuth: true, + mcpProfiles: ['mcp-filesystem'] + }, + deps + ); + + expect(report.ok).toBe(false); + expect(report.summary.failed).toBe(1); + expect(report.checks.find((check) => check.id === 'repo.cli_build_artifact')?.ok).toBe(false); + }); + + it('passes all checks when auth, MCP registration, and services are healthy', async () => { + const profileRegistrations: Record = { + 'mcp-filesystem': { codexName: 'filesystem-local', url: 'http://localhost:7040' }, + 'mcp-playwright': { codexName: 'playwright-local', url: 'http://localhost:7030' } + }; + + const deps: DoctorDependencies = { + fileExists: () => true, + spawnCommand: (command, args) => { + if (command === 'node' && args.includes('--help')) { + return { status: 0, stdout: 'ok', stderr: '' }; + } + + if (command === 'codex' && args.join(' ') === 'login status') { + return { status: 0, stdout: 'Logged in as test-user', stderr: '' }; + } + + if (command === 'codex' && args.join(' ') === 'mcp list --json') { + return { + status: 0, + stdout: JSON.stringify([ + { name: 'filesystem-local' }, + { name: 'playwright-local' } + ]), + stderr: '' + }; + } + + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); + }, + getServiceStatus: async (name) => serviceStatus({ name }), + getCodexRegistration: (name) => profileRegistrations[name] ?? null + }; + + const report = await runDoctor( + { + cwd: '/tmp/codex-synaptic', + mcpProfiles: ['mcp-filesystem', 'mcp-playwright'] + }, + deps + ); + + expect(report.ok).toBe(true); + expect(report.summary.failed).toBe(0); + expect(report.checks.find((check) => check.id === 'repo.cli_exec')?.ok).toBe(true); + expect(report.checks.find((check) => check.id === 'mcp.mcp-filesystem')?.ok).toBe(true); + expect(report.checks.find((check) => check.id === 'mcp.mcp-playwright')?.ok).toBe(true); + }); + + it('returns actionable remediation for failing MCP profile checks', async () => { + const deps: DoctorDependencies = { + fileExists: () => true, + spawnCommand: (command, args) => { + if (command === 'node' && args.includes('--help')) { + return { status: 0, stdout: 'ok', stderr: '' }; + } + + if (command === 'codex' && args.join(' ') === 'mcp list --json') { + return { + status: 0, + stdout: '[]', + stderr: '' + }; + } + + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); + }, + getServiceStatus: async () => serviceStatus({ running: false, healthy: false }), + getCodexRegistration: () => ({ codexName: 'filesystem-local', url: 'http://localhost:7040' }) + }; + + const report = await runDoctor( + { + cwd: '/tmp/codex-synaptic', + skipCodexAuth: true, + mcpProfiles: ['mcp-filesystem'] + }, + deps + ); + + const mcpCheck = report.checks.find((check) => check.id === 'mcp.mcp-filesystem'); + expect(mcpCheck?.ok).toBe(false); + expect(mcpCheck?.remediation).toContain('codex-synaptic env up mcp-filesystem'); + expect(mcpCheck?.remediation).toContain('codex-synaptic env codex-register mcp-filesystem'); + }); +}); diff --git a/tests/cli/launch.test.ts b/tests/cli/launch.test.ts new file mode 100644 index 0000000..ffb7509 --- /dev/null +++ b/tests/cli/launch.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { runLaunch, type LaunchDependencies } from '../../src/cli/launch'; +import type { DoctorReport } from '../../src/cli/doctor'; + +const passingDoctorReport: DoctorReport = { + ok: true, + summary: { passed: 6, failed: 0, total: 6 }, + checks: [] +}; + +describe('runLaunch', () => { + it('returns ready=true when all launch gates pass', async () => { + const ensuredProfiles: string[] = []; + const spawnCalls: string[] = []; + + const deps: LaunchDependencies = { + fileExists: () => true, + spawnCommand: (command, args) => { + spawnCalls.push(`${command} ${args.join(' ')}`); + + if (command === 'node' && args.includes('--help')) { + return { status: 0, stdout: 'ok', stderr: '' }; + } + + if (command === 'codex' && args.join(' ') === 'login status') { + return { status: 0, stdout: 'Logged in as test-user', stderr: '' }; + } + + if (command === 'codex' && args[0] === 'mcp' && args[1] === 'remove') { + return { status: 0, stdout: '', stderr: '' }; + } + + if (command === 'codex' && args[0] === 'mcp' && args[1] === 'add') { + return { status: 0, stdout: '', stderr: '' }; + } + + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); + }, + getBackgroundStatus: () => ({ running: false }), + startBackground: async () => ({ running: true, pid: 43210 }), + ensureService: async (name) => { + ensuredProfiles.push(name); + }, + getCodexRegistration: (name) => { + if (name === 'mcp-filesystem') { + return { codexName: 'filesystem-local', url: 'http://localhost:7040' }; + } + if (name === 'mcp-playwright') { + return { codexName: 'playwright-local', url: 'http://localhost:7030' }; + } + return null; + }, + runDoctor: async () => passingDoctorReport + }; + + const report = await runLaunch( + { + cwd: '/tmp/codex-synaptic', + strict: true, + mcpProfiles: ['mcp-filesystem', 'mcp-playwright'] + }, + deps + ); + + expect(report.ok).toBe(true); + expect(report.nextAction).toBe('continue'); + expect(report.steps.map((step) => step.id)).toEqual([ + 'repo.preflight', + 'codex.auth', + 'runtime.daemon', + 'mcp.up', + 'mcp.codex_register', + 'doctor.strict' + ]); + expect(ensuredProfiles).toEqual(['mcp-filesystem', 'mcp-playwright']); + expect(spawnCalls).toContain('codex mcp add filesystem-local --url http://localhost:7040'); + expect(spawnCalls).toContain('codex mcp add playwright-local --url http://localhost:7030'); + }); + + it('fail-fast stops immediately on the first failing gate in strict mode', async () => { + let doctorCalled = false; + + const report = await runLaunch( + { + cwd: '/tmp/codex-synaptic', + strict: true, + mcpProfiles: ['mcp-filesystem'] + }, + { + fileExists: () => false, + runDoctor: async () => { + doctorCalled = true; + return passingDoctorReport; + } + } + ); + + expect(report.ok).toBe(false); + expect(report.nextAction).toBe('stop'); + expect(report.steps).toHaveLength(1); + expect(report.steps[0].id).toBe('repo.preflight'); + expect(report.doctor.summary.total).toBe(0); + expect(doctorCalled).toBe(false); + }); + + it('returns remediation commands when MCP startup fails', async () => { + const report = await runLaunch( + { + cwd: '/tmp/codex-synaptic', + strict: true, + skipCodexAuth: true, + mcpProfiles: ['mcp-filesystem'] + }, + { + fileExists: () => true, + spawnCommand: (command, args) => { + if (command === 'node' && args.includes('--help')) { + return { status: 0, stdout: 'ok', stderr: '' }; + } + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); + }, + getBackgroundStatus: () => ({ running: true, pid: 999 }), + ensureService: async () => { + throw new Error('docker compose timeout'); + }, + runDoctor: async () => passingDoctorReport + } + ); + + expect(report.ok).toBe(false); + expect(report.nextAction).toBe('stop'); + const mcpStep = report.steps.find((step) => step.id === 'mcp.up'); + expect(mcpStep?.ok).toBe(false); + expect(mcpStep?.remediation).toContain('codex-synaptic env docker-login mcp-filesystem'); + expect(mcpStep?.remediation).toContain('codex-synaptic env up mcp-filesystem'); + expect(mcpStep?.remediation).toContain('codex-synaptic env codex-register mcp-filesystem --replace'); + }); +}); diff --git a/tests/e2e/cli-smoke.test.ts b/tests/e2e/cli-smoke.test.ts index 50f14ba..9458069 100644 --- a/tests/e2e/cli-smoke.test.ts +++ b/tests/e2e/cli-smoke.test.ts @@ -106,12 +106,24 @@ describe('codex-synaptic CLI smoke suite', () => { expect(stdout).toContain('repo.cli_build_artifact'); }); + it('exposes launch command gating options', () => { + const { stdout } = runCli(['launch', '--help']); + expect(stdout).toContain('--mcp-profiles'); + expect(stdout).toContain('--no-strict'); + expect(stdout).toContain('--skip-codex-auth'); + }); + it('includes desktop commander profile in env planning', () => { const { stdout } = runCli(['env', 'plan', 'mcp-desktop-commander']); expect(stdout).toContain('mcp-desktop-commander'); expect(stdout).toContain('codex mcp name'); }); + it('exposes docker-login helper under env command surface', () => { + const { stdout } = runCli(['env', '--help']); + expect(stdout).toContain('docker-login'); + }); + it('exposes the tui command surface', () => { const { stdout } = runCli(['tui', '--help']); expect(stdout).toContain('attach-daemon'); diff --git a/tests/env/service-manager.test.ts b/tests/env/service-manager.test.ts index a0bd65d..445aafe 100644 --- a/tests/env/service-manager.test.ts +++ b/tests/env/service-manager.test.ts @@ -31,4 +31,13 @@ describe('serviceManager profiles', () => { url: 'http://localhost:7040' }); }); + + it('derives docker registries for MCP profiles', () => { + const registries = serviceManager.registriesForProfiles([ + 'mcp-filesystem', + 'mcp-playwright', + 'mcp-desktop-commander' + ]); + expect(registries).toContain('ghcr.io'); + }); }); From d97253c3bd64535f82e82ab39dc99aa18bc542f2 Mon Sep 17 00:00:00 2001 From: Chris Dukes Date: Fri, 13 Feb 2026 23:35:15 -0600 Subject: [PATCH 02/11] Recreate launch doctor workflow --- .codex-synaptic/memory.db | Bin 651264 -> 667648 bytes src/cli/index.ts | 14 +++++-- src/cli/launch.ts | 20 ++++++++-- src/core/errors.ts | 13 ++++++ tests/cli/doctor.test.ts | 82 ++++++++++++++++++++++++++++++++++++++ tests/cli/launch.test.ts | 43 ++++++++++++++++++++ 6 files changed, 166 insertions(+), 6 deletions(-) diff --git a/.codex-synaptic/memory.db b/.codex-synaptic/memory.db index 8c8284600de9fcfbe3234f32eeace056ba85fc91..427c8704290c8257fe2d387b5af30ea0f0eee802 100644 GIT binary patch delta 6389 zcmZu#Ym8l06~6bLd++Q$uXaKw0z;wG*Yv?m&)H``2k3AqwUjb#hXUo@8BLinV3+}o z0fga(gM<*3$q|pnAXFeo(1Zq)qNv!2CdLq369dL3LZhM$DCO}3{ef$*eeRsYJ(KjV zWV+gK*6aJ$+V}o9hwguK!=nSW2TP^WQwyuc$`2l%e0mFEy(i!=N>9bz{*9#f-@X6r zJ=uF=p)v5{BmPA1nc(?Bv={85XbkL=(FoWlqLmMPxb>kAx0c~kfsY5DDtv12sl%rO zKIF)UTj}xz?=s>Y={QC|D9_U$(`V@+`aSv>{U&{wK0xoKU!imKR(g;gpx4oTbe!&> z4SE3;bPL@`*U**pGqjI((Hi-Ld`vzd?~=F4TjVd~Pvj5ecjVXP7vu;zOr9stkSEA@ zNld;@9wJS05BV~=oqU0Op4>$ClL@kmY$s#HkiL)%lOZxl21q|yMkvJf-{bVI+I-C` zRVq_xC(#~2do$Xb(B6pl2DI0sy$#x8ccA>o-?M}3pq1}P@vuL-Yy%g;>w3nc5puHIF7}~99FG71E+6&N*qBUrB&6|f` zukZ(nR-ol*L$nNSfYwL51?>phVYKI?-Hdh<+Kp&8pdCWH9_>1`YtgO&8&)c-@rOaQ ztI(c@_FS|p(Vl~L0PWdFUi>qisIOgKdZ)B(dG9$r8@pB4rJWP>0J*K>?)pQuZ&iQj z9jUxpexvkG96b6PZzfuqOuScFPgUUaP%EL5!#}I6NRB0RcTLGa^LXmj4awfOY4>V1 zD#VDA;q|NUWj>R`J{u1BDxZ&r*3+Dc&Os>RpqNM|j#v5-sVark@#&X0wt##95@snN zUo>xz2I9W?G$dt1YP>?HNFa<3g%0A`c-Y~{(ZJ%UB98SmzZjjJOm&u9Kn#G6c9dOi zR4{EqUrNaDYiU?wlmQ$UML6q${LaGsl3)Iv){ngLH@cx~QxA~7ue77ItiN}2&sg`q zuIbMEXiT2%c)5P8cB1-TwdVCzhRR`S$3kOe6(B|sqSKa$D<(9H8*`KOFi_mER;9bv z2sR28(yW*ndYZG*aB{rAyx!qRWpM`YoTQ&KcX!R`AoP>+DH^O1zO9aCL7_V6fsi)mLJ|;CkTf5oQytncWh6_s zzEl~pZR=XY&1eWvL9zenX--F*l7(k0X+#1d3gX86WSuE(v>#7AGnC@!QEqW~AtY`+ zQ0Joi=1tSXJ+t$~DyaI|&c7m;wZXQK_69I}LSh$xxpEw8XO>X1yT0P;I} zcribP-Sd~o&w3!gAw$#U z<{HKzDve{!LI$D|&pp^`SZEujbaAY?^)wGgYm!5+r((nd%lzhv3TEdq&XN~SR$535 zwMZAWh+7YoX>~5S03%9=+5;U)mOoe7lnxP6P3huX0}?cACK}8k`4E$e8=st!Joy~m zQ`Lbq8EZK*O{pa+Zao0C3ZY!{eA~R{BlQ&y%4eZSj`w)2k|W)eT4J`X2UvSv#@f(e zSdgI8ZD5?EEnAILQ>vICV3M&yhZay+uw}Wff zgPUBsPK^3)@O3+bQV$?)%_9LHwbehMK;ZlFTR%rZM<67D*6j%ZP)dS}azIuhAf*3U zi5RbO!4ymSo~vYN$OEle8>IjZc`y@Qm^D~fnTQ7;u;V*k=bW2Rv}T2_h5v$N(5jsb zsRsl#Io4kuLWvh35?Zw)BRrJSfN&&7-*=jWsUjt`S|T{4#B?;8kyL;2n`|Qq$HZ`nh*Bo{xrpHS(NLPpJr`0f3=I!j zT!i!-6T{*%K@W)JmW(+Z4pJRv=`j$pY!Y_DeFjIe4wq=4^*F^GD=k~myQ)WY@9Ns$ zc`KbK-|l$6{z~ol)z`gu;LWq5yty>C(CD{{xi?Egf}p_Bl5(~tpk*vMeTp)m219C? zLbFp34B#FdKvWY6YGQ%rCo3?BN6&R_wlocp5{9>Lozw&I?9Rml8Uirxq8hKm4XR-q z=2SdDRv6w+FhEQ1v+*1nXo!KjzUIVNzy>pm(~PkpHB2ZA;v3+NHXU7&bxDGR7}s(u zNuH3xZnV(mh`ca`Zl@klkzLs#D?vpds&pj~5RuT=`qRQ2AUjOhKGFb)?3j%%&jUhW z%&t8EV>}RWIsWxF4=-cP68T|Raq|T!KX&G721){8gLh8ZWd*A%z1R>483J4^mJ1m& z9bJ|y7f6X=$PiR;5o!T1qx8~7IOK?7?Hqw5)6;E7Hehx%?Q;fS5#u!x27ZwA4V2g8 zI)L35AWKZ)F_C(#%)K>NPh}2rFz^7Fo2s!;f*#~2439Sxv}+vVpvG;_ss$SqfO1I& z@cL6N8NeiQOM#3QYld5DHo7$HB+R=oev6J&_%KqwRy_GtuZK@BM0C9Lmk2ODVzGiJ%AW?aAywQH)7H4(^z$sQ|Wl{%#6^4Hl=E0(wA* z4F!F7dc-M|w8Da~LRWnti30I-uV;+Tt**ku5WtWn z2cv7T`p6P#bp;=W7Of_Q;xZFYo2y%3g{NWafiCRNx&WoJv9*@86h8a(z%sYJRB=h8 z2L|n$Tpv(e7XIZJEa8csd(+s^?4J=Mi%mrjjM>$>M4{;Li1-Obw3-Y<4dZ{Z;K_K7cJ8=@t zU+mhy*f9bz6A&{4F$)m00x=s9vjZ{5_Ahpv0uz}TI5%%+zrgvKaeLP#&Pvve6C9Yf zZ@$SH$R@+XvVwtMfKP^3lgFIfg)5LVo+F37imjb>3d@R(jU6m3jTtSH(;a!aw6 + ) { + super(code, message, context, false); + this.name = 'CliGateError'; + } +} + export class AgentError extends CodexSynapticError { constructor(code: ErrorCode, message: string, context?: Record, retryable: boolean = true) { super(code, message, context, retryable); diff --git a/tests/cli/doctor.test.ts b/tests/cli/doctor.test.ts index 33a8a90..2ac04d7 100644 --- a/tests/cli/doctor.test.ts +++ b/tests/cli/doctor.test.ts @@ -46,6 +46,47 @@ describe('runDoctor', () => { expect(report.checks.find((check) => check.id === 'repo.cli_build_artifact')?.ok).toBe(false); }); + it('fails codex auth check when codex login status returns non-zero', async () => { + const deps: DoctorDependencies = { + fileExists: () => true, + spawnCommand: (command, args) => { + if (command === 'node' && args.includes('--help')) { + return { status: 0, stdout: 'ok', stderr: '' }; + } + + if (command === 'codex' && args.join(' ') === 'login status') { + return { status: 1, stdout: '', stderr: 'Not logged in' }; + } + + if (command === 'codex' && args.join(' ') === 'mcp list --json') { + return { + status: 0, + stdout: '[{"name":"filesystem-local"}]', + stderr: '' + }; + } + + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); + }, + getServiceStatus: async () => serviceStatus(), + getCodexRegistration: () => ({ codexName: 'filesystem-local', url: 'http://localhost:7040' }) + }; + + const report = await runDoctor( + { + cwd: '/tmp/codex-synaptic', + mcpProfiles: ['mcp-filesystem'] + }, + deps + ); + + const authCheck = report.checks.find((check) => check.id === 'codex.auth'); + expect(authCheck?.ok).toBe(false); + expect(authCheck?.details).toContain('Not logged in'); + expect(authCheck?.remediation).toContain('codex login'); + expect(report.ok).toBe(false); + }); + it('passes all checks when auth, MCP registration, and services are healthy', async () => { const profileRegistrations: Record = { 'mcp-filesystem': { codexName: 'filesystem-local', url: 'http://localhost:7040' }, @@ -131,4 +172,45 @@ describe('runDoctor', () => { expect(mcpCheck?.remediation).toContain('codex-synaptic env up mcp-filesystem'); expect(mcpCheck?.remediation).toContain('codex-synaptic env codex-register mcp-filesystem'); }); + + it('fails codex MCP parsing checks when codex mcp list returns malformed JSON', async () => { + const deps: DoctorDependencies = { + fileExists: () => true, + spawnCommand: (command, args) => { + if (command === 'node' && args.includes('--help')) { + return { status: 0, stdout: 'ok', stderr: '' }; + } + + if (command === 'codex' && args.join(' ') === 'mcp list --json') { + return { + status: 0, + stdout: 'not-json', + stderr: '' + }; + } + + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); + }, + getServiceStatus: async () => serviceStatus(), + getCodexRegistration: () => ({ codexName: 'filesystem-local', url: 'http://localhost:7040' }) + }; + + const report = await runDoctor( + { + cwd: '/tmp/codex-synaptic', + skipCodexAuth: true, + mcpProfiles: ['mcp-filesystem'] + }, + deps + ); + + const mcpListCheck = report.checks.find((check) => check.id === 'codex.mcp_list'); + expect(mcpListCheck?.ok).toBe(false); + expect(mcpListCheck?.remediation).toContain('codex mcp list --json'); + + const mcpProfileCheck = report.checks.find((check) => check.id === 'mcp.mcp-filesystem'); + expect(mcpProfileCheck?.ok).toBe(false); + expect(mcpProfileCheck?.remediation).toContain('codex-synaptic env codex-register mcp-filesystem'); + expect(report.ok).toBe(false); + }); }); diff --git a/tests/cli/launch.test.ts b/tests/cli/launch.test.ts index ffb7509..6ee818a 100644 --- a/tests/cli/launch.test.ts +++ b/tests/cli/launch.test.ts @@ -135,4 +135,47 @@ describe('runLaunch', () => { expect(mcpStep?.remediation).toContain('codex-synaptic env up mcp-filesystem'); expect(mcpStep?.remediation).toContain('codex-synaptic env codex-register mcp-filesystem --replace'); }); + + it('captures MCP bridge error classification when codex registration add fails', async () => { + let doctorCalled = false; + const report = await runLaunch( + { + cwd: '/tmp/codex-synaptic', + strict: true, + skipCodexAuth: true, + mcpProfiles: ['mcp-filesystem'] + }, + { + fileExists: () => true, + spawnCommand: (command, args) => { + if (command === 'node' && args.includes('--help')) { + return { status: 0, stdout: 'ok', stderr: '' }; + } + if (command === 'codex' && args[0] === 'mcp' && args[1] === 'remove') { + return { status: 0, stdout: '', stderr: '' }; + } + if (command === 'codex' && args[0] === 'mcp' && args[1] === 'add') { + return { status: 1, stdout: 'denied', stderr: 'permission denied' }; + } + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); + }, + getBackgroundStatus: () => ({ running: true, pid: 999 }), + ensureService: async () => {}, + getCodexRegistration: () => ({ codexName: 'filesystem-local', url: 'http://localhost:7040' }), + runDoctor: async () => { + doctorCalled = true; + return passingDoctorReport; + } + } + ); + + expect(report.ok).toBe(false); + expect(report.nextAction).toBe('stop'); + expect(doctorCalled).toBe(false); + const registrationStep = report.steps.find((step) => step.id === 'mcp.codex_register'); + expect(registrationStep?.ok).toBe(false); + expect(registrationStep?.details).toContain('codex mcp add failed for filesystem-local'); + expect(registrationStep?.remediation).toContain('codex-synaptic env codex-register mcp-filesystem --replace'); + expect((registrationStep?.metadata as { code?: string } | undefined)?.code).toBe('MCP_ERROR'); + }); }); From 4e6b154d9182e93b57689582a1f936fe101abef9 Mon Sep 17 00:00:00 2001 From: Chris Dukes Date: Tue, 24 Feb 2026 00:06:42 -0600 Subject: [PATCH 03/11] Stabilize launch MCP readiness --- .codex-synaptic/memory.db | Bin 667648 -> 778240 bytes .github/workflows/ci-non-mcp-gates.yml | 64 +++++ docs/uat/CODEX_MACOS_UAT_RUNBOOK.md | 346 +++++++++++++++++++++++++ docs/uat/UAT_READINESS_TRACKER.md | 220 ++++++++++++++++ src/cli/doctor.ts | 6 + src/cli/env-bootstrap.ts | 182 +++++++++++++ src/cli/index.ts | 98 ++----- src/cli/launch.ts | 32 ++- src/env/service-manager.ts | 58 ++++- tests/cli/doctor.test.ts | 1 + tests/cli/env-bootstrap.test.ts | 111 ++++++++ tests/cli/launch.test.ts | 4 + 12 files changed, 1033 insertions(+), 89 deletions(-) create mode 100644 .github/workflows/ci-non-mcp-gates.yml create mode 100644 docs/uat/CODEX_MACOS_UAT_RUNBOOK.md create mode 100644 docs/uat/UAT_READINESS_TRACKER.md create mode 100644 src/cli/env-bootstrap.ts create mode 100644 tests/cli/env-bootstrap.test.ts diff --git a/.codex-synaptic/memory.db b/.codex-synaptic/memory.db index 427c8704290c8257fe2d387b5af30ea0f0eee802..d3f8870904eedd5d77c6f6f8fa147c265fdf7703 100644 GIT binary patch delta 39703 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 N!+ON2#3{td{|^U}eUbnG delta 387 zcmZoTpx^L7V}cYLQ!4`l!{Uhwc1%pI8xxl3a~Sd60g5mzp3GyAGSN|*vsH1u(S*Ft-J;v<0xX1+cXRu(t(pYzyFIEa2x)XXNKx%E14J|2zL3 zutBd`SQr>sR!#ozV#EeylrTP=zV0Ze_+&p*psA9EE3Dr7!0`L3%S zh|#rK-pzxPg?|Hs%j9f-*Kq#R41y)8d5L)?x?qR#f*i&PA~^UT!ljam63g=0L87cc zN26!}`-BT5#l+0az{$nH!05rjyq*05_kBht&X?0qT;`OS&UTTrZTqu}+?A}`nQw4T uVw}G04rc<30JAFN^q)65%bA$?CJVaU-Tv|}=WfRB3dcFWF)e$*82|tPI&dZc diff --git a/.github/workflows/ci-non-mcp-gates.yml b/.github/workflows/ci-non-mcp-gates.yml new file mode 100644 index 0000000..df981a4 --- /dev/null +++ b/.github/workflows/ci-non-mcp-gates.yml @@ -0,0 +1,64 @@ +name: CI Non-MCP Gates + +on: + push: + pull_request: + workflow_dispatch: + +concurrency: + group: ci-non-mcp-gates-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + non_mcp_gates: + name: Build, Test, Lint, Preflight + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + env: + CI: "true" + CODEX_AUTO_LINK: "false" + steps: + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "20" + cache: npm + + - name: Install dependencies + run: | + set -euo pipefail + npm ci + + - name: Build + run: | + set -euo pipefail + npm run build + + - name: Test + env: + HOME: ${{ runner.temp }}/codex-synaptic-ci-home + run: | + set -euo pipefail + mkdir -p "${HOME}" + npm test + + - name: Lint + run: | + set -euo pipefail + npm run lint + + - name: Release preflight (canonical repo) + if: github.repository == 'clduab11/codex-synaptic' + run: | + set -euo pipefail + npm run release:preflight + + - name: Release preflight skipped (non-canonical repo) + if: github.repository != 'clduab11/codex-synaptic' + run: | + echo "Skipping release preflight: repository is ${{ github.repository }} (expects clduab11/codex-synaptic)." diff --git a/docs/uat/CODEX_MACOS_UAT_RUNBOOK.md b/docs/uat/CODEX_MACOS_UAT_RUNBOOK.md new file mode 100644 index 0000000..af409ce --- /dev/null +++ b/docs/uat/CODEX_MACOS_UAT_RUNBOOK.md @@ -0,0 +1,346 @@ +# Codex for macOS UAT Runbook (Launch/Doctor MCP Readiness) + +Last updated: 2026-02-23 +Audience: UAT operators validating Codex for macOS + `codex-synaptic` launch readiness. +Scope: UAT readiness only (not PRD release readiness). + +## Purpose + +Provide a deterministic, testable procedure for validating the Codex for macOS startup path in this repository, with `launch` and `doctor` as hard readiness gates for default MCP profiles: + +- `mcp-filesystem` +- `mcp-playwright` +- `mcp-desktop-commander` + +This runbook is aligned with: + +- `AGENTS.md` (Startup Gate semantics) +- `README.md` (operator command deck + MCP workflow) +- `docs/guides/codex-macos-workflows.md` +- `docs/mcp/README.md` + +## UAT Pass Criteria (Top-Level) + +UAT launch readiness is considered `PASS` only when all of the following are true: + +1. `node dist/cli/index.js doctor --strict --json` exits `0` and returns JSON with: + - `"ok": true` + - `"summary.failed": 0` + - passing checks for default MCP profiles (`mcp.mcp-filesystem`, `mcp.mcp-playwright`, `mcp.mcp-desktop-commander`) +2. `node dist/cli/index.js launch --strict --json` exits `0` and returns JSON with: + - `"ok": true` + - `"nextAction": "continue"` + - all launch steps `ok=true` including `mcp.up`, `mcp.codex_register`, and `doctor.strict` +3. Default `mcp-filesystem` behavior remains read-only unless explicitly opted into controlled write. + +## UAT Environment Prerequisites + +Do not start the run until these are confirmed: + +- macOS host with Docker Desktop installed and running +- Node.js/npm installed (repo uses Node 20+) +- Codex CLI installed and on `PATH` (`codex --help`) +- Codex CLI authenticated (`codex login status`) +- Network access to pull MCP images +- Docker registry credentials with access to required images (GHCR-backed images are used by default MCP profiles) + +Notes: + +- CLI local `.env` autoload is enabled by default. Set `CODEX_CLI_ENV_AUTOLOAD=0` to disable auto-loading for UAT runs. +- For non-JSON commands, the CLI may emit an env bootstrap banner to `stderr` (not `stdout`). Treat local env source details as sensitive operational context. +- Set `CODEX_CLI_ENV_BANNER_VERBOSE=1` only when debugging env source paths; avoid using it in shared logs/screenshots. +- Do not capture or paste secrets from `codex login`, Docker login prompts, or local `.env` files. + +## Recommended Evidence Capture (Optional but Strongly Recommended) + +Create a dated folder and store JSON outputs for handoff: + +```bash +mkdir -p docs/uat/evidence/2026-02-23 +``` + +Suggested evidence artifacts: + +- `doctor.strict.json` +- `launch.strict.json` +- `codex.mcp.list.json` +- `env.status.txt` + +## UAT Procedure (Exact Commands) + +Run all commands from the repo root: + +```bash +cd /absolute/path/to/codex-synaptic +``` + +### 1) Build the CLI artifacts + +```bash +npm install +npm run build +``` + +Pass criteria: + +- `npm install` completes without fatal errors +- `npm run build` exits `0` +- `dist/cli/index.js` exists + +### 2) Verify Codex CLI + MCP command surface + +```bash +codex --help +codex mcp --help +codex mcp add --help +codex login status +``` + +Pass criteria: + +- All commands exit `0` +- `codex login status` indicates logged-in state (not "not logged in") + +### 3) Inspect default MCP profiles and registration targets + +```bash +node dist/cli/index.js env plan mcp-filesystem mcp-playwright mcp-desktop-commander +``` + +Pass criteria: + +- Output includes all 3 profile names +- Output shows expected Codex MCP names: + - `filesystem-local` + - `playwright-local` + - `desktop-commander` +- `mcp-filesystem` notes read-only default mode + +### 4) Authenticate Docker registry access for MCP images + +```bash +node dist/cli/index.js env docker-login mcp-filesystem mcp-playwright mcp-desktop-commander +``` + +Pass criteria: + +- Command exits `0` +- Docker login succeeds for required registries (typically `ghcr.io`) + +Common failure indicators (blockers): + +- `error from registry: denied` +- `pull access denied` +- `unauthorized` + +### 5) Start default MCP profiles (read-only filesystem mode by default) + +```bash +node dist/cli/index.js env up mcp-filesystem mcp-playwright mcp-desktop-commander +``` + +Pass criteria: + +- Command exits `0` +- No healthcheck timeout errors +- Filesystem profile starts without controlled-write flags (default safe mode) + +### 6) Verify MCP runtime health + +```bash +node dist/cli/index.js env status mcp-filesystem mcp-playwright mcp-desktop-commander +``` + +Pass criteria for each profile: + +- `running: yes` +- `healthy: yes` (or probe-equivalent success) +- no blocking diagnostics + +If collecting evidence: + +```bash +node dist/cli/index.js env status mcp-filesystem mcp-playwright mcp-desktop-commander | tee docs/uat/evidence/2026-02-23/env.status.txt +``` + +### 7) Register MCP HTTP endpoints with Codex + +```bash +node dist/cli/index.js env codex-register mcp-filesystem mcp-playwright mcp-desktop-commander --replace +codex mcp list --json +``` + +Pass criteria: + +- Registration command exits `0` +- `codex mcp list --json` exits `0` +- JSON includes all names: + - `filesystem-local` + - `playwright-local` + - `desktop-commander` + +If collecting evidence: + +```bash +codex mcp list --json > docs/uat/evidence/2026-02-23/codex.mcp.list.json +``` + +### 8) Run strict doctor gate (authoritative diagnostic pass) + +```bash +node dist/cli/index.js doctor --strict --json +``` + +Pass criteria: + +- Exit code `0` +- JSON contains: + - `"ok": true` + - `"summary": { "failed": 0, ... }` +- Checks include and pass: + - `repo.cli_build_artifact` + - `repo.cli_exec` + - `codex.auth` + - `codex.mcp_list` + - `mcp.mcp-filesystem` + - `mcp.mcp-playwright` + - `mcp.mcp-desktop-commander` + +If collecting evidence: + +```bash +node dist/cli/index.js doctor --strict --json > docs/uat/evidence/2026-02-23/doctor.strict.json +``` + +### 9) Run strict launch gate (hard startup gate) + +```bash +node dist/cli/index.js launch --strict --json +``` + +Pass criteria: + +- Exit code `0` +- JSON contains: + - `"ok": true` + - `"nextAction": "continue"` +- `steps` contains all required launch gate steps with `"ok": true`: + - `repo.preflight` + - `codex.auth` + - `runtime.daemon` + - `mcp.up` + - `mcp.codex_register` + - `doctor.strict` +- `doctor.ok` is `true` + +If collecting evidence: + +```bash +node dist/cli/index.js launch --strict --json > docs/uat/evidence/2026-02-23/launch.strict.json +``` + +## Deterministic Fail/Block Conditions + +Mark UAT launch readiness `FAIL` (or `BLOCKED`) if any of the following occur: + +- `launch --strict --json` exits non-zero +- `launch` JSON returns `"ok": false` or `"nextAction": "stop"` +- `doctor --strict --json` exits non-zero +- `doctor` JSON returns `"ok": false` or any failed default MCP check +- Any default MCP profile is not running/healthy/registered +- Docker registry auth/pull denial prevents `mcp.up` (for example GHCR denial) +- Codex auth not available (`codex login status` not logged in) + +## Failure Triage (Fast Path) + +Use the failure message/remediation emitted by `launch`/`doctor`. Common paths: + +### A. Docker image pull/auth denied (GHCR) + +Symptom examples: + +- `error from registry: denied` +- `pull access denied` + +Remediation: + +```bash +node dist/cli/index.js env docker-login mcp-filesystem mcp-playwright mcp-desktop-commander +node dist/cli/index.js env up mcp-filesystem mcp-playwright mcp-desktop-commander +``` + +Then retry: + +```bash +node dist/cli/index.js doctor --strict --json +node dist/cli/index.js launch --strict --json +``` + +### B. MCP registration drift + +Symptom: + +- `doctor` shows `registered=false` for one or more MCP checks + +Remediation: + +```bash +node dist/cli/index.js env codex-register mcp-filesystem mcp-playwright mcp-desktop-commander --replace +codex mcp list --json +``` + +### C. Service running but unhealthy / health timeout + +Symptom: + +- `healthy=false` +- `Service healthcheck timed out ...` + +Remediation: + +```bash +node dist/cli/index.js env status mcp-filesystem mcp-playwright mcp-desktop-commander +node dist/cli/index.js env up mcp-filesystem mcp-playwright mcp-desktop-commander +``` + +If persistent, capture `env status` output and treat as UAT blocker. + +### D. Codex auth missing + +Symptom: + +- `codex.auth` fails in `doctor`/`launch` + +Remediation: + +```bash +codex login +codex login status +``` + +## UAT Acceptance Checklist (Operator Sign-Off) + +Use this checklist during the run and archive it with evidence: + +- [ ] Running on macOS UAT host with Docker Desktop active +- [ ] `npm install` completed successfully +- [ ] `npm run build` completed successfully +- [ ] `codex login status` confirms logged-in state +- [ ] `env plan` confirms default MCP profiles and expected Codex registration names +- [ ] `env docker-login` completed successfully (registry access verified) +- [ ] `env up` started `mcp-filesystem`, `mcp-playwright`, `mcp-desktop-commander` +- [ ] `env status` shows `running: yes` and `healthy: yes` for all default MCP profiles +- [ ] `env codex-register ... --replace` completed successfully +- [ ] `codex mcp list --json` includes `filesystem-local`, `playwright-local`, `desktop-commander` +- [ ] `doctor --strict --json` exits `0` with `"ok": true` +- [ ] `launch --strict --json` exits `0` with `"ok": true` and `"nextAction": "continue"` +- [ ] No secrets captured in shared evidence/logs/screenshots + +## Out of Scope for This UAT Runbook + +This runbook validates launch/doctor MCP readiness only. It does not certify: + +- CI/CD release pipeline completeness +- package publication scope hardening +- dependency vulnerability remediation +- full beta/PRD readiness criteria from `docs/beta-readiness-checklist.md` diff --git a/docs/uat/UAT_READINESS_TRACKER.md b/docs/uat/UAT_READINESS_TRACKER.md new file mode 100644 index 0000000..6fa98df --- /dev/null +++ b/docs/uat/UAT_READINESS_TRACKER.md @@ -0,0 +1,220 @@ +# UAT Readiness Tracker (Codex for macOS Integration) + +## Scope + +UAT readiness (not PRD) for deterministic, testable Codex for macOS launch/doctor MCP readiness flow. + +Primary objectives (priority order): + +- A. Reproducible `launch --strict --json` and `doctor --strict --json` behavior in a documented UAT environment +- B. UAT runbook/checklist with exact commands and pass/fail criteria +- C. CI coverage for non-MCP gates (build/test/lint/preflight) +- D. Local secret hygiene guardrails (`src/cli/.env` auto-loading path) +- E. Package publication scope hardening +- F. Dependency audit remediation/triage + +## Working Rules + +- Small, safe chunks only (one major subsystem at a time) +- Update this file after each chunk with: changes, verification, risks, next exact step +- Respect `AGENTS.md` / `README.md` launch gate semantics as source of truth +- Do not print secrets (especially local `.env` values) + +## Chunk Plan + +1. Chunk 1: Reproduce and harden MCP/launch diagnostics (no broad refactors) — `DONE` +2. Chunk 2: UAT bootstrap/runbook + acceptance checklist — `DONE` +3. Chunk 3: CI workflow for build/test/lint/preflight — `DONE` +4. Chunk 4: Secret hygiene guardrails for local `.env` loading — `DONE` +5. Chunk 5: Packaging scope hardening (`npm pack` contents) +6. Chunk 6: Dependency audit remediation/triage + risk documentation +7. Chunk 7: Full UAT smoke run + final PASS/FAIL report + +## Chunk Log + +### Chunk 1 — MCP/Launch Diagnostics Hardening + +- Status: `PASS` (diagnostics hardening complete; UAT readiness still blocked by MCP image registry access) +- Goal: Make MCP startup failures in `launch`/`doctor` more deterministic and actionable (especially Docker/GHCR image pull/auth failures) without changing launch gate semantics. +- Notes (pre-change): + - Verified docs/source-of-truth references in `AGENTS.md` and `README.md` for default launch gate MCP profiles. + - Identified current weakness: `launch` collapses MCP startup failures into a generic `ensureService` error path. + +#### What changed + +- `src/env/service-manager.ts` + - `ensureService()` now captures Docker Compose startup failures and wraps them with classified, actionable errors. + - Added classification for common Docker/MCP failure modes, including registry/image pull auth denial (GHCR-style `error from registry: denied`), daemon unavailable, and missing Docker CLI. + - Error messages now include profile name, compose command, exit status, and truncated raw Docker output for deterministic debugging. +- `src/cli/launch.ts` + - MCP startup now tracks the specific failing profile and any previously started profiles. + - `mcp.up` launch step now emits profile-specific failure details and metadata (`failedProfile`, `startedProfiles`). + - `mcp.up` remediation now includes `env status ` in addition to docker-login/up/register. +- `src/cli/doctor.ts` + - Failing MCP profile checks now include `codex-synaptic env docker-login ` before `env up` when the profile depends on registry-hosted images. +- Tests + - Updated launch/doctor tests to assert the new remediation/details behavior. + +#### Verification evidence + +- `npx vitest run tests/cli/launch.test.ts tests/cli/doctor.test.ts tests/env/service-manager.test.ts` + - Passed (`15/15` tests) +- `npx tsc --noEmit` + - Passed (no output / exit 0) +- `npm run build` + - Passed (rebuilt `dist/`) +- `node dist/cli/index.js doctor --strict --skip-codex-auth --json` + - Expected fail (exit 1) due default MCP profiles not running/registered + - Improvement verified: each failing default MCP check now includes `env docker-login ` in remediation +- `node dist/cli/index.js launch --strict --skip-codex-auth --json` + - Expected fail (exit 1) on `mcp.up` + - Improvement verified: failure is now deterministic and profile-specific (`mcp-filesystem`), with classified cause `Docker image pull/auth denied...` and explicit GHCR-focused remediation + - Launch gate semantics preserved: `ok=false`, `nextAction="stop"`, doctor not executed after strict fail-fast + +#### Risks / open questions + +- UAT remains blocked until the UAT environment can authenticate/pull required MCP images (at minimum `ghcr.io/context-labs/filesystem-mcp:latest`; likely also `playwright-mcp` and `desktop-commander`). +- Docker Compose warns that `version` in `docker/mcp/*.yml` is obsolete. This is non-blocking for Chunk 1 but may create noise in UAT evidence. +- `doctor` still does not proactively test registry auth; it now emits better remediation, but actual access is only proven during `env up` / `launch`. + +#### Next exact step + +- Chunk 2: create a UAT bootstrap/runbook + acceptance checklist with exact commands, expected JSON pass/fail fields, and explicit pre-reqs (Docker running, GHCR auth, Codex login, default MCP profile startup/register sequence). + +### Chunk 2 — UAT Bootstrap Runbook + Acceptance Checklist + +- Status: `PASS` +- Goal: Produce a deterministic UAT runbook/checklist for Codex for macOS launch/doctor MCP readiness using repo source-of-truth behavior. + +#### What changed + +- Added `/Users/chrisdukes/LocalProjects/codex-synaptic/docs/uat/CODEX_MACOS_UAT_RUNBOOK.md` + - Exact bootstrap and readiness commands (`build`, `env plan`, `env docker-login`, `env up`, `env status`, `env codex-register`, `doctor --strict --json`, `launch --strict --json`) + - Explicit pass/fail JSON criteria for `doctor` and `launch` + - Deterministic fail/block conditions for UAT status + - Failure triage paths (registry auth, registration drift, health timeout, Codex auth) + - Operator sign-off acceptance checklist + - Notes on secret handling and `src/cli/.env` banner hygiene + +#### Verification evidence + +- `rg -n "doctor --strict --json|launch --strict --json|env docker-login|mcp-filesystem|nextAction|read-only" docs/uat/CODEX_MACOS_UAT_RUNBOOK.md` + - Passed content spot-check (required commands/criteria present) +- `npx prettier --check docs/uat/CODEX_MACOS_UAT_RUNBOOK.md docs/uat/UAT_READINESS_TRACKER.md` + - Initial check flagged tracker formatting only (no content issue in runbook) +- `npx prettier --write docs/uat/CODEX_MACOS_UAT_RUNBOOK.md docs/uat/UAT_READINESS_TRACKER.md` + - Applied formatting +- `npx prettier --check docs/uat/CODEX_MACOS_UAT_RUNBOOK.md docs/uat/UAT_READINESS_TRACKER.md` + - Passed after formatting + +#### Risks / open questions + +- Runbook uses a fixed example evidence date (`2026-02-23`) in example paths; operators should replace with actual run date. +- UAT remains environment-blocked until GHCR credentials/image pulls succeed (tracked in Chunk 1 risks). +- The runbook intentionally documents current behavior; if CLI JSON schema changes, pass/fail criteria must be updated. + +#### Next exact step + +- Chunk 3: add/strengthen CI for non-MCP gates (`npm run build`, `npm test`, `npm run lint`, `npm run release:preflight`) and document the workflow outcome in this tracker. + +### Chunk 3 — CI Workflow for Non-MCP Gates + +- Status: `PASS` +- Goal: Add a deterministic GitHub Actions workflow for non-MCP gates (`build`, `test`, `lint`, `release:preflight`) and harden it against daemon-state test flakiness. + +#### What changed + +- Added `/Users/chrisdukes/LocalProjects/codex-synaptic/.github/workflows/ci-non-mcp-gates.yml` + - Triggers: `push`, `pull_request`, `workflow_dispatch` + - Single Ubuntu/Node 20 job with pinned `actions/checkout` and `actions/setup-node` + - Runs `npm ci`, `npm run build`, `npm test`, `npm run lint` + - Runs `npm run release:preflight` only on canonical repo (`clduab11/codex-synaptic`) + - Skips preflight on non-canonical repos (forks) to avoid expected origin-fragment failures + - Sets `CODEX_AUTO_LINK=false` to prevent CLI auto-link side effects in CI + - Isolates `HOME` for the `npm test` step (`${{ runner.temp }}/codex-synaptic-ci-home`) to avoid stale local daemon state causing false failures on self-hosted runners + +#### Verification evidence + +- Workflow syntax/format: + - `npx prettier --check .github/workflows/ci-non-mcp-gates.yml` + - Passed + - `node -e "…js-yaml load…"` + - Passed (`YAML_OK`, later verified `Test` step `HOME` override present) +- Local command verification (mirroring CI gates): + - `CODEX_AUTO_LINK=false npm run build` + - Passed + - `npm run lint` + - Passed with warnings only (0 errors, 5 warnings) + - `npm run release:preflight` + - Expected fail in local dev branch state (dirty working tree) + - Confirmed failure reason is local tracked/untracked changes, not script/runtime breakage +- Test stability verification: + - `npm test` + - Failed locally because an active background daemon in the default state directory changed CLI behavior for daemon-sensitive tests (`commands`, `openai-usage`, `cli-smoke`) + - `HOME="$(mktemp -d)" npm test -- tests/cli/openai-usage.test.ts tests/cli/commands.test.ts tests/e2e/cli-smoke.test.ts` + - Passed (`25/25`), confirming the `HOME` isolation mitigation + - `HOME="$(mktemp -d)" npm test -- --reporter=dot` + - Passed full suite (`245/245`) + +#### Risks / open questions + +- New workflow is validated locally (syntax + command behavior), but not yet executed in GitHub Actions within this branch. +- `release:preflight` is intentionally skipped on non-canonical repositories/forks; this reduces false failures but means forks will not enforce that gate. +- `npm test` emits noisy logs/warnings that are expected in this repo; workflow currently accepts them as long as exit code is `0`. + +#### Next exact step + +- Chunk 4: add secret-hygiene guardrails around local `src/cli/.env` auto-loading (especially banner behavior + safety notes) while preserving backward compatibility. + +### Chunk 4 — Secret Hygiene Guardrails for Local `.env` Auto-Loading + +- Status: `PASS` +- Goal: Preserve `.env` auto-loading compatibility while reducing accidental leakage risk and keeping JSON output deterministic. + +#### What changed + +- Added `/Users/chrisdukes/LocalProjects/codex-synaptic/src/cli/env-bootstrap.ts` + - Extracted CLI env bootstrap logic into a testable helper module + - Added `CODEX_CLI_ENV_AUTOLOAD=0` support to disable CLI env auto-loading + - Added banner controls: + - `CODEX_CLI_ENV_BANNER=0` to suppress banner + - `CODEX_CLI_ENV_BANNER_VERBOSE=1` to show env source paths + - JSON-mode (`--json`) banner suppression by default (override with `CODEX_CLI_ENV_BANNER_FORCE=1`) + - Default banner is now sanitized (generic local `.env` count, no file paths) and includes a local-sensitive note when `src/cli/.env` is loaded +- Updated `/Users/chrisdukes/LocalProjects/codex-synaptic/src/cli/index.ts` + - Uses helper module for env bootstrap and banner decisions + - Moves env bootstrap banner output to `stderr` (not `stdout`) to avoid contaminating command output streams +- Added tests in `/Users/chrisdukes/LocalProjects/codex-synaptic/tests/cli/env-bootstrap.test.ts` + - env parser no-override behavior + - autoload toggle behavior + - JSON-mode banner suppression / forced banner override + - sanitized vs verbose banner formatting + - bootstrap file precedence/order semantics +- Updated UAT runbook notes in `/Users/chrisdukes/LocalProjects/codex-synaptic/docs/uat/CODEX_MACOS_UAT_RUNBOOK.md` + - documents `CODEX_CLI_ENV_AUTOLOAD=0`, stderr banner behavior, and `CODEX_CLI_ENV_BANNER_VERBOSE=1` caution + +#### Verification evidence + +- `npx vitest run tests/cli/env-bootstrap.test.ts` + - Passed (`5/5`) +- `npx tsc --noEmit` + - Passed +- `npm run build` + - Passed +- `HOME="$(mktemp -d)" npm test -- tests/cli/commands.test.ts tests/e2e/cli-smoke.test.ts tests/cli/openai-usage.test.ts` + - Passed (`25/25`) +- `node -e "...spawnSync doctor --skip-codex-auth --json..."` + - Verified JSON output remains clean (`stdout` starts with `{`) and env banner is suppressed in JSON mode +- `node -e "...spawnSync system status..."` with isolated `HOME` + - Verified env banner appears on `stderr` and not `stdout` + - Verified `stdout` still contains expected command output (`System not started`) + +#### Risks / open questions + +- The new env banner hygiene note is helpful but may increase `stderr` noise for non-JSON commands when `src/cli/.env` is present. +- Existing users who relied on exact env source paths in startup banners will now need `CODEX_CLI_ENV_BANNER_VERBOSE=1`. +- Secret values are still loadable from local env files by design; this chunk reduces leakage risk and output contamination, but does not change trust requirements for local env file management. + +#### Next exact step + +- Chunk 5: tighten package publication scope (`files` whitelist and/or `.npmignore`), verify `npm pack --dry-run` contents, and document residual packaging risks. diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 27ad23b..f3e4dc1 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -44,6 +44,7 @@ export interface DoctorDependencies { ) => Pick, 'status' | 'stdout' | 'stderr'>; getServiceStatus?: (name: string) => Promise; getCodexRegistration?: (name: string) => { codexName: string; url: string } | null; + registriesForProfiles?: (names: string[]) => string[]; } function parseCodexMcpNames(payload: unknown): string[] { @@ -133,6 +134,8 @@ export async function runDoctor(options: DoctorOptions = {}, deps: DoctorDepende const getServiceStatus = deps.getServiceStatus ?? ((name: string) => serviceManager.status(name)); const getCodexRegistration = deps.getCodexRegistration ?? ((name: string) => serviceManager.codexRegistration(name)); + const registriesForProfiles = deps.registriesForProfiles + ?? ((names: string[]) => serviceManager.registriesForProfiles(names)); const checks: DoctorCheck[] = []; @@ -227,6 +230,9 @@ export async function runDoctor(options: DoctorOptions = {}, deps: DoctorDepende const remediationParts: string[] = []; if (!status.running || !healthy) { + if (registriesForProfiles([profileName]).length > 0) { + remediationParts.push(`codex-synaptic env docker-login ${profileName}`); + } remediationParts.push(`codex-synaptic env up ${profileName}`); } if (registration && !registered) { diff --git a/src/cli/env-bootstrap.ts b/src/cli/env-bootstrap.ts new file mode 100644 index 0000000..9ce4d43 --- /dev/null +++ b/src/cli/env-bootstrap.ts @@ -0,0 +1,182 @@ +import { existsSync, readFileSync } from "fs"; +import { relative, resolve } from "path"; + +function parseBooleanFlag( + value: string | undefined, + fallback: boolean, +): boolean { + if (value === undefined) { + return fallback; + } + + const normalized = value.trim().toLowerCase(); + if (!normalized) { + return fallback; + } + + if (["1", "true", "yes", "on"].includes(normalized)) { + return true; + } + + if (["0", "false", "no", "off"].includes(normalized)) { + return false; + } + + return fallback; +} + +export function shouldAutoLoadCliEnv( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return parseBooleanFlag(env.CODEX_CLI_ENV_AUTOLOAD, true); +} + +export function shouldShowCliEnvBanner( + options: { + env?: NodeJS.ProcessEnv; + cliSilent?: boolean; + argv?: string[]; + } = {}, +): boolean { + const env = options.env ?? process.env; + const cliSilent = options.cliSilent === true; + const argv = options.argv ?? process.argv; + + if (cliSilent) { + return false; + } + + // Keep JSON stdout clean by default; allow override for debugging. + if ( + argv.includes("--json") && + !parseBooleanFlag(env.CODEX_CLI_ENV_BANNER_FORCE, false) + ) { + return false; + } + + return parseBooleanFlag(env.CODEX_CLI_ENV_BANNER, true); +} + +export function loadEnvFile( + filePath: string, + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (!existsSync(filePath)) { + return false; + } + + try { + const content = readFileSync(filePath, "utf8"); + const lines = content.split(/\r?\n/); + let applied = false; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + + const separatorIndex = line.indexOf("="); + if (separatorIndex === -1) { + continue; + } + + const key = line.slice(0, separatorIndex).trim(); + if (!key) { + continue; + } + + let value = line.slice(separatorIndex + 1).trim(); + if (!value) { + value = ""; + } + + const startsWithQuote = value.startsWith('"') || value.startsWith("'"); + const endsWithQuote = value.endsWith('"') || value.endsWith("'"); + if (startsWithQuote && endsWithQuote && value.length >= 2) { + value = value.slice(1, -1); + } + + value = value + .replace(/\\n/g, "\n") + .replace(/\\r/g, "\r") + .replace(/\\t/g, "\t"); + + if (env[key] === undefined) { + env[key] = value; + applied = true; + } + } + + return applied; + } catch { + return false; + } +} + +export function bootstrapCliEnv( + options: { + cwd?: string; + env?: NodeJS.ProcessEnv; + } = {}, +): string[] { + const cwd = options.cwd ?? process.cwd(); + const env = options.env ?? process.env; + const sources: string[] = []; + + const candidates = [ + resolve(cwd, ".env"), + resolve(cwd, ".env.local"), + resolve(cwd, "src/cli/.env"), + ]; + + const seen = new Set(); + for (const candidate of candidates) { + if (seen.has(candidate)) { + continue; + } + seen.add(candidate); + if (loadEnvFile(candidate, env)) { + sources.push(candidate); + } + } + + return sources; +} + +export function buildCliEnvBootstrapMessages( + loadedSources: string[], + options: { + cwd?: string; + env?: NodeJS.ProcessEnv; + } = {}, +): string[] { + if (!loadedSources.length) { + return []; + } + + const cwd = options.cwd ?? process.cwd(); + const env = options.env ?? process.env; + const verbosePaths = + parseBooleanFlag(env.CODEX_CLI_ENV_BANNER_VERBOSE, false) || + env.CODEX_DEBUG === "1"; + const loadedSrcCliEnv = loadedSources.some((source) => + /(^|[\\/])src[\\/]cli[\\/]\.env$/.test(source), + ); + + const firstLine = verbosePaths + ? `⚙️ Environment variables loaded from ${loadedSources + .map((source) => relative(cwd, source) || source) + .join(", ")}` + : `⚙️ Environment variables loaded from local .env file(s) (${loadedSources.length}).`; + + const lines = [firstLine]; + + if (loadedSrcCliEnv) { + lines.push( + "🔒 `src/cli/.env` is local-sensitive state. Avoid sharing logs/screenshots with local env details; set CODEX_CLI_ENV_AUTOLOAD=0 to disable auto-loading.", + ); + } + + return lines; +} diff --git a/src/cli/index.ts b/src/cli/index.ts index e06b4ef..6316244 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -80,82 +80,16 @@ import { runDoctor } from './doctor.js'; import { collectLaunchRemediations, runLaunch } from './launch.js'; +import { + bootstrapCliEnv, + buildCliEnvBootstrapMessages, + shouldAutoLoadCliEnv, + shouldShowCliEnvBanner +} from './env-bootstrap.js'; -function loadEnvFile(filePath: string): boolean { - if (!existsSync(filePath)) { - return false; - } - - try { - const content = readFileSync(filePath, 'utf8'); - const lines = content.split(/\r?\n/); - let applied = false; - - for (const rawLine of lines) { - const line = rawLine.trim(); - if (!line || line.startsWith('#')) { - continue; - } - - const separatorIndex = line.indexOf('='); - if (separatorIndex === -1) { - continue; - } - - const key = line.slice(0, separatorIndex).trim(); - if (!key) { - continue; - } - - let value = line.slice(separatorIndex + 1).trim(); - if (!value) { - value = ''; - } - - const startsWithQuote = value.startsWith('"') || value.startsWith("'"); - const endsWithQuote = value.endsWith('"') || value.endsWith("'"); - if (startsWithQuote && endsWithQuote && value.length >= 2) { - value = value.slice(1, -1); - } - - value = value.replace(/\\n/g, '\n').replace(/\\r/g, '\r').replace(/\\t/g, '\t'); - - if (process.env[key] === undefined) { - process.env[key] = value; - applied = true; - } - } - - return applied; - } catch { - return false; - } -} - -function bootstrapCliEnv(): string[] { - const sources: string[] = []; - const cwd = process.cwd(); - const candidates = [ - resolve(cwd, '.env'), - resolve(cwd, '.env.local'), - resolve(cwd, 'src/cli/.env') - ]; - - const seen = new Set(); - for (const candidate of candidates) { - if (seen.has(candidate)) { - continue; - } - seen.add(candidate); - if (loadEnvFile(candidate)) { - sources.push(candidate); - } - } - - return sources; -} - -const loadedEnvSources = bootstrapCliEnv(); +const loadedEnvSources = shouldAutoLoadCliEnv(process.env) + ? bootstrapCliEnv({ cwd: process.cwd(), env: process.env }) + : []; const program = new Command(); const session = CliSession.getInstance(); @@ -171,14 +105,12 @@ if (cliSilent) { rootLogger.setConsoleLevel(LogLevel.ERROR); } -if (!cliSilent && loadedEnvSources.length) { - console.log( - chalk.gray( - `⚙️ Environment variables loaded from ${loadedEnvSources - .map((source) => relative(process.cwd(), source) || source) - .join(', ')}` - ) - ); +if (loadedEnvSources.length && shouldShowCliEnvBanner({ env: process.env, cliSilent, argv: process.argv })) { + const lines = buildCliEnvBootstrapMessages(loadedEnvSources, { cwd: process.cwd(), env: process.env }); + for (const line of lines) { + const colorize = line.startsWith('🔒') ? chalk.yellow : chalk.gray; + process.stderr.write(`${colorize(line)}\n`); + } } type BackgroundJob = { diff --git a/src/cli/launch.ts b/src/cli/launch.ts index 71e4892..daec563 100644 --- a/src/cli/launch.ts +++ b/src/cli/launch.ts @@ -253,21 +253,43 @@ export async function runLaunch(options: LaunchOptions = {}, deps: LaunchDepende details: 'No MCP profiles requested for launch gating.' }; } else { - try { - for (const profileName of profileNames) { + const startedProfiles: string[] = []; + let failedProfile: string | null = null; + let startupError: Error | null = null; + + for (const profileName of profileNames) { + try { await ensureService(profileName, { waitForHealth: true }); + startedProfiles.push(profileName); + } catch (error) { + failedProfile = profileName; + startupError = error as Error; + break; } + } + + if (!startupError) { mcpUpStep = { id: 'mcp.up', ok: true, details: `Started ${profileNames.length} MCP profile(s): ${profileNames.join(', ')}` }; - } catch (error) { + } else { + const targetedProfiles = failedProfile ? [failedProfile] : profileNames; + const remediationParts = [ + buildMcpBootstrapRemediation(targetedProfiles), + `codex-synaptic env status ${targetedProfiles.join(' ')}` + ]; + mcpUpStep = { id: 'mcp.up', ok: false, - details: `Failed to start required MCP profile(s): ${(error as Error).message}`, - remediation: buildMcpBootstrapRemediation(profileNames) + details: `Failed to start MCP profile ${failedProfile ?? 'unknown'} after starting ${startedProfiles.length}/${profileNames.length}: ${startupError.message}`, + remediation: remediationParts.join(' && '), + metadata: { + failedProfile: failedProfile ?? undefined, + startedProfiles + } }; } } diff --git a/src/env/service-manager.ts b/src/env/service-manager.ts index 0e1c348..bc74f81 100644 --- a/src/env/service-manager.ts +++ b/src/env/service-manager.ts @@ -193,7 +193,11 @@ class ServiceManager { const env = this.resolveExecEnv(name, options); this.logger.info('env', `Starting service ${name}`, { command: cmd }); - execSync(cmd, { stdio: 'inherit', env }); + try { + execSync(cmd, { stdio: 'pipe', env, encoding: 'utf8' }); + } catch (error) { + throw this.wrapComposeStartError(name, profile, cmd, error); + } if (options?.waitForHealth !== false) { await this.waitForServiceHealth(name, profile); @@ -351,6 +355,58 @@ class ServiceManager { return null; } + private wrapComposeStartError(name: string, profile: ServiceProfile, cmd: string, error: unknown): Error { + const execError = error as { + status?: number | null; + stdout?: string | Buffer; + stderr?: string | Buffer; + message?: string; + }; + + const stdout = this.toText(execError.stdout); + const stderr = this.toText(execError.stderr); + const combined = [stderr, stdout] + .filter(Boolean) + .join('\n') + .trim(); + const output = combined || (execError.message?.trim() ?? 'Unknown docker compose error'); + const exitStatus = typeof execError.status === 'number' ? execError.status : null; + const images = profile.dockerImages?.join(', ') || 'unknown image'; + + let diagnosis = `Docker compose startup failed for ${name}`; + let remediation = 'Verify Docker is running, then retry.'; + + if (/pull access denied|requested access to the resource is denied|insufficient_scope|unauthorized|authentication required|error from registry:\s*denied/i.test(output)) { + diagnosis = `Docker image pull/auth denied for ${name} (${images})`; + remediation = `Run \`codex-synaptic env docker-login ${name}\` and retry \`codex-synaptic env up ${name}\`.`; + } else if (/Cannot connect to the Docker daemon|Is the docker daemon running/i.test(output)) { + diagnosis = `Docker daemon unavailable while starting ${name}`; + remediation = 'Start Docker Desktop (or the Docker daemon) and retry.'; + } else if (/command not found|ENOENT/i.test(output)) { + diagnosis = `Docker CLI unavailable while starting ${name}`; + remediation = 'Install Docker with the Compose plugin and ensure `docker compose` works.'; + } + + const truncatedOutput = output.length > 500 ? `${output.slice(0, 500)}…` : output; + const exitLabel = exitStatus === null ? 'unknown' : String(exitStatus); + + return new Error( + `${diagnosis} (exit=${exitLabel}, compose=${cmd}). ${remediation} Raw docker output: ${truncatedOutput}` + ); + } + + private toText(value: unknown): string { + if (typeof value === 'string') { + return value.trim(); + } + + if (Buffer.isBuffer(value)) { + return value.toString('utf8').trim(); + } + + return ''; + } + private async probeService(profile: ServiceProfile): Promise { if (profile.healthcheck?.url) { return this.probeHttp(profile.healthcheck.url, 2000); diff --git a/tests/cli/doctor.test.ts b/tests/cli/doctor.test.ts index 2ac04d7..786b8d7 100644 --- a/tests/cli/doctor.test.ts +++ b/tests/cli/doctor.test.ts @@ -169,6 +169,7 @@ describe('runDoctor', () => { const mcpCheck = report.checks.find((check) => check.id === 'mcp.mcp-filesystem'); expect(mcpCheck?.ok).toBe(false); + expect(mcpCheck?.remediation).toContain('codex-synaptic env docker-login mcp-filesystem'); expect(mcpCheck?.remediation).toContain('codex-synaptic env up mcp-filesystem'); expect(mcpCheck?.remediation).toContain('codex-synaptic env codex-register mcp-filesystem'); }); diff --git a/tests/cli/env-bootstrap.test.ts b/tests/cli/env-bootstrap.test.ts new file mode 100644 index 0000000..6ae7551 --- /dev/null +++ b/tests/cli/env-bootstrap.test.ts @@ -0,0 +1,111 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { describe, expect, it } from "vitest"; +import { + bootstrapCliEnv, + buildCliEnvBootstrapMessages, + loadEnvFile, + shouldAutoLoadCliEnv, + shouldShowCliEnvBanner, +} from "../../src/cli/env-bootstrap"; + +describe("cli env bootstrap helper", () => { + it("does not override existing environment variables", () => { + const tempDir = mkdtempSync(join(tmpdir(), "codex-env-bootstrap-")); + const envPath = join(tempDir, ".env"); + writeFileSync(envPath, "EXISTING_KEY=from-file\nNEW_KEY=from-file\n"); + + const env: NodeJS.ProcessEnv = { + EXISTING_KEY: "from-env", + }; + + const applied = loadEnvFile(envPath, env); + expect(applied).toBe(true); + expect(env.EXISTING_KEY).toBe("from-env"); + expect(env.NEW_KEY).toBe("from-file"); + + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("autoload is enabled by default and can be disabled explicitly", () => { + expect(shouldAutoLoadCliEnv({})).toBe(true); + expect(shouldAutoLoadCliEnv({ CODEX_CLI_ENV_AUTOLOAD: "0" })).toBe(false); + expect(shouldAutoLoadCliEnv({ CODEX_CLI_ENV_AUTOLOAD: "false" })).toBe( + false, + ); + expect(shouldAutoLoadCliEnv({ CODEX_CLI_ENV_AUTOLOAD: "1" })).toBe(true); + }); + + it("suppresses env banner in json mode unless forced", () => { + expect( + shouldShowCliEnvBanner({ + env: {}, + cliSilent: false, + argv: ["node", "dist/cli/index.js", "doctor", "--json"], + }), + ).toBe(false); + + expect( + shouldShowCliEnvBanner({ + env: { CODEX_CLI_ENV_BANNER_FORCE: "1" }, + cliSilent: false, + argv: ["node", "dist/cli/index.js", "doctor", "--json"], + }), + ).toBe(true); + }); + + it("builds sanitized banner messages by default and verbose paths on opt-in", () => { + const cwd = "/repo"; + const sources = ["/repo/.env.local", "/repo/src/cli/.env"]; + + const defaultMessages = buildCliEnvBootstrapMessages(sources, { + cwd, + env: {}, + }); + expect(defaultMessages[0]).toContain("local .env file(s)"); + expect(defaultMessages[0]).toContain("(2)"); + expect(defaultMessages[0]).not.toContain("src/cli/.env"); + expect(defaultMessages.some((line) => line.includes("src/cli/.env"))).toBe( + true, + ); + expect( + defaultMessages.some((line) => line.includes("CODEX_CLI_ENV_AUTOLOAD=0")), + ).toBe(true); + + const verboseMessages = buildCliEnvBootstrapMessages(sources, { + cwd, + env: { CODEX_CLI_ENV_BANNER_VERBOSE: "1" }, + }); + expect(verboseMessages[0]).toContain(".env.local"); + expect(verboseMessages[0]).toContain("src/cli/.env"); + }); + + it("bootstraps candidate env files in precedence order without duplicates", () => { + const tempDir = mkdtempSync(join(tmpdir(), "codex-env-bootstrap-")); + writeFileSync(join(tempDir, ".env"), "BASE_KEY=base\n"); + writeFileSync( + join(tempDir, ".env.local"), + "BASE_KEY=local-override-ignored\nLOCAL_KEY=local\n", + ); + const srcCliDir = join(tempDir, "src", "cli"); + mkdirSync(srcCliDir, { recursive: true }); + writeFileSync(join(tempDir, "src", "cli", ".env"), "CLI_KEY=cli\n", { + flag: "w", + }); + + const env: NodeJS.ProcessEnv = {}; + const sources = bootstrapCliEnv({ cwd: tempDir, env }); + + expect(sources.map((source) => source.replace(`${tempDir}/`, ""))).toEqual([ + ".env", + ".env.local", + "src/cli/.env", + ]); + expect(env.BASE_KEY).toBe("base"); + expect(env.LOCAL_KEY).toBe("local"); + expect(env.CLI_KEY).toBe("cli"); + + rmSync(tempDir, { recursive: true, force: true }); + }); +}); diff --git a/tests/cli/launch.test.ts b/tests/cli/launch.test.ts index 6ee818a..52e84c4 100644 --- a/tests/cli/launch.test.ts +++ b/tests/cli/launch.test.ts @@ -131,9 +131,13 @@ describe('runLaunch', () => { expect(report.nextAction).toBe('stop'); const mcpStep = report.steps.find((step) => step.id === 'mcp.up'); expect(mcpStep?.ok).toBe(false); + expect(mcpStep?.details).toContain('mcp-filesystem'); + expect(mcpStep?.details).toContain('after starting 0/1'); expect(mcpStep?.remediation).toContain('codex-synaptic env docker-login mcp-filesystem'); expect(mcpStep?.remediation).toContain('codex-synaptic env up mcp-filesystem'); expect(mcpStep?.remediation).toContain('codex-synaptic env codex-register mcp-filesystem --replace'); + expect(mcpStep?.remediation).toContain('codex-synaptic env status mcp-filesystem'); + expect((mcpStep?.metadata as { failedProfile?: string } | undefined)?.failedProfile).toBe('mcp-filesystem'); }); it('captures MCP bridge error classification when codex registration add fails', async () => { From f2cf4ae628c282b6894b15e10ae4954f25623f7c Mon Sep 17 00:00:00 2001 From: Chris Dukes Date: Tue, 24 Feb 2026 09:26:15 -0600 Subject: [PATCH 04/11] Fix UAT launch json output --- .codex-synaptic/memory.db | Bin 778240 -> 778240 bytes docker/mcp/docker-compose.playwright.yml | 4 +- docs/uat/CODEX_MACOS_UAT_RUNBOOK.md | 24 +- docs/uat/UAT_FINAL_REPORT.md | 141 ++++++ docs/uat/UAT_READINESS_TRACKER.md | 397 ++++++++++++++++ .../00a-ghcr-pull-filesystem.cmd.txt | 1 + .../00a-ghcr-pull-filesystem.exit | 1 + .../00a-ghcr-pull-filesystem.out.txt | 1 + .../00b-ghcr-pull-playwright.cmd.txt | 1 + .../00b-ghcr-pull-playwright.exit | 1 + .../00b-ghcr-pull-playwright.out.txt | 1 + .../00c-ghcr-pull-desktop-commander.cmd.txt | 1 + .../00c-ghcr-pull-desktop-commander.exit | 1 + .../00c-ghcr-pull-desktop-commander.out.txt | 1 + .../2026-02-24-rerun-1/01-build.cmd.txt | 1 + .../evidence/2026-02-24-rerun-1/01-build.exit | 1 + .../2026-02-24-rerun-1/01-build.out.txt | 4 + .../2026-02-24-rerun-1/02-codex-help.cmd.txt | 1 + .../2026-02-24-rerun-1/02-codex-help.exit | 1 + .../2026-02-24-rerun-1/02-codex-help.out.txt | 114 +++++ .../03-codex-mcp-help.cmd.txt | 1 + .../2026-02-24-rerun-1/03-codex-mcp-help.exit | 1 + .../03-codex-mcp-help.out.txt | 30 ++ .../04-codex-mcp-add-help.cmd.txt | 1 + .../04-codex-mcp-add-help.exit | 1 + .../04-codex-mcp-add-help.out.txt | 36 ++ .../05-codex-login-status.cmd.txt | 1 + .../05-codex-login-status.exit | 1 + .../05-codex-login-status.out.txt | 1 + .../2026-02-24-rerun-1/06-env-plan.cmd.txt | 1 + .../2026-02-24-rerun-1/06-env-plan.exit | 1 + .../2026-02-24-rerun-1/06-env-plan.out.txt | 24 + .../07-env-docker-login.cmd.txt | 1 + .../07-env-docker-login.exit | 1 + .../07-env-docker-login.out.txt | 10 + .../2026-02-24-rerun-1/08-env-up.cmd.txt | 1 + .../2026-02-24-rerun-1/08-env-up.exit | 1 + .../2026-02-24-rerun-1/08-env-up.out.txt | 7 + .../2026-02-24-rerun-1/09-env-status.cmd.txt | 1 + .../2026-02-24-rerun-1/09-env-status.exit | 1 + .../2026-02-24-rerun-1/09-env-status.out.txt | 20 + .../10-env-codex-register.cmd.txt | 1 + .../10-env-codex-register.exit | 1 + .../10-env-codex-register.out.txt | 8 + .../11-codex-mcp-list.cmd.txt | 1 + .../11-codex-mcp-list.err.txt | 0 .../2026-02-24-rerun-1/11-codex-mcp-list.exit | 1 + .../11-codex-mcp-list.redaction.txt | 1 + .../12-doctor-strict-json.cmd.txt | 1 + .../12-doctor-strict-json.err.txt | 1 + .../12-doctor-strict-json.exit | 1 + .../13-launch-strict-json.cmd.txt | 1 + .../13-launch-strict-json.err.txt | 1 + .../13-launch-strict-json.exit | 1 + .../evidence/2026-02-24-rerun-1/_status.tsv | 17 + .../2026-02-24-rerun-1/codex.mcp.list.json | 125 +++++ .../2026-02-24-rerun-1/doctor.strict.json | 60 +++ .../2026-02-24-rerun-1/launch.strict.json | 40 ++ docs/uat/evidence/2026-02-24/01-build.cmd.txt | 1 + docs/uat/evidence/2026-02-24/01-build.exit | 1 + docs/uat/evidence/2026-02-24/01-build.out.txt | 4 + .../evidence/2026-02-24/02-codex-help.cmd.txt | 1 + .../evidence/2026-02-24/02-codex-help.exit | 1 + .../evidence/2026-02-24/02-codex-help.out.txt | 114 +++++ .../2026-02-24/03-codex-mcp-help.cmd.txt | 1 + .../2026-02-24/03-codex-mcp-help.exit | 1 + .../2026-02-24/03-codex-mcp-help.out.txt | 30 ++ .../2026-02-24/04-codex-mcp-add-help.cmd.txt | 1 + .../2026-02-24/04-codex-mcp-add-help.exit | 1 + .../2026-02-24/04-codex-mcp-add-help.out.txt | 36 ++ .../2026-02-24/05-codex-login-status.cmd.txt | 1 + .../2026-02-24/05-codex-login-status.exit | 1 + .../2026-02-24/05-codex-login-status.out.txt | 1 + .../evidence/2026-02-24/06-env-plan.cmd.txt | 1 + docs/uat/evidence/2026-02-24/06-env-plan.exit | 1 + .../evidence/2026-02-24/06-env-plan.out.txt | 24 + .../2026-02-24/07-env-docker-login.cmd.txt | 1 + .../2026-02-24/07-env-docker-login.exit | 1 + .../2026-02-24/07-env-docker-login.out.txt | 5 + .../uat/evidence/2026-02-24/08-env-up.cmd.txt | 1 + docs/uat/evidence/2026-02-24/08-env-up.exit | 1 + .../uat/evidence/2026-02-24/08-env-up.out.txt | 9 + .../evidence/2026-02-24/09-env-status.cmd.txt | 1 + .../evidence/2026-02-24/09-env-status.exit | 1 + .../evidence/2026-02-24/09-env-status.out.txt | 20 + .../2026-02-24/10-env-codex-register.cmd.txt | 1 + .../2026-02-24/10-env-codex-register.exit | 1 + .../2026-02-24/10-env-codex-register.out.txt | 8 + .../2026-02-24/11-codex-mcp-list.cmd.txt | 1 + .../2026-02-24/11-codex-mcp-list.err.txt | 0 .../2026-02-24/11-codex-mcp-list.exit | 1 + .../2026-02-24/12-doctor-strict-json.cmd.txt | 1 + .../2026-02-24/12-doctor-strict-json.err.txt | 1 + .../2026-02-24/12-doctor-strict-json.exit | 1 + .../2026-02-24/13-launch-strict-json.cmd.txt | 1 + .../2026-02-24/13-launch-strict-json.err.txt | 1 + .../2026-02-24/13-launch-strict-json.exit | 1 + docs/uat/evidence/2026-02-24/_status.tsv | 14 + .../evidence/2026-02-24/codex.mcp.list.json | 125 +++++ .../evidence/2026-02-24/doctor.strict.json | 60 +++ .../evidence/2026-02-24/launch.strict.json | 41 ++ .../2026-02-24/launch.strict.payload.json | 40 ++ .../launch.strict.stdout-prefix.txt | 1 + package-lock.json | 428 +++++++++++++----- package.json | 16 +- src/cli/index.ts | 3 +- src/cli/launch.ts | 25 +- src/env/service-manager.ts | 2 +- tests/cli/launch.test.ts | 49 +- 109 files changed, 2053 insertions(+), 130 deletions(-) create mode 100644 docs/uat/UAT_FINAL_REPORT.md create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/00a-ghcr-pull-filesystem.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/00a-ghcr-pull-filesystem.exit create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/00a-ghcr-pull-filesystem.out.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/00b-ghcr-pull-playwright.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/00b-ghcr-pull-playwright.exit create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/00b-ghcr-pull-playwright.out.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/00c-ghcr-pull-desktop-commander.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/00c-ghcr-pull-desktop-commander.exit create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/00c-ghcr-pull-desktop-commander.out.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/01-build.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/01-build.exit create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/01-build.out.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/02-codex-help.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/02-codex-help.exit create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/02-codex-help.out.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/03-codex-mcp-help.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/03-codex-mcp-help.exit create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/03-codex-mcp-help.out.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/04-codex-mcp-add-help.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/04-codex-mcp-add-help.exit create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/04-codex-mcp-add-help.out.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/05-codex-login-status.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/05-codex-login-status.exit create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/05-codex-login-status.out.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/06-env-plan.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/06-env-plan.exit create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/06-env-plan.out.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/07-env-docker-login.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/07-env-docker-login.exit create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/07-env-docker-login.out.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/08-env-up.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/08-env-up.exit create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/08-env-up.out.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/09-env-status.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/09-env-status.exit create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/09-env-status.out.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/10-env-codex-register.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/10-env-codex-register.exit create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/10-env-codex-register.out.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/11-codex-mcp-list.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/11-codex-mcp-list.err.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/11-codex-mcp-list.exit create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/11-codex-mcp-list.redaction.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/12-doctor-strict-json.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/12-doctor-strict-json.err.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/12-doctor-strict-json.exit create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/13-launch-strict-json.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/13-launch-strict-json.err.txt create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/13-launch-strict-json.exit create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/_status.tsv create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/codex.mcp.list.json create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/doctor.strict.json create mode 100644 docs/uat/evidence/2026-02-24-rerun-1/launch.strict.json create mode 100644 docs/uat/evidence/2026-02-24/01-build.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24/01-build.exit create mode 100644 docs/uat/evidence/2026-02-24/01-build.out.txt create mode 100644 docs/uat/evidence/2026-02-24/02-codex-help.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24/02-codex-help.exit create mode 100644 docs/uat/evidence/2026-02-24/02-codex-help.out.txt create mode 100644 docs/uat/evidence/2026-02-24/03-codex-mcp-help.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24/03-codex-mcp-help.exit create mode 100644 docs/uat/evidence/2026-02-24/03-codex-mcp-help.out.txt create mode 100644 docs/uat/evidence/2026-02-24/04-codex-mcp-add-help.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24/04-codex-mcp-add-help.exit create mode 100644 docs/uat/evidence/2026-02-24/04-codex-mcp-add-help.out.txt create mode 100644 docs/uat/evidence/2026-02-24/05-codex-login-status.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24/05-codex-login-status.exit create mode 100644 docs/uat/evidence/2026-02-24/05-codex-login-status.out.txt create mode 100644 docs/uat/evidence/2026-02-24/06-env-plan.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24/06-env-plan.exit create mode 100644 docs/uat/evidence/2026-02-24/06-env-plan.out.txt create mode 100644 docs/uat/evidence/2026-02-24/07-env-docker-login.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24/07-env-docker-login.exit create mode 100644 docs/uat/evidence/2026-02-24/07-env-docker-login.out.txt create mode 100644 docs/uat/evidence/2026-02-24/08-env-up.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24/08-env-up.exit create mode 100644 docs/uat/evidence/2026-02-24/08-env-up.out.txt create mode 100644 docs/uat/evidence/2026-02-24/09-env-status.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24/09-env-status.exit create mode 100644 docs/uat/evidence/2026-02-24/09-env-status.out.txt create mode 100644 docs/uat/evidence/2026-02-24/10-env-codex-register.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24/10-env-codex-register.exit create mode 100644 docs/uat/evidence/2026-02-24/10-env-codex-register.out.txt create mode 100644 docs/uat/evidence/2026-02-24/11-codex-mcp-list.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24/11-codex-mcp-list.err.txt create mode 100644 docs/uat/evidence/2026-02-24/11-codex-mcp-list.exit create mode 100644 docs/uat/evidence/2026-02-24/12-doctor-strict-json.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24/12-doctor-strict-json.err.txt create mode 100644 docs/uat/evidence/2026-02-24/12-doctor-strict-json.exit create mode 100644 docs/uat/evidence/2026-02-24/13-launch-strict-json.cmd.txt create mode 100644 docs/uat/evidence/2026-02-24/13-launch-strict-json.err.txt create mode 100644 docs/uat/evidence/2026-02-24/13-launch-strict-json.exit create mode 100644 docs/uat/evidence/2026-02-24/_status.tsv create mode 100644 docs/uat/evidence/2026-02-24/codex.mcp.list.json create mode 100644 docs/uat/evidence/2026-02-24/doctor.strict.json create mode 100644 docs/uat/evidence/2026-02-24/launch.strict.json create mode 100644 docs/uat/evidence/2026-02-24/launch.strict.payload.json create mode 100644 docs/uat/evidence/2026-02-24/launch.strict.stdout-prefix.txt diff --git a/.codex-synaptic/memory.db b/.codex-synaptic/memory.db index d3f8870904eedd5d77c6f6f8fa147c265fdf7703..4e45cb9731a97896f411b768f40a787f764a0e8a 100644 GIT binary patch delta 539 zcmZoTpx4gUN-pqJky{I!Z;UiN*PON>)l9ndupm1v}Jb%}tGr&5aFB zb(9hd3UV@2Q#|vMi&Aq_^GcMg45wer;}X|12{ACYGBUO@FwrwKHjh%OH8LqMR?x@VADQJYv1#;%51_l;}hUQouU_RZkgG)>u z;s8S{BcKCJj4>Sml4;E7n7%QTOKba%liWUR%s)BrZfC#1eV>ty^CjnFAm`ad?vL!# Y-{0hB3;1BGA&g