From d775bf3f986e90e1fe435f3faf839bd2a52b5e53 Mon Sep 17 00:00:00 2001 From: Andrew Levine Date: Sun, 30 Aug 2026 21:57:52 -0400 Subject: [PATCH 1/4] fix(codex): restore owned hook feature state --- .../test/agent-context.test.js | 91 ++++++++++++- runtimes/codex/README.md | 15 ++- runtimes/codex/hook-feature-receipt.js | 120 ++++++++++++++++++ runtimes/codex/setup.sh | 120 ++++++++++++++++-- 4 files changed, 329 insertions(+), 17 deletions(-) create mode 100644 runtimes/codex/hook-feature-receipt.js diff --git a/modules/jarvos-agent-context/test/agent-context.test.js b/modules/jarvos-agent-context/test/agent-context.test.js index ea389d55..e461a82f 100644 --- a/modules/jarvos-agent-context/test/agent-context.test.js +++ b/modules/jarvos-agent-context/test/agent-context.test.js @@ -973,7 +973,7 @@ function runCodexSetup(envOverrides = {}) { const configPath = path.join(tmp, 'codex-config.toml'); fs.mkdirSync(binDir, { recursive: true }); fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 }); - fs.writeFileSync(configPath, '', 'utf8'); + fs.writeFileSync(configPath, envOverrides.FAKE_CODEX_CONFIG_INITIAL || '', 'utf8'); // Fake Codex records invocations and persists only a disposable MCP model. const fakeCodex = [ '#!/usr/bin/env node', @@ -1116,6 +1116,7 @@ function runCodexSetup(envOverrides = {}) { providerCliStatePath, providerStatePath, receiptPath: path.join(codexHome, 'jarvos-codex-mcp-receipt.json'), + hookFeatureReceiptPath: path.join(codexHome, 'jarvos-codex-hook-feature-receipt.json'), result, rerun: invoke, cleanup() { @@ -1429,6 +1430,94 @@ test('Codex hook rollback failure does not block MCP or provider rollback', () = } }); +test('Codex hook-feature rollback restores the exact pre-setup sections without empty tables', () => { + const initialConfig = [ + '[features]', + 'hooks = false', + 'codex_hooks = true', + 'unrelated = true', + '', + '[hooks]', + 'SessionStart = [{ hooks = [{ type = "command", command = "user-session-start" }] }]', + '', + ].join('\n'); + const run = runCodexSetup({ + FAKE_CODEX_APP_SERVER_MODE: 'success', + FAKE_CODEX_CONFIG_INITIAL: initialConfig, + }); + try { + assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); + assert.equal(fs.statSync(run.hookFeatureReceiptPath).mode & 0o777, 0o600); + 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.equal(fs.readFileSync(run.configPath, 'utf8'), initialConfig); + assert.doesNotMatch(fs.readFileSync(run.configPath, 'utf8'), /\[hooks\]\s*\n\s*\n/); + } 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 = fs.readFileSync(run.configPath, 'utf8').replace('hooks = true', 'hooks = false'); + fs.writeFileSync(run.configPath, changed, 'utf8'); + + 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.equal(fs.readFileSync(run.configPath, 'utf8'), 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 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); + fs.appendFileSync(run.configPath, '\n[unrelated]\nvalue = "kept"\n'); + + 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 rollback ignores stale forward-install bindings', () => { const run = runCodexSetup({ FAKE_CODEX_APP_SERVER_MODE: 'success' }); try { diff --git a/runtimes/codex/README.md b/runtimes/codex/README.md index 3c9f935e..a3f0d416 100644 --- a/runtimes/codex/README.md +++ b/runtimes/codex/README.md @@ -129,9 +129,18 @@ 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`, profile-scoped ownership +receipt. Before setup changes jarvOS lifecycle configuration, it snapshots only +the affected `[hooks]`, `[features]`, `[shell_environment_policy]`, and +`[shell_environment_policy.set]` tables. Rollback restores those exact +pre-setup tables only when their complete post-setup snapshot still matches; +unrelated tables can change concurrently without blocking restoration. A +missing, malformed, or stale receipt (or detected jarvOS hook state from an +older unreceipted install) leaves that state untouched and reports a nonzero +hook phase while MCP and provider rollback continue independently. If setup +failed before changing hooks, a receipt-free rollback is a no-op. Codex hook +trust is separately managed by Codex and is not restored by this receipt. ### Optional authenticated control-plane host diff --git a/runtimes/codex/hook-feature-receipt.js b/runtimes/codex/hook-feature-receipt.js new file mode 100644 index 00000000..b2b8a476 --- /dev/null +++ b/runtimes/codex/hook-feature-receipt.js @@ -0,0 +1,120 @@ +'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/v1'; +const SECTION_KEYS = ['hooks', 'features', 'shellEnvironmentPolicy', 'shellEnvironmentPolicySet']; +const RECEIPT_KEYS = ['schemaVersion', 'profileDigest', 'before', 'after']; + +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 assertProfileDirectory(profilePath, { create = false } = {}) { + if (typeof profilePath !== 'string' || !path.isAbsolute(profilePath)) fail('CODEX_HOME must be absolute'); + const absolute = path.resolve(profilePath); + if (!fs.existsSync(absolute)) { + if (!create) fail('CODEX_HOME does not exist'); + fs.mkdirSync(absolute, { recursive: true, mode: 0o700 }); + } + const stat = fs.lstatSync(absolute); + const uid = typeof process.getuid === 'function' ? process.getuid() : null; + if (stat.isSymbolicLink() || !stat.isDirectory()) fail('CODEX_HOME must be a real directory'); + if (uid !== null && stat.uid !== uid) fail('CODEX_HOME must be owned by the current user'); + if ((stat.mode & 0o022) !== 0) fail('CODEX_HOME must not be group- or world-writable'); + const real = fs.realpathSync(absolute); + const systemAlias = absolute === '/tmp' || absolute.startsWith('/tmp/') + ? `/private${absolute}` + : (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, options = {}) { + const profile = assertProfileDirectory(profilePath, options); + const receipt = path.resolve(receiptPath); + if (path.dirname(receipt) !== profile) fail('hook-feature receipt must be directly inside CODEX_HOME'); + return { profile, receipt, profileDigest: digest(fs.realpathSync(profile)) }; +} + +function assertReceiptFile(receiptPath) { + const stat = fs.lstatSync(receiptPath); + const uid = typeof process.getuid === 'function' ? process.getuid() : null; + if (stat.isSymbolicLink() || !stat.isFile()) fail('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'); +} + +function validateSnapshot(snapshot) { + if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot) + || Object.keys(snapshot).sort().join('\0') !== [...SECTION_KEYS].sort().join('\0')) { + fail('hook-feature receipt snapshot has an unsupported shape'); + } + for (const key of SECTION_KEYS) { + if (snapshot[key] !== null && typeof snapshot[key] !== 'string') fail('hook-feature receipt snapshot is invalid'); + } + return snapshot; +} + +function validateReceipt(value, profileDigest) { + 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) { + fail('hook-feature receipt is not a recognized jarvOS ownership record'); + } + validateSnapshot(value.before); + validateSnapshot(value.after); + return value; +} + +function readReceipt(receiptPath, profilePath) { + const value = context(receiptPath, profilePath); + try { fs.lstatSync(value.receipt); } catch (error) { + if (error.code === 'ENOENT') return null; + throw error; + } + assertReceiptFile(value.receipt); + 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); +} + +function snapshotsEqual(left, right) { + return JSON.stringify(validateSnapshot(left)) === JSON.stringify(validateSnapshot(right)); +} + +function claimReceipt(receiptPath, profilePath, before, after) { + validateSnapshot(before); + validateSnapshot(after); + const value = context(receiptPath, profilePath, { create: true }); + if (readReceipt(value.receipt, value.profile)) fail('hook-feature receipt already exists'); + const receipt = { schemaVersion: SCHEMA_VERSION, profileDigest: value.profileDigest, before, after }; + let fd; + try { + fd = fs.openSync(value.receipt, 'wx', 0o600); + fs.writeFileSync(fd, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8'); + fs.fsyncSync(fd); + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + return receipt; +} + +function clearReceipt(receiptPath, profilePath, expectedAfter) { + const value = context(receiptPath, profilePath); + const receipt = readReceipt(value.receipt, value.profile); + 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, claimReceipt, clearReceipt, readReceipt, snapshotsEqual, validateSnapshot }; diff --git a/runtimes/codex/setup.sh b/runtimes/codex/setup.sh index 2ffed191..dc750014 100755 --- a/runtimes/codex/setup.sh +++ b/runtimes/codex/setup.sh @@ -16,6 +16,8 @@ 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_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 +110,11 @@ 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 + # 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 @@ -604,12 +611,16 @@ 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' +if [ "$ROLLBACK_MODE" = "1" ] && [ ! -f "$HOOK_FEATURE_RECEIPT_MODULE" ]; then + HOOK_PHASE_STATUS=1 + echo "Codex hook-feature rollback receipt helper is unavailable; preserving hook and feature state." >&2 +elif [ "$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:-}" "$HOOK_FEATURE_RECEIPT_MODULE" "$HOOK_FEATURE_RECEIPT_PATH" "$CODEX_HOME" <<'NODE' const fs = require('fs'); const path = require('path'); -const [configPath, legacyHooksPath, hookScript, turnHookScript, dispatcher, rollback, bridgeCommand, codexSessionMapRoot, stagedRoot] = process.argv.slice(2); +const [configPath, legacyHooksPath, hookScript, turnHookScript, dispatcher, rollback, bridgeCommand, codexSessionMapRoot, stagedRoot, receiptModule, receiptPath, codexHome] = process.argv.slice(2); +const hookFeatureReceipt = require(receiptModule); const original = fs.readFileSync(configPath, 'utf8'); let next = original; @@ -857,6 +868,47 @@ function tomlTableRange(lines, header) { return { start, end }; } +const MANAGED_CONFIG_TABLES = [ + ['hooks', 'hooks'], + ['features', 'features'], + ['shellEnvironmentPolicySet', 'shell_environment_policy.set'], + ['shellEnvironmentPolicy', 'shell_environment_policy'], +]; + +function managedTableSnapshot(content) { + const lines = content.split(/\n/); + const snapshot = {}; + for (const [key, header] of MANAGED_CONFIG_TABLES) { + const { start, end } = tomlTableRange(lines, header); + snapshot[key] = start < 0 ? null : lines.slice(start, end).join('\n'); + } + return hookFeatureReceipt.validateSnapshot(snapshot); +} + +function replaceManagedTable(content, header, replacement) { + const lines = content.split(/\n/); + const { start, end } = tomlTableRange(lines, header); + if (start >= 0) { + lines.splice(start, end - start, ...(replacement === null ? [] : replacement.split('\n'))); + return lines.join('\n'); + } + if (replacement === null) return content; + const suffix = content.endsWith('\n') || content.length === 0 ? '' : '\n'; + return `${content}${suffix}${replacement}\n`; +} + +function restoreManagedTables(content, snapshot) { + hookFeatureReceipt.validateSnapshot(snapshot); + let restored = content; + // Restore the nested table first so removing an absent parent never leaves + // a dangling child table. All replacements are guarded by the full post-setup + // snapshot before this function is reached. + for (const [key, header] of MANAGED_CONFIG_TABLES) { + restored = replaceManagedTable(restored, header, snapshot[key]); + } + return restored; +} + 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)}]`); @@ -985,21 +1037,42 @@ function stewardshipBridgeEnvironment(command, codexMapRoot) { }; } +const beforeManagedTables = managedTableSnapshot(original); +const existingHookFeatureReceipt = hookFeatureReceipt.readReceipt(receiptPath, codexHome); +if (existingHookFeatureReceipt && !hookFeatureReceipt.snapshotsEqual(existingHookFeatureReceipt.after, beforeManagedTables)) { + fail('jarvOS-owned hook or feature state changed after setup; preserving it and its receipt'); +} + +function hasUnreceiptedJarvosHookState(content) { + // A missing receipt must never authorize cleanup of a pre-receipt install. + // The feature flag alone is intentionally not evidence: it is a normal + // Codex preference that a user may have set independently. + return ownedHookPaths.some((target) => content.includes(target)) + || STEWARDSHIP_ENVIRONMENT_KEYS.some((key) => content.includes(key)); +} + let migrated = null; -if (fs.existsSync(legacyHooksPath)) { +let rollbackWithoutReceipt = false; +if (rollback !== '1' && 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); + if (!existingHookFeatureReceipt) { + if (hasUnreceiptedJarvosHookState(original)) { + fail('no jarvOS-owned hook-feature receipt was found for existing jarvOS hook state; preserving the Codex configuration'); + } + // Setup can fail before touching hooks (for example an MCP or provider + // preflight failure). That has no hook state to reconcile, so treat this + // narrow, receipt-free case as a no-op rather than failing rollback. + rollbackWithoutReceipt = true; + } else { + next = restoreManagedTables(original, existingHookFeatureReceipt.before); + } } else { + const bridgeEnvironment = stewardshipBridgeEnvironment(bridgeCommand, codexSessionMapRoot); validateHookTable(next); const startCommand = dispatcher ? `${shellQuote(dispatcher)} --harness codex --action session-start` @@ -1010,21 +1083,42 @@ if (rollback === '1') { 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'); +} + +const afterManagedTables = managedTableSnapshot(next); +if (rollback !== '1' && existingHookFeatureReceipt && !hookFeatureReceipt.snapshotsEqual(existingHookFeatureReceipt.after, afterManagedTables)) { + fail('existing jarvOS hook-feature receipt does not describe the requested setup state'); } -next = setFeature(next, 'hooks', 'true'); -next = removeFeature(next, 'codex_hooks'); if (next !== original || migrated) { + if (rollback === '1') { + // The current snapshot was checked against the receipt before any write. + // A write failure retains the receipt and fails closed on the next attempt. + } else if (!existingHookFeatureReceipt) { + hookFeatureReceipt.claimReceipt(receiptPath, codexHome, beforeManagedTables, afterManagedTables); + } 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); + if (rollback === '1') hookFeatureReceipt.clearReceipt(receiptPath, codexHome, existingHookFeatureReceipt.after); 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}`); + if (rollback === '1') { + if (rollbackWithoutReceipt) { + console.log(`Codex hook-feature rollback found no jarvOS-owned receipt or hook state: ${configPath}`); + } else { + hookFeatureReceipt.clearReceipt(receiptPath, codexHome, existingHookFeatureReceipt.after); + console.log(`Codex hook-feature state was already restored: ${configPath}`); + } + } else { + console.log(`Codex config already has jarvOS hooks enabled: ${configPath}`); + } } NODE then From 1b9a273583f0522d4f1e18e75cc8b370167c4384 Mon Sep 17 00:00:00 2001 From: Andrew Levine Date: Sun, 30 Aug 2026 22:04:36 -0400 Subject: [PATCH 2/4] fix(codex): recover hook receipt transactions --- .../test/agent-context.test.js | 29 +++++++++++++++ runtimes/codex/hook-feature-receipt.js | 37 +++++++++++++++---- runtimes/codex/setup.sh | 28 ++++++++++++-- 3 files changed, 84 insertions(+), 10 deletions(-) diff --git a/modules/jarvos-agent-context/test/agent-context.test.js b/modules/jarvos-agent-context/test/agent-context.test.js index e461a82f..4ca8ea22 100644 --- a/modules/jarvos-agent-context/test/agent-context.test.js +++ b/modules/jarvos-agent-context/test/agent-context.test.js @@ -1500,6 +1500,35 @@ test('Codex hook-feature rollback fails closed for unreceipted jarvOS hook state } }); +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')); + fs.writeFileSync(run.configPath, '', 'utf8'); + 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 { diff --git a/runtimes/codex/hook-feature-receipt.js b/runtimes/codex/hook-feature-receipt.js index b2b8a476..c365898a 100644 --- a/runtimes/codex/hook-feature-receipt.js +++ b/runtimes/codex/hook-feature-receipt.js @@ -4,9 +4,9 @@ const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); -const SCHEMA_VERSION = 'jarvos-codex-hook-feature-receipt/v1'; +const SCHEMA_VERSION = 'jarvos-codex-hook-feature-receipt/v2'; const SECTION_KEYS = ['hooks', 'features', 'shellEnvironmentPolicy', 'shellEnvironmentPolicySet']; -const RECEIPT_KEYS = ['schemaVersion', 'profileDigest', 'before', 'after']; +const RECEIPT_KEYS = ['schemaVersion', 'profileDigest', 'state', 'before', 'after']; function fail(message) { const error = new Error(message); @@ -67,7 +67,8 @@ function validateSnapshot(snapshot) { function validateReceipt(value, profileDigest) { 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.schemaVersion !== SCHEMA_VERSION || value.profileDigest !== profileDigest + || !['pending', 'active'].includes(value.state)) { fail('hook-feature receipt is not a recognized jarvOS ownership record'); } validateSnapshot(value.before); @@ -96,16 +97,38 @@ function claimReceipt(receiptPath, profilePath, before, after) { validateSnapshot(after); const value = context(receiptPath, profilePath, { create: true }); if (readReceipt(value.receipt, value.profile)) fail('hook-feature receipt already exists'); - const receipt = { schemaVersion: SCHEMA_VERSION, profileDigest: value.profileDigest, before, after }; + const receipt = { schemaVersion: SCHEMA_VERSION, profileDigest: value.profileDigest, state: 'pending', before, after }; + writeReceipt(value.receipt, receipt); + return receipt; +} + +function writeReceipt(receiptPath, receipt) { let fd; try { - fd = fs.openSync(value.receipt, 'wx', 0o600); + 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); } - return receipt; +} + +function activateReceipt(receiptPath, profilePath, expectedAfter) { + const value = context(receiptPath, profilePath); + const receipt = readReceipt(value.receipt, value.profile); + 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'); + } + // `rename` is atomic within CODEX_HOME. The replacement remains 0600 and a + // torn process leaves either the pending or active record, both recoverable. + 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, expectedAfter) { @@ -117,4 +140,4 @@ function clearReceipt(receiptPath, profilePath, expectedAfter) { return true; } -module.exports = { SCHEMA_VERSION, claimReceipt, clearReceipt, readReceipt, snapshotsEqual, validateSnapshot }; +module.exports = { SCHEMA_VERSION, claimReceipt, activateReceipt, clearReceipt, readReceipt, snapshotsEqual, validateSnapshot }; diff --git a/runtimes/codex/setup.sh b/runtimes/codex/setup.sh index dc750014..bd1c6239 100755 --- a/runtimes/codex/setup.sh +++ b/runtimes/codex/setup.sh @@ -1038,9 +1038,30 @@ function stewardshipBridgeEnvironment(command, codexMapRoot) { } const beforeManagedTables = managedTableSnapshot(original); -const existingHookFeatureReceipt = hookFeatureReceipt.readReceipt(receiptPath, codexHome); -if (existingHookFeatureReceipt && !hookFeatureReceipt.snapshotsEqual(existingHookFeatureReceipt.after, beforeManagedTables)) { - fail('jarvOS-owned hook or feature state changed after setup; preserving it and its receipt'); +let existingHookFeatureReceipt = hookFeatureReceipt.readReceipt(receiptPath, codexHome); +if (existingHookFeatureReceipt) { + const isBefore = hookFeatureReceipt.snapshotsEqual(existingHookFeatureReceipt.before, beforeManagedTables); + const isAfter = hookFeatureReceipt.snapshotsEqual(existingHookFeatureReceipt.after, beforeManagedTables); + if (existingHookFeatureReceipt.state === 'pending') { + // Claim happens before the managed write. A crash therefore leaves either + // the old state (safe to abandon) or the completed state (safe to activate). + if (isBefore) { + hookFeatureReceipt.clearReceipt(receiptPath, codexHome, existingHookFeatureReceipt.after); + existingHookFeatureReceipt = null; + } else if (isAfter) { + hookFeatureReceipt.activateReceipt(receiptPath, codexHome, existingHookFeatureReceipt.after); + existingHookFeatureReceipt = hookFeatureReceipt.readReceipt(receiptPath, codexHome); + } else { + fail('pending jarvOS hook-feature transaction no longer matches either safe state; preserving the Codex configuration'); + } + } else if (isBefore) { + // Rollback writes before it clears its active receipt. This exact state is + // proof of a completed write interrupted before receipt cleanup. + hookFeatureReceipt.clearReceipt(receiptPath, codexHome, existingHookFeatureReceipt.after); + existingHookFeatureReceipt = null; + } else if (!isAfter) { + fail('jarvOS-owned hook or feature state changed after setup; preserving it and its receipt'); + } } function hasUnreceiptedJarvosHookState(content) { @@ -1105,6 +1126,7 @@ if (next !== original || migrated) { writeAtomically(configPath, next); if (migrated) fs.unlinkSync(legacyHooksPath); if (rollback === '1') hookFeatureReceipt.clearReceipt(receiptPath, codexHome, existingHookFeatureReceipt.after); + else hookFeatureReceipt.activateReceipt(receiptPath, codexHome, afterManagedTables); 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}`); From 3c5957f8c92bbada975671d97e3f20709e92c7ce Mon Sep 17 00:00:00 2001 From: Andrew Levine Date: Sun, 30 Aug 2026 22:25:34 -0400 Subject: [PATCH 3/4] fix(codex): transact hook config through app server --- .../test/agent-context.test.js | 276 +++++++-- .../test/work-action-host.test.js | 13 + runtimes/codex/README.md | 38 +- runtimes/codex/hook-feature-receipt.js | 124 ++-- runtimes/codex/hook-feature-transaction.js | 192 ++++++ runtimes/codex/setup.sh | 553 +----------------- 6 files changed, 549 insertions(+), 647 deletions(-) create mode 100644 runtimes/codex/hook-feature-transaction.js diff --git a/modules/jarvos-agent-context/test/agent-context.test.js b/modules/jarvos-agent-context/test/agent-context.test.js index 4ca8ea22..b95832f0 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, envOverrides.FAKE_CODEX_CONFIG_INITIAL || '', '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.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,6 +1161,8 @@ function runCodexSetup(envOverrides = {}) { tmp, codexHome, configPath, + configModelPath, + rpcLog, codexLog, fakeCodexPath, mcpStatePath, @@ -1119,6 +1172,8 @@ function runCodexSetup(envOverrides = {}) { hookFeatureReceiptPath: path.join(codexHome, 'jarvos-codex-hook-feature-receipt.json'), result, rerun: invoke, + readConfigModel, + writeConfigModel, cleanup() { fs.rmSync(tmp, { recursive: true, force: true }); }, @@ -1198,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 { @@ -1284,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(); } @@ -1412,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); @@ -1430,24 +1487,31 @@ test('Codex hook rollback failure does not block MCP or provider rollback', () = } }); -test('Codex hook-feature rollback restores the exact pre-setup sections without empty tables', () => { - const initialConfig = [ - '[features]', - 'hooks = false', - 'codex_hooks = true', - 'unrelated = true', - '', - '[hooks]', - 'SessionStart = [{ hooks = [{ type = "command", command = "user-session-start" }] }]', - '', - ].join('\n'); +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: initialConfig, + 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/v3'); + 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', + ]); const configured = fs.readFileSync(run.configPath, 'utf8'); assert.match(configured, /hooks = true/); assert.doesNotMatch(configured, /codex_hooks = true/); @@ -1456,8 +1520,7 @@ test('Codex hook-feature rollback restores the exact pre-setup sections without 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(fs.readFileSync(run.configPath, 'utf8'), initialConfig); - assert.doesNotMatch(fs.readFileSync(run.configPath, 'utf8'), /\[hooks\]\s*\n\s*\n/); + assert.deepEqual(run.readConfigModel(), initialModel); } finally { run.cleanup(); } @@ -1467,14 +1530,15 @@ test('Codex hook-feature rollback preserves changed managed sections but still c const run = runCodexSetup({ FAKE_CODEX_APP_SERVER_MODE: 'success' }); try { assert.equal(run.result.status, 0, run.result.stderr || run.result.stdout); - const changed = fs.readFileSync(run.configPath, 'utf8').replace('hooks = true', 'hooks = false'); - fs.writeFileSync(run.configPath, changed, 'utf8'); + 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.equal(fs.readFileSync(run.configPath, 'utf8'), changed); + 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 { @@ -1500,6 +1564,24 @@ test('Codex hook-feature rollback fails closed for unreceipted jarvOS hook state } }); +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 { @@ -1519,7 +1601,7 @@ test('Codex hook-feature rollback clears an active receipt after its completed w 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.configPath, '', '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); @@ -1533,7 +1615,9 @@ 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); - fs.appendFileSync(run.configPath, '\n[unrelated]\nvalue = "kept"\n'); + 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); @@ -1547,6 +1631,95 @@ test('Codex hook-feature rollback preserves unrelated concurrent configuration', } }); +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 { @@ -1568,7 +1741,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); @@ -1580,7 +1753,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/runtimes/codex/README.md b/runtimes/codex/README.md index a3f0d416..7b212d76 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 @@ -130,17 +131,23 @@ 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. -Hook and feature rollback has its own mode-`0600`, profile-scoped ownership -receipt. Before setup changes jarvOS lifecycle configuration, it snapshots only -the affected `[hooks]`, `[features]`, `[shell_environment_policy]`, and -`[shell_environment_policy.set]` tables. Rollback restores those exact -pre-setup tables only when their complete post-setup snapshot still matches; -unrelated tables can change concurrently without blocking restoration. A -missing, malformed, or stale receipt (or detected jarvOS hook state from an -older unreceipted install) leaves that state untouched and reports a nonzero -hook phase while MCP and provider rollback continue independently. If setup -failed before changing hooks, a receipt-free rollback is a no-op. Codex hook -trust is separately managed by Codex and is not restored by this receipt. +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`. 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. ### Optional authenticated control-plane host @@ -207,7 +214,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/hook-feature-receipt.js b/runtimes/codex/hook-feature-receipt.js index c365898a..c7be0735 100644 --- a/runtimes/codex/hook-feature-receipt.js +++ b/runtimes/codex/hook-feature-receipt.js @@ -4,9 +4,17 @@ const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); -const SCHEMA_VERSION = 'jarvos-codex-hook-feature-receipt/v2'; -const SECTION_KEYS = ['hooks', 'features', 'shellEnvironmentPolicy', 'shellEnvironmentPolicySet']; -const RECEIPT_KEYS = ['schemaVersion', 'profileDigest', 'state', 'before', 'after']; +const SCHEMA_VERSION = 'jarvos-codex-hook-feature-receipt/v3'; +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 RECEIPT_KEYS = ['schemaVersion', 'profileDigest', 'configDigest', 'state', 'before', 'after']; function fail(message) { const error = new Error(message); @@ -18,7 +26,21 @@ function digest(value) { return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; } -function assertProfileDirectory(profilePath, { create = false } = {}) { +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)) { @@ -31,44 +53,40 @@ function assertProfileDirectory(profilePath, { create = false } = {}) { 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/') - ? `/private${absolute}` - : (absolute === '/var' || absolute.startsWith('/var/')) ? `/private${absolute}` : null; + 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, options = {}) { - const profile = assertProfileDirectory(profilePath, options); +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, receipt, profileDigest: digest(fs.realpathSync(profile)) }; -} - -function assertReceiptFile(receiptPath) { - const stat = fs.lstatSync(receiptPath); - const uid = typeof process.getuid === 'function' ? process.getuid() : null; - if (stat.isSymbolicLink() || !stat.isFile()) fail('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'); + 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') !== [...SECTION_KEYS].sort().join('\0')) { + || Object.keys(snapshot).sort().join('\0') !== [...OWNED_PATHS].sort().join('\0')) { fail('hook-feature receipt snapshot has an unsupported shape'); } - for (const key of SECTION_KEYS) { - if (snapshot[key] !== null && typeof snapshot[key] !== 'string') fail('hook-feature receipt snapshot is invalid'); + 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 validateReceipt(value, profileDigest) { +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 - || !['pending', 'active'].includes(value.state)) { + || value.configDigest !== configDigest || !['pending', 'active'].includes(value.state)) { fail('hook-feature receipt is not a recognized jarvOS ownership record'); } validateSnapshot(value.before); @@ -76,30 +94,31 @@ function validateReceipt(value, profileDigest) { return value; } -function readReceipt(receiptPath, profilePath) { - const value = context(receiptPath, profilePath); +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; } - assertReceiptFile(value.receipt); + 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); + return validateReceipt(parsed, value.profileDigest, value.configDigest); } function snapshotsEqual(left, right) { - return JSON.stringify(validateSnapshot(left)) === JSON.stringify(validateSnapshot(right)); -} - -function claimReceipt(receiptPath, profilePath, before, after) { - validateSnapshot(before); - validateSnapshot(after); - const value = context(receiptPath, profilePath, { create: true }); - if (readReceipt(value.receipt, value.profile)) fail('hook-feature receipt already exists'); - const receipt = { schemaVersion: SCHEMA_VERSION, profileDigest: value.profileDigest, state: 'pending', before, after }; - writeReceipt(value.receipt, receipt); - return receipt; + 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) { @@ -113,15 +132,20 @@ function writeReceipt(receiptPath, receipt) { } } -function activateReceipt(receiptPath, profilePath, expectedAfter) { - const value = context(receiptPath, profilePath); - const receipt = readReceipt(value.receipt, value.profile); +function claimReceipt(receiptPath, profilePath, configPath, before, after) { + validateSnapshot(before); validateSnapshot(after); + 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 }; + 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'); - } - // `rename` is atomic within CODEX_HOME. The replacement remains 0600 and a - // torn process leaves either the pending or active record, both recoverable. + 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' }); @@ -131,13 +155,13 @@ function activateReceipt(receiptPath, profilePath, expectedAfter) { } } -function clearReceipt(receiptPath, profilePath, expectedAfter) { - const value = context(receiptPath, profilePath); - const receipt = readReceipt(value.receipt, value.profile); +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, claimReceipt, activateReceipt, clearReceipt, readReceipt, snapshotsEqual, validateSnapshot }; +module.exports = { SCHEMA_VERSION, OWNED_PATHS, claimReceipt, activateReceipt, clearReceipt, readReceipt, snapshotsEqual, validateSnapshot }; diff --git a/runtimes/codex/hook-feature-transaction.js b/runtimes/codex/hook-feature-transaction.js new file mode 100644 index 00000000..43c7f3bc --- /dev/null +++ b/runtimes/codex/hook-feature-transaction.js @@ -0,0 +1,192 @@ +'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 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 current = snapshot(layer.config || {}); + 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); + } + const edits = 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' })); + 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, desiredSnapshot, transact }; diff --git a/runtimes/codex/setup.sh b/runtimes/codex/setup.sh index bd1c6239..64137ff1 100755 --- a/runtimes/codex/setup.sh +++ b/runtimes/codex/setup.sh @@ -17,6 +17,7 @@ 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:-}" @@ -115,6 +116,16 @@ if [ "$ROLLBACK_MODE" != "1" ]; then 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 @@ -611,539 +622,19 @@ if [ ! -f "$CODEX_CONFIG" ]; then touch "$CODEX_CONFIG" || HOOK_PHASE_STATUS=1 fi -if [ "$ROLLBACK_MODE" = "1" ] && [ ! -f "$HOOK_FEATURE_RECEIPT_MODULE" ]; 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 hook-feature rollback receipt helper is unavailable; preserving hook and feature state." >&2 + echo "Codex app-server is unavailable; preserving hook and feature state." >&2 elif [ "$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:-}" "$HOOK_FEATURE_RECEIPT_MODULE" "$HOOK_FEATURE_RECEIPT_PATH" "$CODEX_HOME" <<'NODE' -const fs = require('fs'); -const path = require('path'); - -const [configPath, legacyHooksPath, hookScript, turnHookScript, dispatcher, rollback, bridgeCommand, codexSessionMapRoot, stagedRoot, receiptModule, receiptPath, codexHome] = process.argv.slice(2); -const hookFeatureReceipt = require(receiptModule); -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 }; -} - -const MANAGED_CONFIG_TABLES = [ - ['hooks', 'hooks'], - ['features', 'features'], - ['shellEnvironmentPolicySet', 'shell_environment_policy.set'], - ['shellEnvironmentPolicy', 'shell_environment_policy'], -]; - -function managedTableSnapshot(content) { - const lines = content.split(/\n/); - const snapshot = {}; - for (const [key, header] of MANAGED_CONFIG_TABLES) { - const { start, end } = tomlTableRange(lines, header); - snapshot[key] = start < 0 ? null : lines.slice(start, end).join('\n'); - } - return hookFeatureReceipt.validateSnapshot(snapshot); -} - -function replaceManagedTable(content, header, replacement) { - const lines = content.split(/\n/); - const { start, end } = tomlTableRange(lines, header); - if (start >= 0) { - lines.splice(start, end - start, ...(replacement === null ? [] : replacement.split('\n'))); - return lines.join('\n'); - } - if (replacement === null) return content; - const suffix = content.endsWith('\n') || content.length === 0 ? '' : '\n'; - return `${content}${suffix}${replacement}\n`; -} - -function restoreManagedTables(content, snapshot) { - hookFeatureReceipt.validateSnapshot(snapshot); - let restored = content; - // Restore the nested table first so removing an absent parent never leaves - // a dangling child table. All replacements are guarded by the full post-setup - // snapshot before this function is reached. - for (const [key, header] of MANAGED_CONFIG_TABLES) { - restored = replaceManagedTable(restored, header, snapshot[key]); - } - return restored; -} - -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, - }; -} - -const beforeManagedTables = managedTableSnapshot(original); -let existingHookFeatureReceipt = hookFeatureReceipt.readReceipt(receiptPath, codexHome); -if (existingHookFeatureReceipt) { - const isBefore = hookFeatureReceipt.snapshotsEqual(existingHookFeatureReceipt.before, beforeManagedTables); - const isAfter = hookFeatureReceipt.snapshotsEqual(existingHookFeatureReceipt.after, beforeManagedTables); - if (existingHookFeatureReceipt.state === 'pending') { - // Claim happens before the managed write. A crash therefore leaves either - // the old state (safe to abandon) or the completed state (safe to activate). - if (isBefore) { - hookFeatureReceipt.clearReceipt(receiptPath, codexHome, existingHookFeatureReceipt.after); - existingHookFeatureReceipt = null; - } else if (isAfter) { - hookFeatureReceipt.activateReceipt(receiptPath, codexHome, existingHookFeatureReceipt.after); - existingHookFeatureReceipt = hookFeatureReceipt.readReceipt(receiptPath, codexHome); - } else { - fail('pending jarvOS hook-feature transaction no longer matches either safe state; preserving the Codex configuration'); - } - } else if (isBefore) { - // Rollback writes before it clears its active receipt. This exact state is - // proof of a completed write interrupted before receipt cleanup. - hookFeatureReceipt.clearReceipt(receiptPath, codexHome, existingHookFeatureReceipt.after); - existingHookFeatureReceipt = null; - } else if (!isAfter) { - fail('jarvOS-owned hook or feature state changed after setup; preserving it and its receipt'); - } -} - -function hasUnreceiptedJarvosHookState(content) { - // A missing receipt must never authorize cleanup of a pre-receipt install. - // The feature flag alone is intentionally not evidence: it is a normal - // Codex preference that a user may have set independently. - return ownedHookPaths.some((target) => content.includes(target)) - || STEWARDSHIP_ENVIRONMENT_KEYS.some((key) => content.includes(key)); -} - -let migrated = null; -let rollbackWithoutReceipt = false; -if (rollback !== '1' && 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); -} - -if (rollback === '1') { - if (!existingHookFeatureReceipt) { - if (hasUnreceiptedJarvosHookState(original)) { - fail('no jarvOS-owned hook-feature receipt was found for existing jarvOS hook state; preserving the Codex configuration'); - } - // Setup can fail before touching hooks (for example an MCP or provider - // preflight failure). That has no hook state to reconcile, so treat this - // narrow, receipt-free case as a no-op rather than failing rollback. - rollbackWithoutReceipt = true; - } else { - next = restoreManagedTables(original, existingHookFeatureReceipt.before); - } -} else { - const bridgeEnvironment = stewardshipBridgeEnvironment(bridgeCommand, codexSessionMapRoot); - 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'); -} - -const afterManagedTables = managedTableSnapshot(next); -if (rollback !== '1' && existingHookFeatureReceipt && !hookFeatureReceipt.snapshotsEqual(existingHookFeatureReceipt.after, afterManagedTables)) { - fail('existing jarvOS hook-feature receipt does not describe the requested setup state'); -} - -if (next !== original || migrated) { - if (rollback === '1') { - // The current snapshot was checked against the receipt before any write. - // A write failure retains the receipt and fails closed on the next attempt. - } else if (!existingHookFeatureReceipt) { - hookFeatureReceipt.claimReceipt(receiptPath, codexHome, beforeManagedTables, afterManagedTables); - } - 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); - if (rollback === '1') hookFeatureReceipt.clearReceipt(receiptPath, codexHome, existingHookFeatureReceipt.after); - else hookFeatureReceipt.activateReceipt(receiptPath, codexHome, afterManagedTables); - 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 { - if (rollback === '1') { - if (rollbackWithoutReceipt) { - console.log(`Codex hook-feature rollback found no jarvOS-owned receipt or hook state: ${configPath}`); - } else { - hookFeatureReceipt.clearReceipt(receiptPath, codexHome, existingHookFeatureReceipt.after); - console.log(`Codex hook-feature state was already restored: ${configPath}`); - } - } else { - console.log(`Codex config already has jarvOS hooks enabled: ${configPath}`); - } -} -NODE - 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 From a49af74443adc79bdd696bce27bec4543ce7e6b2 Mon Sep 17 00:00:00 2001 From: Andrew Levine Date: Sun, 30 Aug 2026 22:37:08 -0400 Subject: [PATCH 4/4] fix(codex): restore absent config parents --- .../test/agent-context.test.js | 50 +++++- modules/jarvos-runtime-kit/src/index.js | 26 ++- .../test/stewardship-live-adapters.test.js | 152 +++++++++--------- runtimes/codex/README.md | 9 +- runtimes/codex/adapter.json | 3 +- runtimes/codex/hook-feature-receipt.js | 23 ++- runtimes/codex/hook-feature-transaction.js | 49 +++++- runtimes/codex/setup.sh | 3 + 8 files changed, 220 insertions(+), 95 deletions(-) diff --git a/modules/jarvos-agent-context/test/agent-context.test.js b/modules/jarvos-agent-context/test/agent-context.test.js index b95832f0..869856a5 100644 --- a/modules/jarvos-agent-context/test/agent-context.test.js +++ b/modules/jarvos-agent-context/test/agent-context.test.js @@ -1025,7 +1025,7 @@ function runCodexSetup(envOverrides = {}) { " 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.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 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');", @@ -1500,7 +1500,7 @@ test('Codex hook-feature rollback restores exact owned key presence and values', 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/v3'); + 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(), [ @@ -1512,6 +1512,12 @@ test('Codex hook-feature rollback restores exact owned key presence and values', '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/); @@ -1526,6 +1532,46 @@ test('Codex hook-feature rollback restores exact owned key presence and values', } }); +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 { 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 7b212d76..0d5e0762 100644 --- a/runtimes/codex/README.md +++ b/runtimes/codex/README.md @@ -136,7 +136,9 @@ 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`. Setup or rollback then submits one +`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. @@ -149,6 +151,11 @@ 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 Private installs that supply an authenticated host service and credential file 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 index c7be0735..7a14857c 100644 --- a/runtimes/codex/hook-feature-receipt.js +++ b/runtimes/codex/hook-feature-receipt.js @@ -4,7 +4,7 @@ const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); -const SCHEMA_VERSION = 'jarvos-codex-hook-feature-receipt/v3'; +const SCHEMA_VERSION = 'jarvos-codex-hook-feature-receipt/v4'; const OWNED_PATHS = [ 'hooks.SessionStart', 'hooks.UserPromptSubmit', @@ -14,7 +14,8 @@ const OWNED_PATHS = [ 'shell_environment_policy.set.JARVOS_STEWARDSHIP_CODEX_SESSION_MAP_ROOT', 'shell_environment_policy.set.JARVOS_STEWARDSHIP_BRIDGE_CONTEXT_FILE', ]; -const RECEIPT_KEYS = ['schemaVersion', 'profileDigest', 'configDigest', 'state', 'before', 'after']; +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); @@ -82,6 +83,15 @@ function validateSnapshot(snapshot) { 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') @@ -91,6 +101,7 @@ function validateReceipt(value, profileDigest, configDigest) { } validateSnapshot(value.before); validateSnapshot(value.after); + validateParentPresence(value.parentBefore); return value; } @@ -132,11 +143,11 @@ function writeReceipt(receiptPath, receipt) { } } -function claimReceipt(receiptPath, profilePath, configPath, before, after) { - validateSnapshot(before); validateSnapshot(after); +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 }; + const receipt = { schemaVersion: SCHEMA_VERSION, profileDigest: value.profileDigest, configDigest: value.configDigest, state: 'pending', before, after, parentBefore }; writeReceipt(value.receipt, receipt); return receipt; } @@ -164,4 +175,4 @@ function clearReceipt(receiptPath, profilePath, configPath, expectedAfter) { return true; } -module.exports = { SCHEMA_VERSION, OWNED_PATHS, claimReceipt, activateReceipt, clearReceipt, readReceipt, snapshotsEqual, validateSnapshot }; +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 index 43c7f3bc..2de90dab 100644 --- a/runtimes/codex/hook-feature-transaction.js +++ b/runtimes/codex/hook-feature-transaction.js @@ -29,6 +29,35 @@ function getPath(config, keyPath) { 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; @@ -127,7 +156,8 @@ async function transact(options) { 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 current = snapshot(layer.config || {}); + const currentConfig = layer.config || {}; + const current = snapshot(currentConfig); let receipt = receiptApi.readReceipt(options.receiptPath, options.codexHome, options.configPath); let recoveredNoop = false; if (receipt) { @@ -166,11 +196,18 @@ async function transact(options) { 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); + if (!receipt) receipt = receiptApi.claimReceipt( + options.receiptPath, + options.codexHome, + options.configPath, + current, + target, + parentPresence(currentConfig), + ); } - const edits = 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' })); + 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 }); @@ -189,4 +226,4 @@ async function main() { } if (require.main === module) main().catch((error) => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; }); -module.exports = { snapshot, desiredSnapshot, transact }; +module.exports = { snapshot, parentPresence, desiredSnapshot, rollbackEdits, transact }; diff --git a/runtimes/codex/setup.sh b/runtimes/codex/setup.sh index 64137ff1..c2c6bbad 100755 --- a/runtimes/codex/setup.sh +++ b/runtimes/codex/setup.sh @@ -617,6 +617,9 @@ 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