diff --git a/modules/jarvos-agent-context/test/agent-context.test.js b/modules/jarvos-agent-context/test/agent-context.test.js index ea389d55..869856a5 100644 --- a/modules/jarvos-agent-context/test/agent-context.test.js +++ b/modules/jarvos-agent-context/test/agent-context.test.js @@ -958,8 +958,25 @@ test('Codex setup registers only credential file path, never the secret value', assert.doesNotMatch(source, /CONTROL_PLANE_CREDENTIAL="\$\{JARVOS_CONTROL_PLANE_CREDENTIAL(?!_FILE)/); }); -// Executable setup.sh branches with a fake codex on PATH and a temp CODEX_CONFIG. -// Never mutates the real ~/.codex/config.toml. +function renderFakeCodexToml(config) { + const scalar = (value) => { + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'boolean' || typeof value === 'number') return String(value); + if (Array.isArray(value)) return `[${value.map(scalar).join(', ')}]`; + return `{ ${Object.entries(value).map(([key, item]) => `${key} = ${scalar(item)}`).join(', ')} }`; + }; + const sections = []; + const visit = (value, keys) => { + const direct = Object.entries(value).filter(([, item]) => item === null || typeof item !== 'object' || Array.isArray(item)); + if (keys.length && direct.length) sections.push(`[${keys.join('.')}]\n${direct.map(([key, item]) => `${key} = ${scalar(item)}`).join('\n')}`); + for (const [key, item] of Object.entries(value)) if (item && typeof item === 'object' && !Array.isArray(item)) visit(item, [...keys, key]); + }; + visit(config, []); + return sections.length ? `${sections.join('\n\n')}\n` : ''; +} + +// Executable setup.sh branches with a semantic fake app-server and a temp +// CODEX_CONFIG. Never mutates the real ~/.codex/config.toml. function runCodexSetup(envOverrides = {}) { const repoRoot = path.join(__dirname, '..', '..', '..'); const setupPath = path.join(repoRoot, 'runtimes', 'codex', 'setup.sh'); @@ -971,10 +988,20 @@ function runCodexSetup(envOverrides = {}) { 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'); + const configModelPath = path.join(tmp, 'codex-config-model.json'); + const rpcLog = path.join(tmp, 'codex-rpc.log'); fs.mkdirSync(binDir, { recursive: true }); fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 }); - fs.writeFileSync(configPath, '', 'utf8'); - // Fake Codex records invocations and persists only a disposable MCP model. + const initialConfigModel = envOverrides.FAKE_CODEX_CONFIG_INITIAL_MODEL + ? JSON.parse(envOverrides.FAKE_CODEX_CONFIG_INITIAL_MODEL) : {}; + const writeConfigModel = (value) => { + fs.writeFileSync(configModelPath, JSON.stringify(value), 'utf8'); + fs.writeFileSync(configPath, renderFakeCodexToml(value), 'utf8'); + }; + const readConfigModel = () => JSON.parse(fs.readFileSync(configModelPath, 'utf8')); + writeConfigModel(initialConfigModel); + // Fake Codex records invocations and persists disposable semantic user-layer + // and MCP models with content-derived versions. const fakeCodex = [ '#!/usr/bin/env node', "const fs = require('node:fs');", @@ -985,40 +1012,61 @@ function runCodexSetup(envOverrides = {}) { `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\');', + " const crypto = require('node:crypto');", + ` const modelPath = ${JSON.stringify(configModelPath)};`, + ` const configFile = ${JSON.stringify(configPath)};`, + " const canonicalConfigFile = fs.realpathSync(configFile);", + ` const rpcLogPath = ${JSON.stringify(rpcLog)};`, + " const readModel = () => JSON.parse(fs.readFileSync(modelPath, 'utf8'));", + " const scalar = (value) => { if (typeof value === 'string') return JSON.stringify(value); if (typeof value === 'boolean' || typeof value === 'number') return String(value); if (Array.isArray(value)) return `[${value.map(scalar).join(', ')}]`; return `{ ${Object.entries(value).map(([key, item]) => `${key} = ${scalar(item)}`).join(', ')} }`; };", + " const render = (config) => { const sections = []; const visit = (value, keys) => { const direct = Object.entries(value).filter(([, item]) => item === null || typeof item !== 'object' || Array.isArray(item)); if (keys.length && direct.length) sections.push(`[${keys.join('.')}]\\n${direct.map(([key, item]) => `${key} = ${scalar(item)}`).join('\\n')}`); for (const [key, item] of Object.entries(value)) if (item && typeof item === 'object' && !Array.isArray(item)) visit(item, [...keys, key]); }; visit(config, []); return sections.length ? `${sections.join('\\n\\n')}\\n` : ''; };", + " const writeModel = (value) => { fs.writeFileSync(modelPath, JSON.stringify(value)); fs.writeFileSync(configFile, render(value)); };", + " const registration = () => fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, 'utf8')).transport : null;", + " const userConfig = () => { const value = JSON.parse(JSON.stringify(readModel())); const current = registration(); if (current) { value.mcp_servers ||= {}; value.mcp_servers.jarvos = current; } return value; };", + " const version = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');", + " const setAt = (value, keyPath, replacement) => { const keys = keyPath.split('.'); let cursor = value; for (const key of keys.slice(0, -1)) cursor = cursor[key] ||= {}; const leaf = keys.at(-1); if (replacement === null) delete cursor[leaf]; else cursor[leaf] = replacement; };", + " const owned = new Set(['hooks', 'features', 'shell_environment_policy', 'shell_environment_policy.set', 'hooks.SessionStart', 'hooks.UserPromptSubmit', 'features.hooks', 'features.codex_hooks', 'shell_environment_policy.set.JARVOS_STEWARDSHIP_BRIDGE_COMMAND', 'shell_environment_policy.set.JARVOS_STEWARDSHIP_CODEX_SESSION_MAP_ROOT', 'shell_environment_policy.set.JARVOS_STEWARDSHIP_BRIDGE_CONTEXT_FILE']);", + " 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;', + " 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; }", + " fs.appendFileSync(rpcLogPath, `${message.method} ${JSON.stringify(message.params || {})}\\n`);", " 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;', - ' }', + " const config = userConfig(); const layers = [{ name: { type: 'user', file: canonicalConfigFile, profile: null }, version: version(config), config }];", + " if (process.env.FAKE_CODEX_HOOK_APP_SERVER_MODE === 'overridden') layers.push({ name: { type: 'system' }, version: 'higher', config: { features: { hooks: false } } });", + " emit({ id: message.id, result: { config, origins: {}, layers } }); 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: {} } }));", + " const params = message.params || {}; const edits = params.edits || []; const before = userConfig();", + " const isMcp = edits.length === 1 && edits[0].keyPath === 'mcp_servers.jarvos';", + " const validHook = edits.length > 0 && edits.every((edit) => owned.has(edit.keyPath));", + " const expectedFile = isMcp ? configFile : canonicalConfigFile;", + " if (params.filePath !== expectedFile || params.expectedVersion !== version(before) || (!isMcp && !validHook) || edits.some((edit) => edit.mergeStrategy !== 'replace')) { emit({ id: message.id, error: { code: -32009, message: 'configuration version conflict' } }); continue; }", + " const mode = isMcp ? process.env.FAKE_CODEX_APP_SERVER_MODE : (process.env.FAKE_CODEX_HOOK_APP_SERVER_MODE || 'success');", + " if (isMcp && !['success', 'success-readd', 'conflict', 'overridden'].includes(mode)) { emit({ id: message.id, error: { code: -32000, message: 'app-server CAS unavailable' } }); continue; }", + " if (mode === 'conflict') {", + " if (isMcp) fs.writeFileSync(statePath, JSON.stringify({ name: 'jarvos', transport: { type: 'stdio', command: 'concurrent-replacement', args: [], env: {} } }));", + " else { const concurrent = readModel(); concurrent.unrelated ||= {}; concurrent.unrelated.concurrent = 'kept'; writeModel(concurrent); }", " 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 (isMcp) {", + " if (mode === 'overridden') fs.writeFileSync(statePath, JSON.stringify({ name: 'jarvos', transport: { type: 'stdio', command: 'higher-layer-override', args: [], env: {} } }));", + " else { try { fs.unlinkSync(statePath); } catch (error) { if (error.code !== 'ENOENT') throw error; } if (mode === 'success-readd') fs.writeFileSync(statePath, JSON.stringify({ name: 'jarvos', transport: { type: 'stdio', command: 'foreign-readd', args: [], env: {} } })); }", + " } else { const model = readModel(); for (const edit of edits) setAt(model, edit.keyPath, edit.value); writeModel(model); }", + " const status = mode === 'overridden' ? 'okOverridden' : 'ok';", + " emit({ id: message.id, result: { status, version: version(userConfig()), filePath: expectedFile, overriddenMetadata: status === 'okOverridden' ? { keyPath: edits[0].keyPath } : 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); }", @@ -1073,6 +1121,9 @@ function runCodexSetup(envOverrides = {}) { 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'); + if (envOverrides.FAKE_CODEX_LEGACY_HOOKS) { + fs.writeFileSync(path.join(codexHome, 'hooks.json'), envOverrides.FAKE_CODEX_LEGACY_HOOKS, 'utf8'); + } const env = { ...process.env, @@ -1110,14 +1161,19 @@ function runCodexSetup(envOverrides = {}) { tmp, codexHome, configPath, + configModelPath, + rpcLog, codexLog, fakeCodexPath, mcpStatePath, providerCliStatePath, providerStatePath, receiptPath: path.join(codexHome, 'jarvos-codex-mcp-receipt.json'), + hookFeatureReceiptPath: path.join(codexHome, 'jarvos-codex-hook-feature-receipt.json'), result, rerun: invoke, + readConfigModel, + writeConfigModel, cleanup() { fs.rmSync(tmp, { recursive: true, force: true }); }, @@ -1197,7 +1253,7 @@ test('Codex setup preserves a present MCP registration when app-server CAS is un 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.filter((entry) => entry === 'app-server --listen stdio://').length, 4); 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 { @@ -1283,7 +1339,7 @@ test('Codex MCP rollback clears an active receipt when its registration is alrea 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:\/\//); + assert.match(commands, /app-server --listen stdio:\/\//, 'hook rollback still uses its semantic app-server transaction'); } finally { run.cleanup(); } @@ -1411,7 +1467,9 @@ test('Codex hook rollback failure does not block MCP or provider rollback', () = 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 drifted = run.readConfigModel(); + drifted.hooks.SessionStart = 'not-an-array'; + run.writeConfigModel(drifted); const result = run.rerun({ JARVOS_MANAGED_HARNESS_ROLLBACK: '1' }); assert.notEqual(result.status, 0); @@ -1429,6 +1487,285 @@ test('Codex hook rollback failure does not block MCP or provider rollback', () = } }); +test('Codex hook-feature rollback restores exact owned key presence and values', () => { + const initialModel = { + features: { hooks: false, codex_hooks: true, unrelated: true }, + hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'user-session-start' }] }] }, + }; + const run = runCodexSetup({ + FAKE_CODEX_APP_SERVER_MODE: 'success', + FAKE_CODEX_CONFIG_INITIAL_MODEL: JSON.stringify(initialModel), + }); + try { + assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); + assert.equal(fs.statSync(run.hookFeatureReceiptPath).mode & 0o777, 0o600); + const ownership = JSON.parse(fs.readFileSync(run.hookFeatureReceiptPath, 'utf8')); + assert.equal(ownership.schemaVersion, 'jarvos-codex-hook-feature-receipt/v4'); + assert.deepEqual(ownership.before['features.hooks'], { present: true, value: false }); + assert.deepEqual(ownership.before['hooks.UserPromptSubmit'], { present: false, value: null }); + assert.deepEqual(Object.keys(ownership.before).sort(), [ + 'features.codex_hooks', + 'features.hooks', + 'hooks.SessionStart', + 'hooks.UserPromptSubmit', + 'shell_environment_policy.set.JARVOS_STEWARDSHIP_BRIDGE_COMMAND', + 'shell_environment_policy.set.JARVOS_STEWARDSHIP_BRIDGE_CONTEXT_FILE', + 'shell_environment_policy.set.JARVOS_STEWARDSHIP_CODEX_SESSION_MAP_ROOT', + ]); + assert.deepEqual(ownership.parentBefore, { + hooks: true, + features: true, + shell_environment_policy: false, + 'shell_environment_policy.set': false, + }); + const configured = fs.readFileSync(run.configPath, 'utf8'); + assert.match(configured, /hooks = true/); + assert.doesNotMatch(configured, /codex_hooks = true/); + assert.match(configured, /jarvos-session-start-hook\.js/); + + const result = run.rerun({ JARVOS_MANAGED_HARNESS_ROLLBACK: '1' }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(run.hookFeatureReceiptPath), false); + assert.deepEqual(run.readConfigModel(), initialModel); + } finally { + run.cleanup(); + } +}); + +test('Codex hook-feature rollback removes only originally absent empty parent tables', () => { + 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.readFileSync(run.configPath, 'utf8'), ''); + assert.deepEqual(run.readConfigModel(), {}); + } finally { + run.cleanup(); + } +}); + +test('Codex hook-feature rollback preserves unrelated keys concurrently added inside owned parents', () => { + const run = runCodexSetup({ + FAKE_CODEX_APP_SERVER_MODE: 'success', + JARVOS_STEWARDSHIP_BRIDGE_COMMAND: 'jarvos-bridge', + JARVOS_STEWARDSHIP_CODEX_SESSION_MAP_ROOT: '/private/jarvos-map', + }); + try { + assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); + const concurrent = run.readConfigModel(); + concurrent.hooks.OtherEvent = [{ hooks: [{ type: 'command', command: 'keep-hook' }] }]; + concurrent.features.keep_feature = true; + concurrent.shell_environment_policy.inherit = 'all'; + concurrent.shell_environment_policy.set.USER_VALUE = 'keep'; + run.writeConfigModel(concurrent); + + const result = run.rerun({ JARVOS_MANAGED_HARNESS_ROLLBACK: '1' }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.deepEqual(run.readConfigModel(), { + hooks: { OtherEvent: [{ hooks: [{ type: 'command', command: 'keep-hook' }] }] }, + features: { keep_feature: true }, + shell_environment_policy: { set: { USER_VALUE: 'keep' }, inherit: 'all' }, + }); + } finally { + run.cleanup(); + } +}); + +test('Codex hook-feature rollback preserves changed managed sections but still completes MCP cleanup', () => { + const run = runCodexSetup({ FAKE_CODEX_APP_SERVER_MODE: 'success' }); + try { + assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); + const changed = run.readConfigModel(); + changed.features.hooks = false; + run.writeConfigModel(changed); + + const result = run.rerun({ JARVOS_MANAGED_HARNESS_ROLLBACK: '1' }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /hook or feature state changed|hooks=1/i); + assert.equal(fs.existsSync(run.hookFeatureReceiptPath), true); + assert.deepEqual(run.readConfigModel(), changed); + assert.equal(fs.existsSync(run.receiptPath), false, 'independent MCP rollback should complete'); + assert.equal(fs.existsSync(run.mcpStatePath), false); + } finally { + run.cleanup(); + } +}); + +test('Codex hook-feature rollback fails closed for unreceipted jarvOS hook state', () => { + const run = runCodexSetup({ FAKE_CODEX_APP_SERVER_MODE: 'success' }); + try { + assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); + const configured = fs.readFileSync(run.configPath, 'utf8'); + fs.unlinkSync(run.hookFeatureReceiptPath); + + const result = run.rerun({ JARVOS_MANAGED_HARNESS_ROLLBACK: '1' }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /no jarvOS-owned hook-feature receipt.*preserving|hooks=1/i); + assert.equal(fs.readFileSync(run.configPath, 'utf8'), configured); + assert.equal(fs.existsSync(run.receiptPath), false, 'independent MCP rollback should complete'); + assert.equal(fs.existsSync(run.mcpStatePath), false); + } finally { + run.cleanup(); + } +}); + +test('Codex hook-feature rollback preserves corrupt receipt state while MCP cleanup completes', () => { + const run = runCodexSetup({ FAKE_CODEX_APP_SERVER_MODE: 'success' }); + try { + assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); + const configured = run.readConfigModel(); + fs.writeFileSync(run.hookFeatureReceiptPath, '{}\n', { encoding: 'utf8', mode: 0o600 }); + const result = run.rerun({ JARVOS_MANAGED_HARNESS_ROLLBACK: '1' }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /not a recognized jarvOS ownership record|hooks=1/i); + assert.deepEqual(run.readConfigModel(), configured); + assert.equal(fs.existsSync(run.hookFeatureReceiptPath), true); + assert.equal(fs.existsSync(run.receiptPath), false); + assert.equal(fs.existsSync(run.mcpStatePath), false); + } finally { + run.cleanup(); + } +}); + +test('Codex hook-feature receipt recovers a pending claim after the managed write', () => { + const run = runCodexSetup({ FAKE_CODEX_APP_SERVER_MODE: 'success' }); + try { + assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); + const receipt = JSON.parse(fs.readFileSync(run.hookFeatureReceiptPath, 'utf8')); + fs.writeFileSync(run.hookFeatureReceiptPath, JSON.stringify({ ...receipt, state: 'pending' }), { mode: 0o600 }); + const result = run.rerun(); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(JSON.parse(fs.readFileSync(run.hookFeatureReceiptPath, 'utf8')).state, 'active'); + } finally { + run.cleanup(); + } +}); + +test('Codex hook-feature rollback clears an active receipt after its completed write', () => { + const run = runCodexSetup({ FAKE_CODEX_APP_SERVER_MODE: 'success' }); + try { + assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); + const receipt = JSON.parse(fs.readFileSync(run.hookFeatureReceiptPath, 'utf8')); + run.writeConfigModel({}); + const result = run.rerun({ JARVOS_MANAGED_HARNESS_ROLLBACK: '1' }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(run.hookFeatureReceiptPath), false); + assert.equal(receipt.state, 'active'); + } finally { + run.cleanup(); + } +}); + +test('Codex hook-feature rollback preserves unrelated concurrent configuration', () => { + const run = runCodexSetup({ FAKE_CODEX_APP_SERVER_MODE: 'success' }); + try { + assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); + const concurrent = run.readConfigModel(); + concurrent.unrelated = { value: 'kept' }; + run.writeConfigModel(concurrent); + + const result = run.rerun({ JARVOS_MANAGED_HARNESS_ROLLBACK: '1' }); + assert.equal(result.status, 0, result.stderr || result.stdout); + const restored = fs.readFileSync(run.configPath, 'utf8'); + assert.match(restored, /\[unrelated\]\nvalue = "kept"/); + assert.doesNotMatch(restored, /\[hooks\]/); + assert.doesNotMatch(restored, /\[features\]/); + assert.equal(fs.existsSync(run.hookFeatureReceiptPath), false); + } finally { + run.cleanup(); + } +}); + +test('Codex hook setup uses one exact-version semantic batch and recovers a conflict without losing unrelated config', () => { + const run = runCodexSetup({ + FAKE_CODEX_APP_SERVER_MODE: 'success', + FAKE_CODEX_HOOK_APP_SERVER_MODE: 'conflict', + }); + try { + assert.notEqual(run.result.status, 0); + assert.match(run.result.stderr, /version-bound configuration transaction|preserving/i); + assert.equal(JSON.parse(fs.readFileSync(run.hookFeatureReceiptPath, 'utf8')).state, 'pending'); + assert.deepEqual(run.readConfigModel(), { unrelated: { concurrent: 'kept' } }); + + const recovered = run.rerun({ FAKE_CODEX_HOOK_APP_SERVER_MODE: 'success' }); + assert.equal(recovered.status, 0, recovered.stderr || recovered.stdout); + assert.equal(JSON.parse(fs.readFileSync(run.hookFeatureReceiptPath, 'utf8')).state, 'active'); + assert.equal(run.readConfigModel().unrelated.concurrent, 'kept'); + const batches = fs.readFileSync(run.rpcLog, 'utf8').trim().split('\n') + .filter((line) => line.startsWith('config/batchWrite ')) + .map((line) => JSON.parse(line.slice('config/batchWrite '.length))) + .filter((params) => params.edits.some((edit) => edit.keyPath === 'hooks.SessionStart')); + assert.equal(batches.length, 2, 'each attempt must submit exactly one hook batch'); + for (const batch of batches) { + assert.equal(batch.filePath, fs.realpathSync(run.configPath)); + assert.equal(typeof batch.expectedVersion, 'string'); + assert.ok(batch.expectedVersion.length > 0); + assert.ok(batch.edits.every((edit) => edit.mergeStrategy === 'replace')); + } + } finally { + run.cleanup(); + } +}); + +test('Codex hook transaction restores only owned nested keys and accepts higher-layer override status', () => { + const initialModel = { + shell_environment_policy: { + inherit: 'all', + set: { + USER_VALUE: 'keep', + JARVOS_STEWARDSHIP_BRIDGE_COMMAND: 'old-command', + JARVOS_STEWARDSHIP_CODEX_SESSION_MAP_ROOT: '/old/map', + JARVOS_STEWARDSHIP_BRIDGE_CONTEXT_FILE: '/old/context', + }, + }, + features: { hooks: false, codex_hooks: true, unrelated: true }, + }; + const run = runCodexSetup({ + FAKE_CODEX_APP_SERVER_MODE: 'success', + FAKE_CODEX_HOOK_APP_SERVER_MODE: 'overridden', + FAKE_CODEX_CONFIG_INITIAL_MODEL: JSON.stringify(initialModel), + JARVOS_STEWARDSHIP_BRIDGE_COMMAND: 'jarvos-bridge', + JARVOS_STEWARDSHIP_CODEX_SESSION_MAP_ROOT: '/private/jarvos-map', + }); + try { + assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); + const configured = run.readConfigModel(); + assert.equal(configured.shell_environment_policy.inherit, 'all'); + assert.equal(configured.shell_environment_policy.set.USER_VALUE, 'keep'); + assert.equal(configured.shell_environment_policy.set.JARVOS_STEWARDSHIP_BRIDGE_COMMAND, 'jarvos-bridge'); + assert.equal(Object.hasOwn(configured.shell_environment_policy.set, 'JARVOS_STEWARDSHIP_BRIDGE_CONTEXT_FILE'), false); + + const rollback = run.rerun({ + JARVOS_MANAGED_HARNESS_ROLLBACK: '1', + FAKE_CODEX_HOOK_APP_SERVER_MODE: 'overridden', + }); + assert.equal(rollback.status, 0, rollback.stderr || rollback.stdout); + const restored = run.readConfigModel(); + if (restored.hooks && Object.keys(restored.hooks).length === 0) delete restored.hooks; + assert.deepEqual(restored, initialModel); + assert.equal(fs.existsSync(run.hookFeatureReceiptPath), false); + } finally { + run.cleanup(); + } +}); + +test('Codex semantic setup leaves legacy hooks.json untouched and fails closed', () => { + const legacy = '{"hooks":{"SessionStart":[]}}\n'; + const run = runCodexSetup({ FAKE_CODEX_LEGACY_HOOKS: legacy }); + try { + assert.notEqual(run.result.status, 0); + assert.match(run.result.stderr, /legacy .*hooks\.json.*explicit semantic migration/i); + assert.equal(fs.readFileSync(path.join(run.codexHome, 'hooks.json'), 'utf8'), legacy); + assert.deepEqual(run.readConfigModel(), {}); + assert.equal(fs.existsSync(run.hookFeatureReceiptPath), false); + assert.equal(fs.existsSync(run.receiptPath), false); + assert.equal(fs.existsSync(run.mcpStatePath), false); + } finally { + run.cleanup(); + } +}); + test('Codex rollback ignores stale forward-install bindings', () => { const run = runCodexSetup({ FAKE_CODEX_APP_SERVER_MODE: 'success' }); try { @@ -1450,7 +1787,7 @@ test('Codex rollback ignores stale forward-install bindings', () => { } }); -test('Codex rollback without its CLI still performs independent hook cleanup', () => { +test('Codex rollback without its CLI preserves hook ownership while other cleanup remains independent', () => { const run = runCodexSetup(); try { assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); @@ -1462,7 +1799,8 @@ test('Codex rollback without its CLI still performs independent hook cleanup', ( 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/); + assert.match(fs.readFileSync(run.configPath, 'utf8'), /jarvos-session-start-hook\.js|jarvos-session-turn-hook\.js/); + assert.equal(fs.existsSync(run.hookFeatureReceiptPath), true, 'hook receipt must be preserved without app-server'); } finally { run.cleanup(); } 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 def69923..d03f4204 100644 --- a/modules/jarvos-agent-context/test/work-action-host.test.js +++ b/modules/jarvos-agent-context/test/work-action-host.test.js @@ -371,6 +371,17 @@ function writeFakeMcpCli(binPath, recordPath) { `const recordPath = ${JSON.stringify(recordPath)};`, "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`;", + "const hookStatePath = `${statePath}.hooks.json`;", + "if (args[0] === 'app-server') {", + " const configFile = fs.realpathSync(process.env.CODEX_CONFIG); const readHooks = () => fs.existsSync(hookStatePath) ? JSON.parse(fs.readFileSync(hookStatePath, 'utf8')) : {}; const version = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');", + " const setAt = (value, keyPath, replacement) => { const keys = keyPath.split('.'); let cursor = value; for (const key of keys.slice(0, -1)) cursor = cursor[key] ||= {}; if (replacement === null) delete cursor[keys.at(-1)]; else cursor[keys.at(-1)] = replacement; };", + " 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; const message = JSON.parse(line);", + " if (message.method === 'initialize') { emit({ id: message.id, result: { ok: true } }); continue; }", + " if (message.method === 'config/read') { const config = readHooks(); emit({ id: message.id, result: { config, origins: {}, layers: [{ name: { type: 'user', file: configFile, profile: null }, version: version(config), config }] } }); continue; }", + " if (message.method === 'config/batchWrite') { const config = readHooks(); if (message.params.filePath !== configFile || message.params.expectedVersion !== version(config)) process.exit(9); for (const edit of message.params.edits) setAt(config, edit.keyPath, edit.value); fs.writeFileSync(hookStatePath, JSON.stringify(config)); emit({ id: message.id, result: { status: 'ok', version: version(config), filePath: configFile, overriddenMetadata: null } }); }", + " } }); process.stdin.resume(); return;", + "}", "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); }", @@ -536,6 +547,8 @@ test('rerunning Codex setup from a different immutable runtime preserves the sam 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.copyFileSync(path.join(REPO_ROOT, 'runtimes', 'codex', 'hook-feature-receipt.js'), path.join(runtime2CodexDir, 'hook-feature-receipt.js')); + fs.copyFileSync(path.join(REPO_ROOT, 'runtimes', 'codex', 'hook-feature-transaction.js'), path.join(runtime2CodexDir, 'hook-feature-transaction.js')); fs.chmodSync(path.join(runtime2CodexDir, 'setup.sh'), 0o755); const runFrom = (setupPath) => { diff --git a/modules/jarvos-runtime-kit/src/index.js b/modules/jarvos-runtime-kit/src/index.js index 5089e52d..91b307c1 100644 --- a/modules/jarvos-runtime-kit/src/index.js +++ b/modules/jarvos-runtime-kit/src/index.js @@ -1057,8 +1057,17 @@ function validateManifest(manifest) { } } - if (manifest.configWrites && !manifest.configWrites.backupBeforeWrite) { - add(errors, 'configWrites.backupBeforeWrite must be true when configWrites is declared'); + if (manifest.configWrites + && manifest.configWrites.backupBeforeWrite !== true + && manifest.configWrites.atomicExpectedVersionWrite !== true) { + add(errors, 'configWrites must declare backupBeforeWrite or atomicExpectedVersionWrite'); + } + if (manifest.configWrites?.atomicExpectedVersionWrite === true + && (typeof manifest.configWrites.transactionScript !== 'string' + || !manifest.configWrites.transactionScript + || path.isAbsolute(manifest.configWrites.transactionScript) + || manifest.configWrites.transactionScript.split(/[\\/]+/).includes('..'))) { + add(errors, 'configWrites.transactionScript must be a safe relative path for atomicExpectedVersionWrite'); } const managedCompound = manifest.managedProviders?.['compound-engineering']; if (managedCompound !== undefined) { @@ -1226,6 +1235,19 @@ function checkRuntime(manifestPath, options = {}) { add(errors, 'setup script declares config writes but no backup behavior was detected'); } } + if (manifest.configWrites?.atomicExpectedVersionWrite) { + const transactionScript = path.resolve(path.dirname(manifestPath), manifest.configWrites.transactionScript); + const transactionRelative = path.relative(path.dirname(manifestPath), transactionScript); + if (transactionRelative.startsWith(`..${path.sep}`) || path.isAbsolute(transactionRelative) || !fs.existsSync(transactionScript)) { + add(errors, 'atomic config transaction script is missing or outside the runtime'); + } else { + const transactionSource = fs.readFileSync(transactionScript, 'utf8'); + if (![/config\/read/, /includeLayers/, /config\/batchWrite/, /expectedVersion/] + .every((pattern) => pattern.test(transactionSource))) { + add(errors, 'atomic config transaction must use a layered expectedVersion batch write'); + } + } + } if (manifest.controlPlane && fs.existsSync(setupScript)) { // Manifest-driven: require the declared host/credential env names and an diff --git a/modules/jarvos-runtime-kit/test/stewardship-live-adapters.test.js b/modules/jarvos-runtime-kit/test/stewardship-live-adapters.test.js index ece8e133..157cf92c 100644 --- a/modules/jarvos-runtime-kit/test/stewardship-live-adapters.test.js +++ b/modules/jarvos-runtime-kit/test/stewardship-live-adapters.test.js @@ -1002,6 +1002,48 @@ function count(content, pattern) { return (content.match(pattern) || []).length; } +function renderCodexTestToml(config) { + const scalar = (value) => { + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'boolean' || typeof value === 'number') return String(value); + if (Array.isArray(value)) return `[${value.map(scalar).join(', ')}]`; + return `{ ${Object.entries(value).map(([key, item]) => `${key} = ${scalar(item)}`).join(', ')} }`; + }; + const sections = []; + const visit = (value, keys) => { + const direct = Object.entries(value).filter(([, item]) => item === null || typeof item !== 'object' || Array.isArray(item)); + if (keys.length && direct.length) sections.push(`[${keys.join('.')}]\n${direct.map(([key, item]) => `${key} = ${scalar(item)}`).join('\n')}`); + for (const [key, item] of Object.entries(value)) if (item && typeof item === 'object' && !Array.isArray(item)) visit(item, [...keys, key]); + }; + visit(config, []); + return sections.length ? `${sections.join('\n\n')}\n` : ''; +} + +function writeFakeCodexAppServer(executable, configPath, initialModel = {}) { + const modelPath = `${configPath}.semantic.json`; + fs.writeFileSync(modelPath, JSON.stringify(initialModel)); + fs.writeFileSync(configPath, renderCodexTestToml(initialModel)); + fs.writeFileSync(executable, [ + '#!/usr/bin/env node', + "const fs = require('node:fs'); const crypto = require('node:crypto');", + `const configPath = ${JSON.stringify(configPath)}; const modelPath = ${JSON.stringify(modelPath)};`, + "const args = process.argv.slice(2); if (args[0] === 'mcp' && args[1] === 'get') process.exit(1); if (args[0] !== 'app-server') process.exit(0);", + "const scalar = (value) => { if (typeof value === 'string') return JSON.stringify(value); if (typeof value === 'boolean' || typeof value === 'number') return String(value); if (Array.isArray(value)) return `[${value.map(scalar).join(', ')}]`; return `{ ${Object.entries(value).map(([key, item]) => `${key} = ${scalar(item)}`).join(', ')} }`; };", + "const render = (config) => { const sections = []; const visit = (value, keys) => { const direct = Object.entries(value).filter(([, item]) => item === null || typeof item !== 'object' || Array.isArray(item)); if (keys.length && direct.length) sections.push(`[${keys.join('.')}]\\n${direct.map(([key, item]) => `${key} = ${scalar(item)}`).join('\\n')}`); for (const [key, item] of Object.entries(value)) if (item && typeof item === 'object' && !Array.isArray(item)) visit(item, [...keys, key]); }; visit(config, []); return sections.length ? `${sections.join('\\n\\n')}\\n` : ''; };", + "const read = () => JSON.parse(fs.readFileSync(modelPath, 'utf8')); const version = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');", + "const setAt = (value, keyPath, replacement) => { const keys = keyPath.split('.'); let cursor = value; for (const key of keys.slice(0, -1)) cursor = cursor[key] ||= {}; if (replacement === null) delete cursor[keys.at(-1)]; else cursor[keys.at(-1)] = replacement; };", + "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; const message = JSON.parse(line);", + "if (message.method === 'initialize') { emit({ id: message.id, result: { ok: true } }); continue; }", + "if (message.method === 'config/read') { const config = read(); emit({ id: message.id, result: { config, origins: {}, layers: [{ name: { type: 'user', file: fs.realpathSync(configPath), profile: null }, version: version(config), config }] } }); continue; }", + "if (message.method === 'config/batchWrite') { const config = read(); if (message.params.filePath !== fs.realpathSync(configPath) || message.params.expectedVersion !== version(config)) process.exit(9); for (const edit of message.params.edits) setAt(config, edit.keyPath, edit.value); fs.writeFileSync(modelPath, JSON.stringify(config)); fs.writeFileSync(configPath, render(config)); emit({ id: message.id, result: { status: 'ok', version: version(config), filePath: fs.realpathSync(configPath), overriddenMetadata: null } }); }", + '} }); process.stdin.resume();', + '', + ].join('\n'), { encoding: 'utf8', mode: 0o755 }); + fs.chmodSync(executable, 0o755); + return { modelPath, read: () => JSON.parse(fs.readFileSync(modelPath, 'utf8')) }; +} + function assertCodexParsesConfig(config) { const result = spawnSync('codex', ['features', 'list'], { encoding: 'utf8', @@ -1480,29 +1522,20 @@ test('Codex setup merges both jarvOS lifecycle hooks without replacing user hook for (const file of ['jarvos-session-start-hook.js', 'jarvos-session-turn-hook.js']) fs.copyFileSync(path.join(ROOT, 'runtimes', 'codex', file), path.join(staged, 'runtimes', 'codex', file)); fs.mkdirSync(bin, { recursive: true }); const codex = path.join(bin, 'codex'); - fs.writeFileSync(codex, [ - '#!/usr/bin/env sh', - 'if [ "$1" = "mcp" ] && [ "$2" = "get" ]; then exit 1; fi', - 'exit 0', - '', - ].join('\n'), { encoding: 'utf8', mode: 0o755 }); - fs.chmodSync(codex, 0o755); - fs.writeFileSync(config, [ - '[hooks]', - 'SessionStart = [{ matcher = "startup", hooks = [{ type = "command", command = "user-session-start" }] }]', - 'UserPromptSubmit = [{ hooks = [{ type = "command", command = "user-prompt-submit" }] }]', - '', - '[shell_environment_policy]', - 'set = { EXISTING = "keep" }', - '', - '[unrelated]', - 'value = true', - '', - ].join('\n'), 'utf8'); - let prior = fs.readFileSync(config, 'utf8'); - prior = prior.replace('SessionStart = [', `SessionStart = [{ matcher = "startup", hooks = [{ type = "command", command = ${JSON.stringify(`node ${JSON.stringify(path.join(staged, 'runtimes', 'codex', 'jarvos-session-start-hook.js'))}`)}, async = false, timeout = 30 }] }, `); - prior = prior.replace('UserPromptSubmit = [', `UserPromptSubmit = [{ hooks = [{ type = "command", command = ${JSON.stringify(`node ${JSON.stringify(path.join(staged, 'runtimes', 'codex', 'jarvos-session-turn-hook.js'))}`)}, async = false, timeout = 30 }] }, `); - fs.writeFileSync(config, prior); + writeFakeCodexAppServer(codex, config, { + hooks: { + SessionStart: [ + { matcher: 'startup', hooks: [{ type: 'command', command: `node ${JSON.stringify(path.join(staged, 'runtimes', 'codex', 'jarvos-session-start-hook.js'))}`, async: false, timeout: 30 }] }, + { matcher: 'startup', hooks: [{ type: 'command', command: 'user-session-start' }] }, + ], + UserPromptSubmit: [ + { hooks: [{ type: 'command', command: `node ${JSON.stringify(path.join(staged, 'runtimes', 'codex', 'jarvos-session-turn-hook.js'))}`, async: false, timeout: 30 }] }, + { hooks: [{ type: 'command', command: 'user-prompt-submit' }] }, + ], + }, + shell_environment_policy: { set: { EXISTING: 'keep' } }, + unrelated: { value: true }, + }); const env = { ...cleanEnv(), HOME: path.join(temp, 'home'), @@ -1536,7 +1569,6 @@ test('Codex setup merges both jarvOS lifecycle hooks without replacing user hook assert.doesNotMatch(second, new RegExp(staged.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); assert.doesNotMatch(second, new RegExp(`${ROOT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/runtimes/codex/jarvos-session`)); assert.match(second, /\[unrelated\]\nvalue = true/); - assert.equal(fs.readdirSync(temp).filter((name) => name.startsWith('config.toml.bak-jarvos-')).length, 1); const withoutBridge = { ...env }; delete withoutBridge.JARVOS_STEWARDSHIP_BRIDGE_COMMAND; delete withoutBridge.JARVOS_STEWARDSHIP_CODEX_SESSION_MAP_ROOT; @@ -1563,8 +1595,7 @@ test('Codex setup shell-quotes a stable dispatcher path before persisting hooks' fs.chmodSync(dispatcher, 0o700); fs.mkdirSync(bin, { recursive: true }); const codex = path.join(bin, 'codex'); - fs.writeFileSync(codex, '#!/usr/bin/env sh\nif [ "$1" = "mcp" ] && [ "$2" = "get" ]; then exit 1; fi\nexit 0\n', { mode: 0o755 }); - fs.chmodSync(codex, 0o755); + writeFakeCodexAppServer(codex, config); const env = { ...cleanEnv(), HOME: path.join(temp, 'home'), PATH: `${bin}${path.delimiter}${process.env.PATH || ''}`, @@ -1593,26 +1624,15 @@ test('Codex setup repairs the nested shell environment table without losing user fs.mkdirSync(path.join(staged, 'runtimes', 'codex'), { recursive: true }); for (const file of ['jarvos-session-start-hook.js', 'jarvos-session-turn-hook.js']) fs.copyFileSync(path.join(ROOT, 'runtimes', 'codex', file), path.join(staged, 'runtimes', 'codex', file)); fs.mkdirSync(bin, { recursive: true }); - fs.writeFileSync(path.join(bin, 'codex'), '#!/usr/bin/env sh\nif [ "$1" = "mcp" ] && [ "$2" = "get" ]; then exit 1; fi\nexit 0\n', { mode: 0o755 }); - fs.chmodSync(path.join(bin, 'codex'), 0o755); fs.mkdirSync(codexHome, { recursive: true }); - fs.writeFileSync(config, [ - '[hooks]', - 'SessionStart = [{ hooks = [{ type = "command", command = "user-session-start" }] }]', - '', - '[shell_environment_policy.set]', - 'BROWSER_USE_AVAILABLE_BACKENDS = "chrome,iab"', - 'NODE_REPL_TRUSTED_CODE_PATHS = "/trusted/code"', - '', - // This is the real drift shape: a nested table plus a stale inline set. - '[shell_environment_policy]', - 'set = { JARVOS_STEWARDSHIP_BRIDGE_COMMAND = "old-bridge", JARVOS_STEWARDSHIP_CODEX_SESSION_MAP_ROOT = "/old/map" } # Preserve this trailing user note.', - '# Keep this note for the person maintaining the environment policy.', - '', - '[unrelated]', - 'value = true', - '', - ].join('\n'), 'utf8'); + writeFakeCodexAppServer(path.join(bin, 'codex'), config, { + hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'user-session-start' }] }] }, + shell_environment_policy: { set: { + BROWSER_USE_AVAILABLE_BACKENDS: 'chrome,iab', + NODE_REPL_TRUSTED_CODE_PATHS: '/trusted/code', + } }, + unrelated: { value: true }, + }); const env = { ...cleanEnv(), HOME: path.join(temp, 'home'), @@ -1636,8 +1656,6 @@ test('Codex setup repairs the nested shell environment table without losing user assert.match(configured, /NODE_REPL_TRUSTED_CODE_PATHS = "\/trusted\/code"/); assert.match(configured, /JARVOS_STEWARDSHIP_BRIDGE_COMMAND = "jarvos-stewardship-bridge"/); assert.match(configured, /JARVOS_STEWARDSHIP_CODEX_SESSION_MAP_ROOT = ".*codex-session-map"/); - assert.match(configured, /# Keep this note for the person maintaining the environment policy\./); - assert.match(configured, /# Preserve this trailing user note\./); assertCodexParsesConfig(config); runSetup(script, { ...env, JARVOS_MANAGED_HARNESS_ROLLBACK: '1' }); @@ -1649,15 +1667,13 @@ test('Codex setup repairs the nested shell environment table without losing user assert.match(rolledBack, /BROWSER_USE_AVAILABLE_BACKENDS = "chrome,iab"/); assert.match(rolledBack, /NODE_REPL_TRUSTED_CODE_PATHS = "\/trusted\/code"/); assert.match(rolledBack, /\[unrelated\]\nvalue = true/); - assert.match(rolledBack, /# Keep this note for the person maintaining the environment policy\./); - assert.match(rolledBack, /# Preserve this trailing user note\./); assertCodexParsesConfig(config); } finally { fs.rmSync(temp, { recursive: true, force: true }); } }); -test('Codex stewardship setup migrates legacy hooks.json without losing unrelated hooks', () => { +test('Codex stewardship setup preserves legacy hooks.json and requires explicit semantic migration', () => { const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-codex-hooks-migration-')); const bin = path.join(temp, 'bin'); const home = path.join(temp, 'home'); @@ -1701,36 +1717,18 @@ test('Codex stewardship setup migrates legacy hooks.json without losing unrelate JARVOS_STEWARDSHIP_STABLE_ROOT: stable, }; const script = path.join(ROOT, 'runtimes', 'codex', 'setup.sh'); - runSetup(script, env); - const first = fs.readFileSync(config, 'utf8'); - assert.equal(fs.existsSync(hooksJson), false); - assert.equal(count(first, /dcg --check/g), 1); - assert.equal(count(first, /jarvos-stewardship-dispatcher/g), 2); - assert.match(first, /model-routing/); - assert.match(first, /unrelated-session-hook/); - assert.match(first, /\[unrelated\]\nvalue = true/); - const configBackups = fs.readdirSync(codexHome).filter((name) => name.startsWith('config.toml.bak-jarvos-')); - const hooksBackups = fs.readdirSync(codexHome).filter((name) => name.startsWith('hooks.json.bak-jarvos-')); - assert.equal(configBackups.length, 1); - assert.equal(hooksBackups.length, 1); - assert.equal(fs.readFileSync(path.join(codexHome, configBackups[0]), 'utf8'), originalConfig); - assert.equal(fs.readFileSync(path.join(codexHome, hooksBackups[0]), 'utf8'), `${originalHooks}\n`); - assert.equal(fs.statSync(path.join(codexHome, hooksBackups[0])).mode & 0o777, 0o600); - runSetup(script, env); - assert.equal(fs.readFileSync(config, 'utf8'), first); - assert.equal(fs.readdirSync(codexHome).filter((name) => name.startsWith('hooks.json.bak-jarvos-')).length, 1); - runSetup(script, { ...env, JARVOS_MANAGED_HARNESS_ROLLBACK: '1' }); - const rolledBack = fs.readFileSync(config, 'utf8'); - assert.doesNotMatch(rolledBack, /jarvos-session-(?:start|turn)-hook\.js/); - assert.match(rolledBack, /dcg --check/); - assert.match(rolledBack, /model-routing/); - assert.equal(fs.existsSync(hooksJson), false); + const result = runSetupResult(script, env); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Legacy Codex hooks\.json requires an explicit semantic migration/); + assert.equal(fs.readFileSync(config, 'utf8'), originalConfig); + assert.equal(fs.readFileSync(hooksJson, 'utf8'), `${originalHooks}\n`); + assert.deepEqual(fs.readdirSync(codexHome).filter((name) => name.includes('.bak-jarvos-')), []); } finally { fs.rmSync(temp, { recursive: true, force: true }); } }); -test('Codex hooks migration fails closed for malformed legacy hooks.json', () => { +test('Codex legacy hooks migration fails closed uniformly without parsing or rewriting', () => { const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-codex-hooks-malformed-')); const bin = path.join(temp, 'bin'); const codexHome = path.join(temp, 'codex-home'); @@ -1761,7 +1759,7 @@ test('Codex hooks migration fails closed for malformed legacy hooks.json', () => JARVOS_STEWARDSHIP_STABLE_ROOT: stable, }); assert.notEqual(result.status, 0); - assert.match(result.stderr, /refusing Codex hook migration/); + assert.match(result.stderr, /Legacy Codex hooks\.json requires an explicit semantic migration/); assert.equal(fs.readFileSync(config, 'utf8'), originalConfig); assert.equal(fs.readFileSync(hooksJson, 'utf8'), originalHooks); assert.deepEqual(fs.readdirSync(codexHome).filter((name) => name.includes('.bak-jarvos-')), []); @@ -1777,7 +1775,7 @@ test('Codex hooks migration fails closed for malformed legacy hooks.json', () => JARVOS_STEWARDSHIP_ONLY: '1', JARVOS_MANAGED_REPOSITORIES: '/managed/repository', JARVOS_STAGED_PUBLIC_RUNTIME_ROOT: staged, JARVOS_STEWARDSHIP_STABLE_ROOT: stable, }); assert.notEqual(multilineResult.status, 0); - assert.match(multilineResult.stderr, /one-line inline-array/); + assert.match(multilineResult.stderr, /Legacy Codex hooks\.json requires an explicit semantic migration/); assert.equal(fs.readFileSync(config, 'utf8'), multilineConfig); assert.equal(fs.readFileSync(hooksJson, 'utf8'), validHooks); } finally { diff --git a/runtimes/codex/README.md b/runtimes/codex/README.md index 3c9f935e..0d5e0762 100644 --- a/runtimes/codex/README.md +++ b/runtimes/codex/README.md @@ -71,9 +71,10 @@ From the jarvOS repo root: The script registers a local stdio MCP server named `jarvos`, enables a Codex `SessionStart` hook in `~/.codex/config.toml` for both fresh and resumed -sessions, backs up the config before any -write, and persists the hook's current trusted hash through Codex's app-server -config path so the hook is runnable in Codex app Local sessions. +sessions, and persists the hook's current trusted hash through Codex's +app-server config path so the hook is runnable in Codex app Local sessions. +Hook and feature changes are semantic app-server edits; setup does not rewrite +or back up the whole TOML file. On a public or minimal install with no private host configured, that command is enough: setup registers the shared MCP server without control-plane host @@ -129,9 +130,31 @@ 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. + +Hook and feature rollback has its own mode-`0600` ownership receipt, bound to +the selected profile and canonical `CODEX_CONFIG`. A `config/read` with +`includeLayers` selects that exact user layer and snapshots presence plus value +for only `hooks.SessionStart`, `hooks.UserPromptSubmit`, `features.hooks`, +`features.codex_hooks`, and the three jarvOS stewardship keys below +`shell_environment_policy.set`. The receipt also records whether the containing +`hooks`, `features`, `shell_environment_policy`, and nested `set` tables existed +before setup. Setup or rollback then submits one +`config/batchWrite` containing `replace`/`null` edits, the canonical +`filePath`, and that layer's exact `expectedVersion`. Both `ok` and +`okOverridden` prove the user-layer write; a higher layer remains untouched. +Unrelated keys and tables can change concurrently without becoming jarvOS +owned. A conflict, unavailable app-server, ambiguous layer, drift in an owned +key, or missing/malformed receipt fails closed and preserves the receipt for +recovery while independent MCP and provider rollback continue. Pending and +active receipt states distinguish crashes before/after the atomic write. A +legacy `hooks.json` is never deleted implicitly; setup stops for an explicit +semantic migration. Codex hook trust is separately managed by Codex and is not +restored by this receipt. + +On rollback, an originally absent parent table is removed in that same atomic +batch only if the exact current user-layer parent becomes empty after restoring +the owned keys. A concurrently added sibling keeps its parent and is preserved; +the `expectedVersion` guard rejects a race between that check and the write. ### Optional authenticated control-plane host @@ -198,7 +221,8 @@ validated, bounded pending judgment and lets the in-session agent run the listed bridge answer command without locating private runtime state. The private bridge resolves its context by the current `CODEX_THREAD_ID`; setup never persists a session-specific context path. Setup does not print either -value, and rollback removes only these two entries. +value, and rollback restores the exact prior presence/value of those two keys +and the retired context-file key without changing other policy settings. ## Available Tools diff --git a/runtimes/codex/adapter.json b/runtimes/codex/adapter.json index 9aef22c9..8197f8b8 100644 --- a/runtimes/codex/adapter.json +++ b/runtimes/codex/adapter.json @@ -216,7 +216,8 @@ } }, "configWrites": { - "backupBeforeWrite": true, + "atomicExpectedVersionWrite": true, + "transactionScript": "hook-feature-transaction.js", "paths": [ "~/.codex/config.toml" ] diff --git a/runtimes/codex/hook-feature-receipt.js b/runtimes/codex/hook-feature-receipt.js new file mode 100644 index 00000000..7a14857c --- /dev/null +++ b/runtimes/codex/hook-feature-receipt.js @@ -0,0 +1,178 @@ +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +const SCHEMA_VERSION = 'jarvos-codex-hook-feature-receipt/v4'; +const OWNED_PATHS = [ + 'hooks.SessionStart', + 'hooks.UserPromptSubmit', + 'features.hooks', + 'features.codex_hooks', + 'shell_environment_policy.set.JARVOS_STEWARDSHIP_BRIDGE_COMMAND', + 'shell_environment_policy.set.JARVOS_STEWARDSHIP_CODEX_SESSION_MAP_ROOT', + 'shell_environment_policy.set.JARVOS_STEWARDSHIP_BRIDGE_CONTEXT_FILE', +]; +const PARENT_PATHS = ['hooks', 'features', 'shell_environment_policy', 'shell_environment_policy.set']; +const RECEIPT_KEYS = ['schemaVersion', 'profileDigest', 'configDigest', 'state', 'before', 'after', 'parentBefore']; + +function fail(message) { + const error = new Error(message); + error.code = 'JARVOS_CODEX_HOOK_FEATURE_RECEIPT_INVALID'; + throw error; +} + +function digest(value) { + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} + +function canonicalFile(configPath) { + if (typeof configPath !== 'string' || !path.isAbsolute(configPath)) fail('CODEX_CONFIG must be absolute'); + const absolute = path.resolve(configPath); + const stat = fs.lstatSync(absolute); + const uid = typeof process.getuid === 'function' ? process.getuid() : null; + if (stat.isSymbolicLink() || !stat.isFile()) fail('CODEX_CONFIG must be a real file'); + if (uid !== null && stat.uid !== uid) fail('CODEX_CONFIG must be owned by the current user'); + if ((stat.mode & 0o022) !== 0) fail('CODEX_CONFIG must not be group- or world-writable'); + const real = fs.realpathSync(absolute); + const systemAlias = absolute.startsWith('/tmp/') || absolute.startsWith('/var/') ? `/private${absolute}` : null; + if (real !== absolute && real !== systemAlias) fail('CODEX_CONFIG must not use a symbolic-link path'); + return real; +} + +function profileDirectory(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 systemAlias = absolute === '/tmp' || absolute.startsWith('/tmp/') || absolute === '/var' || absolute.startsWith('/var/') ? `/private${absolute}` : null; + if (real !== absolute && real !== systemAlias) fail('CODEX_HOME must not use a symbolic-link path'); + return absolute; +} + +function context(receiptPath, profilePath, configPath, options = {}) { + const profile = profileDirectory(profilePath, options); + const config = canonicalFile(configPath); + const receipt = path.resolve(receiptPath); + if (path.dirname(receipt) !== profile) fail('hook-feature receipt must be directly inside CODEX_HOME'); + return { profile, config, receipt, profileDigest: digest(fs.realpathSync(profile)), configDigest: digest(config) }; +} + +function validateSnapshot(snapshot) { + if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot) + || Object.keys(snapshot).sort().join('\0') !== [...OWNED_PATHS].sort().join('\0')) { + fail('hook-feature receipt snapshot has an unsupported shape'); + } + for (const key of OWNED_PATHS) { + const item = snapshot[key]; + if (!item || typeof item !== 'object' || Array.isArray(item) + || Object.keys(item).sort().join('\0') !== 'present\0value' + || typeof item.present !== 'boolean' || (!item.present && item.value !== null)) { + fail('hook-feature receipt snapshot is invalid'); + } + } + return snapshot; +} + +function validateParentPresence(value) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || Object.keys(value).sort().join('\0') !== [...PARENT_PATHS].sort().join('\0') + || PARENT_PATHS.some((key) => typeof value[key] !== 'boolean')) { + fail('hook-feature receipt parent presence has an unsupported shape'); + } + return value; +} + +function validateReceipt(value, profileDigest, configDigest) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || Object.keys(value).sort().join('\0') !== [...RECEIPT_KEYS].sort().join('\0') + || value.schemaVersion !== SCHEMA_VERSION || value.profileDigest !== profileDigest + || value.configDigest !== configDigest || !['pending', 'active'].includes(value.state)) { + fail('hook-feature receipt is not a recognized jarvOS ownership record'); + } + validateSnapshot(value.before); + validateSnapshot(value.after); + validateParentPresence(value.parentBefore); + return value; +} + +function readReceipt(receiptPath, profilePath, configPath) { + const value = context(receiptPath, profilePath, configPath); + try { fs.lstatSync(value.receipt); } catch (error) { + if (error.code === 'ENOENT') return null; + throw error; + } + const stat = fs.lstatSync(value.receipt); + const uid = typeof process.getuid === 'function' ? process.getuid() : null; + if (stat.isSymbolicLink() || !stat.isFile()) fail('hook-feature receipt must be a regular file'); + if (uid !== null && stat.uid !== uid) fail('hook-feature receipt must be owned by the current user'); + if ((stat.mode & 0o777) !== 0o600) fail('hook-feature receipt must have mode 0600'); + let parsed; + try { parsed = JSON.parse(fs.readFileSync(value.receipt, 'utf8')); } catch (_) { fail('hook-feature receipt is not valid JSON'); } + return validateReceipt(parsed, value.profileDigest, value.configDigest); +} + +function snapshotsEqual(left, right) { + const canonical = (value) => { + if (Array.isArray(value)) return value.map(canonical); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])); + } + return value; + }; + return JSON.stringify(canonical(validateSnapshot(left))) === JSON.stringify(canonical(validateSnapshot(right))); +} + +function writeReceipt(receiptPath, receipt) { + let fd; + try { + fd = fs.openSync(receiptPath, 'wx', 0o600); + fs.writeFileSync(fd, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8'); + fs.fsyncSync(fd); + } finally { + if (fd !== undefined) fs.closeSync(fd); + } +} + +function claimReceipt(receiptPath, profilePath, configPath, before, after, parentBefore) { + validateSnapshot(before); validateSnapshot(after); validateParentPresence(parentBefore); + const value = context(receiptPath, profilePath, configPath, { create: true }); + if (readReceipt(value.receipt, value.profile, value.config)) fail('hook-feature receipt already exists'); + const receipt = { schemaVersion: SCHEMA_VERSION, profileDigest: value.profileDigest, configDigest: value.configDigest, state: 'pending', before, after, parentBefore }; + writeReceipt(value.receipt, receipt); + return receipt; +} + +function activateReceipt(receiptPath, profilePath, configPath, expectedAfter) { + const value = context(receiptPath, profilePath, configPath); + const receipt = readReceipt(value.receipt, value.profile, value.config); + if (!receipt) fail('hook-feature receipt disappeared before activation'); + if (receipt.state !== 'pending' || !snapshotsEqual(receipt.after, expectedAfter)) fail('hook-feature receipt does not match the completed setup state'); + const temporary = path.join(value.profile, `.${path.basename(value.receipt)}.${process.pid}.${Date.now()}.tmp`); + try { + writeReceipt(temporary, { ...receipt, state: 'active' }); + fs.renameSync(temporary, value.receipt); + } finally { + try { fs.unlinkSync(temporary); } catch (error) { if (error.code !== 'ENOENT') throw error; } + } +} + +function clearReceipt(receiptPath, profilePath, configPath, expectedAfter) { + const value = context(receiptPath, profilePath, configPath); + const receipt = readReceipt(value.receipt, value.profile, value.config); + if (!receipt) return false; + if (!snapshotsEqual(receipt.after, expectedAfter)) fail('hook-feature receipt does not match the intended rollback state'); + fs.unlinkSync(value.receipt); + return true; +} + +module.exports = { SCHEMA_VERSION, OWNED_PATHS, PARENT_PATHS, claimReceipt, activateReceipt, clearReceipt, readReceipt, snapshotsEqual, validateSnapshot, validateParentPresence }; diff --git a/runtimes/codex/hook-feature-transaction.js b/runtimes/codex/hook-feature-transaction.js new file mode 100644 index 00000000..2de90dab --- /dev/null +++ b/runtimes/codex/hook-feature-transaction.js @@ -0,0 +1,229 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); +const receiptApi = require('./hook-feature-receipt'); + +const STEWARDSHIP_KEYS = [ + 'JARVOS_STEWARDSHIP_BRIDGE_COMMAND', + 'JARVOS_STEWARDSHIP_CODEX_SESSION_MAP_ROOT', + 'JARVOS_STEWARDSHIP_BRIDGE_CONTEXT_FILE', +]; + +function fail(message) { throw new Error(`refusing Codex hook transaction: ${message}`); } +function own(object, key) { return object !== null && typeof object === 'object' && Object.prototype.hasOwnProperty.call(object, key); } +function clone(value) { return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); } +function samePath(left, right) { + try { return fs.realpathSync(left) === fs.realpathSync(right); } + catch (_) { return path.resolve(left) === path.resolve(right); } +} +function getPath(config, keyPath) { + let cursor = config; + for (const key of keyPath.split('.')) { + if (!own(cursor, key)) return { present: false, value: null }; + cursor = cursor[key]; + } + return { present: true, value: clone(cursor) }; +} +function snapshot(config) { + return receiptApi.validateSnapshot(Object.fromEntries(receiptApi.OWNED_PATHS.map((key) => [key, getPath(config, key)]))); +} +function parentPresence(config) { + return receiptApi.validateParentPresence(Object.fromEntries(receiptApi.PARENT_PATHS.map((key) => [key, getPath(config, key).present]))); +} +function applyPath(config, keyPath, item) { + const keys = keyPath.split('.'); + let cursor = config; + for (const key of keys.slice(0, -1)) cursor = cursor[key] ||= {}; + if (item.present) cursor[keys.at(-1)] = clone(item.value); + else delete cursor[keys.at(-1)]; +} +function semanticEdits(current, target) { + return receiptApi.OWNED_PATHS + .filter((key) => JSON.stringify(current[key]) !== JSON.stringify(target[key])) + .map((keyPath) => ({ keyPath, value: target[keyPath].present ? target[keyPath].value : null, mergeStrategy: 'replace' })); +} +function rollbackEdits(config, current, target, parentBefore) { + let edits = semanticEdits(current, target); + const simulated = clone(config); + for (const key of receiptApi.OWNED_PATHS) applyPath(simulated, key, target[key]); + for (const parent of ['shell_environment_policy.set', 'hooks', 'features', 'shell_environment_policy']) { + const item = getPath(simulated, parent); + if (parentBefore[parent] || !item.present || !item.value || typeof item.value !== 'object' + || Array.isArray(item.value) || Object.keys(item.value).length !== 0) continue; + edits = edits.filter((edit) => edit.keyPath !== parent && !edit.keyPath.startsWith(`${parent}.`)); + edits.push({ keyPath: parent, value: null, mergeStrategy: 'replace' }); + applyPath(simulated, parent, { present: false, value: null }); + } + return edits; +} +function shellQuote(value) { return `'${value.replace(/'/g, "'\"'\"'")}'`; } +function managedCommand(entry, ownedPaths) { + if (!entry || typeof entry !== 'object' || !Array.isArray(entry.hooks)) return false; + return entry.hooks.some((hook) => typeof hook?.command === 'string' && ownedPaths.some((target) => { + const escaped = target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(?:^|[^A-Za-z0-9._/-])${escaped}(?=$|[^A-Za-z0-9._/-])`).test(hook.command); + })); +} +function desiredSnapshot(before, options) { + const desired = clone(before); + const ownedPaths = [options.hookScript, options.turnHookScript, options.dispatcher].filter(Boolean); + if (path.isAbsolute(options.stagedRoot || '')) { + ownedPaths.push(path.join(options.stagedRoot, 'runtimes', 'codex', 'jarvos-session-start-hook.js')); + ownedPaths.push(path.join(options.stagedRoot, 'runtimes', 'codex', 'jarvos-session-turn-hook.js')); + } + for (const event of ['SessionStart', 'UserPromptSubmit']) { + const current = before[`hooks.${event}`]; + if (current.present && !Array.isArray(current.value)) fail(`hooks.${event} in the user layer is not an array`); + desired[`hooks.${event}`] = { present: true, value: (current.present ? current.value : []).filter((entry) => !managedCommand(entry, ownedPaths)) }; + } + const startCommand = options.dispatcher + ? `${shellQuote(options.dispatcher)} --harness codex --action session-start` + : `node ${shellQuote(options.hookScript)}`; + const turnCommand = options.dispatcher + ? `${shellQuote(options.dispatcher)} --harness codex --action session-turn` + : `node ${shellQuote(options.turnHookScript)}`; + desired['hooks.SessionStart'].value.push({ matcher: 'startup|resume', hooks: [{ type: 'command', command: startCommand, async: false, timeout: 30 }] }); + desired['hooks.UserPromptSubmit'].value.push({ hooks: [{ type: 'command', command: turnCommand, async: false, timeout: 30 }] }); + desired['features.hooks'] = { present: true, value: true }; + desired['features.codex_hooks'] = { present: false, value: null }; + if (options.bridgeCommand || options.mapRoot) { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(options.bridgeCommand || '')) fail('JARVOS_STEWARDSHIP_BRIDGE_COMMAND is invalid'); + if (!path.isAbsolute(options.mapRoot || '')) fail('JARVOS_STEWARDSHIP_CODEX_SESSION_MAP_ROOT must be absolute'); + desired['shell_environment_policy.set.JARVOS_STEWARDSHIP_BRIDGE_COMMAND'] = { present: true, value: options.bridgeCommand }; + desired['shell_environment_policy.set.JARVOS_STEWARDSHIP_CODEX_SESSION_MAP_ROOT'] = { present: true, value: options.mapRoot }; + desired['shell_environment_policy.set.JARVOS_STEWARDSHIP_BRIDGE_CONTEXT_FILE'] = { present: false, value: null }; + } + return receiptApi.validateSnapshot(desired); +} +function hasUnreceiptedState(current, ownedPaths) { + for (const event of ['hooks.SessionStart', 'hooks.UserPromptSubmit']) { + const item = current[event]; + if (item.present && Array.isArray(item.value) && item.value.some((entry) => managedCommand(entry, ownedPaths))) return true; + } + return STEWARDSHIP_KEYS.some((key) => current[`shell_environment_policy.set.${key}`].present); +} + +class AppServer { + constructor(executable, cwd) { this.executable = executable; this.cwd = cwd; this.nextId = 1; this.pending = new Map(); this.buffer = ''; } + async start() { + this.child = spawn(this.executable, ['app-server', '--listen', 'stdio://'], { cwd: this.cwd, env: process.env, stdio: ['pipe', 'pipe', 'ignore'] }); + this.child.stdout.setEncoding('utf8'); + this.child.stdout.on('data', (chunk) => this.consume(chunk)); + this.child.on('error', (error) => this.rejectAll(error)); + this.child.on('exit', (code) => this.rejectAll(new Error(`Codex app-server exited (${code ?? 'signal'})`))); + await this.request('initialize', { clientInfo: { name: 'jarvos_setup', title: 'jarvOS Codex setup', version: '0.1.0' }, capabilities: { experimentalApi: true } }); + this.send({ method: 'initialized', params: {} }); + } + send(message) { + if (!this.child?.stdin?.writable) fail('Codex app-server is unavailable'); + this.child.stdin.write(`${JSON.stringify(message)}\n`); + } + request(method, params) { + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`Codex app-server timed out during ${method}`)); }, 30_000); + this.pending.set(id, { resolve, reject, timer }); + this.send({ method, id, params }); + }); + } + consume(chunk) { + this.buffer += chunk; + let newline; + while ((newline = this.buffer.indexOf('\n')) >= 0) { + const line = this.buffer.slice(0, newline).trim(); this.buffer = this.buffer.slice(newline + 1); + if (!line) continue; + let message; try { message = JSON.parse(line); } catch (_) { continue; } + const pending = this.pending.get(message.id); if (!pending) continue; + this.pending.delete(message.id); clearTimeout(pending.timer); + if (message.error) pending.reject(new Error(message.error.message || 'Codex app-server request failed')); + else pending.resolve(message.result); + } + } + rejectAll(error) { for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error); } this.pending.clear(); } + close() { if (this.child && !this.child.killed) this.child.kill(); } +} + +async function transact(options) { + if (!path.isAbsolute(options.configPath)) fail('CODEX_CONFIG must be absolute'); + const targetConfig = fs.realpathSync(options.configPath); + const server = new AppServer(options.executable, options.root); + try { + await server.start(); + const read = await server.request('config/read', { includeLayers: true }); + if (!Array.isArray(read?.layers)) fail('Codex app-server returned no versioned configuration layers'); + const matches = read.layers.filter((layer) => layer?.name?.type === 'user' && typeof layer.name.file === 'string' && samePath(layer.name.file, targetConfig)); + if (matches.length !== 1 || typeof matches[0].version !== 'string' || !matches[0].version) fail('the exact Codex user layer is unavailable or ambiguous'); + const layer = matches[0]; + const currentConfig = layer.config || {}; + const current = snapshot(currentConfig); + let receipt = receiptApi.readReceipt(options.receiptPath, options.codexHome, options.configPath); + let recoveredNoop = false; + if (receipt) { + const atBefore = receiptApi.snapshotsEqual(receipt.before, current); + const atAfter = receiptApi.snapshotsEqual(receipt.after, current); + if (receipt.state === 'pending') { + if (atBefore) { + receiptApi.clearReceipt(options.receiptPath, options.codexHome, options.configPath, receipt.after); + receipt = null; recoveredNoop = options.rollback; + } else if (atAfter) { + receiptApi.activateReceipt(options.receiptPath, options.codexHome, options.configPath, receipt.after); + receipt = receiptApi.readReceipt(options.receiptPath, options.codexHome, options.configPath); + } else fail('pending ownership record matches neither safe transaction state'); + } else if (atBefore) { + receiptApi.clearReceipt(options.receiptPath, options.codexHome, options.configPath, receipt.after); + receipt = null; recoveredNoop = options.rollback; + } else if (!atAfter) fail('owned hook or feature state drifted after setup'); + } + + let target; + if (options.rollback) { + if (!receipt) { + const ownedPaths = [options.hookScript, options.turnHookScript, options.dispatcher].filter(Boolean); + if (path.isAbsolute(options.stagedRoot || '')) { + ownedPaths.push(path.join(options.stagedRoot, 'runtimes', 'codex', 'jarvos-session-start-hook.js')); + ownedPaths.push(path.join(options.stagedRoot, 'runtimes', 'codex', 'jarvos-session-turn-hook.js')); + } + if (!recoveredNoop && hasUnreceiptedState(current, ownedPaths)) fail('no ownership record exists for detected jarvOS hook state'); + console.log(`Codex hook-feature rollback found no owned state: ${options.configPath}`); return; + } + target = receipt.before; + } else { + if (fs.existsSync(options.legacyHooksPath)) fail(`legacy ${options.legacyHooksPath} requires an explicit semantic migration`); + target = desiredSnapshot(current, options); + if (receipt && !receiptApi.snapshotsEqual(receipt.after, target)) fail('the existing ownership record describes a different requested setup state'); + if (receiptApi.snapshotsEqual(current, target)) { + console.log(`Codex config already has the requested jarvOS hooks: ${options.configPath}`); return; + } + if (!receipt) receipt = receiptApi.claimReceipt( + options.receiptPath, + options.codexHome, + options.configPath, + current, + target, + parentPresence(currentConfig), + ); + } + const edits = options.rollback + ? rollbackEdits(currentConfig, current, target, receipt.parentBefore) + : semanticEdits(current, target); + let result; + try { + result = await server.request('config/batchWrite', { filePath: targetConfig, edits, expectedVersion: layer.version, reloadUserConfig: true }); + } catch (_) { fail('Codex app-server rejected the version-bound configuration transaction; preserving the ownership record'); } + if (!result || !['ok', 'okOverridden'].includes(result.status) || typeof result.version !== 'string' || !result.version + || typeof result.filePath !== 'string' || !samePath(result.filePath, targetConfig)) fail('Codex app-server did not confirm the exact configuration transaction'); + if (options.rollback) receiptApi.clearReceipt(options.receiptPath, options.codexHome, options.configPath, receipt.after); + else receiptApi.activateReceipt(options.receiptPath, options.codexHome, options.configPath, target); + console.log(`${options.rollback ? 'Restored' : 'Updated'} Codex hook-feature state transactionally: ${options.configPath}`); + } finally { server.close(); } +} + +async function main() { + const [root, executable, configPath, codexHome, receiptPath, legacyHooksPath, hookScript, turnHookScript, dispatcher, rollback, bridgeCommand, mapRoot, stagedRoot] = process.argv.slice(2); + await transact({ root, executable, configPath, codexHome, receiptPath, legacyHooksPath, hookScript, turnHookScript, dispatcher, rollback: rollback === '1', bridgeCommand, mapRoot, stagedRoot }); +} + +if (require.main === module) main().catch((error) => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; }); +module.exports = { snapshot, parentPresence, desiredSnapshot, rollbackEdits, transact }; diff --git a/runtimes/codex/setup.sh b/runtimes/codex/setup.sh index 2ffed191..c2c6bbad 100755 --- a/runtimes/codex/setup.sh +++ b/runtimes/codex/setup.sh @@ -16,6 +16,9 @@ LEGACY_HOOKS_JSON="$CODEX_HOME/hooks.json" 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" +HOOK_FEATURE_RECEIPT_MODULE="$ROOT/runtimes/codex/hook-feature-receipt.js" +HOOK_FEATURE_TRANSACTION_MODULE="$ROOT/runtimes/codex/hook-feature-transaction.js" +HOOK_FEATURE_RECEIPT_PATH="$CODEX_HOME/jarvos-codex-hook-feature-receipt.json" 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 @@ -108,6 +111,21 @@ if [ "$ROLLBACK_MODE" != "1" ]; then exit 1 fi + if [ ! -f "$HOOK_FEATURE_RECEIPT_MODULE" ]; then + echo "jarvOS Codex hook-feature receipt helper not found: $HOOK_FEATURE_RECEIPT_MODULE" >&2 + exit 1 + fi + + if [ ! -f "$HOOK_FEATURE_TRANSACTION_MODULE" ]; then + echo "jarvOS Codex semantic hook transaction helper not found: $HOOK_FEATURE_TRANSACTION_MODULE" >&2 + exit 1 + fi + + if [ -e "$LEGACY_HOOKS_JSON" ] || [ -L "$LEGACY_HOOKS_JSON" ]; then + echo "Legacy Codex hooks.json requires an explicit semantic migration; preserving it and the profile." >&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, # not a symlink, an owner-only executable file. An owner-only leaf is not @@ -599,435 +617,27 @@ if [ "${JARVOS_STEWARDSHIP_ONLY:-0}" != "1" ]; then fi HOOK_PHASE_STATUS=0 +if [ ! -d "$CODEX_HOME" ]; then + mkdir -m 700 -p "$CODEX_HOME" || HOOK_PHASE_STATUS=1 +fi mkdir -p "$(dirname "$CODEX_CONFIG")" || HOOK_PHASE_STATUS=1 if [ ! -f "$CODEX_CONFIG" ]; then touch "$CODEX_CONFIG" || HOOK_PHASE_STATUS=1 fi -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'); - -const [configPath, legacyHooksPath, hookScript, turnHookScript, dispatcher, rollback, bridgeCommand, codexSessionMapRoot, stagedRoot] = process.argv.slice(2); -const original = fs.readFileSync(configPath, 'utf8'); -let next = original; - -function fail(message) { - throw new Error(`refusing Codex hook migration: ${message}`); -} - -function tomlKey(key) { - return /^[A-Za-z0-9_-]+$/.test(key) ? key : JSON.stringify(key); -} - -function isScalar(value) { - return typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value)); -} - -function tomlScalar(value) { - if (typeof value === 'string') return JSON.stringify(value); - if (typeof value === 'boolean') return String(value); - if (typeof value === 'number' && Number.isFinite(value)) return String(value); - fail('hook fields must be string, boolean, or finite number scalars'); -} - -function shellQuote(value) { - return `'${value.replace(/'/g, "'\"'\"'")}'`; -} - -function renderHookEntry(entry) { - return `{ ${Object.entries(entry).map(([key, value]) => { - if (key === 'hooks') return `${tomlKey(key)} = [${value.map(renderCommandHook).join(', ')}]`; - return `${tomlKey(key)} = ${tomlScalar(value)}`; - }).join(', ')} }`; -} - -function renderCommandHook(hook) { - return `{ ${Object.entries(hook).map(([key, value]) => `${tomlKey(key)} = ${tomlScalar(value)}`).join(', ')} }`; -} - -function validateHookEntry(entry, label) { - if (!entry || Array.isArray(entry) || typeof entry !== 'object') fail(`${label} must be an object`); - if (!Array.isArray(entry.hooks) || entry.hooks.length === 0) fail(`${label}.hooks must be a non-empty array`); - for (const [key, value] of Object.entries(entry)) { - if (key === 'hooks') continue; - if (!isScalar(value)) fail(`${label}.${key} is unsupported`); - } - entry.hooks.forEach((hook, index) => { - if (!hook || Array.isArray(hook) || typeof hook !== 'object') fail(`${label}.hooks[${index}] must be an object`); - if (typeof hook.type !== 'string' || typeof hook.command !== 'string') fail(`${label}.hooks[${index}] requires string type and command`); - for (const [key, value] of Object.entries(hook)) if (!isScalar(value)) fail(`${label}.hooks[${index}].${key} is unsupported`); - }); - return entry; -} - -function validateHookMap(hooks, label) { - if (!hooks || Array.isArray(hooks) || typeof hooks !== 'object') fail(`${label} must be an object`); - const validated = {}; - for (const [event, entries] of Object.entries(hooks)) { - if (!/^[A-Za-z0-9_-]+$/.test(event) || !Array.isArray(entries)) fail(`${label}.${event} must be a supported event array`); - validated[event] = entries.map((entry, index) => validateHookEntry(entry, `${label}.${event}[${index}]`)); - } - return validated; -} - -function parseLegacyHooks(file) { - let parsed; - try { - parsed = JSON.parse(fs.readFileSync(file, 'utf8')); - } catch (error) { - fail(`cannot parse ${file}: ${error.message}`); - } - if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object' || Object.keys(parsed).length !== 1 || !Object.prototype.hasOwnProperty.call(parsed, 'hooks')) { - fail(`${file} must contain only a hooks object`); - } - return validateHookMap(parsed.hooks, 'hooks'); -} - -function topLevelHookEntries(value) { - if (!value.startsWith('[') || !value.endsWith(']')) fail('hooks must use a one-line inline array'); - const entries = []; let start = 1; let braces = 0; let brackets = 0; let quote = null; let escaped = false; - for (let index = 1; index < value.length - 1; index += 1) { - const character = value[index]; - if (quote) { - if (escaped) escaped = false; - else if (character === '\\' && quote === '"') escaped = true; - else if (character === quote) quote = null; - continue; - } - if (character === '"' || character === "'") { quote = character; continue; } - if (character === '{') braces += 1; - else if (character === '}') braces -= 1; - else if (character === '[') brackets += 1; - else if (character === ']') brackets -= 1; - if (braces < 0 || brackets < 0) fail('invalid hooks inline array'); - if (character === ',' && braces === 0 && brackets === 0) { entries.push(value.slice(start, index).trim()); start = index + 1; } - } - if (quote || braces !== 0 || brackets !== 0) fail('unsupported hooks inline array'); - const tail = value.slice(start, -1).trim(); if (tail) entries.push(tail); - return entries; -} - -function hookIdentity(entry) { - const matcher = /\bmatcher\s*=\s*("(?:\\.|[^"\\])*"|'[^']*')/.exec(entry); - const commands = [...entry.matchAll(/\bcommand\s*=\s*("(?:\\.|[^"\\])*"|'[^']*')/g)].map((match) => match[1]); - return commands.length === 1 ? `${matcher ? matcher[1] : ''}\u0000${commands[0]}` : null; -} - -function dedupe(entries) { - const seen = new Set(); - return entries.filter((entry) => { - const identity = hookIdentity(entry); - if (!identity) return true; - if (seen.has(identity)) return false; - seen.add(identity); - return true; - }); -} - -const ownedHookPaths = [hookScript, turnHookScript, dispatcher].filter(Boolean); -if (path.isAbsolute(stagedRoot || '')) { - ownedHookPaths.push(path.join(stagedRoot, 'runtimes', 'codex', 'jarvos-session-start-hook.js')); - ownedHookPaths.push(path.join(stagedRoot, 'runtimes', 'codex', 'jarvos-session-turn-hook.js')); -} -function isManagedJarvosHook(entry) { - return ownedHookPaths.some((target) => new RegExp(`(?:^|[^A-Za-z0-9._/-])${target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=$|[^A-Za-z0-9._/-])`).test(entry)); -} - -function hookTableRange(lines) { - const start = lines.findIndex((line) => /^\[hooks\]\s*$/.test(line)); - if (start < 0) return { start, end: lines.length }; - let end = lines.length; - for (let index = start + 1; index < lines.length; index += 1) if (/^\s*\[/.test(lines[index])) { end = index; break; } - return { start, end }; -} - -function validateHookTable(content) { - const lines = content.split(/\n/); const { start, end } = hookTableRange(lines); - for (let index = start + 1; start >= 0 && index < end; index += 1) { - if (/^\s*(?:#.*)?$/.test(lines[index])) continue; - const match = /^\s*[A-Za-z0-9_-]+\s*=\s*(\[.*\])\s*(?:#.*)?$/.exec(lines[index]); - if (!match) fail('hooks table must use one-line inline-array assignments'); - topLevelHookEntries(match[1]); - } -} - -function setHook(content, event, hook, removeManaged) { - const lines = content.split(/\n/); const { start, end } = hookTableRange(lines); - if (start < 0) { - if (!hook) return content; - const suffix = content.endsWith('\n') || content.length === 0 ? '' : '\n'; - return `${content}${suffix}\n[hooks]\n${tomlKey(event)} = [${hook}]\n`; - } - const eventLine = new RegExp(`^\\s*${event}\\s*=\\s*(\\[.*\\])\\s*(?:#.*)?$`); - for (let index = start + 1; index < end; index += 1) { - const match = eventLine.exec(lines[index]); - if (!match) continue; - const existing = topLevelHookEntries(match[1]); - const retained = removeManaged ? existing.filter((entry) => !isManagedJarvosHook(entry)) : existing; - const entries = hook ? dedupe([...retained, hook]) : retained; - lines[index] = `${event} = [${entries.join(', ')}]`; - return lines.join('\n'); - } - if (!hook) return content; - lines.splice(end, 0, `${event} = [${hook}]`); - return lines.join('\n'); -} - -function stamp() { - return new Date().toISOString().replace(/[:.]/g, '').replace('T', '-').replace('Z', 'Z'); -} - -function backup(file, suffix) { - const target = `${file}.bak-jarvos-${suffix}`; - fs.copyFileSync(file, target); - fs.chmodSync(target, fs.statSync(file).mode); - return target; -} - -function writeAtomically(file, content) { - const mode = fs.statSync(file).mode; - const temporary = path.join(path.dirname(file), `.${path.basename(file)}.jarvos-${process.pid}-${Date.now()}.tmp`); - try { - fs.writeFileSync(temporary, content, { encoding: 'utf8', mode }); - fs.chmodSync(temporary, mode); - fs.renameSync(temporary, file); - } finally { - if (fs.existsSync(temporary)) fs.unlinkSync(temporary); - } -} - -function setFeature(content, key, value) { - const headerRe = /^\[features\]\s*$/m; - if (!headerRe.test(content)) { - const suffix = content.endsWith('\n') || content.length === 0 ? '' : '\n'; - return `${content}${suffix}\n[features]\n${key} = ${value}\n`; - } - - const lines = content.split(/\n/); - const start = lines.findIndex((line) => /^\[features\]\s*$/.test(line)); - let end = lines.length; - for (let i = start + 1; i < lines.length; i += 1) { - if (/^\s*\[/.test(lines[i])) { - end = i; - break; - } - } - - const keyRe = new RegExp(`^\\s*${key}\\s*=`); - for (let i = start + 1; i < end; i += 1) { - if (keyRe.test(lines[i])) { - lines[i] = `${key} = ${value}`; - return lines.join('\n'); - } - } - lines.splice(end, 0, `${key} = ${value}`); - return lines.join('\n'); -} - -function removeFeature(content, key) { - const lines = content.split(/\n/); - const start = lines.findIndex((line) => /^\[features\]\s*$/.test(line)); - if (start < 0) return content; - - let end = lines.length; - for (let i = start + 1; i < lines.length; i += 1) { - if (/^\s*\[/.test(lines[i])) { - end = i; - break; - } - } - - const keyRe = new RegExp(`^\\s*${key}\\s*=`); - return lines.filter((line, index) => { - if (index <= start || index >= end) return true; - return !keyRe.test(line); - }).join('\n'); -} - -function tomlTableRange(lines, header) { - const headerRe = new RegExp(`^\\[${header.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]\\s*$`); - const matches = lines.map((line, index) => headerRe.test(line) ? index : -1).filter((index) => index >= 0); - if (matches.length > 1) fail(`${header} must be declared at most once`); - const start = matches[0] ?? -1; - if (start < 0) return { start, end: lines.length }; - let end = lines.length; - for (let index = start + 1; index < lines.length; index += 1) if (/^\s*\[/.test(lines[index])) { end = index; break; } - return { start, end }; -} - -function parseEnvironmentSet(value) { - if (!value.startsWith('{') || !value.endsWith('}')) fail('shell_environment_policy.set must use a one-line inline table'); - const entries = topLevelHookEntries(`[${value.slice(1, -1)}]`); - const result = {}; - for (const entry of entries) { - const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*("(?:\\.|[^"\\])*")\s*$/.exec(entry); - if (!match) fail('shell_environment_policy.set supports only string environment values'); - try { result[match[1]] = JSON.parse(match[2]); } catch { fail('shell_environment_policy.set contains an invalid string'); } - } - return result; -} - -function renderEnvironmentSet(entries) { - return `{ ${Object.entries(entries).map(([key, value]) => `${key} = ${JSON.stringify(value)}`).join(', ')} }`; -} - -const STEWARDSHIP_ENVIRONMENT_KEYS = [ - 'JARVOS_STEWARDSHIP_BRIDGE_COMMAND', - 'JARVOS_STEWARDSHIP_CODEX_SESSION_MAP_ROOT', - 'JARVOS_STEWARDSHIP_BRIDGE_CONTEXT_FILE', -]; - -function environmentSetAssignment(lines, range) { - let index = -1; let entries = {}; let comment = ''; - for (let candidate = range.start + 1; candidate < range.end; candidate += 1) { - const match = /^\s*set\s*=\s*(\{.*\})\s*(#.*)?\s*$/.exec(lines[candidate]); - if (!match) continue; - if (index >= 0) fail('shell_environment_policy must contain at most one set assignment'); - index = candidate; entries = parseEnvironmentSet(match[1]); comment = match[2] || ''; - } - return { index, entries, comment }; -} - -function environmentSubtableEntries(lines, range) { - const entries = new Map(); - for (let index = range.start + 1; index < range.end; index += 1) { - if (/^\s*(?:#.*)?$/.test(lines[index])) continue; - const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*("(?:\\.|[^"\\])*")\s*(?:#.*)?$/.exec(lines[index]); - if (!match) fail('shell_environment_policy.set supports only string environment values'); - if (entries.has(match[1])) fail(`shell_environment_policy.set contains duplicate ${match[1]} assignments`); - try { entries.set(match[1], { value: JSON.parse(match[2]), index }); } catch { fail('shell_environment_policy.set contains an invalid string'); } - } - return entries; -} - -function setStewardshipBridgeEnvironment(content, bridge) { - if (bridge === undefined) return content; - let lines = content.split(/\n/); - const policyRange = tomlTableRange(lines, 'shell_environment_policy'); - const subtableRange = tomlTableRange(lines, 'shell_environment_policy.set'); - if (policyRange.start < 0 && subtableRange.start < 0) { - if (!bridge) return content; - const suffix = content.endsWith('\n') || content.length === 0 ? '' : '\n'; - return `${content}${suffix}\n[shell_environment_policy]\nset = ${renderEnvironmentSet(bridge)}\n`; - } - - const inline = policyRange.start >= 0 ? environmentSetAssignment(lines, policyRange) : { index: -1, entries: {}, comment: '' }; - if (subtableRange.start < 0) { - const entries = { ...inline.entries }; - for (const key of STEWARDSHIP_ENVIRONMENT_KEYS) delete entries[key]; - if (bridge) Object.assign(entries, bridge); - if (Object.keys(entries).length) { - const line = `set = ${renderEnvironmentSet(entries)}${inline.comment ? ` ${inline.comment}` : ''}`; - if (inline.index >= 0) lines[inline.index] = line; - else lines.splice(policyRange.end, 0, line); - return lines.join('\n'); - } - if (inline.index >= 0) { - if (inline.comment) lines[inline.index] = inline.comment; - else lines.splice(inline.index, 1); - } - return lines.join('\n'); - } - - // TOML permits either `set = { ... }` or `[shell_environment_policy.set]`, - // but never both. Prefer the existing nested table and repair a stale inline - // representation by moving its unrelated values into that table. - const nested = environmentSubtableEntries(lines, subtableRange); - for (const [key, value] of Object.entries(inline.entries)) { - const existing = nested.get(key); - if (existing && existing.value !== value) fail(`shell_environment_policy.set conflicts with inline set for ${key}`); - } - const remove = []; - if (inline.index >= 0) { - // A trailing comment belongs to the user, not the invalid inline table. - // Keep it as a standalone comment while removing only the assignment. - if (inline.comment) lines[inline.index] = inline.comment; - else remove.push(inline.index); - } - for (const key of STEWARDSHIP_ENVIRONMENT_KEYS) { - const existing = nested.get(key); - if (existing) remove.push(existing.index); - } - for (const index of remove.sort((a, b) => b - a)) lines.splice(index, 1); - - // An empty parent header is not useful, and is invalid when it follows the - // nested table it implicitly defined. Remove only that header: comments in - // an otherwise-empty user section remain useful documentation and must not - // be swept up with jarvOS's stale inline assignment. - const repairedPolicyRange = tomlTableRange(lines, 'shell_environment_policy'); - if (repairedPolicyRange.start >= 0 && !lines.slice(repairedPolicyRange.start + 1, repairedPolicyRange.end).some((line) => !/^\s*(?:#.*)?$/.test(line))) { - lines.splice(repairedPolicyRange.start, 1); - } - - // Re-find the nested table after removals, then append only values absent - // from it. This keeps unrelated nested-table lines and their comments intact. - const repairedRange = tomlTableRange(lines, 'shell_environment_policy.set'); - const repaired = environmentSubtableEntries(lines, repairedRange); - const additions = []; - for (const [key, value] of Object.entries(inline.entries)) { - if (STEWARDSHIP_ENVIRONMENT_KEYS.includes(key) || repaired.has(key)) continue; - additions.push(`${key} = ${JSON.stringify(value)}`); - } - if (bridge) for (const [key, value] of Object.entries(bridge)) additions.push(`${key} = ${JSON.stringify(value)}`); - if (additions.length) lines.splice(repairedRange.end, 0, ...additions); - return lines.join('\n'); -} - -function stewardshipBridgeEnvironment(command, codexMapRoot) { - if (!command && !codexMapRoot) return undefined; - if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(command || '')) fail('JARVOS_STEWARDSHIP_BRIDGE_COMMAND must be a bounded executable name'); - if (!path.isAbsolute(codexMapRoot || '')) fail('JARVOS_STEWARDSHIP_CODEX_SESSION_MAP_ROOT must be an absolute path'); - return { - JARVOS_STEWARDSHIP_BRIDGE_COMMAND: command, - JARVOS_STEWARDSHIP_CODEX_SESSION_MAP_ROOT: codexMapRoot, - }; -} - -let migrated = null; -if (fs.existsSync(legacyHooksPath)) { - migrated = parseLegacyHooks(legacyHooksPath); - validateHookTable(next); - for (const [event, entries] of Object.entries(migrated)) for (const entry of entries) next = setHook(next, event, renderHookEntry(entry), false); -} - -const bridgeEnvironment = stewardshipBridgeEnvironment(bridgeCommand, codexSessionMapRoot); - -if (rollback === '1') { - validateHookTable(next); - next = setHook(next, 'SessionStart', null, true); - next = setHook(next, 'UserPromptSubmit', null, true); - next = setStewardshipBridgeEnvironment(next, null); -} else { - validateHookTable(next); - const startCommand = dispatcher - ? `${shellQuote(dispatcher)} --harness codex --action session-start` - : `node ${shellQuote(hookScript)}`; - const turnCommand = dispatcher - ? `${shellQuote(dispatcher)} --harness codex --action session-turn` - : `node ${shellQuote(turnHookScript)}`; - next = setHook(next, 'SessionStart', renderHookEntry({ matcher: 'startup|resume', hooks: [{ type: 'command', command: startCommand, async: false, timeout: 30 }] }), true); - next = setHook(next, 'UserPromptSubmit', renderHookEntry({ hooks: [{ type: 'command', command: turnCommand, async: false, timeout: 30 }] }), true); - next = setStewardshipBridgeEnvironment(next, bridgeEnvironment); -} -next = setFeature(next, 'hooks', 'true'); -next = removeFeature(next, 'codex_hooks'); - -if (next !== original || migrated) { - const backupStamp = stamp(); - const backupPath = next !== original ? backup(configPath, backupStamp) : null; - const legacyBackupPath = migrated ? backup(legacyHooksPath, backupStamp) : null; - writeAtomically(configPath, next); - if (migrated) fs.unlinkSync(legacyHooksPath); - console.log(`Updated Codex config for jarvOS hooks: ${configPath}`); - if (backupPath) console.log(`Backup: ${backupPath}`); - if (legacyBackupPath) console.log(`Migrated legacy Codex hooks with backup: ${legacyBackupPath}`); -} else { - console.log(`Codex config already has jarvOS hooks enabled: ${configPath}`); -} -NODE - then +if [ ! -f "$HOOK_FEATURE_TRANSACTION_MODULE" ] || [ ! -f "$HOOK_FEATURE_RECEIPT_MODULE" ]; then + HOOK_PHASE_STATUS=1 + echo "Codex semantic hook transaction helpers are unavailable; preserving hook and feature state." >&2 +elif [ "$CODEX_EXECUTABLE_AVAILABLE" -ne 1 ]; then + HOOK_PHASE_STATUS=1 + echo "Codex app-server is unavailable; preserving hook and feature state." >&2 +elif [ "$HOOK_PHASE_STATUS" -eq 0 ]; then + if ! node "$HOOK_FEATURE_TRANSACTION_MODULE" \ + "$ROOT" "$CODEX_EXECUTABLE" "$CODEX_CONFIG" "$CODEX_HOME" \ + "$HOOK_FEATURE_RECEIPT_PATH" "$LEGACY_HOOKS_JSON" "$HOOK_SCRIPT" \ + "$TURN_HOOK_SCRIPT" "$STEWARDSHIP_DISPATCHER" "$ROLLBACK_MODE" \ + "$STEWARDSHIP_BRIDGE_COMMAND" "$STEWARDSHIP_CODEX_SESSION_MAP_ROOT" \ + "${JARVOS_STAGED_PUBLIC_RUNTIME_ROOT:-}"; then HOOK_PHASE_STATUS=1 fi fi