diff --git a/modules/jarvos-agent-context/test/agent-context.test.js b/modules/jarvos-agent-context/test/agent-context.test.js index 18429dfd..ea389d55 100644 --- a/modules/jarvos-agent-context/test/agent-context.test.js +++ b/modules/jarvos-agent-context/test/agent-context.test.js @@ -965,27 +965,121 @@ function runCodexSetup(envOverrides = {}) { const setupPath = path.join(repoRoot, 'runtimes', 'codex', 'setup.sh'); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-codex-setup-')); const binDir = path.join(tmp, 'bin'); + const codexHome = path.join(tmp, 'codex-home'); const codexLog = path.join(tmp, 'codex-args.log'); + const mcpStatePath = path.join(tmp, 'mcp-state.json'); + const providerCliStatePath = path.join(tmp, 'provider-codex-state.json'); + const providerStatePath = path.join(codexHome, 'jarvos-compound-engineering.state.json'); const configPath = path.join(tmp, 'codex-config.toml'); fs.mkdirSync(binDir, { recursive: true }); + fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 }); fs.writeFileSync(configPath, '', 'utf8'); - // Fake codex records invocations and pretends jarvos is not registered. + // Fake Codex records invocations and persists only a disposable MCP model. const fakeCodex = [ - '#!/usr/bin/env bash', - 'set -euo pipefail', - `printf '%s\\n' "$*" >> ${JSON.stringify(codexLog)}`, - 'if [ "${1:-}" = "mcp" ] && [ "${2:-}" = "get" ]; then exit 1; fi', - 'exit 0', + '#!/usr/bin/env node', + "const fs = require('node:fs');", + "const path = require('node:path');", + 'const args = process.argv.slice(2);', + `fs.appendFileSync(${JSON.stringify(codexLog)}, args.join(' ') + '\\n');`, + `const statePath = ${JSON.stringify(mcpStatePath)};`, + `const providerStatePath = ${JSON.stringify(providerCliStatePath)};`, + `if (args[0] === '--version') { process.stdout.write(${JSON.stringify(`codex-cli ${envOverrides.FAKE_CODEX_VERSION || '0.146.0'}\n`)}); process.exit(0); }`, + "if (args[0] === 'app-server') {", + " if (!['success', 'success-readd', 'conflict', 'overridden'].includes(process.env.FAKE_CODEX_APP_SERVER_MODE)) process.exit(8);", + ' const emit = (value) => process.stdout.write(JSON.stringify(value) + \'\\n\');', + " let input = '';", + " process.stdin.setEncoding('utf8');", + " process.stdin.on('data', (chunk) => {", + ' input += chunk; let newline;', + " while ((newline = input.indexOf('\\n')) >= 0) {", + " const line = input.slice(0, newline).trim(); input = input.slice(newline + 1);", + " if (!line) continue;", + " let message; try { message = JSON.parse(line); } catch (_) { continue; }", + " if (message.method === 'initialize') { emit({ id: message.id, result: { ok: true } }); continue; }", + " if (message.method === 'config/read') {", + " const registration = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, 'utf8')).transport : null;", + " const config = registration ? { mcp_servers: { jarvos: registration } } : {};", + " emit({ id: message.id, result: { config, origins: {}, layers: [{ name: { type: 'user', file: process.env.CODEX_CONFIG, profile: null }, version: 'fake-version', config }] } });", + ' continue;', + ' }', + " if (message.method === 'config/batchWrite') {", + " const edit = message.params?.edits?.[0];", + " if (message.params?.filePath !== process.env.CODEX_CONFIG || message.params?.expectedVersion !== 'fake-version' || edit?.keyPath !== 'mcp_servers.jarvos' || edit.value !== null || edit.mergeStrategy !== 'replace') process.exit(9);", + " if (process.env.FAKE_CODEX_APP_SERVER_MODE === 'conflict') {", + " fs.writeFileSync(statePath, JSON.stringify({ name: 'jarvos', transport: { type: 'stdio', command: 'concurrent-replacement', args: [], env: {} } }));", + " emit({ id: message.id, error: { code: -32009, message: 'configuration version conflict' } }); continue;", + ' }', + " if (process.env.FAKE_CODEX_APP_SERVER_MODE === 'overridden') { fs.writeFileSync(statePath, JSON.stringify({ name: 'jarvos', transport: { type: 'stdio', command: 'higher-layer-override', args: [], env: {} } })); emit({ id: message.id, result: { status: 'okOverridden', version: 'fake-after', filePath: process.env.CODEX_CONFIG, overriddenMetadata: { keyPath: 'mcp_servers.jarvos' } } }); continue; }", + ' try { fs.unlinkSync(statePath); } catch (error) { if (error.code !== "ENOENT") throw error; }', + " if (process.env.FAKE_CODEX_APP_SERVER_MODE === 'success-readd') fs.writeFileSync(statePath, JSON.stringify({ name: 'jarvos', transport: { type: 'stdio', command: 'foreign-readd', args: [], env: {} } }));", + " emit({ id: message.id, result: { status: 'ok', version: 'fake-after', filePath: process.env.CODEX_CONFIG, overriddenMetadata: null } });", + ' }', + ' }', + ' });', + ' process.stdin.resume();', + ' return;', + '}', + "if (args[0] === 'mcp' && args[1] === 'list') {", + " if (process.env.FAKE_CODEX_LIST_MODE === 'fail') process.exit(8);", + " if (process.env.FAKE_CODEX_LIST_MODE === 'malformed') { process.stdout.write('{'); process.exit(0); }", + " const entries = fs.existsSync(statePath) ? [JSON.parse(fs.readFileSync(statePath, 'utf8'))] : [];", + ' process.stdout.write(JSON.stringify(entries)); process.exit(0);', + '}', + "if (args[0] === 'mcp' && args[1] === 'get') {", + " if (process.env.FAKE_CODEX_GET_MODE === 'fail') process.exit(9);", + " if (!fs.existsSync(statePath)) process.exit(1);", + " if (process.env.FAKE_CODEX_GET_MALFORMED === '1') { process.stdout.write('{'); process.exit(0); }", + " process.stdout.write(fs.readFileSync(statePath, 'utf8')); process.exit(0);", + '}', + "if (args[0] === 'mcp' && args[1] === 'remove') {", + " if (process.env.FAKE_CODEX_REMOVE_MODE === 'fail') process.exit(7);", + " if (process.env.FAKE_CODEX_REMOVE_MODE === 'retain') process.exit(0);", + ' try { fs.unlinkSync(statePath); } catch (error) { if (error.code !== "ENOENT") throw error; }', + ' process.exit(0);', + '}', + "if (args[0] === 'mcp' && args[1] === 'add') {", + " if (process.env.FAKE_CODEX_ADD_MODE === 'fail-before') process.exit(6);", + ' const env = {}; let index = 2;', + " while (args[index] === '--env') { const value = args[index + 1]; const split = value.indexOf('='); env[value.slice(0, split)] = value.slice(split + 1); index += 2; }", + " if (args[index] !== 'jarvos' || args[index + 1] !== '--') process.exit(2);", + ' const command = args[index + 2]; const commandArgs = args.slice(index + 3);', + " fs.writeFileSync(statePath, JSON.stringify({ name: 'jarvos', transport: { type: 'stdio', command, args: commandArgs, env } }));", + " if (process.env.FAKE_CODEX_ADD_MODE === 'write-then-fail') process.exit(6);", + ' process.exit(0);', + '}', + "if (args.join(' ') === 'plugin list --json') {", + " const value = fs.existsSync(providerStatePath) ? JSON.parse(fs.readFileSync(providerStatePath, 'utf8')) : { installed: [], marketplaces: [] };", + " process.stdout.write(JSON.stringify({ installed: value.installed || [] })); process.exit(0);", + '}', + "if (args.join(' ') === 'plugin marketplace list --json') {", + " const value = fs.existsSync(providerStatePath) ? JSON.parse(fs.readFileSync(providerStatePath, 'utf8')) : { installed: [], marketplaces: [] };", + " process.stdout.write(JSON.stringify({ marketplaces: value.marketplaces || [] })); process.exit(0);", + '}', + "if (args[0] === 'plugin' && args[1] === 'remove') {", + " const value = fs.existsSync(providerStatePath) ? JSON.parse(fs.readFileSync(providerStatePath, 'utf8')) : { installed: [], marketplaces: [] };", + " value.installed = (value.installed || []).filter((entry) => entry.pluginId !== args[2]); fs.writeFileSync(providerStatePath, JSON.stringify(value)); process.exit(0);", + '}', + "if (args[0] === 'plugin' && args[1] === 'marketplace' && args[2] === 'remove') {", + " const value = fs.existsSync(providerStatePath) ? JSON.parse(fs.readFileSync(providerStatePath, 'utf8')) : { installed: [], marketplaces: [] };", + " value.marketplaces = (value.marketplaces || []).filter((entry) => entry.name !== args[3]); fs.writeFileSync(providerStatePath, JSON.stringify(value)); process.exit(0);", + '}', + 'process.exit(0);', '', ].join('\n'); - const fakeCodexPath = path.join(binDir, 'codex'); + const fakeCodexPath = path.join(binDir, 'selected-codex'); fs.writeFileSync(fakeCodexPath, fakeCodex, { encoding: 'utf8', mode: 0o755 }); fs.chmodSync(fakeCodexPath, 0o755); + const pathCodex = path.join(binDir, 'codex'); + fs.writeFileSync(pathCodex, `#!/usr/bin/env bash\nprintf '%s\\n' 'path-codex-was-used' >> ${JSON.stringify(codexLog)}\nexit 99\n`, { encoding: 'utf8', mode: 0o755 }); + fs.chmodSync(pathCodex, 0o755); + if (envOverrides.FAKE_CODEX_INITIAL_JSON) fs.writeFileSync(mcpStatePath, envOverrides.FAKE_CODEX_INITIAL_JSON, 'utf8'); const env = { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`, + CODEX_HOME: codexHome, CODEX_CONFIG: configPath, + JARVOS_CODEX_EXECUTABLE: fakeCodexPath, // Public-only setup: clear private host bindings unless the caller sets them. JARVOS_CONTROL_PLANE_SERVICE_MODULE: '', JARVOS_CONTROL_PLANE_CREDENTIAL_FILE: '', @@ -1005,23 +1099,62 @@ function runCodexSetup(envOverrides = {}) { if (!env.JARVOS_STEWARDSHIP_CODEX_SESSION_MAP_ROOT) delete env.JARVOS_STEWARDSHIP_CODEX_SESSION_MAP_ROOT; if (!env.JARVOS_STEWARDSHIP_STABLE_ROOT) delete env.JARVOS_STEWARDSHIP_STABLE_ROOT; - const result = spawnSync('bash', [setupPath], { + const invoke = (overrides = {}) => spawnSync('bash', [setupPath], { encoding: 'utf8', cwd: repoRoot, - env, + env: { ...env, ...overrides }, maxBuffer: 4 * 1024 * 1024, }); + const result = invoke(); return { tmp, + codexHome, configPath, codexLog, + fakeCodexPath, + mcpStatePath, + providerCliStatePath, + providerStatePath, + receiptPath: path.join(codexHome, 'jarvos-codex-mcp-receipt.json'), result, + rerun: invoke, cleanup() { fs.rmSync(tmp, { recursive: true, force: true }); }, }; } +function seedCodexProviderRollback(run) { + const revision = 'e36ddb8cbd4dd902d3b6ddd96165a783b0ac4711'; + const marketplaceRoot = path.join(run.codexHome, 'compound-marketplace'); + fs.mkdirSync(marketplaceRoot, { recursive: true }); + fs.writeFileSync(path.join(marketplaceRoot, '.codex-marketplace-install.json'), JSON.stringify({ + source_type: 'git', + source: 'https://github.com/EveryInc/compound-engineering-plugin.git', + revision, + }), { encoding: 'utf8', mode: 0o600 }); + fs.writeFileSync(run.providerCliStatePath, JSON.stringify({ + installed: [{ + pluginId: 'compound-engineering@compound-engineering-plugin', + name: 'compound-engineering', + marketplaceName: 'compound-engineering-plugin', + version: '3.21.4', + enabled: true, + }], + marketplaces: [{ name: 'compound-engineering-plugin', root: marketplaceRoot }], + }), { encoding: 'utf8', mode: 0o600 }); + fs.writeFileSync(run.providerStatePath, JSON.stringify({ + schemaVersion: 'jarvos-codex-provider-state/v1', + provider: 'compound-engineering', + version: '3.21.4', + revision, + marketplace: 'compound-engineering-plugin', + plugin: 'compound-engineering@compound-engineering-plugin', + marketplaceAdded: true, + pluginAdded: true, + }), { encoding: 'utf8', mode: 0o600 }); +} + test('Codex setup succeeds publicly with no control-plane host pair', () => { const run = runCodexSetup(); try { @@ -1042,6 +1175,433 @@ test('Codex setup succeeds publicly with no control-plane host pair', () => { } }); +test('Codex setup preserves a present MCP registration when app-server CAS is unavailable', () => { + const run = runCodexSetup(); + try { + assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); + const receipt = JSON.parse(fs.readFileSync(run.receiptPath, 'utf8')); + assert.equal(receipt.state, 'active'); + assert.equal(fs.statSync(run.receiptPath).mode & 0o777, 0o600); + + const repeated = run.rerun(); + assert.equal(repeated.status, 0, repeated.stderr || repeated.stdout); + const rolledBack = run.rerun({ + JARVOS_MANAGED_HARNESS_ROLLBACK: '1', + JARVOS_PROJECTS_CONTEXT_CONFIG: '/different-after-setup/projects.json', + }); + assert.notEqual(rolledBack.status, 0); + assert.match(rolledBack.stderr, /app-server|preserving|reconciliation/i); + assert.equal(fs.existsSync(run.receiptPath), true); + assert.equal(fs.existsSync(run.mcpStatePath), true); + + const commands = fs.readFileSync(run.codexLog, 'utf8').trim().split('\n'); + assert.equal(commands.filter((entry) => entry.startsWith('mcp add ')).length, 1); + assert.equal(commands.filter((entry) => entry === 'mcp remove jarvos').length, 0); + assert.equal(commands.filter((entry) => entry === 'app-server --listen stdio://').length, 1); + assert.equal(commands.includes('path-codex-was-used'), false); + assert.doesNotMatch(fs.readFileSync(run.configPath, 'utf8'), /jarvos-session-start-hook\.js|jarvos-session-turn-hook\.js/); + } finally { + run.cleanup(); + } +}); + +test('Codex MCP rollback uses an exact app-server CAS and clears only its receipt', () => { + const run = runCodexSetup({ FAKE_CODEX_APP_SERVER_MODE: 'success' }); + try { + assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); + const result = run.rerun({ JARVOS_MANAGED_HARNESS_ROLLBACK: '1' }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(run.receiptPath), false); + assert.equal(fs.existsSync(run.mcpStatePath), false); + const commands = fs.readFileSync(run.codexLog, 'utf8'); + assert.match(commands, /app-server --listen stdio:\/\//); + assert.doesNotMatch(commands, /mcp remove jarvos/); + assert.doesNotMatch(fs.readFileSync(run.configPath, 'utf8'), /jarvos-session-start-hook\.js|jarvos-session-turn-hook\.js/); + } finally { + run.cleanup(); + } +}); + +test('Codex MCP CAS conflict preserves a concurrent replacement and still rolls back other phases', () => { + const run = runCodexSetup({ FAKE_CODEX_APP_SERVER_MODE: 'conflict' }); + try { + assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); + seedCodexProviderRollback(run); + const result = run.rerun({ JARVOS_MANAGED_HARNESS_ROLLBACK: '1' }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /CAS conflict|preserving|reconciliation/i); + assert.equal(JSON.parse(fs.readFileSync(run.mcpStatePath, 'utf8')).transport.command, 'concurrent-replacement'); + assert.equal(fs.existsSync(run.receiptPath), true); + assert.equal(fs.existsSync(run.providerStatePath), false); + assert.deepEqual(JSON.parse(fs.readFileSync(run.providerCliStatePath, 'utf8')), { installed: [], marketplaces: [] }); + assert.doesNotMatch(fs.readFileSync(run.codexLog, 'utf8'), /mcp remove jarvos/); + assert.doesNotMatch(fs.readFileSync(run.configPath, 'utf8'), /jarvos-session-start-hook\.js|jarvos-session-turn-hook\.js/); + const commands = fs.readFileSync(run.codexLog, 'utf8'); + assert.match(commands, /plugin remove compound-engineering@compound-engineering-plugin --json/); + assert.match(commands, /plugin marketplace remove compound-engineering-plugin --json/); + } finally { + run.cleanup(); + } +}); + +test('Codex MCP CAS re-add leaves the new registration without the old receipt', () => { + const run = runCodexSetup({ FAKE_CODEX_APP_SERVER_MODE: 'success-readd' }); + try { + assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); + const result = run.rerun({ JARVOS_MANAGED_HARNESS_ROLLBACK: '1' }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(run.receiptPath), false); + assert.equal(JSON.parse(fs.readFileSync(run.mcpStatePath, 'utf8')).transport.command, 'foreign-readd'); + assert.doesNotMatch(fs.readFileSync(run.codexLog, 'utf8'), /mcp remove jarvos/); + } finally { + run.cleanup(); + } +}); + +test('Codex MCP CAS clears its receipt when a higher layer overrides the committed user-layer deletion', () => { + const run = runCodexSetup({ FAKE_CODEX_APP_SERVER_MODE: 'overridden' }); + try { + assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); + const result = run.rerun({ JARVOS_MANAGED_HARNESS_ROLLBACK: '1' }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(run.receiptPath), false); + assert.equal(JSON.parse(fs.readFileSync(run.mcpStatePath, 'utf8')).transport.command, 'higher-layer-override'); + assert.doesNotMatch(fs.readFileSync(run.codexLog, 'utf8'), /mcp remove jarvos/); + } finally { + run.cleanup(); + } +}); + +test('Codex MCP rollback clears an active receipt when its registration is already absent', () => { + const run = runCodexSetup(); + try { + assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); + fs.unlinkSync(run.mcpStatePath); + const result = run.rerun({ JARVOS_MANAGED_HARNESS_ROLLBACK: '1' }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(run.receiptPath), false); + assert.equal(fs.existsSync(run.mcpStatePath), false); + const commands = fs.readFileSync(run.codexLog, 'utf8'); + assert.doesNotMatch(commands, /mcp remove jarvos/); + assert.doesNotMatch(commands, /app-server --listen stdio:\/\//); + } finally { + run.cleanup(); + } +}); + +test('Codex setup preserves state when MCP inspection is inconclusive', () => { + const run = runCodexSetup({ + FAKE_CODEX_GET_MODE: 'fail', + FAKE_CODEX_LIST_MODE: 'fail', + }); + try { + assert.notEqual(run.result.status, 0); + assert.match(run.result.stderr, /inspect|preserving/i); + assert.equal(fs.existsSync(run.receiptPath), false); + const commands = fs.readFileSync(run.codexLog, 'utf8'); + assert.doesNotMatch(commands, /mcp add |mcp remove /); + } finally { + run.cleanup(); + } +}); + +test('Codex setup preserves a pre-existing or later-changed jarvOS MCP registration', () => { + const foreign = runCodexSetup({ + FAKE_CODEX_INITIAL_JSON: JSON.stringify({ + name: 'jarvos', + transport: { type: 'stdio', command: 'other-command', args: [], env: {} }, + }), + }); + try { + assert.notEqual(foreign.result.status, 0); + assert.match(foreign.result.stderr, /did not create it|preserving/i); + assert.equal(fs.existsSync(foreign.receiptPath), false); + const log = fs.readFileSync(foreign.codexLog, 'utf8'); + assert.doesNotMatch(log, /mcp (?:add|remove) /); + } finally { + foreign.cleanup(); + } + + const changed = runCodexSetup(); + try { + assert.equal(changed.result.status, 0, changed.result.stderr || changed.result.stdout); + const value = JSON.parse(fs.readFileSync(changed.mcpStatePath, 'utf8')); + value.transport.command = 'locally-changed-command'; + fs.writeFileSync(changed.mcpStatePath, JSON.stringify(value)); + const rolledBack = changed.rerun({ JARVOS_MANAGED_HARNESS_ROLLBACK: '1' }); + assert.notEqual(rolledBack.status, 0); + assert.match(rolledBack.stderr, /changed after setup|preserving/i); + assert.equal(fs.existsSync(changed.receiptPath), true); + assert.equal(fs.existsSync(changed.mcpStatePath), true); + assert.doesNotMatch(fs.readFileSync(changed.codexLog, 'utf8'), /mcp remove jarvos/); + } finally { + changed.cleanup(); + } + + const behaviorChanged = runCodexSetup(); + try { + assert.equal(behaviorChanged.result.status, 0, behaviorChanged.result.stderr || behaviorChanged.result.stdout); + const value = JSON.parse(fs.readFileSync(behaviorChanged.mcpStatePath, 'utf8')); + value.enabled = false; + fs.writeFileSync(behaviorChanged.mcpStatePath, JSON.stringify(value)); + const rolledBack = behaviorChanged.rerun({ JARVOS_MANAGED_HARNESS_ROLLBACK: '1' }); + assert.notEqual(rolledBack.status, 0); + assert.equal(fs.existsSync(behaviorChanged.receiptPath), true); + assert.doesNotMatch(fs.readFileSync(behaviorChanged.codexLog, 'utf8'), /mcp remove jarvos/); + } finally { + behaviorChanged.cleanup(); + } +}); + +test('Codex MCP rollback failures do not block hook or provider rollback', () => { + const cases = [ + { + label: 'corrupt receipt', + prepare(run) { + fs.writeFileSync(run.receiptPath, '{}\n', { encoding: 'utf8', mode: 0o600 }); + }, + overrides: {}, + }, + { + label: 'inconclusive inspection', + prepare() {}, + overrides: { FAKE_CODEX_GET_MODE: 'fail', FAKE_CODEX_LIST_MODE: 'fail' }, + }, + { + label: 'changed registration', + prepare(run) { + const value = JSON.parse(fs.readFileSync(run.mcpStatePath, 'utf8')); + value.transport.command = 'locally-changed-command'; + fs.writeFileSync(run.mcpStatePath, JSON.stringify(value)); + }, + overrides: {}, + }, + { + label: 'present matching registration', + prepare() {}, + overrides: {}, + }, + ]; + + for (const scenario of cases) { + const run = runCodexSetup(); + try { + assert.equal(run.result.status, 0, `${scenario.label}: ${run.result.stderr || run.result.stdout}`); + seedCodexProviderRollback(run); + scenario.prepare(run); + const result = run.rerun({ JARVOS_MANAGED_HARNESS_ROLLBACK: '1', ...scenario.overrides }); + assert.notEqual(result.status, 0, `${scenario.label} should report unresolved MCP rollback`); + assert.equal(fs.existsSync(run.receiptPath), true, `${scenario.label} should preserve its receipt`); + assert.equal(fs.existsSync(run.mcpStatePath), true, `${scenario.label} should preserve its registration`); + assert.equal(fs.existsSync(run.providerStatePath), false, `${scenario.label} should still roll back provider state`); + assert.deepEqual(JSON.parse(fs.readFileSync(run.providerCliStatePath, 'utf8')), { installed: [], marketplaces: [] }); + assert.doesNotMatch(fs.readFileSync(run.codexLog, 'utf8'), /mcp remove jarvos/); + assert.doesNotMatch(fs.readFileSync(run.configPath, 'utf8'), /jarvos-session-start-hook\.js|jarvos-session-turn-hook\.js/); + const commands = fs.readFileSync(run.codexLog, 'utf8'); + assert.match(commands, /plugin remove compound-engineering@compound-engineering-plugin --json/); + assert.match(commands, /plugin marketplace remove compound-engineering-plugin --json/); + } finally { + run.cleanup(); + } + } +}); + +test('Codex hook rollback failure does not block MCP or provider rollback', () => { + const run = runCodexSetup({ FAKE_CODEX_APP_SERVER_MODE: 'success' }); + try { + assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); + seedCodexProviderRollback(run); + fs.writeFileSync(run.configPath, '[hooks]\nSessionStart = "not-an-array"\n', 'utf8'); + + const result = run.rerun({ JARVOS_MANAGED_HARNESS_ROLLBACK: '1' }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /hook rollback did not complete|hooks=1/i); + assert.equal(fs.existsSync(run.receiptPath), false, 'MCP receipt should still be cleared'); + assert.equal(fs.existsSync(run.mcpStatePath), false, 'MCP registration should still be removed'); + assert.equal(fs.existsSync(run.providerStatePath), false, 'provider state should still be rolled back'); + assert.deepEqual(JSON.parse(fs.readFileSync(run.providerCliStatePath, 'utf8')), { installed: [], marketplaces: [] }); + const commands = fs.readFileSync(run.codexLog, 'utf8'); + assert.doesNotMatch(commands, /mcp remove jarvos/); + assert.match(commands, /plugin remove compound-engineering@compound-engineering-plugin --json/); + assert.match(commands, /plugin marketplace remove compound-engineering-plugin --json/); + } finally { + run.cleanup(); + } +}); + +test('Codex rollback ignores stale forward-install bindings', () => { + const run = runCodexSetup({ FAKE_CODEX_APP_SERVER_MODE: 'success' }); + try { + assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); + seedCodexProviderRollback(run); + const result = run.rerun({ + JARVOS_MANAGED_HARNESS_ROLLBACK: '1', + JARVOS_MCP_STABLE_ENTRYPOINT: 'stale-relative-entrypoint', + JARVOS_CONTROL_PLANE_SERVICE_MODULE: 'stale-relative-service', + JARVOS_WORK_ACTION_SERVICE_MODULE: 'stale-relative-work-service', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(run.receiptPath), false); + assert.equal(fs.existsSync(run.mcpStatePath), false); + assert.equal(fs.existsSync(run.providerStatePath), false); + assert.doesNotMatch(fs.readFileSync(run.configPath, 'utf8'), /jarvos-session-start-hook\.js|jarvos-session-turn-hook\.js/); + } finally { + run.cleanup(); + } +}); + +test('Codex rollback without its CLI still performs independent hook cleanup', () => { + const run = runCodexSetup(); + try { + assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); + const result = run.rerun({ + JARVOS_MANAGED_HARNESS_ROLLBACK: '1', + JARVOS_CODEX_EXECUTABLE: path.join(run.tmp, 'missing-codex'), + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /continuing rollback phases|mcp=1/i); + assert.equal(fs.existsSync(run.receiptPath), true, 'MCP receipt must be preserved without the CLI'); + assert.equal(fs.existsSync(run.mcpStatePath), true, 'MCP registration must be preserved without the CLI'); + assert.doesNotMatch(fs.readFileSync(run.configPath, 'utf8'), /jarvos-session-start-hook\.js|jarvos-session-turn-hook\.js/); + } finally { + run.cleanup(); + } +}); + +test('Codex setup reconciles bounded add outcomes without broad ownership claims', () => { + const beforeWrite = runCodexSetup({ FAKE_CODEX_ADD_MODE: 'fail-before' }); + try { + assert.notEqual(beforeWrite.result.status, 0); + assert.equal(JSON.parse(fs.readFileSync(beforeWrite.receiptPath, 'utf8')).state, 'pending'); + assert.equal(fs.existsSync(beforeWrite.mcpStatePath), false); + const rolledBack = beforeWrite.rerun({ JARVOS_MANAGED_HARNESS_ROLLBACK: '1' }); + assert.equal(rolledBack.status, 0, rolledBack.stderr || rolledBack.stdout); + assert.equal(fs.existsSync(beforeWrite.receiptPath), false); + assert.doesNotMatch(fs.readFileSync(beforeWrite.codexLog, 'utf8'), /mcp remove jarvos/); + } finally { + beforeWrite.cleanup(); + } + + const afterWrite = runCodexSetup({ FAKE_CODEX_ADD_MODE: 'write-then-fail' }); + try { + assert.equal(afterWrite.result.status, 0, afterWrite.result.stderr || afterWrite.result.stdout); + assert.equal(JSON.parse(fs.readFileSync(afterWrite.receiptPath, 'utf8')).state, 'active'); + } finally { + afterWrite.cleanup(); + } +}); + +test('Codex MCP rollback retains its receipt when CAS cannot be confirmed', () => { + const failed = runCodexSetup(); + try { + assert.equal(failed.result.status, 0, failed.result.stderr || failed.result.stdout); + const result = failed.rerun({ JARVOS_MANAGED_HARNESS_ROLLBACK: '1' }); + assert.notEqual(result.status, 0); + assert.equal(fs.existsSync(failed.receiptPath), true); + assert.equal(fs.existsSync(failed.mcpStatePath), true); + assert.doesNotMatch(fs.readFileSync(failed.codexLog, 'utf8'), /mcp remove jarvos/); + } finally { + failed.cleanup(); + } + + const uncertain = runCodexSetup(); + try { + assert.equal(uncertain.result.status, 0, uncertain.result.stderr || uncertain.result.stdout); + const result = uncertain.rerun({ JARVOS_MANAGED_HARNESS_ROLLBACK: '1', FAKE_CODEX_APP_SERVER_MODE: 'conflict' }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /CAS conflict|preserving|reconciliation/i); + assert.equal(fs.existsSync(uncertain.receiptPath), true); + assert.doesNotMatch(fs.readFileSync(uncertain.codexLog, 'utf8'), /mcp remove jarvos/); + } finally { + uncertain.cleanup(); + } +}); + +test('Codex MCP receipt rejects loose modes, invalid content, and symlinks', () => { + const receiptApi = require(path.join(__dirname, '..', '..', '..', 'runtimes', 'codex', 'mcp-registration-receipt.js')); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-codex-receipt-')); + const codexHome = path.join(tmp, 'codex-home'); + const receiptPath = path.join(codexHome, 'jarvos-codex-mcp-receipt.json'); + fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 }); + const fingerprint = receiptApi.fingerprintRegistration({ command: 'node', args: ['server.js'], env: {} }); + try { + const behaviorChanges = [ + { command: 'node', args: ['server.js'], env: {}, enabled: false }, + { command: 'node', args: ['server.js'], env: {}, env_vars: ['PASSTHROUGH'] }, + { command: 'node', args: ['server.js'], env: {}, startup_timeout_sec: 5 }, + { command: 'node', args: ['server.js'], env: {}, tool_timeout_sec: 7 }, + { command: 'node', args: ['server.js'], env: {}, enabled_tools: ['one'] }, + { command: 'node', args: ['server.js'], env: {}, disabled_tools: ['two'] }, + ]; + for (const changed of behaviorChanges) { + assert.notEqual(receiptApi.fingerprintRegistration(changed), fingerprint); + } + + receiptApi.claimReceipt(receiptPath, codexHome, fingerprint); + fs.chmodSync(receiptPath, 0o644); + assert.throws(() => receiptApi.readReceipt(receiptPath, codexHome), /mode 0600/); + + fs.chmodSync(receiptPath, 0o600); + fs.writeFileSync(receiptPath, '{}\n', { mode: 0o600 }); + assert.throws(() => receiptApi.readReceipt(receiptPath, codexHome), /unsupported shape|recognized/); + + const target = path.join(codexHome, 'receipt-target.json'); + fs.renameSync(receiptPath, target); + fs.symlinkSync(target, receiptPath); + assert.throws(() => receiptApi.readReceipt(receiptPath, codexHome), /regular file/); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +test('managed Codex provider version mismatch stops before profile mutation', () => { + const run = runCodexSetup({ + JARVOS_CODEX_PROVIDER_MODE: 'new-managed', + FAKE_CODEX_VERSION: '0.145.0', + }); + try { + assert.notEqual(run.result.status, 0); + assert.match(run.result.stderr, /not covered|conformance/i); + assert.equal(fs.readFileSync(run.configPath, 'utf8'), ''); + assert.equal(fs.existsSync(run.receiptPath), false); + assert.equal(fs.existsSync(run.mcpStatePath), false); + const log = fs.readFileSync(run.codexLog, 'utf8'); + assert.match(log, /^--version\n$/); + assert.doesNotMatch(log, /mcp |plugin /); + + const rollback = run.rerun({ JARVOS_MANAGED_HARNESS_ROLLBACK: '1' }); + assert.equal(rollback.status, 0, rollback.stderr || rollback.stdout); + } finally { + run.cleanup(); + } +}); + +test('managed Codex provider preflight accepts the exact reviewed CLI version without writes', () => { + const run = runCodexSetup(); + try { + assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); + const manager = path.join(__dirname, '..', '..', '..', 'runtimes', 'codex', 'compound-engineering-activation.js'); + const before = fs.readdirSync(run.codexHome).sort(); + const result = spawnSync(process.execPath, [manager], { + cwd: path.join(__dirname, '..', '..', '..'), + encoding: 'utf8', + env: { + ...process.env, + HOME: run.tmp, + CODEX_HOME: run.codexHome, + JARVOS_CODEX_EXECUTABLE: run.fakeCodexPath, + JARVOS_CODEX_PROVIDER_MODE: 'new-managed', + JARVOS_CODEX_PROVIDER_PREFLIGHT: '1', + }, + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /0\.146\.0|no provider changes/i); + assert.deepEqual(fs.readdirSync(run.codexHome).sort(), before); + } finally { + run.cleanup(); + } +}); + test('Codex setup optionally binds work-action host env without requiring it', () => { const hostTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-setup-todo-host-')); try { diff --git a/modules/jarvos-agent-context/test/work-action-host.test.js b/modules/jarvos-agent-context/test/work-action-host.test.js index 1efac59c..def69923 100644 --- a/modules/jarvos-agent-context/test/work-action-host.test.js +++ b/modules/jarvos-agent-context/test/work-action-host.test.js @@ -357,7 +357,7 @@ test('Claude and Codex setup scripts pass optional work-action env and never req assert.doesNotMatch(source, /: "\$\{JARVOS_PROJECTS_CONTEXT_CONFIG:\?/); } assert.match(claude, /claude mcp add --scope user "\$\{MCP_ENV_ARGS\[@\]\}" jarvos -- "\$\{MCP_COMMAND\[@\]\}"/); - assert.match(codex, /codex mcp add "\$\{MCP_ENV_ARGS\[@\]\}" jarvos -- "\$\{MCP_COMMAND\[@\]\}"/); + assert.match(codex, /"\$CODEX_EXECUTABLE" mcp add "\$\{MCP_ENV_ARGS\[@\]\}" jarvos -- "\$\{MCP_COMMAND\[@\]\}"/); }); // Records the argv of a fake `codex`/`claude` CLI so setup.sh's real MCP @@ -366,11 +366,22 @@ function writeFakeMcpCli(binPath, recordPath) { fs.writeFileSync(binPath, [ '#!/usr/bin/env node', "const fs = require('fs');", + "const crypto = require('crypto');", 'const args = process.argv.slice(2);', `const recordPath = ${JSON.stringify(recordPath)};`, - "if (args[0] === 'mcp' && args[1] === 'get') process.exit(1);", - "if (args[0] === 'mcp' && args[1] === 'remove') process.exit(0);", - "if (args[0] === 'mcp' && args[1] === 'add') { fs.writeFileSync(recordPath, JSON.stringify(args)); process.exit(0); }", + "const scope = process.env.CODEX_HOME || process.env.CLAUDE_SETTINGS || process.env.HOME || 'default';", + "const statePath = `${recordPath}.${crypto.createHash('sha256').update(scope).digest('hex')}.state.json`;", + "if (args[0] === 'mcp' && args[1] === 'list') { const entries = fs.existsSync(statePath) ? [JSON.parse(fs.readFileSync(statePath, 'utf8'))] : []; process.stdout.write(JSON.stringify(entries)); process.exit(0); }", + "if (args[0] === 'mcp' && args[1] === 'get') { if (!fs.existsSync(statePath)) process.exit(1); process.stdout.write(fs.readFileSync(statePath, 'utf8')); process.exit(0); }", + "if (args[0] === 'mcp' && args[1] === 'remove') { try { fs.unlinkSync(statePath); } catch (error) { if (error.code !== 'ENOENT') throw error; } process.exit(0); }", + "if (args[0] === 'mcp' && args[1] === 'add') {", + " fs.writeFileSync(recordPath, JSON.stringify(args));", + " const separator = args.indexOf('--'); const nameIndex = args.indexOf('jarvos'); const env = {};", + " for (let index = 2; index < nameIndex; index += 1) if (args[index] === '--env') { const binding = args[++index]; const split = binding.indexOf('='); env[binding.slice(0, split)] = binding.slice(split + 1); }", + " const command = args[separator + 1]; const commandArgs = args.slice(separator + 2);", + " fs.writeFileSync(statePath, JSON.stringify({ name: 'jarvos', enabled: true, startup_timeout_sec: null, tool_timeout_sec: null, enabled_tools: null, disabled_tools: null, transport: { type: 'stdio', command, args: commandArgs, cwd: null, env, env_vars: [] } }));", + " process.exit(0);", + "}", 'process.exit(0);', '', ].join('\n'), { encoding: 'utf8', mode: 0o755 }); @@ -524,6 +535,7 @@ test('rerunning Codex setup from a different immutable runtime preserves the sam fs.writeFileSync(path.join(runtime2CodexDir, 'jarvos-session-turn-hook.js'), '// stub\n'); fs.writeFileSync(path.join(runtime2CodexDir, 'trust-session-start-hook.js'), '// stub\n'); fs.copyFileSync(path.join(REPO_ROOT, 'runtimes', 'codex', 'setup.sh'), path.join(runtime2CodexDir, 'setup.sh')); + fs.copyFileSync(path.join(REPO_ROOT, 'runtimes', 'codex', 'mcp-registration-receipt.js'), path.join(runtime2CodexDir, 'mcp-registration-receipt.js')); fs.chmodSync(path.join(runtime2CodexDir, 'setup.sh'), 0o755); const runFrom = (setupPath) => { diff --git a/package.json b/package.json index 9f3a21bb..ac09b400 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ "clawpatch:map": "node scripts/clawpatch-runner.js map", "clawpatch:status": "node scripts/clawpatch-runner.js status", "clawpatch:review:dry-run": "node scripts/clawpatch-runner.js review --limit 1 --dry-run", - "test": "bash tests/smoke-test.sh && node --test modules/jarvos-ontology/test/*.test.js && node --test modules/jarvos-agent-context/test/agent-context.test.js modules/jarvos-agent-context/test/projects-context.test.js && node --test modules/jarvos-control-plane/test/*.test.js && node --test modules/jarvos-coding/test/*.test.js && node --test modules/jarvos-runtime-kit/test/*.test.js && node --test modules/jarvos-skills/test/*.test.js && node --test modules/jarvos-memory/test/transcript-retrieval.test.js && node tests/modules-smoke-test.js && node tests/cli-smoke-test.js && node --test tests/doctor-checks-test.js tests/doctor-modules-test.js && node --test tests/openclaw-plugin-persistence-docs-test.js && node --test tests/pack-manifest-test.js && node --test tests/release-please-config-test.js && node --test tests/release-readiness-check-test.js tests/release-status-test.js && node --test tests/unreleased-drift-check-test.js && node --test tests/public-journal-boundary.test.js tests/active-assistant-runtime-bridge-test.js tests/active-assistant-synthesis-contract-test.js && node --test modules/jarvos-secondbrain/tests/config-resolution.test.js modules/jarvos-secondbrain/tests/journal-lifecycle.test.js modules/jarvos-secondbrain/tests/journal-health.test.js modules/jarvos-secondbrain/tests/journal-maintenance.test.js modules/jarvos-secondbrain/tests/journal-maintenance-schedule.test.js modules/jarvos-secondbrain/tests/journal-maintenance-paths.test.js modules/jarvos-secondbrain/tests/journal-projects-section.test.js modules/jarvos-secondbrain/tests/projects.test.js modules/jarvos-secondbrain/tests/project-context.test.js modules/jarvos-secondbrain/packages/jarvos-secondbrain-projects/test/records.test.js modules/jarvos-secondbrain/packages/jarvos-secondbrain-projects/test/project-context.test.js modules/jarvos-secondbrain/packages/jarvos-secondbrain-projects/test/projects-context.test.js modules/jarvos-secondbrain/packages/jarvos-secondbrain-projects/test/provider-contracts.test.js modules/jarvos-secondbrain/packages/jarvos-secondbrain-projects/test/migrate.test.js modules/jarvos-secondbrain/packages/jarvos-secondbrain-projects/test/journal-projection.test.js modules/jarvos-secondbrain/tests/link-to-journal.test.js modules/jarvos-secondbrain/tests/vault-storage-adapter-journal.test.js modules/jarvos-secondbrain/tests/personality-note-journal-contract.test.js modules/jarvos-secondbrain/tests/journal-backlink-recovery.test.js && node --test tests/secondbrain-external-integrations-doc-test.js && node --test tests/v05-shipgate-test.js && bash scripts/smoke-test.sh", + "test": "bash tests/smoke-test.sh && node --test modules/jarvos-ontology/test/*.test.js && node --test modules/jarvos-agent-context/test/agent-context.test.js modules/jarvos-agent-context/test/projects-context.test.js modules/jarvos-agent-context/test/work-action-host.test.js && node --test modules/jarvos-control-plane/test/*.test.js && node --test modules/jarvos-coding/test/*.test.js && node --test modules/jarvos-runtime-kit/test/*.test.js && node --test modules/jarvos-skills/test/*.test.js && node --test modules/jarvos-memory/test/transcript-retrieval.test.js && node tests/modules-smoke-test.js && node tests/cli-smoke-test.js && node --test tests/doctor-checks-test.js tests/doctor-modules-test.js && node --test tests/openclaw-plugin-persistence-docs-test.js && node --test tests/pack-manifest-test.js && node --test tests/release-please-config-test.js && node --test tests/release-readiness-check-test.js tests/release-status-test.js && node --test tests/unreleased-drift-check-test.js && node --test tests/public-journal-boundary.test.js tests/active-assistant-runtime-bridge-test.js tests/active-assistant-synthesis-contract-test.js && node --test modules/jarvos-secondbrain/tests/config-resolution.test.js modules/jarvos-secondbrain/tests/journal-lifecycle.test.js modules/jarvos-secondbrain/tests/journal-health.test.js modules/jarvos-secondbrain/tests/journal-maintenance.test.js modules/jarvos-secondbrain/tests/journal-maintenance-schedule.test.js modules/jarvos-secondbrain/tests/journal-maintenance-paths.test.js modules/jarvos-secondbrain/tests/journal-projects-section.test.js modules/jarvos-secondbrain/tests/projects.test.js modules/jarvos-secondbrain/tests/project-context.test.js modules/jarvos-secondbrain/packages/jarvos-secondbrain-projects/test/records.test.js modules/jarvos-secondbrain/packages/jarvos-secondbrain-projects/test/project-context.test.js modules/jarvos-secondbrain/packages/jarvos-secondbrain-projects/test/projects-context.test.js modules/jarvos-secondbrain/packages/jarvos-secondbrain-projects/test/provider-contracts.test.js modules/jarvos-secondbrain/packages/jarvos-secondbrain-projects/test/migrate.test.js modules/jarvos-secondbrain/packages/jarvos-secondbrain-projects/test/journal-projection.test.js modules/jarvos-secondbrain/tests/link-to-journal.test.js modules/jarvos-secondbrain/tests/vault-storage-adapter-journal.test.js modules/jarvos-secondbrain/tests/personality-note-journal-contract.test.js modules/jarvos-secondbrain/tests/journal-backlink-recovery.test.js && node --test tests/secondbrain-external-integrations-doc-test.js && node --test tests/v05-shipgate-test.js && bash scripts/smoke-test.sh", "test:bootstrap": "bash tests/smoke-test.sh", "test:modules": "node tests/modules-smoke-test.js", "test:structure": "bash scripts/smoke-test.sh", diff --git a/runtimes/codex/README.md b/runtimes/codex/README.md index 9549e562..3c9f935e 100644 --- a/runtimes/codex/README.md +++ b/runtimes/codex/README.md @@ -97,6 +97,10 @@ state, and records the provider-owned additions under the selected `CODEX_HOME`. It refuses to replace a stale, disabled, locally changed, or unverifiable installation. `existing` (the default) and `disabled` modes leave the profile untouched and jarvOS uses native fallback when CE is unavailable. +Managed activation also requires the running Codex CLI version to exactly match +the version recorded by the reviewed conformance receipt. Setup performs this +check before writing the selected profile, and the provider manager repeats it +immediately before activation. To remove only additions made by jarvOS, use the same profile and explicit rollback flag: @@ -109,6 +113,26 @@ Rollback preserves the marketplace when another plugin now uses it and refuses to remove provider state that changed after jarvOS installed it. Restart Codex after activation or rollback before relying on the new profile state. +Codex MCP setup follows the same ownership boundary. A mode-`0600` receipt in +the selected `CODEX_HOME` records only a normalized fingerprint of the jarvOS +registration created by setup. Rollback never invokes name-only +`codex mcp remove`. For a present matching registration it can use an atomic +Codex app-server `config/batchWrite` guarded by the exact user-layer +`expectedVersion`; the receipt is cleared only after app-server confirms the +atomic edit succeeded. Both `ok` and `okOverridden` are successful writes: the +latter means the jarvOS-owned user-layer entry was removed but a higher-priority +layer still supplies the effective value, which rollback preserves. If +app-server CAS is unavailable, the layer or registration changed, or the write +cannot be confirmed, the registration and receipt are preserved for manual +reconciliation and rollback exits nonzero. An already absent registration +clears a valid receipt. Rollback ignores stale forward-install bindings that it +does not need. Missing subsystem prerequisites are reported after every +independent hook, MCP, and Compound Engineering provider cleanup that can still +run; one phase does not short-circuit the others. +This receipt covers MCP ownership only. Hook trust and feature-setting +restoration are separate profile concerns and are not claimed as transactional +by this mechanism. + ### Optional authenticated control-plane host Private installs that supply an authenticated host service and credential file diff --git a/runtimes/codex/compound-engineering-activation.js b/runtimes/codex/compound-engineering-activation.js index 8902c5c9..582f9401 100644 --- a/runtimes/codex/compound-engineering-activation.js +++ b/runtimes/codex/compound-engineering-activation.js @@ -21,6 +21,7 @@ const ROOT = path.resolve(__dirname, '..', '..'); const CAPABILITY_PATH = path.join(__dirname, 'compound-engineering-capability.json'); const CONFORMANCE_PATH = path.join(__dirname, 'compound-engineering-conformance.json'); const STATE_VERSION = 'jarvos-codex-provider-state/v1'; +const CODEX_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; const PROVIDER_ID = 'compound-engineering'; const MARKETPLACE_NAME = 'compound-engineering-plugin'; const PLUGIN_SELECTOR = 'compound-engineering@compound-engineering-plugin'; @@ -46,10 +47,13 @@ function readJson(filePath, label) { } } -function assertProfileDirectory(profilePath) { +function assertProfileDirectory(profilePath, { create = true } = {}) { if (typeof profilePath !== 'string' || !path.isAbsolute(profilePath)) fail('CODEX_HOME must be absolute'); const absolute = path.resolve(profilePath); - if (!fs.existsSync(absolute)) fs.mkdirSync(absolute, { recursive: true, mode: 0o700 }); + if (!fs.existsSync(absolute)) { + if (!create) return absolute; + fs.mkdirSync(absolute, { recursive: true, mode: 0o700 }); + } const stat = fs.lstatSync(absolute); if (stat.isSymbolicLink() || !stat.isDirectory()) fail('CODEX_HOME must be a real directory'); if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) fail('CODEX_HOME must be owned by the current user'); @@ -111,8 +115,12 @@ function commandEnvironment() { return env; } +function codexExecutable() { + return process.env.JARVOS_CODEX_EXECUTABLE || 'codex'; +} + function runCodex(args, { expectJson = false } = {}) { - const result = spawnSync(process.env.JARVOS_CODEX_EXECUTABLE || 'codex', args, { + const result = spawnSync(codexExecutable(), args, { cwd: ROOT, env: commandEnvironment(), encoding: 'utf8', @@ -190,7 +198,32 @@ function ensureApprovedEvidence() { const validation = validateCodexConformanceReceipt(conformance, { capability }); if (!validation.ok) fail('shipped Codex provider conformance receipt is not approved'); if (capability.admission !== 'supported' || capability.activation.candidateOnly !== false) fail('Compound Engineering provider is not admitted for activation'); - return capability; + return { capability, conformance }; +} + +function readLiveCodexVersion() { + const result = spawnSync(codexExecutable(), ['--version'], { + cwd: ROOT, + env: commandEnvironment(), + encoding: 'utf8', + timeout: 30_000, + maxBuffer: 128 * 1024, + }); + if (result.error || result.status !== 0) fail('could not read the running Codex CLI version'); + const output = `${result.stdout || ''}\n${result.stderr || ''}`; + const versions = output.match(/\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/g) || []; + if (versions.length !== 1 || !CODEX_SEMVER.test(versions[0])) fail('running Codex CLI returned an unusable version'); + return versions[0]; +} + +function assertLiveCodexVersion(conformance) { + const expected = conformance?.discovery?.codexVersion; + if (typeof expected !== 'string' || !CODEX_SEMVER.test(expected)) fail('Codex provider conformance has no valid covered CLI version'); + const actual = readLiveCodexVersion(); + if (actual !== expected) { + fail(`managed Compound Engineering activation is not covered for Codex CLI ${actual}; conformance covers ${expected}. Update Codex or repeat the disposable conformance validation before retrying`); + } + return actual; } function stateMatchesInstallation(state, current) { @@ -299,7 +332,7 @@ function activate({ capability, statePath }) { } } -function rollback({ capability, statePath }) { +function rollback({ statePath }) { const state = readState(statePath); if (!state) { console.log('No jarvOS-owned Compound Engineering activation was found; preserving the Codex profile.'); @@ -314,16 +347,28 @@ function main() { const mode = process.env.JARVOS_CODEX_PROVIDER_MODE || (process.env.JARVOS_PROFILE === 'codex' ? 'new-managed' : 'existing'); if (!SAFE_MODE.has(mode)) fail('JARVOS_CODEX_PROVIDER_MODE must be existing, new-managed, opt-in, or disabled'); - const profile = assertProfileDirectory(process.env.CODEX_HOME || path.join(process.env.HOME || '', '.codex')); + const rollbackRequested = process.env.JARVOS_MANAGED_HARNESS_ROLLBACK === '1'; + const preflightRequested = process.env.JARVOS_CODEX_PROVIDER_PREFLIGHT === '1'; + const managedMode = mode === 'new-managed' || mode === 'opt-in'; + if (preflightRequested && !managedMode) fail('Codex provider preflight requires new-managed or opt-in mode'); + const profile = assertProfileDirectory( + process.env.CODEX_HOME || path.join(process.env.HOME || '', '.codex'), + { create: !preflightRequested && !rollbackRequested }, + ); process.env.CODEX_HOME = profile; const statePath = path.join(profile, 'jarvos-compound-engineering.state.json'); - const capability = ensureApprovedEvidence(); - if (process.env.JARVOS_MANAGED_HARNESS_ROLLBACK === '1') return rollback({ capability, statePath }); + if (rollbackRequested) return rollback({ statePath }); + const evidence = ensureApprovedEvidence(); + const liveCodexVersion = managedMode ? assertLiveCodexVersion(evidence.conformance) : undefined; + if (preflightRequested) { + console.log(`Codex ${liveCodexVersion} is covered by the reviewed Compound Engineering conformance receipt; no provider changes were made.`); + return { status: 'preflight', codexVersion: liveCodexVersion }; + } if (mode === 'existing' || mode === 'disabled') { console.log(`Compound Engineering provider setup is ${mode === 'disabled' ? 'disabled' : 'preserve-only'} for this Codex profile.`); return { status: mode }; } - return activate({ capability, statePath }); + return activate({ capability: evidence.capability, statePath }); } if (require.main === module) { @@ -337,7 +382,9 @@ if (require.main === module) { module.exports = { activate, + assertLiveCodexVersion, discover, main, + readLiveCodexVersion, rollback, }; diff --git a/runtimes/codex/mcp-registration-receipt.js b/runtimes/codex/mcp-registration-receipt.js new file mode 100644 index 00000000..ab971081 --- /dev/null +++ b/runtimes/codex/mcp-registration-receipt.js @@ -0,0 +1,317 @@ +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +const SCHEMA_VERSION = 'jarvos-codex-mcp-receipt/v1'; +const STATES = new Set(['pending', 'active']); +const RECEIPT_KEYS = [ + 'schemaVersion', + 'registration', + 'profileDigest', + 'desiredFingerprint', + 'state', +]; + +function fail(message) { + const error = new Error(message); + error.code = 'JARVOS_CODEX_MCP_RECEIPT_INVALID'; + throw error; +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function digest(value) { + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} + +function assertProfileDirectory(profilePath, { create = false } = {}) { + if (typeof profilePath !== 'string' || !path.isAbsolute(profilePath)) fail('CODEX_HOME must be absolute'); + const absolute = path.resolve(profilePath); + if (!fs.existsSync(absolute)) { + if (!create) fail('CODEX_HOME does not exist'); + fs.mkdirSync(absolute, { recursive: true, mode: 0o700 }); + } + const stat = fs.lstatSync(absolute); + const uid = typeof process.getuid === 'function' ? process.getuid() : null; + if (stat.isSymbolicLink() || !stat.isDirectory()) fail('CODEX_HOME must be a real directory'); + if (uid !== null && stat.uid !== uid) fail('CODEX_HOME must be owned by the current user'); + if ((stat.mode & 0o022) !== 0) fail('CODEX_HOME must not be group- or world-writable'); + const real = fs.realpathSync(absolute); + const allowedSystemAlias = (absolute === '/tmp' || absolute.startsWith('/tmp/')) + ? `/private${absolute}` + : (absolute === '/var' || absolute.startsWith('/var/')) ? `/private${absolute}` : null; + if (real !== absolute && real !== allowedSystemAlias) fail('CODEX_HOME must not use a symbolic-link path'); + return absolute; +} + +function receiptContext(receiptPath, profilePath, options = {}) { + const profile = assertProfileDirectory(profilePath, options); + const receipt = path.resolve(receiptPath); + if (path.dirname(receipt) !== profile) fail('MCP receipt must be directly inside CODEX_HOME'); + return { profile, receipt, profileDigest: digest(fs.realpathSync(profile)) }; +} + +function assertReceiptFile(receiptPath) { + const stat = fs.lstatSync(receiptPath); + const uid = typeof process.getuid === 'function' ? process.getuid() : null; + if (stat.isSymbolicLink() || !stat.isFile()) fail('MCP receipt must be a regular file'); + if (uid !== null && stat.uid !== uid) fail('MCP receipt must be owned by the current user'); + if ((stat.mode & 0o777) !== 0o600) fail('MCP receipt must have mode 0600'); +} + +function validateReceipt(value, expectedProfileDigest) { + if (!isObject(value) || Object.keys(value).sort().join('\0') !== [...RECEIPT_KEYS].sort().join('\0')) { + fail('MCP receipt has an unsupported shape'); + } + if (value.schemaVersion !== SCHEMA_VERSION || value.registration !== 'jarvos' + || value.profileDigest !== expectedProfileDigest + || !/^sha256:[a-f0-9]{64}$/.test(value.desiredFingerprint || '') + || !STATES.has(value.state)) { + fail('MCP receipt is not a recognized jarvOS ownership record'); + } + return value; +} + +function readReceipt(receiptPath, profilePath) { + const context = receiptContext(receiptPath, profilePath); + try { fs.lstatSync(context.receipt); } catch (error) { + if (error.code === 'ENOENT') return null; + throw error; + } + assertReceiptFile(context.receipt); + let value; + try { + value = JSON.parse(fs.readFileSync(context.receipt, 'utf8')); + } catch (_) { + fail('MCP receipt is not valid JSON'); + } + return validateReceipt(value, context.profileDigest); +} + +function renderReceipt(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function claimReceipt(receiptPath, profilePath, desiredFingerprint) { + if (!/^sha256:[a-f0-9]{64}$/.test(desiredFingerprint || '')) fail('desired MCP fingerprint is invalid'); + const context = receiptContext(receiptPath, profilePath, { create: true }); + const existing = readReceipt(context.receipt, context.profile); + if (existing) { + const current = existing; + if (current.desiredFingerprint !== desiredFingerprint) fail('existing MCP receipt describes a different registration'); + return current; + } + const value = { + schemaVersion: SCHEMA_VERSION, + registration: 'jarvos', + profileDigest: context.profileDigest, + desiredFingerprint, + state: 'pending', + }; + let fd; + try { + fd = fs.openSync(context.receipt, 'wx', 0o600); + fs.writeFileSync(fd, renderReceipt(value), 'utf8'); + fs.fsyncSync(fd); + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + return value; +} + +function updateReceiptState(receiptPath, profilePath, desiredFingerprint, state) { + if (!STATES.has(state)) fail('MCP receipt state is invalid'); + const context = receiptContext(receiptPath, profilePath); + const current = readReceipt(context.receipt, context.profile); + if (!current || current.desiredFingerprint !== desiredFingerprint) fail('MCP receipt does not match the intended registration'); + const next = { ...current, state }; + const temporary = path.join(context.profile, `.${path.basename(context.receipt)}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`); + let fd; + try { + fd = fs.openSync(temporary, 'wx', 0o600); + fs.writeFileSync(fd, renderReceipt(next), 'utf8'); + fs.fsyncSync(fd); + fs.closeSync(fd); + fd = undefined; + fs.renameSync(temporary, context.receipt); + fs.chmodSync(context.receipt, 0o600); + } finally { + if (fd !== undefined) fs.closeSync(fd); + try { fs.unlinkSync(temporary); } catch (error) { if (error.code !== 'ENOENT') throw error; } + } + return next; +} + +function clearReceipt(receiptPath, profilePath, desiredFingerprint) { + const context = receiptContext(receiptPath, profilePath); + const current = readReceipt(context.receipt, context.profile); + if (!current) return false; + if (current.desiredFingerprint !== desiredFingerprint) fail('MCP receipt does not match the intended registration'); + fs.unlinkSync(context.receipt); + return true; +} + +function normalizeEnvironment(value) { + if (value === undefined || value === null) return {}; + if (!isObject(value)) fail('MCP environment must be an object'); + const normalized = {}; + for (const key of Object.keys(value).sort()) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof value[key] !== 'string') fail('MCP environment is invalid'); + normalized[key] = value[key]; + } + return normalized; +} + +function normalizeStringSet(value, label, { defaultValue = null } = {}) { + if (value === undefined || value === null) return defaultValue; + if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) fail(`${label} must be a string array`); + return [...new Set(value)].sort(); +} + +function normalizeTimeout(value, label) { + if (value === undefined || value === null) return null; + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) fail(`${label} must be a positive number`); + return value; +} + +function normalizeRegistration(value) { + if (!isObject(value)) fail('MCP registration is invalid'); + const transport = isObject(value.transport) ? value.transport : value; + const type = transport.type || transport.transport || value.transport_type || 'stdio'; + if (type !== 'stdio') fail('jarvOS MCP registration must use stdio'); + const command = transport.command; + const args = transport.args === undefined ? [] : transport.args; + const cwd = transport.cwd === undefined ? null : transport.cwd; + const enabled = value.enabled === undefined ? true : value.enabled; + if (typeof command !== 'string' || command.length === 0 + || !Array.isArray(args) || args.some((entry) => typeof entry !== 'string') + || (cwd !== null && typeof cwd !== 'string') || typeof enabled !== 'boolean') { + fail('MCP stdio command is invalid'); + } + return { + transport: 'stdio', + command, + args, + cwd, + env: normalizeEnvironment(transport.env), + envVars: normalizeStringSet(transport.env_vars ?? value.env_vars, 'MCP environment passthrough', { defaultValue: [] }), + enabled, + startupTimeoutSeconds: normalizeTimeout(value.startup_timeout_sec ?? transport.startup_timeout_sec, 'MCP startup timeout'), + toolTimeoutSeconds: normalizeTimeout(value.tool_timeout_sec ?? transport.tool_timeout_sec, 'MCP tool timeout'), + enabledTools: normalizeStringSet(value.enabled_tools ?? transport.enabled_tools, 'MCP enabled tools'), + disabledTools: normalizeStringSet(value.disabled_tools ?? transport.disabled_tools, 'MCP disabled tools'), + }; +} + +function fingerprintRegistration(value) { + return digest(JSON.stringify(normalizeRegistration(value))); +} + +function desiredRegistration(command, args, env) { + return normalizeRegistration({ command, args, env }); +} + +function parseJsonArgument(value, label) { + try { return JSON.parse(value); } catch (_) { fail(`${label} is invalid JSON`); } +} + +function main(argv = process.argv.slice(2)) { + const [action, ...args] = argv; + if (action === 'profile') { + process.stdout.write(assertProfileDirectory(args[0], { create: args[1] === 'create' })); + return; + } + if (action === 'desired-cli') { + const separator = args.indexOf('--'); + if (separator < 0 || separator === args.length - 1) fail('desired MCP command is missing'); + const options = args.slice(0, separator); + const command = args[separator + 1]; + const commandArgs = args.slice(separator + 2); + const env = {}; + for (let index = 0; index < options.length; index += 2) { + if (options[index] !== '--env' || typeof options[index + 1] !== 'string') fail('desired MCP options are invalid'); + const equals = options[index + 1].indexOf('='); + if (equals <= 0) fail('desired MCP environment is invalid'); + env[options[index + 1].slice(0, equals)] = options[index + 1].slice(equals + 1); + } + process.stdout.write(fingerprintRegistration(desiredRegistration(command, commandArgs, env))); + return; + } + if (action === 'desired') { + const [command, argsJson, envJson] = args; + process.stdout.write(fingerprintRegistration(desiredRegistration( + command, + parseJsonArgument(argsJson, 'MCP args'), + parseJsonArgument(envJson, 'MCP environment'), + ))); + return; + } + if (action === 'observe') { + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { input += chunk; }); + process.stdin.on('end', () => { + try { process.stdout.write(fingerprintRegistration(parseJsonArgument(input, 'observed MCP registration'))); } + catch (error) { process.stderr.write(`${error.message}\n`); process.exitCode = 1; } + }); + return; + } + if (action === 'list-state') { + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { input += chunk; }); + process.stdin.on('end', () => { + try { + const value = parseJsonArgument(input, 'Codex MCP list'); + const entries = Array.isArray(value) ? value : value?.servers; + if (!Array.isArray(entries) || entries.some((entry) => !isObject(entry) || typeof entry.name !== 'string')) { + fail('Codex MCP list has an unsupported shape'); + } + process.stdout.write(entries.some((entry) => entry.name === 'jarvos') ? 'present' : 'absent'); + } catch (error) { process.stderr.write(`${error.message}\n`); process.exitCode = 1; } + }); + return; + } + const [receiptPath, profilePath, desiredFingerprint] = args; + if (action === 'fingerprint') { + const value = readReceipt(receiptPath, profilePath); + if (!value) fail('MCP receipt does not exist'); + process.stdout.write(value.desiredFingerprint); + return; + } + if (action === 'state') { + const value = readReceipt(receiptPath, profilePath); + if (value && value.desiredFingerprint !== desiredFingerprint) fail('MCP receipt does not match the intended registration'); + process.stdout.write(value ? value.state : 'missing'); + } else if (action === 'claim') { + process.stdout.write(claimReceipt(receiptPath, profilePath, desiredFingerprint).state); + } else if (action === 'activate') { + process.stdout.write(updateReceiptState(receiptPath, profilePath, desiredFingerprint, 'active').state); + } else if (action === 'clear') { + clearReceipt(receiptPath, profilePath, desiredFingerprint); + } else { + fail('unknown MCP receipt action'); + } +} + +if (require.main === module) { + try { main(); } catch (error) { + process.stderr.write(`jarvOS Codex MCP receipt failed: ${error.message}\n`); + process.exitCode = 1; + } +} + +module.exports = { + SCHEMA_VERSION, + claimReceipt, + clearReceipt, + desiredRegistration, + fingerprintRegistration, + normalizeRegistration, + readReceipt, + updateReceiptState, +}; diff --git a/runtimes/codex/setup.sh b/runtimes/codex/setup.sh index b9398994..2ffed191 100755 --- a/runtimes/codex/setup.sh +++ b/runtimes/codex/setup.sh @@ -10,6 +10,13 @@ TRUST_SCRIPT="$ROOT/runtimes/codex/trust-session-start-hook.js" CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" CODEX_CONFIG="${CODEX_CONFIG:-$CODEX_HOME/config.toml}" LEGACY_HOOKS_JSON="$CODEX_HOME/hooks.json" +# The receipt is intentionally limited to the MCP registration created by +# this setup run. It authorizes receipt-scoped rollback reconciliation; it is +# not a copy of the Codex configuration and never contains credential values. +MCP_RECEIPT_MODULE="$ROOT/runtimes/codex/mcp-registration-receipt.js" +MCP_RECEIPT_PATH="$CODEX_HOME/jarvos-codex-mcp-receipt.json" +MCP_LOCK_PATH="$CODEX_HOME/.jarvos-codex-mcp.lock" +export CODEX_HOME CODEX_CONFIG CONTROL_PLANE_SERVICE_MODULE="${JARVOS_CONTROL_PLANE_SERVICE_MODULE:-}" # Setup registers only a non-secret file path. Never pass the credential value # through `codex mcp add --env` — that puts it on argv and persists it in config. @@ -19,12 +26,18 @@ STEWARDSHIP_CODEX_SESSION_MAP_ROOT="${JARVOS_STEWARDSHIP_CODEX_SESSION_MAP_ROOT: STEWARDSHIP_STABLE_ROOT="${JARVOS_STEWARDSHIP_STABLE_ROOT:-}" STEWARDSHIP_DISPATCHER="" CODEX_PROVIDER_MODE="${JARVOS_CODEX_PROVIDER_MODE:-}" +CODEX_EXECUTABLE="${JARVOS_CODEX_EXECUTABLE:-codex}" # Optional owner-controlled stable selector-aware entrypoint. When set, this # is what gets persisted in Codex config instead of this immutable install's # own MCP script -- so a later selected-runtime transition does not require # rewriting persisted client config. Unset preserves the current portable # behavior: register this run's own $MCP_SERVER directly. STABLE_MCP_ENTRYPOINT="${JARVOS_MCP_STABLE_ENTRYPOINT:-}" +ROLLBACK_MODE="${JARVOS_MANAGED_HARNESS_ROLLBACK:-0}" +CODEX_EXECUTABLE_AVAILABLE=0 +MCP_ROLLBACK_STATUS=0 +HOOK_ROLLBACK_STATUS=0 +PROVIDER_ROLLBACK_STATUS=0 # The private installer materializes this owner-controlled bundle once. Native # configuration must refer to it, never to a selected immutable runtime stage. @@ -48,35 +61,52 @@ elif [ "${JARVOS_MANAGED_HARNESS_ROLLBACK:-0}" = "1" ] && [ -n "$STEWARDSHIP_STA STEWARDSHIP_DISPATCHER="$STEWARDSHIP_STABLE_ROOT/jarvos-stewardship-dispatcher" fi -if ! command -v codex >/dev/null 2>&1; then +if command -v "$CODEX_EXECUTABLE" >/dev/null 2>&1; then + CODEX_EXECUTABLE="$(command -v "$CODEX_EXECUTABLE")" + case "$CODEX_EXECUTABLE" in + /*) ;; + *) CODEX_EXECUTABLE="$(cd "$(dirname "$CODEX_EXECUTABLE")" && pwd)/$(basename "$CODEX_EXECUTABLE")" ;; + esac + CODEX_EXECUTABLE_AVAILABLE=1 + JARVOS_CODEX_EXECUTABLE="$CODEX_EXECUTABLE" + export JARVOS_CODEX_EXECUTABLE +elif [ "$ROLLBACK_MODE" = "1" ]; then + echo "Codex CLI is unavailable; continuing rollback phases that do not require it." >&2 +else echo "codex CLI not found on PATH" >&2 exit 1 fi -if [ ! -f "$MCP_SERVER" ]; then - echo "jarvOS MCP server not found: $MCP_SERVER" >&2 - exit 1 -fi +if [ "$ROLLBACK_MODE" != "1" ]; then + if [ ! -f "$MCP_SERVER" ]; then + echo "jarvOS MCP server not found: $MCP_SERVER" >&2 + exit 1 + fi -if [ ! -f "$MANAGED_HOOKS_JSON" ]; then - echo "jarvOS Codex hooks config not found: $MANAGED_HOOKS_JSON" >&2 - exit 1 -fi + if [ ! -f "$MANAGED_HOOKS_JSON" ]; then + echo "jarvOS Codex hooks config not found: $MANAGED_HOOKS_JSON" >&2 + exit 1 + fi -if [ ! -f "$HOOK_SCRIPT" ]; then - echo "jarvOS Codex hook script not found: $HOOK_SCRIPT" >&2 - exit 1 -fi + if [ ! -f "$HOOK_SCRIPT" ]; then + echo "jarvOS Codex hook script not found: $HOOK_SCRIPT" >&2 + exit 1 + fi -if [ ! -f "$TURN_HOOK_SCRIPT" ]; then - echo "jarvOS Codex turn hook script not found: $TURN_HOOK_SCRIPT" >&2 - exit 1 -fi + if [ ! -f "$TURN_HOOK_SCRIPT" ]; then + echo "jarvOS Codex turn hook script not found: $TURN_HOOK_SCRIPT" >&2 + exit 1 + fi -if [ ! -f "$TRUST_SCRIPT" ]; then - echo "jarvOS Codex hook trust script not found: $TRUST_SCRIPT" >&2 - exit 1 -fi + if [ ! -f "$TRUST_SCRIPT" ]; then + echo "jarvOS Codex hook trust script not found: $TRUST_SCRIPT" >&2 + exit 1 + fi + + if [ ! -f "$MCP_RECEIPT_MODULE" ]; then + echo "jarvOS Codex MCP receipt helper not found: $MCP_RECEIPT_MODULE" >&2 + exit 1 + fi # The stable entrypoint is what setup registers with Codex, so it must be # validated to the same bar as the stable stewardship dispatcher: absolute, @@ -227,30 +257,355 @@ append_optional_mcp_env JARVOS_COMMON_WORK_SERVICE_MODULE "${JARVOS_COMMON_WORK_ MCP_HAS_HOST_BINDING=${#MCP_ENV_ARGS[@]} MCP_ENV_ARGS+=(--env "JARVOS_COMMON_WORK_HARNESS=codex") -if [ "${JARVOS_STEWARDSHIP_ONLY:-0}" != "1" ]; then - if codex mcp get jarvos >/dev/null 2>&1; then - codex mcp remove jarvos >/dev/null +# Managed provider admission is checked before the first profile write. The +# provider manager repeats this check immediately before activation below. + REQUESTED_PROVIDER_MODE="$CODEX_PROVIDER_MODE" + if [ -z "$REQUESTED_PROVIDER_MODE" ] && [ "${JARVOS_PROFILE:-}" = "codex" ]; then + REQUESTED_PROVIDER_MODE="new-managed" + fi + case "$REQUESTED_PROVIDER_MODE" in + ""|existing|disabled) ;; + *) + JARVOS_CODEX_PROVIDER_MODE="$REQUESTED_PROVIDER_MODE" \ + JARVOS_CODEX_PROVIDER_PREFLIGHT=1 \ + node "$ROOT/runtimes/codex/compound-engineering-activation.js" + ;; + esac +fi + +codex_mcp_fingerprint() { + local payload fingerprint list_payload list_state + if ! payload="$("$CODEX_EXECUTABLE" mcp get jarvos --json 2>/dev/null)"; then + if ! list_payload="$("$CODEX_EXECUTABLE" mcp list --json 2>/dev/null)"; then + return 2 + fi + if ! list_state="$(printf '%s' "$list_payload" | node "$MCP_RECEIPT_MODULE" list-state)"; then + return 2 + fi + if [ "$list_state" = "absent" ]; then + return 1 + fi + return 2 + fi + if ! fingerprint="$(printf '%s' "$payload" | node "$MCP_RECEIPT_MODULE" observe)"; then + return 2 + fi + printf '%s' "$fingerprint" +} + +release_mcp_lock() { + if [ "${MCP_LOCK_HELD:-0}" = "1" ]; then + rmdir "$MCP_LOCK_PATH" 2>/dev/null || true + MCP_LOCK_HELD=0 + fi +} + +# `codex mcp remove` has no expected-fingerprint/CAS guard. Rollback therefore +# uses Codex's app-server config transaction when it is available: read the +# exact user-layer version, verify that layer's jarvos entry, and submit an +# atomic expectedVersion edit. Any unavailable, changed, or conflicting state +# is preserved for manual reconciliation. There is deliberately no CLI remove +# fallback: a successful exit from a name-only command is not proof that the +# registration was still the one jarvOS created. +codex_mcp_cas_remove() { + local desired_fingerprint="$1" + node - "$ROOT" "$MCP_RECEIPT_MODULE" "$CODEX_EXECUTABLE" "$CODEX_CONFIG" "$desired_fingerprint" <<'NODE' +const fs = require('node:fs'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const [root, receiptModule, executable, configPath, desiredFingerprint] = process.argv.slice(2); +const { fingerprintRegistration } = require(receiptModule); +const targetConfig = path.resolve(configPath); +let child; +let buffer = ''; +let completed = false; +let timer; + +function samePath(left, right) { + try { return fs.realpathSync(left) === fs.realpathSync(right); } + catch (_) { return path.resolve(left) === path.resolve(right); } +} + +function fail(message) { + if (completed) return; + completed = true; + if (timer) clearTimeout(timer); + if (child && !child.killed) child.kill(); + process.stderr.write(`${message}\n`); + process.exitCode = 1; +} + +function finish() { + if (completed) return; + completed = true; + if (timer) clearTimeout(timer); + if (child && !child.killed) child.kill(); +} + +function send(message) { + if (!child || child.killed || !child.stdin.writable) return fail('Codex app-server became unavailable during MCP rollback'); + child.stdin.write(`${JSON.stringify(message)}\n`); +} + +function handle(message) { + if (completed) return; + if (message.error) { + if (message.id === 3) return fail('Codex app-server CAS conflict; preserving the jarvOS MCP registration and receipt'); + return fail('Codex app-server could not inspect the Codex configuration; preserving the jarvOS MCP registration and receipt'); + } + if (message.id === 1) { + send({ method: 'initialized', params: {} }); + send({ method: 'config/read', id: 2, params: { includeLayers: true } }); + return; + } + if (message.id === 2) { + const layers = message.result?.layers; + if (!Array.isArray(layers)) return fail('Codex app-server returned no versioned configuration layers; preserving the jarvOS MCP registration and receipt'); + const layer = layers.find((entry) => entry?.name?.type === 'user' && typeof entry.name.file === 'string' && samePath(entry.name.file, targetConfig)); + if (!layer || typeof layer.version !== 'string') return fail('The configured Codex user layer was not available for CAS rollback; preserving the jarvOS MCP registration and receipt'); + const registration = layer.config?.mcp_servers?.jarvos; + if (!registration) return fail('The jarvOS MCP registration is not present in the configured user layer; preserving its receipt for manual reconciliation'); + let fingerprint; + try { fingerprint = fingerprintRegistration(registration); } + catch (_) { return fail('The configured jarvOS MCP registration is not fingerprintable; preserving it and its receipt'); } + if (fingerprint !== desiredFingerprint) return fail('The configured jarvOS MCP registration changed before CAS rollback; preserving it and its receipt'); + send({ + method: 'config/batchWrite', + id: 3, + params: { + filePath: targetConfig, + edits: [{ keyPath: 'mcp_servers.jarvos', value: null, mergeStrategy: 'replace' }], + expectedVersion: layer.version, + reloadUserConfig: true, + }, + }); + return; + } + if (message.id === 3) { + const result = message.result; + if (!result || !['ok', 'okOverridden'].includes(result.status) || typeof result.version !== 'string' + || typeof result.filePath !== 'string' || !samePath(result.filePath, targetConfig)) { + return fail('Codex app-server did not confirm the expected atomic MCP rollback; preserving the registration and receipt'); + } + finish(); + } +} + +try { + child = spawn(executable, ['app-server', '--listen', 'stdio://'], { + cwd: root, + env: process.env, + stdio: ['pipe', 'pipe', 'ignore'], + }); +} catch (_) { + fail('Codex app-server was unavailable for MCP CAS rollback'); +} + +if (!child) process.exitCode = 1; +else { + child.on('error', () => fail('Codex app-server was unavailable for MCP CAS rollback')); + child.on('exit', () => { + if (!completed) fail('Codex app-server exited before MCP CAS rollback completed'); + }); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (!line) continue; + let message; + try { message = JSON.parse(line); } catch (_) { continue; } + handle(message); + } + }); + timer = setTimeout(() => fail('Codex app-server timed out during MCP CAS rollback'), 30_000); + send({ + method: 'initialize', + id: 1, + params: { + clientInfo: { name: 'jarvos_setup', title: 'jarvOS Codex setup', version: '0.1.0' }, + capabilities: { experimentalApi: true }, + }, + }); +} +NODE +} + +rollback_mcp_registration() { + local desired_fingerprint receipt_state current_fingerprint observe_status + + if [ ! -e "$MCP_RECEIPT_PATH" ] && [ ! -L "$MCP_RECEIPT_PATH" ]; then + echo "No jarvOS-owned Codex MCP registration was found; preserving the profile." + return 0 + fi + + if ! desired_fingerprint="$(node "$MCP_RECEIPT_MODULE" fingerprint "$MCP_RECEIPT_PATH" "$CODEX_HOME")"; then + echo "The jarvOS MCP receipt is corrupt or unsafe; preserving it and the MCP registration for manual reconciliation." >&2 + return 1 + fi + if ! receipt_state="$(node "$MCP_RECEIPT_MODULE" state "$MCP_RECEIPT_PATH" "$CODEX_HOME" "$desired_fingerprint")"; then + echo "The jarvOS MCP receipt could not be validated; preserving it and the MCP registration for manual reconciliation." >&2 + return 1 + fi + if ! node "$MCP_RECEIPT_MODULE" profile "$CODEX_HOME" >/dev/null; then + echo "The recorded Codex profile could not be validated; preserving the MCP registration and receipt." >&2 + return 1 fi - if [ ${#MCP_ENV_ARGS[@]} -gt 0 ]; then - codex mcp add "${MCP_ENV_ARGS[@]}" jarvos -- "${MCP_COMMAND[@]}" - if [ "$MCP_HAS_HOST_BINDING" -gt 0 ]; then - echo "Registered jarvOS MCP server for Codex with host bindings: ${MCP_COMMAND[*]}" - else - echo "Registered jarvOS MCP server for Codex: ${MCP_COMMAND[*]}" + if ! mkdir -m 700 "$MCP_LOCK_PATH" 2>/dev/null; then + echo "Another jarvOS Codex MCP setup or rollback is already in progress." >&2 + return 1 + fi + MCP_LOCK_HELD=1 + trap release_mcp_lock EXIT + + if current_fingerprint="$(codex_mcp_fingerprint)"; then + observe_status=0 + else + observe_status=$? + fi + + if [ "$observe_status" -eq 1 ]; then + if ! node "$MCP_RECEIPT_MODULE" clear "$MCP_RECEIPT_PATH" "$CODEX_HOME" "$desired_fingerprint"; then + echo "The jarvOS MCP registration is absent, but its receipt could not be cleared; preserving the receipt." >&2 + release_mcp_lock + trap - EXIT + return 1 + fi + echo "The recorded jarvOS MCP registration is already absent; cleared its receipt." + release_mcp_lock + trap - EXIT + return 0 + fi + + if [ "$observe_status" -eq 2 ]; then + echo "Could not verify the current jarvOS MCP registration; preserving it and its receipt." >&2 + release_mcp_lock + trap - EXIT + return 1 + fi + + if [ "$current_fingerprint" != "$desired_fingerprint" ]; then + echo "The jarvOS MCP registration changed after setup; preserving it and its receipt." >&2 + release_mcp_lock + trap - EXIT + return 1 + fi + + if ! codex_mcp_cas_remove "$desired_fingerprint"; then + release_mcp_lock + trap - EXIT + return 1 + fi + + if ! node "$MCP_RECEIPT_MODULE" clear "$MCP_RECEIPT_PATH" "$CODEX_HOME" "$desired_fingerprint"; then + echo "The MCP registration was removed by CAS, but its receipt could not be cleared; preserving the receipt." >&2 + release_mcp_lock + trap - EXIT + return 1 + fi + echo "Removed the recorded jarvOS MCP registration through an atomic Codex app-server CAS transaction." + release_mcp_lock + trap - EXIT + return 0 +} + +if [ "${JARVOS_STEWARDSHIP_ONLY:-0}" != "1" ]; then + if [ "${JARVOS_MANAGED_HARNESS_ROLLBACK:-0}" = "1" ]; then + if { [ ! -e "$MCP_RECEIPT_PATH" ] && [ ! -L "$MCP_RECEIPT_PATH" ]; }; then + rollback_mcp_registration + elif [ "$CODEX_EXECUTABLE_AVAILABLE" -ne 1 ] || [ ! -f "$MCP_RECEIPT_MODULE" ]; then + echo "Codex MCP rollback prerequisites are unavailable; preserving the registration and receipt while continuing other rollback phases." >&2 + MCP_ROLLBACK_STATUS=1 + elif ! rollback_mcp_registration; then + MCP_ROLLBACK_STATUS=1 fi else - codex mcp add jarvos -- "${MCP_COMMAND[@]}" - echo "Registered jarvOS MCP server for Codex: ${MCP_COMMAND[*]}" + MCP_DESIRED_FINGERPRINT="" + MCP_RECEIPT_STATE="missing" + MCP_DESIRED_FINGERPRINT="$(node "$MCP_RECEIPT_MODULE" desired-cli "${MCP_ENV_ARGS[@]}" -- "${MCP_COMMAND[@]}")" + if [ -e "$MCP_RECEIPT_PATH" ] || [ -L "$MCP_RECEIPT_PATH" ]; then + MCP_RECEIPT_STATE="$(node "$MCP_RECEIPT_MODULE" state "$MCP_RECEIPT_PATH" "$CODEX_HOME" "$MCP_DESIRED_FINGERPRINT")" + fi + + if [ "$MCP_RECEIPT_STATE" != "missing" ] || [ "${JARVOS_MANAGED_HARNESS_ROLLBACK:-0}" != "1" ]; then + node "$MCP_RECEIPT_MODULE" profile "$CODEX_HOME" create >/dev/null + if ! mkdir -m 700 "$MCP_LOCK_PATH" 2>/dev/null; then + echo "Another jarvOS Codex MCP setup or rollback is already in progress." >&2 + exit 1 + fi + MCP_LOCK_HELD=1 + trap release_mcp_lock EXIT + + set +e + MCP_CURRENT_FINGERPRINT="$(codex_mcp_fingerprint)" + MCP_OBSERVE_STATUS=$? + set -e + + if [ "$MCP_RECEIPT_STATE" = "missing" ]; then + if [ "$MCP_OBSERVE_STATUS" -eq 0 ]; then + echo "Codex already has an MCP registration named jarvos; preserving it because this setup did not create it." >&2 + exit 1 + elif [ "$MCP_OBSERVE_STATUS" -eq 2 ]; then + echo "Could not inspect the existing jarvOS MCP registration; preserving it." >&2 + exit 1 + fi + MCP_RECEIPT_STATE="$(node "$MCP_RECEIPT_MODULE" claim "$MCP_RECEIPT_PATH" "$CODEX_HOME" "$MCP_DESIRED_FINGERPRINT")" + elif [ "$MCP_OBSERVE_STATUS" -eq 0 ]; then + if [ "$MCP_CURRENT_FINGERPRINT" != "$MCP_DESIRED_FINGERPRINT" ]; then + echo "The recorded jarvOS MCP registration no longer matches setup; preserving it." >&2 + exit 1 + fi + node "$MCP_RECEIPT_MODULE" activate "$MCP_RECEIPT_PATH" "$CODEX_HOME" "$MCP_DESIRED_FINGERPRINT" >/dev/null + echo "The recorded jarvOS MCP registration is already current." + MCP_RECEIPT_STATE="active" + elif [ "$MCP_OBSERVE_STATUS" -eq 2 ]; then + echo "Could not inspect the recorded jarvOS MCP registration; preserving it." >&2 + exit 1 + elif [ "$MCP_RECEIPT_STATE" = "active" ]; then + echo "The recorded jarvOS MCP registration is unexpectedly absent; preserving the receipt for reconciliation." >&2 + exit 1 + fi + + if [ "$MCP_RECEIPT_STATE" = "pending" ]; then + "$CODEX_EXECUTABLE" mcp add "${MCP_ENV_ARGS[@]}" jarvos -- "${MCP_COMMAND[@]}" || true + set +e + MCP_AFTER_ADD="$(codex_mcp_fingerprint)" + MCP_AFTER_ADD_STATUS=$? + set -e + if [ "$MCP_AFTER_ADD_STATUS" -eq 0 ] && [ "$MCP_AFTER_ADD" = "$MCP_DESIRED_FINGERPRINT" ]; then + node "$MCP_RECEIPT_MODULE" activate "$MCP_RECEIPT_PATH" "$CODEX_HOME" "$MCP_DESIRED_FINGERPRINT" >/dev/null + if [ "$MCP_HAS_HOST_BINDING" -gt 0 ]; then + echo "Registered jarvOS MCP server for Codex with host bindings: ${MCP_COMMAND[*]}" + else + echo "Registered jarvOS MCP server for Codex: ${MCP_COMMAND[*]}" + fi + elif [ "$MCP_AFTER_ADD_STATUS" -eq 1 ]; then + echo "Codex did not establish the requested jarvOS MCP registration; the pending receipt permits a safe retry." >&2 + exit 1 + else + echo "Codex reported a different jarvOS MCP registration; preserving it for manual reconciliation." >&2 + exit 1 + fi + fi + + release_mcp_lock + trap - EXIT + fi fi fi -mkdir -p "$(dirname "$CODEX_CONFIG")" +HOOK_PHASE_STATUS=0 +mkdir -p "$(dirname "$CODEX_CONFIG")" || HOOK_PHASE_STATUS=1 if [ ! -f "$CODEX_CONFIG" ]; then - touch "$CODEX_CONFIG" + touch "$CODEX_CONFIG" || HOOK_PHASE_STATUS=1 fi -node - "$CODEX_CONFIG" "$LEGACY_HOOKS_JSON" "$HOOK_SCRIPT" "$TURN_HOOK_SCRIPT" "$STEWARDSHIP_DISPATCHER" "${JARVOS_MANAGED_HARNESS_ROLLBACK:-0}" "$STEWARDSHIP_BRIDGE_COMMAND" "$STEWARDSHIP_CODEX_SESSION_MAP_ROOT" "${JARVOS_STAGED_PUBLIC_RUNTIME_ROOT:-}" <<'NODE' +if [ "$HOOK_PHASE_STATUS" -eq 0 ]; then + if ! node - "$CODEX_CONFIG" "$LEGACY_HOOKS_JSON" "$HOOK_SCRIPT" "$TURN_HOOK_SCRIPT" "$STEWARDSHIP_DISPATCHER" "${JARVOS_MANAGED_HARNESS_ROLLBACK:-0}" "$STEWARDSHIP_BRIDGE_COMMAND" "$STEWARDSHIP_CODEX_SESSION_MAP_ROOT" "${JARVOS_STAGED_PUBLIC_RUNTIME_ROOT:-}" <<'NODE' const fs = require('fs'); const path = require('path'); @@ -672,14 +1027,25 @@ if (next !== original || migrated) { console.log(`Codex config already has jarvOS hooks enabled: ${configPath}`); } NODE + then + HOOK_PHASE_STATUS=1 + fi +fi -if [ "$CODEX_CONFIG" = "$HOME/.codex/config.toml" ]; then +if [ "$HOOK_PHASE_STATUS" -ne 0 ]; then + if [ "${JARVOS_MANAGED_HARNESS_ROLLBACK:-0}" = "1" ]; then + HOOK_ROLLBACK_STATUS=1 + echo "Codex hook rollback did not complete; continuing with independent provider cleanup." >&2 + else + exit 1 + fi +elif [ "$ROLLBACK_MODE" != "1" ] && [ "$CODEX_CONFIG" = "$HOME/.codex/config.toml" ]; then if node "$TRUST_SCRIPT" "$ROOT" "$HOOK_SCRIPT" && node "$TRUST_SCRIPT" "$ROOT" "$TURN_HOOK_SCRIPT"; then echo "Trusted jarvOS Codex lifecycle hooks." else echo "Could not automatically trust jarvOS Codex lifecycle hooks; review them in Codex hooks settings." >&2 fi -else +elif [ "$ROLLBACK_MODE" != "1" ]; then echo "Skipping automatic hook trust for custom CODEX_CONFIG: $CODEX_CONFIG" fi @@ -690,5 +1056,16 @@ fi # Run this after the hook transaction so a provider reconciliation failure # cannot prevent jarvOS from rolling back its own lifecycle configuration. if [ -n "$CODEX_PROVIDER_MODE" ] || [ "${JARVOS_PROFILE:-}" = "codex" ] || [ "${JARVOS_MANAGED_HARNESS_ROLLBACK:-0}" = "1" ]; then - node "$ROOT/runtimes/codex/compound-engineering-activation.js" + if [ "${JARVOS_MANAGED_HARNESS_ROLLBACK:-0}" = "1" ]; then + if ! node "$ROOT/runtimes/codex/compound-engineering-activation.js"; then + PROVIDER_ROLLBACK_STATUS=1 + fi + else + node "$ROOT/runtimes/codex/compound-engineering-activation.js" + fi +fi + +if [ "$MCP_ROLLBACK_STATUS" -ne 0 ] || [ "$HOOK_ROLLBACK_STATUS" -ne 0 ] || [ "$PROVIDER_ROLLBACK_STATUS" -ne 0 ]; then + echo "Codex rollback incomplete: mcp=$MCP_ROLLBACK_STATUS hooks=$HOOK_ROLLBACK_STATUS provider=$PROVIDER_ROLLBACK_STATUS. Review preserved subsystem state for manual reconciliation." >&2 + exit 1 fi