diff --git a/docs/design/context-profile-delivery.md b/docs/design/context-profile-delivery.md index 57d985fcc..d7eaa9721 100644 --- a/docs/design/context-profile-delivery.md +++ b/docs/design/context-profile-delivery.md @@ -68,7 +68,15 @@ The task call sends the query and selected reference content on standard input t `profile start` is an explicit terminal-only boundary. It revalidates the store and native generation, then launches the pinned Codex binary with inherited terminal capabilities and the isolated home. The bootstrap tells the active agent to resolve context at material task boundaries through bounded structured stdin. It remains prompt-advisory, grants no tools or permissions, and persists no task prose or selected skill bodies. Authentication must be completed separately inside the isolated home; the start path does not inherit or copy provider credentials. -Switching the managed profile makes the old native generation stale until `prepare-native` succeeds. To undo a switch, first `rollback` the managed store, then use `native-rollback` with both roots. `native-recover` handles a retained interruption journal without deleting provider data. Existing sessions retain their original context. These commands support isolated Codex generations, not migration of an existing global installation or native activation for other providers. +## Isolated native Claude generations + +A managed store with `--target claude` uses the same `prepare-native`, `native-status`, `native-rollback`, `native-recover`, `run` and `start` commands. Claude Code loads the carrier for each session with `--plugin-dir`; nothing is installed, no marketplace is registered and no settings are written. The generation holds a private `home/.claude` used as `CLAUDE_CONFIG_DIR`, an empty project and a verified copy of the carrier in `plugin/`. Every Claude call sets `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` and `CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL`, so the isolated home gains no other plugins. + +Preparation admits Claude Code 2.1.292 and later 2.1 patches; the receipt records the exact version, and a changed binary needs a new preparation. It requires a clean `claude plugin validate --json` report, exactly one session plugin (`ecc-context-carrier@inline`, enabled, at the generation's `plugin/` path) from `claude --plugin-dir … plugin list --json`, and a `claude --plugin-dir … plugin details` inventory whose skills equal the carrier selection with zero agents, hooks, MCP servers and LSP servers. `details` has no JSON form; an unparseable or changed report fails closed, which is why the admitted range is bounded to the 2.1 series. The copied files must match the carrier digests after the provider calls. + +The receipt records the host's always-on projection as `nativeObservation` (`surface: plugin-always-on-listing`, `method: claude-plugin-details@`, `approximate: true`). It is Claude Code's own estimate of the listing cost of this one plugin, not whole-context usage. `native-status` checks file integrity of the plugin copy, `CLAUDE.md`, user settings, skills, agents and commands in the isolated home; provider runtime state such as `.claude.json` and plugin bookkeeping is outside those controls, and plugin registration is verified again at each preparation. + +Switching the managed profile makes the old native generation stale until `prepare-native` succeeds. To undo a switch, first `rollback` the managed store, then use `native-rollback` with both roots. `native-recover` handles a retained interruption journal without deleting provider data. Existing sessions retain their original context. These commands support isolated Codex and Claude generations, not migration of an existing global installation or native activation for other providers. Discovery evidence comes from the generation's empty project. Task launch inherits the caller's task working directory, whose repository instructions and native configuration may add context or affect policy. Native readiness attests the isolated home's recorded inventory and integrity, not the complete context or permissions of every possible task directory. diff --git a/docs/design/context-profile-delivery.tdd.md b/docs/design/context-profile-delivery.tdd.md index 33a8396d6..571bd2a75 100644 --- a/docs/design/context-profile-delivery.tdd.md +++ b/docs/design/context-profile-delivery.tdd.md @@ -97,4 +97,15 @@ The 77-prompt routing corpus from #2945 (52 direct, 25 paraphrased) found one wr - Corpus result: zero loads outside the expected set; 19 implicit admissions become 17. The other dropped admission, `skill:cost-tracking`, now defers to the bounded proposal instead of loading. - The existing nine-query auto/agent corpus, fallback, launch, evaluation and retrieval suites pass unchanged. -These boundaries keep the shipped behavior distinct from the M1 release gate. Authenticated outcome observations, a complete Tier 2 disk diff, live-install migration, other-provider activation, whole-context token truth and release defaults remain unverified until their explicit prerequisites are available. +## Isolated Claude native generations + +Session-only Claude discovery adapts the ownership and receipt intent of #2788 to the existing native store; it never writes a marketplace or user settings. + +- RED: 22 cases fail before the provider adapter (preview, prepare, version gate, nine discovery corruptions, details mismatch, static integrity, runtime-state tolerance, provider-home roots, Codex-to-Claude staleness, task launch and interactive start). +- GREEN: all 22 pass. Codex native (39), interactive (8), launch (12), auto launch (5), evaluation (31), store (31) and profile CLI suites pass unchanged; Codex receipts keep their shape, and evaluator Claude launches without a plugin directory keep their arguments and environment. +- Credential-free real run on Linux x64 with Claude Code 2.1.292 and the actual registry: Lean prepared ready with three skills and a 398-token always-on projection; Full prepared ready with 293 skills and 35,658 tokens; Full excluding `skill:python-patterns` verified 292 skills without it at 35,561 tokens. Switching profiles reported `stale`, and native rollback restored the Lean generation. `run --dry-run` resolved the pinned binary with `--plugin-dir`; `start --dry-run` proposed the Claude generation. +- Claude Code auto-updated to 2.1.293 during review, and the exact 2.1.292 pin rejected it. RED: tests for later 2.1 patches fail. GREEN admits 2.1.292 and later 2.1 patches while rejecting 2.1.291, 2.2.0, prereleases and malformed output. A real 2.1.293 run prepared Lean ready at the same 398-token projection; preparation took 2.4 s (provider calls 1.2 s), an unchanged re-preparation 0.9 s and status 11 ms. +- No authenticated model call, interactive turn or native skill invocation ran. The projection is the host's estimate for the plugin listing, not whole-context truth. +- Maintainer credential-free macOS arm64 check, October 10: Claude Code 2.1.296 prepared Lean with three skills (~289 listing tokens), Full with the then-current 302 skills (~25,982 listing tokens), reported stale after each managed profile switch, and restored Lean through managed and native rollback. These are observed plugin-listing projections for that snapshot, not whole-context measurements. + +These boundaries keep the shipped behavior distinct from the M1 release gate. Authenticated outcome observations, a complete Tier 2 disk diff, live-install migration, additional-provider activation, whole-context token truth and release defaults remain unverified until their explicit prerequisites are available. diff --git a/scripts/lib/context-profile-commands.js b/scripts/lib/context-profile-commands.js index cb38b8c5e..f855bdfc6 100644 --- a/scripts/lib/context-profile-commands.js +++ b/scripts/lib/context-profile-commands.js @@ -119,8 +119,9 @@ function execute(options) { stateRoot: options['state-root'], nativeRoot: options['native-root'] }) : null; if (native && !native.ready) throw new Error('Prepare or recover the native generation before launching'); return { launch: require('./context-profile-launch').launchTaskContext({ ...launchInput, dryRun: options.dryRun, - nativeEnvironment: native ? { home: native.home, codexHome: native.codexHome, - codexPath: native.codexPath, executableDigest: native.executableDigest } : null, + nativeEnvironment: native ? { home: native.home, executableDigest: native.executableDigest, + ...(native.target === 'claude' ? { claudeConfigDir: native.claudeConfigDir, claudePath: native.claudePath, pluginDir: native.pluginDir } + : { codexHome: native.codexHome, codexPath: native.codexPath }) } : null, assertCurrent() { if (stored) { const current = require('./context-profile-store').getStoreStatus({ stateRoot: options['state-root'] }); @@ -162,7 +163,7 @@ function run(argv) { const options = parse(argv); const value = execute(options); return { schemaVersion: 'ecc.profile-operation.v1', status: (value.launch?.status === 'failed' || value.interactive?.status === 'failed') ? 'error' : 'success', - summary: options.command === 'start' ? 'Opt-in interactive Codex uses the verified isolated generation and inherited terminal. Context selection remains advisory.' + summary: options.command === 'start' ? 'Opt-in interactive session uses the verified isolated generation and inherited terminal. Context selection remains advisory.' : options.command === 'run' ? 'Task launch uses selected context and the provider configuration. Inspect the launch result.' : options.command === 'resolve' ? 'Task context resolved within the selected profile.' : 'Managed profile generation inspected. Native activation is a separate provider boundary.', diff --git a/scripts/lib/context-profile-interactive.js b/scripts/lib/context-profile-interactive.js index 97c81a31a..1e1617568 100644 --- a/scripts/lib/context-profile-interactive.js +++ b/scripts/lib/context-profile-interactive.js @@ -80,18 +80,19 @@ function startInteractiveProfile({ stateRoot, nativeRoot, dryRun = false } = {}, if (carrier.carrierDigest !== stored.carrierDigest) throw new Error('Stored profile source is stale; set and prepare the current generation before starting'); const current = native.getNativeProfileStatus(input); if (!current.ready || current.revision !== prepared.revision) throw new Error('Native generation changed before interactive launch'); - const env = { PATH: process.env.PATH, HOME: current.home, USERPROFILE: current.home, - CODEX_HOME: current.codexHome, LANG: 'C.UTF-8' }; + const claude = current.target === 'claude'; + const env = { PATH: process.env.PATH, HOME: current.home, USERPROFILE: current.home, LANG: 'C.UTF-8', + ...(claude ? { CLAUDE_CONFIG_DIR: current.claudeConfigDir, ...native.CLAUDE_ISOLATION_ENV } : { CODEX_HOME: current.codexHome }) }; // Terminal capabilities are needed by the TUI; credentials and provider overrides are not inherited. for (const key of ['TERM', 'COLORTERM', 'TERM_PROGRAM', 'SystemRoot']) { if (process.env[key]) env[key] = process.env[key]; } - const bootstrapDigest = io.hash(io.read(path.join(current.codexHome, 'AGENTS.md'))); - const result = (dependencies.execute || spawnSync)(current.codexPath, [], { + const bootstrapDigest = io.hash(io.read(claude ? path.join(current.claudeConfigDir, 'CLAUDE.md') : path.join(current.codexHome, 'AGENTS.md'))); + const result = (dependencies.execute || spawnSync)(current.executable, claude ? ['--plugin-dir', current.pluginDir] : [], { cwd: process.cwd(), env, shell: false, stdio: 'inherit' }); return { schemaVersion: 'ecc.interactive-profile.v1', status: result.error || result.status !== 0 ? 'failed' : 'exited', launched: !result.error, exitCode: result.status ?? null, signal: result.signal || null, - ...(result.error ? { error: 'Native interactive Codex could not be started' } : {}), + ...(result.error ? { error: `Native interactive ${claude ? 'Claude' : 'Codex'} could not be started` } : {}), nativeRevision: current.revision, providerVersion: current.providerVersion, bootstrapDigest, credentialsCopied: false, taskSuccess: 'unverified', enforcement: 'prompt-advisory' }; diff --git a/scripts/lib/context-profile-launch.js b/scripts/lib/context-profile-launch.js index 30c539cd5..2cea0917a 100644 --- a/scripts/lib/context-profile-launch.js +++ b/scripts/lib/context-profile-launch.js @@ -9,6 +9,7 @@ function isolatedEnvironment(nativeEnvironment) { USERPROFILE: nativeEnvironment.home, ...(nativeEnvironment.codexHome ? { CODEX_HOME: nativeEnvironment.codexHome } : {}), ...(nativeEnvironment.claudeConfigDir ? { CLAUDE_CONFIG_DIR: nativeEnvironment.claudeConfigDir } : {}), + ...(nativeEnvironment.pluginDir ? require('./context-profile-native').CLAUDE_ISOLATION_ENV : {}), TMPDIR: nativeEnvironment.home, LANG: 'C.UTF-8' }; if (process.platform === 'win32' && process.env.SystemRoot) env.SystemRoot = process.env.SystemRoot; return env; @@ -22,11 +23,11 @@ function launchTaskContext({ task, target = 'codex', dryRun = false, execute = s if (!Object.hasOwn(adapters, target)) throw new Error(`Unsupported task launcher target: ${target}`); if (!task || typeof task.query !== 'string' || !task.query.trim()) throw new Error('Task launch requires a non-empty query'); if (nativeEnvironment) { - const launchKeys = target === 'claude' - ? { directory: nativeEnvironment.claudeConfigDir, executable: nativeEnvironment.claudePath } - : { directory: nativeEnvironment.codexHome, executable: nativeEnvironment.codexPath }; - if (!path.isAbsolute(nativeEnvironment.home || '') || !path.isAbsolute(launchKeys.directory || '') - || !path.isAbsolute(launchKeys.executable || '') + const launchPaths = target === 'claude' + ? [nativeEnvironment.claudeConfigDir, nativeEnvironment.claudePath, + ...(nativeEnvironment.pluginDir === undefined ? [] : [nativeEnvironment.pluginDir])] + : [nativeEnvironment.codexHome, nativeEnvironment.codexPath]; + if (![nativeEnvironment.home, ...launchPaths].every(value => path.isAbsolute(value || '')) || !/^[a-f0-9]{64}$/.test(nativeEnvironment.executableDigest || '')) throw new Error('Invalid isolated native launch environment'); } let selection = bare @@ -34,7 +35,8 @@ function launchTaskContext({ task, target = 'codex', dryRun = false, execute = s selectionMode: 'manual', reason: 'bare-baseline', receipt: { bindingDigest: 'bare' } } : resolveTaskContext({ ...selectionOptions, task, target, load: !dryRun }); const adapter = { ...adapters[target], - ...(nativeEnvironment ? { command: nativeEnvironment.codexPath || nativeEnvironment.claudePath } : {}) }; + ...(nativeEnvironment ? { command: target === 'claude' ? nativeEnvironment.claudePath : nativeEnvironment.codexPath } : {}), + ...(nativeEnvironment?.pluginDir && target === 'claude' ? { args: ['--plugin-dir', nativeEnvironment.pluginDir, ...adapters.claude.args] } : {}) }; function verifyLaunch() { assertCurrent(); if (nativeEnvironment && require('./context-profile-native-executable').fingerprintExecutable(adapter.command).digest diff --git a/scripts/lib/context-profile-native-executable.js b/scripts/lib/context-profile-native-executable.js index 909173681..bd1fadf35 100644 --- a/scripts/lib/context-profile-native-executable.js +++ b/scripts/lib/context-profile-native-executable.js @@ -14,11 +14,14 @@ function nativeFormat(header) { || header.subarray(0, 2).toString() === 'MZ'; } -function resolveExecutable(command) { +const LABELS = { codex: 'Codex', claude: 'Claude Code' }; + +function resolveExecutable(command, name = 'codex') { + if (!Object.hasOwn(LABELS, name)) throw new Error(`Unsupported native executable: ${name}`); const candidate = path.isAbsolute(command) ? command : (process.env.PATH || '').split(path.delimiter) - .filter(directory => path.isAbsolute(directory)).map(directory => path.join(directory, process.platform === 'win32' ? 'codex.exe' : 'codex')) + .filter(directory => path.isAbsolute(directory)).map(directory => path.join(directory, process.platform === 'win32' ? `${name}.exe` : name)) .find(file => fs.existsSync(file)); - if (!candidate) throw new Error('Native Codex executable was not found'); + if (!candidate) throw new Error(`Native ${LABELS[name]} executable was not found`); let executable = fs.realpathSync(candidate); const before = io.inspect(executable); if (!before.stat.isFile() || before.stat.nlink !== 1 || before.stat.size < 4 || before.stat.size > MAX_BYTES) { @@ -33,7 +36,7 @@ function resolveExecutable(command) { } finally { fs.closeSync(fd); } if (!nativeFormat(header)) { // Supported npm distribution: bind its platform binary, never only its JS shim. - if (path.basename(executable) !== 'codex.js') throw new Error('Native adapter requires a native Codex executable'); + if (name !== 'codex' || path.basename(executable) !== 'codex.js') throw new Error(`Native adapter requires a native ${LABELS[name]} executable`); const packageName = `@openai/codex-${process.platform}-${process.arch}`; let manifest; try { manifest = createRequire(executable).resolve(`${packageName}/package.json`); } diff --git a/scripts/lib/context-profile-native.js b/scripts/lib/context-profile-native.js index 8c1e8e16d..ba504699e 100644 --- a/scripts/lib/context-profile-native.js +++ b/scripts/lib/context-profile-native.js @@ -14,15 +14,38 @@ const { digestObject, stableStringify, validateSchema } = require('./context-pro const { discoverSync } = require('./context-profile-native-discovery'); const { fingerprintExecutable, resolveExecutable } = require('./context-profile-native-executable'); -const VERSION = '0.154.0'; -// 0.155.1: credential-free native-probe verified Lean, include, Full exclusion and resource relocation. -const SUPPORTED_VERSIONS = ['0.154.0', '0.155.1']; const DIGEST = /^[a-f0-9]{64}$/; const ID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/; -const KEYS = new Set(['stateRoot', 'nativeRoot', 'expectedRevision', 'expectedCarrierDigest', 'codexPath']); -const CONTROLS = ['marketplace', 'project', 'home/.agents', 'home/.codex/config.toml', - 'home/.codex/AGENTS.md', 'home/.codex/AGENTS.override.md', 'home/.codex/hooks.json', - 'home/.codex/requirements.toml', 'home/.codex/plugins', 'home/.codex/skills']; +const KEYS = new Set(['stateRoot', 'nativeRoot', 'expectedRevision', 'expectedCarrierDigest', 'codexPath', 'claudePath']); +const PLUGIN = 'ecc-context-carrier'; +// Claude loads the carrier per session with --plugin-dir; nothing is installed. +// Official marketplace auto-install would add plugins to the isolated home. +const CLAUDE_ISOLATION_ENV = Object.freeze({ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: '1' }); +const PROVIDERS = Object.freeze({ + codex: { label: 'Codex', command: 'codex', executableKey: 'codexPath', + // 0.155.1: credential-free native-probe verified Lean, include, Full exclusion and resource relocation. + requiredVersion: '0.154.0', versions: ['0.154.0', '0.155.1'], parseVersion: output => /^codex-cli (\S+)$/.exec(output)?.[1] ?? null, + admits(version) { return this.versions.includes(version); }, + bootstrapPath: 'home/.codex/AGENTS.md', + controls: ['marketplace', 'project', 'home/.agents', 'home/.codex/config.toml', + 'home/.codex/AGENTS.md', 'home/.codex/AGENTS.override.md', 'home/.codex/hooks.json', + 'home/.codex/requirements.toml', 'home/.codex/plugins', 'home/.codex/skills'] }, + // 2.1.292 and 2.1.293: credential-free probes verified Lean and Full session-only discovery and + // host projections. Later 2.1 patches are admitted; every check fails closed on output drift. + claude: { label: 'Claude', command: 'claude', executableKey: 'claudePath', + requiredVersion: '2.1.292', versions: ['>=2.1.292 <2.2.0'], parseVersion: output => /^(2\.1\.(0|[1-9]\d*)) \(Claude Code\)$/.exec(output)?.[1] ?? null, + admits(version) { return /^2\.1\.(0|[1-9]\d*)$/.test(version) && Number(version.split('.')[2]) >= 292; }, + bootstrapPath: 'home/.claude/CLAUDE.md', + // Provider runtime state (.claude.json, projects, backups, plugin bookkeeping) + // is not skill discovery state; plugin registration is re-verified on prepare. + controls: ['plugin', 'project', 'home/.claude/CLAUDE.md', 'home/.claude/settings.json', + 'home/.claude/settings.local.json', 'home/.claude/skills', 'home/.claude/agents', 'home/.claude/commands'] }, +}); +const providerFor = target => { + if (!Object.hasOwn(PROVIDERS, target)) throw new Error('Native preparation requires a configured, recovered Codex or Claude managed store'); + return PROVIDERS[target]; +}; const exists = file => Boolean(fs.lstatSync(file, { throwIfNoEntry: false })); const equal = (a, b) => stableStringify(a) === stableStringify(b); const inside = (a, b) => a === b || a.startsWith(`${b}${path.sep}`); @@ -50,9 +73,10 @@ function inputs(options) { if (!options || typeof options !== 'object' || Array.isArray(options)) throw new Error('Native profile options must be an object'); for (const key of Object.keys(options)) if (!KEYS.has(key)) throw new Error(`Unknown native profile option: ${key}`); const { nativeRoot, stateRoot } = options; + const providerHomes = [path.join(os.homedir(), '.codex'), process.env.CODEX_HOME, + path.join(os.homedir(), '.claude'), process.env.CLAUDE_CONFIG_DIR].filter(Boolean); if (typeof nativeRoot !== 'string' || !path.isAbsolute(nativeRoot) || path.resolve(nativeRoot) !== nativeRoot - || nativeRoot === path.parse(nativeRoot).root || nativeRoot === os.homedir() - || nativeRoot === path.join(os.homedir(), '.codex') || nativeRoot === process.env.CODEX_HOME) { + || nativeRoot === path.parse(nativeRoot).root || nativeRoot === os.homedir() || providerHomes.includes(nativeRoot)) { throw new Error('nativeRoot must be an explicit dedicated isolated root'); } if (typeof stateRoot !== 'string' || !path.isAbsolute(stateRoot)) throw new Error('Managed stateRoot is required'); @@ -62,7 +86,7 @@ function inputs(options) { const canonicalNative = exists(nativeRoot) ? fs.realpathSync(nativeRoot) : path.join(fs.realpathSync(path.dirname(nativeRoot)), path.basename(nativeRoot)); const normalized = value => process.platform === 'win32' || process.platform === 'darwin' ? value.toLowerCase() : value; - const forbidden = [os.homedir(), path.join(os.homedir(), '.codex'), process.env.CODEX_HOME].filter(Boolean); + const forbidden = [os.homedir(), ...providerHomes]; if (forbidden.some(file => normalized(exists(file) ? fs.realpathSync(file) : file) === normalized(canonicalNative))) { throw new Error('nativeRoot must be an explicit dedicated isolated root'); } @@ -73,10 +97,14 @@ function inputs(options) { throw new Error('Invalid native expected revision'); } if (options.expectedCarrierDigest !== undefined && !DIGEST.test(options.expectedCarrierDigest)) throw new Error('Invalid native expected carrier digest'); - if (options.codexPath !== undefined && (typeof options.codexPath !== 'string' - || (options.codexPath !== 'codex' && !path.isAbsolute(options.codexPath)))) throw new Error('codexPath must be codex or an absolute executable path'); + for (const { command, executableKey } of Object.values(PROVIDERS)) { + const value = options[executableKey]; + if (value !== undefined && (typeof value !== 'string' || (value !== command && !path.isAbsolute(value)))) { + throw new Error(`${executableKey} must be ${command} or an absolute executable path`); + } + } io.inspect(nativeRoot, true); - return { ...options, codexPath: options.codexPath || 'codex' }; + return { ...options, codexPath: options.codexPath || 'codex', claudePath: options.claudePath || 'claude' }; } function owner(options, create = false) { @@ -96,9 +124,10 @@ function owner(options, create = false) { function currentStore(options) { const current = getStoreStatus({ stateRoot: options.stateRoot }); - if (!current.configured || current.recoveryRequired || current.target !== 'codex') { - throw new Error('Native preparation requires a configured, recovered Codex managed store'); + if (!current.configured || current.recoveryRequired) { + throw new Error('Native preparation requires a configured, recovered Codex or Claude managed store'); } + providerFor(current.target); if (options.expectedCarrierDigest && current.carrierDigest !== options.expectedCarrierDigest) throw new Error('Managed carrier digest changed since preview'); return current; } @@ -123,8 +152,8 @@ function readState(options) { return state; } -function snapshot(root) { - return CONTROLS.map(relative => { +function snapshot(root, provider) { + return provider.controls.map(relative => { const file = path.join(root, relative); if (relative === 'home/.codex/config.toml') { // Provider-owned runtime config: compare discovery-relevant state only @@ -148,36 +177,48 @@ function snapshot(root) { function loadReceipt(options, state, { allowRefresh = false } = {}) { const root = generation(options, state.generationId); const receipt = io.readJson(path.join(root, 'receipt.json')); + // Receipts written before Claude support carry no target and are Codex receipts. + const target = receipt.target ?? 'codex'; if (digestObject(receipt) !== state.generationReceiptDigest || receipt.schemaVersion !== 'ecc.native-context-receipt.v1' - || receipt.generationId !== state.generationId || !SUPPORTED_VERSIONS.includes(receipt.providerVersion) + || receipt.generationId !== state.generationId || !Object.hasOwn(PROVIDERS, target) + || !PROVIDERS[target].admits(receipt.providerVersion) || receipt.bindingDigest !== digestObject({ nativeRoot: options.nativeRoot, stateRoot: options.stateRoot })) { throw new Error('Native receipt integrity failed'); } + const provider = PROVIDERS[target]; const carrier = io.readJson(path.join(root, 'carrier.json')); validateSchema(carrier, 'context-carrier.schema.json'); const { carrierDigest, ...body } = carrier; if (carrierDigest !== receipt.carrierDigest || digestObject(body) !== carrierDigest) throw new Error('Native carrier digest integrity failed'); - if (!equal(snapshot(root), receipt.controls)) throw new Error('Native discovery configuration or skill bytes changed'); + if (!equal(snapshot(root, provider), receipt.controls)) throw new Error('Native discovery configuration or skill bytes changed'); if (!allowRefresh && (!receipt.executable || !equal(fingerprintExecutable(receipt.executable.path), receipt.executable))) { - throw new Error('Native Codex executable changed since preparation'); + throw new Error(`Native ${provider.label} executable changed since preparation`); } if (receipt.bootstrap) { if (receipt.bootstrap.stateRoot !== options.stateRoot || receipt.bootstrap.nativeRoot !== options.nativeRoot || receipt.bootstrap.carrierDigest !== receipt.carrierDigest) throw new Error('Interactive root binding integrity failed'); if (!allowRefresh) require('./context-profile-interactive').verifyBootstrap(receipt.bootstrap); } - return { receipt, carrier, root }; + return { receipt, carrier, root, target }; +} + +function providerPaths(target, root) { + if (target === 'claude') { + return { codexHome: null, claudeConfigDir: root && path.join(root, 'home/.claude'), pluginDir: root && path.join(root, 'plugin') }; + } + return { codexHome: root && path.join(root, 'home/.codex') }; } function response(options, state, current, pending = false, allowRefresh = false) { const base = { schemaVersion: 'ecc.native-context-status.v1', nativeRoot: options.nativeRoot, stateRoot: options.stateRoot, active: false, ready: false, revision: state?.revision || 0, - status: pending ? 'recovery-required' : 'unconfigured', target: 'codex', - providerVersion: VERSION, home: null, codexHome: null, carrierDigest: null, storeRevision: null, + status: pending ? 'recovery-required' : 'unconfigured', target: current.target, + providerVersion: providerFor(current.target).requiredVersion, home: null, ...providerPaths(current.target, null), + carrierDigest: null, storeRevision: null, currentStoreRevision: current.revision, currentCarrierDigest: current.carrierDigest, discovery: 'unobserved', currentSessionChanged: false, credentialsCopied: false }; if (!state) return base; - const { receipt, carrier, root } = loadReceipt(options, state, { allowRefresh }); + const { receipt, carrier, root, target } = loadReceipt(options, state, { allowRefresh }); let bindingsMatch = true; if (allowRefresh) { try { @@ -185,12 +226,14 @@ function response(options, state, current, pending = false, allowRefresh = false if (receipt.bootstrap) require('./context-profile-interactive').verifyBootstrap(receipt.bootstrap); } catch { bindingsMatch = false; } } - const matches = state.storeRevision === current.revision && receipt.carrierDigest === current.carrierDigest; + const matches = state.storeRevision === current.revision && receipt.carrierDigest === current.carrierDigest + && target === current.target; return { ...base, status: pending ? 'recovery-required' : !bindingsMatch ? 'refresh-required' : matches ? 'ready' : 'stale', - ready: matches && bindingsMatch && !pending, providerVersion: receipt.providerVersion, bootstrap: receipt.bootstrap || null, - home: path.join(root, 'home'), codexHome: path.join(root, 'home/.codex'), + ready: matches && bindingsMatch && !pending, target, providerVersion: receipt.providerVersion, bootstrap: receipt.bootstrap || null, + home: path.join(root, 'home'), ...providerPaths(target, root), carrierDigest: receipt.carrierDigest, storeRevision: state.storeRevision, - codexPath: receipt.executable.path, executable: receipt.executable.path, executableDigest: receipt.executable.digest, + [PROVIDERS[target].executableKey]: receipt.executable.path, executable: receipt.executable.path, executableDigest: receipt.executable.digest, + ...(receipt.nativeObservation ? { nativeObservation: receipt.nativeObservation } : {}), selectedIds: carrier.selectedIds, discovery: 'verified', evidenceScope: 'native-preparation-with-current-file-integrity', activation: 'isolated-home-ready-for-new-session', modelInvocation: 'unobserved' }; } @@ -208,33 +251,91 @@ function previewNativeProfile(input) { exists(path.join(options.nativeRoot, 'pending.json')) || exists(path.join(options.nativeRoot, '.lock')), true) : response(options, null, current); if (options.expectedRevision !== undefined && options.expectedRevision !== before.revision) throw new Error('Native revision changed since preview'); + const provider = providerFor(current.target); return { ...before, status: 'proposed', ready: false, proposedCarrierDigest: current.carrierDigest, - proposedStoreRevision: current.revision, requiredProviderVersion: VERSION, supportedProviderVersions: [...SUPPORTED_VERSIONS] }; + proposedStoreRevision: current.revision, requiredProviderVersion: provider.requiredVersion, + supportedProviderVersions: [...provider.versions] }; } -function environment(root) { - const env = { PATH: process.env.PATH, HOME: path.join(root, 'home'), CODEX_HOME: path.join(root, 'home/.codex'), LANG: 'C.UTF-8' }; +function environment(root, target) { + const env = { PATH: process.env.PATH, HOME: path.join(root, 'home'), LANG: 'C.UTF-8', + ...(target === 'claude' ? { CLAUDE_CONFIG_DIR: path.join(root, 'home/.claude'), ...CLAUDE_ISOLATION_ENV } + : { CODEX_HOME: path.join(root, 'home/.codex') }) }; if (process.platform === 'win32' && process.env.SystemRoot) env.SystemRoot = process.env.SystemRoot; return env; } function command(options, root, args, dependencies) { - if (options.executableBinding && !equal(fingerprintExecutable(options.codexPath), options.executableBinding)) { + const { label } = providerFor(options.target); + if (options.executableBinding && !equal(fingerprintExecutable(options.executablePath), options.executableBinding)) { throw new Error('Native executable changed before provider call'); } - const result = (dependencies.execute || spawnSync)(options.codexPath, args, { - cwd: path.join(root, 'project'), env: environment(root), encoding: 'utf8', shell: false, + const result = (dependencies.execute || spawnSync)(options.executablePath, args, { + cwd: path.join(root, 'project'), env: environment(root, options.target), encoding: 'utf8', shell: false, timeout: 30000, killSignal: 'SIGKILL', maxBuffer: 2 * 1024 * 1024 }); - if (result.error || result.status !== 0) throw new Error('Native Codex command failed; isolated attempt retained for recovery'); - if (typeof result.stdout !== 'string' || Buffer.byteLength(result.stdout) > 2 * 1024 * 1024) throw new Error('Native Codex command output exceeded its bound'); + if (result.error || result.status !== 0) throw new Error(`Native ${label} command failed; isolated attempt retained for recovery`); + if (typeof result.stdout !== 'string' || Buffer.byteLength(result.stdout) > 2 * 1024 * 1024) throw new Error(`Native ${label} command output exceeded its bound`); return result.stdout.trim(); } +function verifyFiles(directory, carrier) { + const observed = io.inventory(directory).files.sort((a, b) => a.path.localeCompare(b.path)); + const expected = carrier.files.map(file => ({ path: file.destinationPath, bytes: file.bytes, digest: file.digest })) + .sort((a, b) => a.path.localeCompare(b.path)); + if (!equal(observed, expected)) throw new Error('Native installed file set or digest mismatch'); +} + +function parseJson(text, message) { + try { return JSON.parse(text); } catch { throw new Error(message); } +} + +function componentLine(lines, label) { + const match = lines.map(line => line.trim()).map(line => new RegExp(`^${label} \\((\\d+)\\)(?:\\s+(.*))?$`).exec(line)).find(Boolean); + if (!match) throw new Error('Native Claude component discovery mismatch'); + return { count: Number(match[1]), rest: match[2] || '' }; +} + +// `claude plugin details` has no JSON form; the pinned version fixes this layout. +function parsePluginDetails(text) { + const lines = text.split(/\r?\n/); + const skills = componentLine(lines, 'Skills'); + const names = skills.count ? skills.rest.split(',').map(name => name.trim()) : []; + const counts = ['Agents', 'Hooks', 'MCP servers', 'LSP servers'].map(label => componentLine(lines, label).count); + const projection = lines.map(line => /^\s*Always-on:\s+~?(\d{1,3}(?:,\d{3})*|\d+)(k?) tok\b/.exec(line)).find(Boolean); + if (!projection) throw new Error('Native Claude token projection is unparseable'); + const tokens = Number(projection[1].replace(/,/g, '')) * (projection[2] ? 1000 : 1); + return { skills: names.length === skills.count ? names.sort() : null, extraComponents: counts.some(count => count !== 0), tokens }; +} + +function verifyClaude(options, root, carrier, dependencies) { + if (PROVIDERS.claude.parseVersion(command(options, root, ['--version'], dependencies)) !== options.providerVersion) { + throw new Error('Native Claude version changed since verification'); + } + const pluginDir = path.join(root, 'plugin'); + const report = parseJson(command(options, root, ['plugin', 'validate', '--json', pluginDir], dependencies), 'Native Claude plugin validation failed'); + if (report?.success !== true || !Array.isArray(report.manifest?.errors) || report.manifest.errors.length + || !Array.isArray(report.contents) || !report.contents.every(item => Array.isArray(item?.errors) && item.errors.length === 0)) { + throw new Error('Native Claude plugin validation failed'); + } + const listed = parseJson(command(options, root, ['--plugin-dir', pluginDir, 'plugin', 'list', '--json'], dependencies), + 'Native Claude plugin discovery mismatch'); + if (!Array.isArray(listed) || listed.length !== 1 || listed[0]?.id !== `${PLUGIN}@inline` || listed[0].scope !== 'session' + || listed[0].enabled !== true || listed[0].installPath !== pluginDir) throw new Error('Native Claude plugin discovery mismatch'); + const details = parsePluginDetails(command(options, root, ['--plugin-dir', pluginDir, 'plugin', 'details', PLUGIN], dependencies)); + if (details.extraComponents || !equal(details.skills, carrier.entries.map(entry => entry.name).sort())) { + throw new Error('Native Claude component discovery mismatch'); + } + verifyFiles(pluginDir, carrier); + return { surface: 'plugin-always-on-listing', method: `claude-plugin-details@${options.providerVersion}`, + tokens: details.tokens, approximate: true }; +} + function verifyNative(options, root, carrier, dependencies) { + if (options.target === 'claude') return verifyClaude(options, root, carrier, dependencies); if (command(options, root, ['--version'], dependencies) !== `codex-cli ${options.providerVersion}`) throw new Error('Native Codex version changed since verification'); - const env = environment(root); const marketplaceName = `ecc-context-${carrier.carrierDigest.slice(0, 16)}`; + const env = environment(root, 'codex'); const marketplaceName = `ecc-context-${carrier.carrierDigest.slice(0, 16)}`; const cache = path.join(env.CODEX_HOME, 'plugins/cache', marketplaceName, 'ecc-context-carrier/local'); - const result = (dependencies.discover || discoverSync)(options.codexPath, { cwd: path.join(root, 'project'), env }); + const result = (dependencies.discover || discoverSync)(options.executablePath, { cwd: path.join(root, 'project'), env }); if (!result || !Array.isArray(result.data) || result.data.length !== 1 || !equal(result.data[0].errors, []) || result.data[0].cwd !== path.join(root, 'project') || !Array.isArray(result.data[0].skills)) throw new Error('Native skill discovery shape, project binding or parser errors'); @@ -249,10 +350,8 @@ function verifyNative(options, root, carrier, dependencies) { const name = skill.name.slice('ecc-context-carrier:'.length); if (!skill.enabled || skill.path !== path.join(cache, 'skills', name, 'SKILL.md')) throw new Error('Native skill discovery enabled state or path mismatch'); } - const observed = io.inventory(cache).files.sort((a, b) => a.path.localeCompare(b.path)); - const expected = carrier.files.map(file => ({ path: file.destinationPath, bytes: file.bytes, digest: file.digest })) - .sort((a, b) => a.path.localeCompare(b.path)); - if (!equal(observed, expected)) throw new Error('Native installed file set or digest mismatch'); + verifyFiles(cache, carrier); + return null; } function checkpoint(dependencies, point) { if (dependencies.onCheckpoint) dependencies.onCheckpoint(point); } @@ -296,19 +395,40 @@ function publish(options, before, current, generationId, receipt, dependencies) return response(options, state, current); } -function register(options, root, carrier, current, dependencies) { - for (const relative of ['home', 'home/.codex', 'project', 'marketplace', 'marketplace/.agents', 'marketplace/.agents/plugins', 'marketplace/carrier']) { - io.mkdir(path.join(root, relative)); - } - const version = command(options, root, ['--version'], dependencies); - const providerVersion = SUPPORTED_VERSIONS.find(value => version === `codex-cli ${value}`); - if (!providerVersion) throw new Error(`Native Codex version must be exactly ${SUPPORTED_VERSIONS.join(' or ')}`); +function copyCarrier(root, destination, carrier, current) { for (const file of carrier.files) { - const relative = `marketplace/carrier/${file.destinationPath}`; + const relative = `${destination}/${file.destinationPath}`; const bytes = io.read(path.join(current.generationRoot, file.destinationPath)); if (io.hash(bytes) !== file.digest || bytes.length !== file.bytes) throw new Error('Managed carrier source digest changed'); io.ensureParents(root, relative); io.writeExclusive(path.join(root, relative), bytes); } +} + +function providerVersionFor(options, root, dependencies) { + const provider = providerFor(options.target); + const version = command(options, root, ['--version'], dependencies); + const providerVersion = provider.parseVersion(version); + if (!providerVersion || !provider.admits(providerVersion)) { + throw new Error(`Native ${provider.label} version must be ${provider.versions.join(' or ')}`); + } + return providerVersion; +} + +function registerClaude(options, root, carrier, current, dependencies) { + for (const relative of ['home', 'home/.claude', 'project', 'plugin']) io.mkdir(path.join(root, relative)); + const providerVersion = providerVersionFor(options, root, dependencies); + copyCarrier(root, 'plugin', carrier, current); + checkpoint(dependencies, 'registered'); + return { providerVersion, nativeObservation: verifyNative({ ...options, providerVersion }, root, carrier, dependencies) }; +} + +function register(options, root, carrier, current, dependencies) { + if (options.target === 'claude') return registerClaude(options, root, carrier, current, dependencies); + for (const relative of ['home', 'home/.codex', 'project', 'marketplace', 'marketplace/.agents', 'marketplace/.agents/plugins', 'marketplace/carrier']) { + io.mkdir(path.join(root, relative)); + } + const providerVersion = providerVersionFor(options, root, dependencies); + copyCarrier(root, 'marketplace/carrier', carrier, current); const name = `ecc-context-${carrier.carrierDigest.slice(0, 16)}`; io.writeExclusive(path.join(root, 'marketplace/.agents/plugins/marketplace.json'), io.jsonBytes({ name, plugins: [{ name: 'ecc-context-carrier', source: { source: 'local', path: './carrier' }, @@ -317,22 +437,23 @@ function register(options, root, carrier, current, dependencies) { command(options, root, ['plugin', 'add', `ecc-context-carrier@${name}`, '--json'], dependencies); checkpoint(dependencies, 'registered'); verifyNative({ ...options, providerVersion }, root, carrier, dependencies); - return providerVersion; + return { providerVersion, nativeObservation: null }; } function prepareNativeProfile(input, dependencies = {}) { let options = inputs(input); const current = currentStore(options); previewNativeProfile(options); - const executable = resolveExecutable(options.codexPath); + const provider = providerFor(current.target); + const executable = resolveExecutable(options[provider.executableKey], provider.command); owner(options, true); - options = { ...options, codexPath: executable.path, executableBinding: executable }; + options = { ...options, target: current.target, executablePath: executable.path, executableBinding: executable }; return locked(options, false, () => { if (exists(path.join(options.nativeRoot, 'pending.json'))) throw new Error('Native attempt requires recovery'); const before = readState(options); if (options.expectedRevision !== undefined && options.expectedRevision !== (before?.revision || 0)) throw new Error('Native revision changed since preview'); const previous = before ? loadReceipt(options, before, { allowRefresh: true }) : null; const bootstrap = require('./context-profile-interactive').bootstrapFor(options, current); - if (before && before.storeRevision === current.revision) { + if (before && before.storeRevision === current.revision && previous.target === current.target) { if (previous.receipt.carrierDigest === current.carrierDigest && equal(previous.receipt.executable, executable) && equal(previous.receipt.bootstrap, bootstrap.binding)) { verifyNative({ ...options, providerVersion: previous.receipt.providerVersion }, previous.root, previous.carrier, dependencies); recheckStore(options, current); @@ -350,11 +471,13 @@ function prepareNativeProfile(input, dependencies = {}) { const { carrierDigest, ...body } = carrier; if (carrierDigest !== current.carrierDigest || digestObject(body) !== carrierDigest) throw new Error('Managed carrier descriptor changed before native registration'); io.writeExclusive(path.join(root, 'carrier.json'), io.jsonBytes(carrier)); - const providerVersion = register(options, root, carrier, current, dependencies); - io.writeExclusive(path.join(root, 'home/.codex/AGENTS.md'), bootstrap.bytes); + const { providerVersion, nativeObservation } = register(options, root, carrier, current, dependencies); + io.writeExclusive(path.join(root, provider.bootstrapPath), bootstrap.bytes); const receipt = { schemaVersion: 'ecc.native-context-receipt.v1', generationId, bindingDigest: digestObject({ nativeRoot: options.nativeRoot, stateRoot: options.stateRoot }), - carrierDigest: carrier.carrierDigest, providerVersion, executable, bootstrap: bootstrap.binding, controls: snapshot(root) }; + ...(current.target === 'codex' ? {} : { target: current.target }), + carrierDigest: carrier.carrierDigest, providerVersion, executable, bootstrap: bootstrap.binding, + ...(nativeObservation ? { nativeObservation } : {}), controls: snapshot(root, provider) }; io.writeExclusive(path.join(root, 'receipt.json'), io.jsonBytes(receipt)); checkpoint(dependencies, 'verified'); return publish(options, before, current, generationId, receipt, dependencies); @@ -373,8 +496,11 @@ function rollbackNativeProfile(input, dependencies = {}) { const receipt = io.readJson(path.join(root, 'receipt.json')); const previous = loadReceipt(options, { generationId: before.previousGenerationId, generationReceiptDigest: before.previousGenerationReceiptDigest }); - if (receipt.carrierDigest !== current.carrierDigest) throw new Error('Rollback the managed store to the previous native carrier first'); - verifyNative({ ...options, providerVersion: receipt.providerVersion, codexPath: receipt.executable.path, executableBinding: receipt.executable }, root, previous.carrier, dependencies); + if (receipt.carrierDigest !== current.carrierDigest || previous.target !== current.target) { + throw new Error('Rollback the managed store to the previous native carrier first'); + } + verifyNative({ ...options, target: previous.target, providerVersion: receipt.providerVersion, + executablePath: receipt.executable.path, executableBinding: receipt.executable }, root, previous.carrier, dependencies); io.atomicJson(path.join(options.nativeRoot, 'pending.json'), { schemaVersion: 'ecc.native-context-pending.v1', before, generationId: before.previousGenerationId, carrierDigest: current.carrierDigest, storeRevision: current.revision }); return publish(options, before, current, before.previousGenerationId, receipt, dependencies); @@ -400,4 +526,5 @@ function recoverNativeProfile(input) { }); } -module.exports = { getNativeProfileStatus, prepareNativeProfile, previewNativeProfile, recoverNativeProfile, rollbackNativeProfile }; +module.exports = { CLAUDE_ISOLATION_ENV, getNativeProfileStatus, prepareNativeProfile, previewNativeProfile, + recoverNativeProfile, rollbackNativeProfile }; diff --git a/scripts/profile.js b/scripts/profile.js index 40c5577dc..0aefd7b06 100644 --- a/scripts/profile.js +++ b/scripts/profile.js @@ -30,7 +30,7 @@ Experimental managed profiles and bounded task context: ecc profile resolve [lean|full] --task-input task.json|- [--load] [--previous receipt.json] [--json] ecc profile run --task-input task.json|- [--state-root ] [--target codex|claude] [--dry-run] [--json] ecc profile set lean|full --state-root [--selection auto|manual|suggest] - [--target codex] [--include skill:] [--exclude skill:] [--dry-run] [--json] + [--target codex|claude] [--include skill:] [--exclude skill:] [--dry-run] [--json] ecc profile status --state-root [--json] ecc profile mode auto|manual|suggest --state-root [--dry-run] [--json] ecc profile rollback --state-root [--expected-revision N] [--json] @@ -42,11 +42,12 @@ Experimental managed profiles and bounded task context: ecc profile native-recover --state-root --native-root [--json] Task input may be one bounded UTF-8 JSON object on stdin with --task-input - (65536 bytes maximum). Start requires prepare-native, launches the pinned native TUI with inherited stdio and provider permissions, -and reads a receipt-bound isolated AGENTS bootstrap. Authenticate separately in the isolated home; credentials are never copied. +and reads a receipt-bound isolated bootstrap (Codex AGENTS.md, Claude CLAUDE.md). Claude loads the carrier with --plugin-dir; +nothing is installed. Authenticate separately in the isolated home; credentials are never copied. Set stages owned generations; provider discovery is verified separately. Resolve returns context only with --load; suggest and --dry-run never return skill bodies. Use resolve --state-root to honor the saved base, mode and exclusions. -Run with --native-root to use a verified isolated Codex generation. Existing sessions are unchanged. +Run with --native-root to use a verified isolated Codex or Claude generation. Existing sessions are unchanged. `; } diff --git a/tests/lib/context-profile-native-claude.test.js b/tests/lib/context-profile-native-claude.test.js new file mode 100644 index 000000000..f74c6892a --- /dev/null +++ b/tests/lib/context-profile-native-claude.test.js @@ -0,0 +1,240 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const { withFixture } = require('./helpers/context-fixture'); +const store = require('../../scripts/lib/context-profile-store'); +const native = () => require('../../scripts/lib/context-profile-native'); + +function fixture(callback) { + return withFixture(repoRoot => { + const parent = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ecc-native-claude-test-')); + const options = { stateRoot: path.join(parent, 'managed'), nativeRoot: path.join(parent, 'native'), claudePath: process.execPath }; + try { + store.applyStore({ repoRoot, stateRoot: options.stateRoot, target: 'claude' }); + return callback(options, repoRoot, parent); + } finally { fs.rmSync(parent, { recursive: true, force: true }); } + }); +} + +function details(pluginDir, overrides) { + const skills = fs.readdirSync(path.join(pluginDir, 'skills')).sort(); + const counts = { agents: 0, hooks: 0, mcp: 0, lsp: 0, ...overrides.counts }; + return ['ecc-context-carrier', ' Source: ecc-context-carrier@inline', '', 'Component inventory', + ` Skills (${skills.length}) ${skills.join(', ')}`, ` Agents (${counts.agents})`, ` Hooks (${counts.hooks})`, + ` MCP servers (${counts.mcp})`, ` LSP servers (${counts.lsp})`, '', 'Projected token cost', + ` Always-on: ${overrides.alwaysOn || '~1,398 tok'} added to every session`, ''].join('\n'); +} + +function provider(overrides = {}) { + const calls = []; + const execute = (command, args, options) => { + calls.push({ command, args, options }); + assert.equal(options.killSignal, 'SIGKILL'); + assert.equal(options.shell, false); + for (const key of ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'NODE_OPTIONS', 'HTTP_PROXY', 'AWS_ACCESS_KEY_ID', 'CODEX_HOME']) { + assert.equal(options.env[key], undefined); + } + assert.equal(options.env.CLAUDE_CONFIG_DIR, path.join(options.env.HOME, '.claude')); + assert.equal(options.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, '1'); + assert.equal(options.env.CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL, '1'); + assert.equal(options.cwd, path.join(path.dirname(options.env.HOME), 'project')); + if (args[0] === '--version') return { status: 0, stdout: `${overrides.version || '2.1.292 (Claude Code)'}\n` }; + if (args[0] === 'plugin' && args[1] === 'validate') { + const report = { success: true, manifest: { errors: [], warnings: [] }, contents: [] }; + return { status: 0, stdout: JSON.stringify(overrides.validate ? overrides.validate(report) : report) }; + } + const pluginDir = args[1]; + if (overrides.tamper) overrides.tamper(pluginDir); + if (args[3] === 'list') { + const plugins = [{ id: 'ecc-context-carrier@inline', version: 'unknown', scope: 'session', enabled: true, installPath: pluginDir }]; + return { status: 0, stdout: JSON.stringify(overrides.list ? overrides.list(plugins) : plugins) }; + } + if (args[3] === 'details') return { status: 0, stdout: details(pluginDir, overrides) }; + return { status: 1, stderr: 'unexpected command' }; + }; + return { execute, calls, ...overrides }; +} + +test('Claude preview is deterministic and never invokes the provider or creates a home', () => fixture(options => { + const first = native().previewNativeProfile(options); + assert.deepEqual(first, native().previewNativeProfile(options)); + assert.equal(first.target, 'claude'); + assert.equal(first.status, 'proposed'); + assert.deepEqual(first.supportedProviderVersions, ['>=2.1.292 <2.2.0']); + assert.equal(fs.existsSync(options.nativeRoot), false); +})); + +test('Claude prepare verifies session-only discovery and records the host token projection', () => fixture(options => { + const dependency = provider(); + const result = native().prepareNativeProfile(options, dependency); + assert.equal(result.status, 'ready'); + assert.equal(result.target, 'claude'); + assert.equal(result.providerVersion, '2.1.292'); + assert.equal(result.discovery, 'verified'); + assert.equal(result.codexHome, null); + assert.equal(result.claudeConfigDir, path.join(result.home, '.claude')); + assert.equal(result.pluginDir, path.join(path.dirname(result.home), 'plugin')); + assert.equal(result.claudePath, process.execPath); + assert.deepEqual(result.nativeObservation, { surface: 'plugin-always-on-listing', + method: 'claude-plugin-details@2.1.292', tokens: 1398, approximate: true }); + assert.equal(result.selectedIds.length, 3); + assert.match(fs.readFileSync(path.join(result.claudeConfigDir, 'CLAUDE.md'), 'utf8'), /grants no tools/); + assert.equal(fs.existsSync(path.join(result.home, '.codex')), false); + const pluginCalls = dependency.calls.filter(call => call.args.includes('--plugin-dir')); + assert.deepEqual(pluginCalls.map(call => call.args.slice(2)), [['plugin', 'list', '--json'], ['plugin', 'details', 'ecc-context-carrier']]); + assert.ok(pluginCalls.every(call => call.args[1] === result.pluginDir)); + assert.equal(native().getNativeProfileStatus(options).status, 'ready'); +})); + +for (const version of ['2.1.293 (Claude Code)', '2.1.1000 (Claude Code)']) { + test(`Claude version gate admits later 2.1 patch "${version}" and records it exactly`, () => fixture(options => { + const result = native().prepareNativeProfile(options, provider({ version })); + assert.equal(result.providerVersion, version.split(' ')[0]); + assert.equal(native().getNativeProfileStatus(options).providerVersion, version.split(' ')[0]); + })); +} + +for (const version of ['2.1.291 (Claude Code)', '2.1.292', '2.1.292 (Claude Code) extra', '2.2.0 (Claude Code)', + '2.1.292-beta (Claude Code)', '3.1.292 (Claude Code)', '02.1.292 (Claude Code)']) { + test(`Claude version gate rejects "${version}" before plugin calls`, () => fixture(options => { + const dependency = provider({ version }); + assert.throws(() => native().prepareNativeProfile(options, dependency), /version/i); + assert.equal(dependency.calls.some(call => call.args.includes('plugin')), false); + })); +} + +const corruptions = { + 'validation error': [{ validate: report => ({ ...report, success: false, manifest: { errors: [{ path: 'skills' }] } }) }, /plugin validation failed/], + 'null validation entry': [{ validate: report => ({ ...report, contents: [null] }) }, /plugin validation failed/], + 'validation entry without errors': [{ validate: report => ({ ...report, contents: [{ type: 'skill' }] }) }, /plugin validation failed/], + 'validation entry with non-array errors': [{ validate: report => ({ ...report, contents: [{ type: 'skill', errors: 'bad' }] }) }, + /plugin validation failed/], + 'validation entry error': [{ validate: report => ({ ...report, contents: [{ type: 'skill', errors: [{ path: 'name' }] }] }) }, + /plugin validation failed/], + 'extra plugin': [{ list: plugins => [...plugins, { id: 'other@market', scope: 'user', enabled: true, installPath: '/elsewhere' }] }, /plugin discovery mismatch/], + 'moved plugin': [{ list: plugins => [{ ...plugins[0], installPath: '/elsewhere' }] }, /plugin discovery mismatch/], + 'disabled plugin': [{ list: plugins => [{ ...plugins[0], enabled: false }] }, /plugin discovery mismatch/], + 'extra agent': [{ counts: { agents: 1 } }, /component discovery mismatch/], + 'extra hook': [{ counts: { hooks: 1 } }, /component discovery mismatch/], + 'extra MCP server': [{ counts: { mcp: 1 } }, /component discovery mismatch/], + 'unparseable token projection': [{ alwaysOn: 'unknown' }, /token projection/], + 'tampered plugin bytes': [{ tamper: pluginDir => fs.appendFileSync(path.join(pluginDir, 'skills/ecc-guide/SKILL.md'), 'tamper') }, + /file set or digest mismatch/], +}; +for (const [label, [overrides, expected]] of Object.entries(corruptions)) { + test(`Claude ${label} never commits a ready pointer`, () => fixture(options => { + assert.throws(() => native().prepareNativeProfile(options, provider(overrides)), expected); + assert.equal(fs.existsSync(path.join(options.nativeRoot, 'state.json')), false); + })); +} + +test('Claude details skill mismatch never commits a ready pointer', () => fixture(options => { + const dependency = provider(); + const execute = dependency.execute; + dependency.execute = (command, args, config) => { + const result = execute(command, args, config); + return args[3] === 'details' ? { ...result, stdout: result.stdout.replace(', ecc-guide', '') } : result; + }; + assert.throws(() => native().prepareNativeProfile(options, dependency), /component discovery mismatch/); + assert.equal(fs.existsSync(path.join(options.nativeRoot, 'state.json')), false); +})); + +test('Claude plugin bytes and added user skills reject static readiness', () => fixture(options => { + const prepared = native().prepareNativeProfile(options, provider()); + fs.mkdirSync(path.join(prepared.claudeConfigDir, 'skills/extra'), { recursive: true }); + fs.writeFileSync(path.join(prepared.claudeConfigDir, 'skills/extra/SKILL.md'), 'extra'); + assert.throws(() => native().getNativeProfileStatus(options), /changed/); + fs.rmSync(path.join(prepared.claudeConfigDir, 'skills'), { recursive: true }); + assert.equal(native().getNativeProfileStatus(options).ready, true); + fs.appendFileSync(path.join(prepared.pluginDir, 'skills/ecc-guide/SKILL.md'), 'tamper'); + assert.throws(() => native().getNativeProfileStatus(options), /changed/); +})); + +test('Claude provider runtime state outside discovery controls keeps readiness', () => fixture(options => { + const prepared = native().prepareNativeProfile(options, provider()); + fs.writeFileSync(path.join(prepared.claudeConfigDir, '.claude.json'), '{"numStartups":1}'); + fs.mkdirSync(path.join(prepared.claudeConfigDir, 'projects/session'), { recursive: true }); + assert.equal(native().getNativeProfileStatus(options).status, 'ready'); +})); + +test('Claude provider homes are never accepted as the native root', () => fixture(options => { + const dependency = provider(); + const previous = process.env.CLAUDE_CONFIG_DIR; + const configured = path.join(path.dirname(options.nativeRoot), 'configured-claude'); + process.env.CLAUDE_CONFIG_DIR = configured; + try { + for (const root of [path.join(os.homedir(), '.claude'), configured]) { + assert.throws(() => native().prepareNativeProfile({ ...options, nativeRoot: root }, dependency), /dedicated/); + } + } finally { + if (previous === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = previous; + } + assert.equal(dependency.calls.length, 0); +})); + +test('a Codex generation becomes stale when the managed store targets Claude', () => fixture((options, repoRoot) => { + store.applyStore({ repoRoot, stateRoot: options.stateRoot, target: 'codex' }); + const codex = provider(); + codex.execute = (_command, args) => args[0] === '--version' ? { status: 0, stdout: 'codex-cli 0.154.0' } : { status: 0, stdout: '{}' }; + codex.discover = (_command, config) => { + const base = path.dirname(config.env.HOME); + const name = JSON.parse(fs.readFileSync(path.join(base, 'marketplace/.agents/plugins/marketplace.json'))).name; + const cache = path.join(config.env.CODEX_HOME, 'plugins/cache', name, 'ecc-context-carrier/local'); + fs.mkdirSync(path.dirname(cache), { recursive: true }); + fs.cpSync(path.join(base, 'marketplace/carrier'), cache, { recursive: true }); + return { data: [{ cwd: config.cwd, errors: [], skills: fs.readdirSync(path.join(cache, 'skills')).map(skill => ({ + name: `ecc-context-carrier:${skill}`, pluginId: `ecc-context-carrier@${name}`, enabled: true, scope: 'user', + path: path.join(cache, 'skills', skill, 'SKILL.md') })) }] }; + }; + const first = native().prepareNativeProfile({ ...options, codexPath: process.execPath, claudePath: undefined }, codex); + assert.equal(first.target, 'codex'); + store.applyStore({ repoRoot, stateRoot: options.stateRoot, target: 'claude' }); + assert.equal(native().getNativeProfileStatus(options).status, 'stale'); + const second = native().prepareNativeProfile(options, provider()); + assert.equal(second.target, 'claude'); + assert.equal(second.revision, first.revision + 1); +})); + +test('Claude task launch loads the verified plugin directory in the isolated home', () => fixture(options => { + const prepared = native().prepareNativeProfile(options, provider()); + const { launchTaskContext } = require('../../scripts/lib/context-profile-launch'); + let called = false; + launchTaskContext({ task: { sessionId: 's', taskId: 't', revision: 1, phase: 'p', query: 'hello', noWorkflow: true }, + target: 'claude', nativeEnvironment: { home: prepared.home, claudeConfigDir: prepared.claudeConfigDir, + claudePath: prepared.claudePath, pluginDir: prepared.pluginDir, executableDigest: prepared.executableDigest }, + execute(command, args, config) { + called = true; + assert.equal(command, prepared.claudePath); + assert.deepEqual(args, ['--plugin-dir', prepared.pluginDir, '--print']); + assert.equal(config.env.CLAUDE_CONFIG_DIR, prepared.claudeConfigDir); + assert.equal(config.env.CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL, '1'); + assert.equal(config.env.CODEX_HOME, undefined); + return { status: 0, stdout: 'ok' }; + } }); + assert.equal(called, true); +})); + +test('Claude interactive start launches the pinned binary with the verified plugin directory', () => { + const parent = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ecc-native-claude-start-')); + const options = { stateRoot: path.join(parent, 'managed'), nativeRoot: path.join(parent, 'native') }; + try { + store.applyStore({ stateRoot: options.stateRoot, target: 'claude' }); + const prepared = native().prepareNativeProfile({ ...options, claudePath: process.execPath }, provider()); + const { startInteractiveProfile } = require('../../scripts/lib/context-profile-interactive'); + let call; + const result = startInteractiveProfile(options, + { execute: (command, args, config) => { call = { command, args, config }; return { status: 0 }; } }); + assert.equal(call.command, prepared.claudePath); + assert.deepEqual(call.args, ['--plugin-dir', prepared.pluginDir]); + assert.equal(call.config.env.CLAUDE_CONFIG_DIR, prepared.claudeConfigDir); + assert.equal(call.config.env.CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL, '1'); + assert.equal(call.config.env.CODEX_HOME, undefined); + assert.equal(call.config.stdio, 'inherit'); + assert.equal(result.status, 'exited'); + assert.match(result.bootstrapDigest, /^[a-f0-9]{64}$/); + } finally { fs.rmSync(parent, { recursive: true, force: true }); } +});