From 6234e005ad0606ebc064b3d2ad03c4feec7f538c Mon Sep 17 00:00:00 2001 From: jinku Date: Fri, 17 Jul 2026 04:11:43 -0700 Subject: [PATCH] release: prepare v1.3.0 --- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 13 +- README.md | 3 +- hooks/hooks.json | 12 - hooks/session-lifecycle-hook.mjs | 99 +----- hooks/stop-review-gate-hook.mjs | 21 +- hooks/unread-result-hook.mjs | 37 ++- internal-skills/cli-runtime/runtime.md | 1 + internal-skills/review-runtime/runtime.md | 1 + package-lock.json | 4 +- package.json | 2 +- scripts/claude-companion.mjs | 149 +++++++-- scripts/installer-cli.mjs | 67 +++- scripts/lib/codex-config.mjs | 182 +++++++++++ scripts/lib/codex-paths.mjs | 105 +++++- scripts/lib/render.mjs | 9 +- scripts/lib/state.mjs | 26 +- skills/adversarial-review/SKILL.md | 2 + skills/rescue/SKILL.md | 2 + skills/review/SKILL.md | 2 + skills/setup/SKILL.md | 1 + tests/codex-config.test.mjs | 158 +++++++++ tests/codex-paths.test.mjs | 177 ++++++++++ tests/hooks.test.mjs | 341 +++++++++++++------- tests/installer-cli.test.mjs | 83 ++++- tests/integration/claude-companion.test.mjs | 186 ++++++++++- tests/render.test.mjs | 14 + tests/skills-contracts.test.mjs | 24 ++ tests/state.test.mjs | 138 +++++++- 29 files changed, 1535 insertions(+), 326 deletions(-) create mode 100644 tests/codex-config.test.mjs create mode 100644 tests/codex-paths.test.mjs diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 7377466..4532d03 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cc", - "version": "1.2.1", + "version": "1.3.0", "description": "Claude Code Plugin for Codex. Delegate code reviews, investigations, and tracked tasks to Claude Code from inside Codex.", "author": { "name": "Sendbird, Inc.", diff --git a/CHANGELOG.md b/CHANGELOG.md index 367f23a..59457d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,14 @@ # Changelog -## Unreleased - -- Send Claude prompts through stdin instead of command-line arguments so large working-tree reviews no longer fail with `spawn ENAMETOOLONG` on Windows, and fail closed if prompt delivery itself errors. -- Resolve native `claude.exe` shims and npm-packaged executables from the active Windows `PATH`, npm prefix, or `APPDATA`, while retaining an explicit `CC_PLUGIN_CODEX_CLAUDE_BIN` override. +## v1.3.0 + +- Preserve the originating workspace when reserved background job ids pass through built-in rescue, review, and adversarial-review forwarding children. +- Store plugin state under Codex's injected marketplace-qualified `PLUGIN_DATA` root, migrate the legacy `cc` and `claude-code` namespaces, and configure the destination plus migration roots for sandboxed writes. Existing sessions receive explicit restart-and-rerun guidance before review-gate changes continue, and uninstall removes the plugin-specific writable-root grants. +- Harden the turn-end review gate with the bundled read-only git MCP server, Claude-compatible Draft-07 structured output, and bounded inline block reasons while preserving full diagnostics in snapshots. +- Remove the non-dispatched `SessionEnd` hook and reap stale background jobs safely during `UserPromptSubmit` without cancelling live work. +- Send Claude prompts through stdin and resolve native Windows/npm Claude executables, including working-tree reviews larger than the Windows command-line limit. +- Bound aggregate untracked review context and retain detached worker diagnostics in managed job logs. +- Refresh marketplace installation guidance, repository agent instructions, GitHub Actions, TypeScript, ESLint, Node type definitions, and runtime globals. ## v1.2.1 diff --git a/README.md b/README.md index aa0ef04..ac4d1d8 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ $cc:setup ``` All checks should pass. If any fail, `$cc:setup` tells you what to fix. +If setup adds the marketplace-qualified plugin-data root and legacy migration roots to the writable-root list, restart Codex and rerun the same command; a requested review-gate toggle waits for that restart. ### 3. Try It @@ -221,7 +222,7 @@ $cc:setup --disable-review-gate # turn it off ``` Setup checks Claude Code availability, native plugin hook feature gates, and review-gate state. If Claude Code isn't installed, it offers to install it. -This is also the repair path for marketplace-installed copies of the plugin: `$cc:setup` confirms `[features].hooks = true` and `[features].plugin_hooks = true`, then trusts this plugin's current native hook hashes so Codex loads the bundled hooks from the active plugin cache. +This is also the repair path for marketplace-installed copies of the plugin: `$cc:setup` confirms `[features].hooks = true` and `[features].plugin_hooks = true`, trusts this plugin's current native hook hashes, and allows sandboxed writes to Codex's injected marketplace-qualified plugin-data root plus the legacy roots needed for one-time migration. If those writable roots were just added, restart Codex and rerun setup before changing the review gate. ## Background Jobs diff --git a/hooks/hooks.json b/hooks/hooks.json index fddbb1b..5219cbd 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -12,18 +12,6 @@ ] } ], - "SessionEnd": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "node \"$PLUGIN_ROOT/hooks/session-lifecycle-hook.mjs\" SessionEnd", - "statusMessage": "Cleaning up Claude Code bridge jobs" - } - ] - } - ], "Stop": [ { "hooks": [ diff --git a/hooks/session-lifecycle-hook.mjs b/hooks/session-lifecycle-hook.mjs index 5e80f42..306efe2 100644 --- a/hooks/session-lifecycle-hook.mjs +++ b/hooks/session-lifecycle-hook.mjs @@ -10,8 +10,6 @@ * * SessionStart: Exports CLAUDE_COMPANION_SESSION_ID via CLAUDE_ENV_FILE. * - * SessionEnd: Cleans up tracked jobs for the session, kills orphan `claude` processes. - * * No broker lifecycle — Claude Code uses direct CLI invocation. */ @@ -22,17 +20,8 @@ import { fileURLToPath } from "node:url"; import { readHookInput } from "./lib/hook-input.mjs"; import { cleanupAfterOfficialUninstall } from "./lib/plugin-install-guard.mjs"; -import { terminateProcessTree, validateProcessIdentity } from "../scripts/lib/process.mjs"; -import { - ACTIVE_JOB_STATUSES, - clearCurrentSession, - getCurrentSession, - listStoredJobs, - setCurrentSession, - transitionJob, -} from "../scripts/lib/state.mjs"; -import { nowIso, SESSION_ID_ENV } from "../scripts/lib/tracked-jobs.mjs"; -import { resolveWorkspaceRoot } from "../scripts/lib/workspace.mjs"; +import { setCurrentSession } from "../scripts/lib/state.mjs"; +import { SESSION_ID_ENV } from "../scripts/lib/tracked-jobs.mjs"; export { SESSION_ID_ENV }; const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA"; @@ -63,67 +52,6 @@ function isNestedCodexSession(inputSessionId) { ); } -// --------------------------------------------------------------------------- -// Session cleanup -// --------------------------------------------------------------------------- - -function cleanupSessionJobs(cwd, sessionId) { - if (!cwd || !sessionId) { - return; - } - - const workspaceRoot = resolveWorkspaceRoot(cwd); - const jobs = listStoredJobs(workspaceRoot); - const sessionJobs = jobs.filter((job) => job.sessionId === sessionId); - if (sessionJobs.length === 0) { - return; - } - - for (const job of sessionJobs) { - const stillRunning = ACTIVE_JOB_STATUSES.has(job.status); - if (!stillRunning) { - continue; - } - const hasPid = Number.isFinite(job.pid); - const hasTrustedPid = - hasPid && - typeof job.pidIdentity === "string" && - job.pidIdentity && - validateProcessIdentity(job.pid, job.pidIdentity); - const canSafelyCancel = !hasPid || hasTrustedPid; - try { - if (hasTrustedPid) { - terminateProcessTree(job.pid); - } - } catch { - // Ignore teardown failures during session shutdown. - } - try { - transitionJob( - workspaceRoot, - job.id, - [job.status], - canSafelyCancel ? "cancelled" : "cancel_failed", - { - completedAt: nowIso(), - errorMessage: canSafelyCancel - ? "Cancelled when the Codex session ended." - : "Refused to terminate a stored process without a matching PID identity.", - pid: canSafelyCancel ? null : job.pid ?? null, - pidIdentity: canSafelyCancel ? null : job.pidIdentity ?? null, - phase: canSafelyCancel ? "cancelled" : "cancel_failed", - } - ); - } catch { - // Ignore state transition races during session shutdown. - } - } -} - -// --------------------------------------------------------------------------- -// Event handlers -// --------------------------------------------------------------------------- - function handleSessionStart(input) { const cwd = input.cwd || process.cwd(); const nestedSession = isNestedCodexSession(input.session_id); @@ -137,24 +65,6 @@ function handleSessionStart(input) { } } -function handleSessionEnd(input) { - const cwd = input.cwd || process.cwd(); - let sessionId = input.session_id || process.env[SESSION_ID_ENV] || null; - if (!sessionId) { - try { - sessionId = getCurrentSession(resolveWorkspaceRoot(cwd)); - } catch { - sessionId = null; - } - } - - // Clean up tracked jobs for this session - cleanupSessionJobs(cwd, sessionId); - if (sessionId) { - clearCurrentSession(cwd, sessionId); - } -} - // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -169,11 +79,6 @@ async function main() { if (eventName === "SessionStart" || !eventName) { // Default to SessionStart (Codex invokes this on session start) handleSessionStart(input); - return; - } - - if (eventName === "SessionEnd") { - handleSessionEnd(input); } } diff --git a/hooks/stop-review-gate-hook.mjs b/hooks/stop-review-gate-hook.mjs index 6783c29..6bedb54 100644 --- a/hooks/stop-review-gate-hook.mjs +++ b/hooks/stop-review-gate-hook.mjs @@ -53,6 +53,7 @@ const SKIP_INTERACTIVE_HOOKS_ENV = "CLAUDE_COMPANION_SKIP_INTERACTIVE_HOOKS"; const STOP_REVIEW_SUCCESS_NOTE = "Claude Code turn-end review passed."; const STOP_REVIEW_NO_EDIT_NOTE = "Claude Code turn-end review skipped: the most recent turn made no net edits."; +const MAX_INLINE_REASON_CHARS = 1_500; function emitDecision(payload) { process.stdout.write(`${JSON.stringify(payload)}\n`); @@ -65,6 +66,19 @@ function logNote(message) { process.stderr.write(`${message}\n`); } +function boundReasonForHookOutput(reason, runId) { + const text = String(reason ?? ""); + if (text.length <= MAX_INLINE_REASON_CHARS) { + return text; + } + const suffix = [ + "", + "", + `Full stop-review output was saved in the ${runId} snapshot.` + ].join("\n"); + return `${text.slice(0, MAX_INLINE_REASON_CHARS).trimEnd()}…${suffix}`; +} + function buildSetupNote(cwd) { const availability = getClaudeAvailability(cwd); if (!availability.available) { @@ -426,11 +440,12 @@ async function main() { runningTaskNote, ...fingerprintFields, }); + const inlineReason = runningTaskNote + ? `${runningTaskNote} ${review.reason}` + : review.reason; emitDecision({ decision: "block", - reason: runningTaskNote - ? `${runningTaskNote} ${review.reason}` - : review.reason, + reason: boundReasonForHookOutput(inlineReason, stopReviewRun.runId), }); return; } diff --git a/hooks/unread-result-hook.mjs b/hooks/unread-result-hook.mjs index f5c83f1..819fe3c 100644 --- a/hooks/unread-result-hook.mjs +++ b/hooks/unread-result-hook.mjs @@ -11,7 +11,13 @@ import { fileURLToPath } from "node:url"; import { readHookInput } from "./lib/hook-input.mjs"; import { cleanupAfterOfficialUninstall } from "./lib/plugin-install-guard.mjs"; -import { getConfig, listJobs, patchJob, writeTurnBaseline } from "../scripts/lib/state.mjs"; +import { + getConfig, + listJobs, + patchJob, + setCurrentSession, + writeTurnBaseline, +} from "../scripts/lib/state.mjs"; import { getWorkingTreeFingerprint } from "../scripts/lib/git.mjs"; import { nowIso, SESSION_ID_ENV } from "../scripts/lib/tracked-jobs.mjs"; import { resolveWorkspaceRoot } from "../scripts/lib/workspace.mjs"; @@ -56,12 +62,12 @@ function buildAdditionalContext(jobs) { ].join("\n"); } -function selectUnreadCompletedJobs(workspaceRoot, sessionId) { +function selectUnreadCompletedJobs(jobs, sessionId) { if (!sessionId) { return []; } - return listJobs(workspaceRoot) + return jobs .filter((job) => job.sessionId === sessionId) .filter((job) => job.status === "completed") .filter((job) => !job.resultViewedAt) @@ -82,6 +88,17 @@ function markJobsNotified(workspaceRoot, jobs) { } } +function listJobsSafely(workspaceRoot) { + try { + // listJobs() triggers the PID-reuse-safe stale job reaper. + return listJobs(workspaceRoot); + } catch { + // Best effort only: unread-result steering should not fail a user prompt + // because stale job reaping hit a filesystem or process-inspection race. + return []; + } +} + function captureTurnBaseline(workspaceRoot, sessionId, cwd) { if (!sessionId) { return; @@ -116,22 +133,28 @@ async function main() { return; } + try { + setCurrentSession(workspaceRoot, sessionId); + } catch { + // Best effort only: an invalid session id should not fail a user prompt. + } const config = getConfig(workspaceRoot); if (config.stopReviewGate) { captureTurnBaseline(workspaceRoot, sessionId, cwd); } + const jobs = listJobsSafely(workspaceRoot); if (isExplicitClaudeStatusRequest(prompt)) { return; } - const jobs = selectUnreadCompletedJobs(workspaceRoot, sessionId); - if (jobs.length === 0) { + const unreadJobs = selectUnreadCompletedJobs(jobs, sessionId); + if (unreadJobs.length === 0) { return; } - markJobsNotified(workspaceRoot, jobs); - process.stdout.write(`${buildAdditionalContext(jobs)}\n`); + markJobsNotified(workspaceRoot, unreadJobs); + process.stdout.write(`${buildAdditionalContext(unreadJobs)}\n`); } main().catch((error) => { diff --git a/internal-skills/cli-runtime/runtime.md b/internal-skills/cli-runtime/runtime.md index a54c81e..ccd205a 100644 --- a/internal-skills/cli-runtime/runtime.md +++ b/internal-skills/cli-runtime/runtime.md @@ -28,6 +28,7 @@ Routing controls: - `--view-state on-success` means the user will see this companion result in the current turn, so the companion may mark it viewed on success. - `--view-state defer` means the parent is not waiting, so the companion must leave the result unread until the user explicitly checks it. - `--owner-session-id ` is an internal parent-session routing control. Preserve it when present so tracked jobs remain visible to the parent session's `$cc:status` / `$cc:result`. +- Whenever the command includes a reserved `--job-id`, also include `--cwd ` using `workspaceRoot` from the same routing-context response. Reserved job ids are workspace-scoped. - Never emit an empty routing placeholder such as `--owner-session-id --job-id`. - Do not add `--quiet-progress` by default for built-in rescue forwarding. Let companion stderr progress remain available in the spawned agent thread. - If the free-text task begins with `/`, treat that slash command as literal Claude Code task text to forward unchanged. Do not execute it as a local Codex slash command or answer it inline. diff --git a/internal-skills/review-runtime/runtime.md b/internal-skills/review-runtime/runtime.md index 692c6e3..368a284 100644 --- a/internal-skills/review-runtime/runtime.md +++ b/internal-skills/review-runtime/runtime.md @@ -24,6 +24,7 @@ Foreground contract: Background contract: - Use `background-routing-context --kind review --json` before spawning the forwarding child. - Preserve `--job-id` only when reserved by the parent helper. +- Whenever preserving that reserved `--job-id`, also pass `--cwd ` using `workspaceRoot` from the same helper response. Reserved job ids are workspace-scoped. - Preserve `--owner-session-id` only when the parent helper returned a non-empty owner session id. - Preserve the parent notification path only when the helper returned a non-empty parent thread id. - Never emit an empty routing placeholder such as `--owner-session-id --job-id`. diff --git a/package-lock.json b/package-lock.json index 0c083ae..417973e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cc-plugin-codex", - "version": "1.2.1", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cc-plugin-codex", - "version": "1.2.1", + "version": "1.3.0", "license": "Apache-2.0", "bin": { "cc-plugin-codex": "scripts/installer-cli.mjs" diff --git a/package.json b/package.json index cf6dcf0..521b950 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-plugin-codex", - "version": "1.2.1", + "version": "1.3.0", "description": "Claude Code Plugin for Codex by Sendbird", "type": "module", "author": { diff --git a/scripts/claude-companion.mjs b/scripts/claude-companion.mjs index fa824ff..af7ed83 100644 --- a/scripts/claude-companion.mjs +++ b/scripts/claude-companion.mjs @@ -29,7 +29,12 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { parseArgs, splitRawArgumentString } from "./lib/args.mjs"; -import { resolveCodexHome } from "./lib/codex-paths.mjs"; +import { + resolveCodexHome, + resolveExpectedPluginDataRoot, + resolvePluginCacheInstallInfo, + resolveWritablePluginDataRoots, +} from "./lib/codex-paths.mjs"; import { getClaudeAvailability, getClaudeAuthStatus, @@ -62,6 +67,7 @@ import { import { binaryAvailable, getProcessIdentity } from "./lib/process.mjs"; import { callCodexAppServer } from "./lib/codex-app-server.mjs"; import { + ensureCodexWritableRoots, ensureNativePluginHooksEnabled, nativePluginHooksStatus, } from "./lib/codex-config.mjs"; @@ -69,6 +75,7 @@ import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; import { parseStructuredOutput } from "./lib/structured-output.mjs"; import { ACTIVE_JOB_STATUSES, + ensureStateDir, generateJobId, getConfig, getCurrentSession, @@ -361,29 +368,9 @@ function configureNativePluginHooks() { return changed; } -function currentPluginCacheInstallInfo() { - const cacheRoot = path.join(CODEX_DIR, "plugins", "cache"); - const relativePath = path.relative(cacheRoot, ROOT_DIR); - if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { - return null; - } - const [marketplaceName, pluginName, version] = relativePath - .split(path.sep) - .filter(Boolean); - if (!marketplaceName || pluginName !== "cc" || !version) { - return null; - } - return { - marketplaceName, - pluginName, - version, - pluginId: `${pluginName}@${marketplaceName}`, - }; -} - function shouldRepairPluginHookTrust() { return ( - Boolean(currentPluginCacheInstallInfo()) || + Boolean(resolvePluginCacheInstallInfo()) || process.env.CC_PLUGIN_CODEX_FORCE_HOOK_TRUST === "1" ); } @@ -418,7 +405,7 @@ function hookNeedsTrust(hook) { } async function repairNativePluginHookTrust(cwd) { - const pluginInfo = currentPluginCacheInstallInfo(); + const pluginInfo = resolvePluginCacheInstallInfo(); if (!shouldRepairPluginHookTrust()) { return { attempted: false, @@ -550,13 +537,22 @@ function ensureClaudeReady(cwd) { } } -function buildSetupReport(cwd, actionsTaken = [], hookTrust = null) { - const workspaceRoot = resolveWorkspaceRoot(cwd); +function buildSetupReport( + cwd, + actionsTaken = [], + hookTrust = null, + { + pluginStateReady = true, + pluginStateDetail = "plugin-data writable root verified", + pluginStateNextStep = null, + pluginConfig = null, + } = {} +) { const nodeStatus = binaryAvailable("node", ["--version"], { cwd }); const claudeStatus = getClaudeAvailability(cwd); const authStatus = getClaudeAuthStatus(cwd); const hooksStatus = checkHooksStatus(); - const config = getConfig(workspaceRoot); + const config = pluginStateReady ? pluginConfig : null; const nextSteps = []; if (!claudeStatus.available) { @@ -571,7 +567,12 @@ function buildSetupReport(cwd, actionsTaken = [], hookTrust = null) { if (hookTrust?.ready === false) { nextSteps.push("Open `/hooks` and trust this plugin's hooks manually, then rerun `$cc:setup`."); } - if (!config.stopReviewGate) { + if (!pluginStateReady) { + nextSteps.push( + pluginStateNextStep ?? + "Repair plugin-data write access, then rerun `$cc:setup`." + ); + } else if (!config?.stopReviewGate) { nextSteps.push( "Optional: run `$cc:setup --enable-review-gate` to require a fresh Claude review before each edit-producing Codex turn finishes." ); @@ -583,13 +584,18 @@ function buildSetupReport(cwd, actionsTaken = [], hookTrust = null) { claudeStatus.available && authStatus.loggedIn && hooksStatus.installed && - hookTrust?.ready !== false, + hookTrust?.ready !== false && + pluginStateReady, node: nodeStatus, claude: claudeStatus, auth: authStatus, hooks: hooksStatus, hookTrust, - reviewGateEnabled: Boolean(config.stopReviewGate), + pluginState: { + ready: pluginStateReady, + detail: pluginStateDetail, + }, + reviewGateEnabled: config ? Boolean(config.stopReviewGate) : null, actionsTaken, nextSteps }; @@ -625,15 +631,88 @@ async function handleSetup(argv) { actionsTaken.push(`Trusted ${hookTrust.trusted} native Codex plugin hooks.`); } - if (options["enable-review-gate"]) { - setConfig(workspaceRoot, "stopReviewGate", true); - actionsTaken.push(`Enabled the turn-end review gate for ${workspaceRoot}.`); + const pluginDataRoot = resolveExpectedPluginDataRoot(); + const pluginDataRoots = resolveWritablePluginDataRoots(pluginDataRoot); + let writableRootChanged = false; + let pluginStateReady = true; + let pluginStateDetail = `plugin data roots verified: ${pluginDataRoots.join(", ")}`; + let pluginStateNextStep = null; + let pluginConfig = null; + const recordPluginStateFailure = (error) => { + pluginStateReady = false; + pluginStateDetail = `unable to access plugin state: ${ + error instanceof Error ? error.message : String(error) + }`; + pluginStateNextStep = + "Restart Codex so the plugin-data writable roots take effect, then rerun `$cc:setup`."; + }; + try { + writableRootChanged = await ensureCodexWritableRoots(cwd, pluginDataRoots); + } catch (error) { + pluginStateReady = false; + pluginStateDetail = `unable to configure plugin data roots: ${ + error instanceof Error ? error.message : String(error) + }`; + pluginStateNextStep = + `Allow ${pluginDataRoots.join(", ")} under ` + + "`sandbox_workspace_write.writable_roots` in `~/.codex/config.toml`, " + + "restart Codex, then rerun `$cc:setup`."; + } + if (writableRootChanged) { + pluginStateReady = false; + pluginStateDetail = + `plugin data roots added; restart required: ${pluginDataRoots.join(", ")}`; + pluginStateNextStep = + "Restart Codex so the new plugin-data writable root takes effect, then rerun `$cc:setup`."; + actionsTaken.push( + `Allowed plugin state writes under ${pluginDataRoots.join(", ")}.` + ); + actionsTaken.push("Restart Codex so existing sessions use the updated writable roots."); + } + + if (pluginStateReady) { + try { + ensureStateDir(workspaceRoot); + pluginConfig = getConfig(workspaceRoot); + } catch (error) { + recordPluginStateFailure(error); + } + } + + const reviewGateChangeRequested = + options["enable-review-gate"] || options["disable-review-gate"]; + if (!pluginStateReady && reviewGateChangeRequested) { + actionsTaken.push( + "Deferred the review-gate change until plugin state write access is ready." + ); + } else if (options["enable-review-gate"]) { + try { + pluginConfig = setConfig(workspaceRoot, "stopReviewGate", true); + actionsTaken.push(`Enabled the turn-end review gate for ${workspaceRoot}.`); + } catch (error) { + recordPluginStateFailure(error); + actionsTaken.push( + "Deferred the review-gate change until plugin state write access is ready." + ); + } } else if (options["disable-review-gate"]) { - setConfig(workspaceRoot, "stopReviewGate", false); - actionsTaken.push(`Disabled the turn-end review gate for ${workspaceRoot}.`); + try { + pluginConfig = setConfig(workspaceRoot, "stopReviewGate", false); + actionsTaken.push(`Disabled the turn-end review gate for ${workspaceRoot}.`); + } catch (error) { + recordPluginStateFailure(error); + actionsTaken.push( + "Deferred the review-gate change until plugin state write access is ready." + ); + } } - const finalReport = buildSetupReport(cwd, actionsTaken, hookTrust); + const finalReport = buildSetupReport(cwd, actionsTaken, hookTrust, { + pluginStateReady, + pluginStateDetail, + pluginStateNextStep, + pluginConfig, + }); outputResult( options.json ? finalReport : renderSetupReport(finalReport), options.json diff --git a/scripts/installer-cli.mjs b/scripts/installer-cli.mjs index 8ba2540..b43b590 100755 --- a/scripts/installer-cli.mjs +++ b/scripts/installer-cli.mjs @@ -11,8 +11,16 @@ import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; import { callCodexAppServer } from "./lib/codex-app-server.mjs"; -import { ensureNativePluginHooksEnabled } from "./lib/codex-config.mjs"; -import { resolveCodexHome } from "./lib/codex-paths.mjs"; +import { + ensureCodexWritableRoots, + ensureNativePluginHooksEnabled, + removeCodexWritableRoots, +} from "./lib/codex-config.mjs"; +import { + resolveCodexHome, + resolveMarketplacePluginDataRoot, + resolveWritablePluginDataRoots, +} from "./lib/codex-paths.mjs"; import { LEGACY_MARKETPLACE_NAME, listManagedPluginCacheEntries, @@ -211,23 +219,54 @@ async function installOrUpdate() { "marketplace.json" ); await installPluginThroughCodex(marketplacePath); + const installedMarketplace = JSON.parse(fs.readFileSync(marketplacePath, "utf8")); + const installedMarketplaceName = + marketplace.marketplaceName ?? + installedMarketplace.name ?? + marketplaceConfig.marketplaceName; + const pluginDataRoot = resolveMarketplacePluginDataRoot(installedMarketplaceName); + const pluginDataRoots = resolveWritablePluginDataRoots(pluginDataRoot); + const writableRootChanged = await ensureCodexWritableRoots( + PACKAGE_ROOT, + pluginDataRoots + ); console.log(`Installed ${PLUGIN_NAME} from ${marketplaceConfig.source} into the Codex plugin cache.`); if (hooksChanged) { console.log("Enabled [features].hooks and [features].plugin_hooks in ~/.codex/config.toml."); - console.log("Restart Codex to make newly enabled native plugin hooks active in existing sessions."); + } + if (writableRootChanged) { + console.log(`Allowed plugin state writes under ${pluginDataRoots.join(", ")}.`); + } + if (hooksChanged || writableRootChanged) { + console.log("Restart Codex so existing sessions use the updated plugin configuration."); } } async function uninstall() { const marketplaceConfig = resolveInstallerMarketplaceConfig(); cleanupLegacyLocalInstall(); + const marketplaceNames = [ + ...new Set([ + marketplaceConfig.marketplaceName, + DEFAULT_MARKETPLACE_NAME, + LEGACY_MARKETPLACE_NAME, + ...listManagedPluginCacheEntries(CODEX_HOME).map( + (entry) => entry.marketplaceName + ), + ]), + ]; + const pluginDataRoots = marketplaceNames.flatMap((marketplaceName) => { + try { + return resolveWritablePluginDataRoots( + resolveMarketplacePluginDataRoot(marketplaceName) + ); + } catch { + return []; + } + }); - for (const marketplaceName of [ - marketplaceConfig.marketplaceName, - DEFAULT_MARKETPLACE_NAME, - LEGACY_MARKETPLACE_NAME, - ]) { + for (const marketplaceName of marketplaceNames) { try { await uninstallPluginThroughCodex(marketplaceName); } catch { @@ -236,6 +275,18 @@ async function uninstall() { } removeManagedPluginConfigSections(); + try { + if (await removeCodexWritableRoots(PACKAGE_ROOT, pluginDataRoots)) { + console.log("Removed plugin data roots from Codex writable-root config."); + } + } catch (error) { + console.error( + `Warning: unable to remove plugin data roots from Codex writable-root config: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + for (const cacheEntry of listManagedPluginCacheEntries(CODEX_HOME)) { fs.rmSync(cacheEntry.cachePath, { recursive: true, force: true }); } diff --git a/scripts/lib/codex-config.mjs b/scripts/lib/codex-config.mjs index 908ff93..687846d 100644 --- a/scripts/lib/codex-config.mjs +++ b/scripts/lib/codex-config.mjs @@ -3,11 +3,193 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { callCodexAppServer } from "./codex-app-server.mjs"; +import { samePath } from "./codex-paths.mjs"; + function normalizeTrailingNewline(text) { return `${String(text).replace(/\s*$/, "")}\n`; } const REQUIRED_NATIVE_HOOK_FEATURES = ["hooks", "plugin_hooks"]; +const WRITABLE_ROOTS_KEY = "sandbox_workspace_write.writable_roots"; +const MAX_CONFIG_WRITE_ATTEMPTS = 3; + +function validPathStrings(paths) { + return paths.filter( + (value) => typeof value === "string" && value.trim() !== "" + ); +} + +function includesPath(paths, candidate) { + return validPathStrings(paths).some((value) => samePath(value, candidate)); +} + +function uniquePaths(paths) { + const validPaths = validPathStrings(paths); + return validPaths.filter( + (candidate, index) => + !validPaths + .slice(0, index) + .some((previous) => samePath(previous, candidate)) + ); +} + +function selectWritableUserLayer(layers) { + const userLayers = (layers ?? []).filter((layer) => layer?.name?.type === "user"); + return ( + userLayers.find( + (layer) => + layer.name.profile != null && + Array.isArray(layer?.config?.sandbox_workspace_write?.writable_roots) + ) ?? + userLayers.find( + (layer) => layer.name.profile === null || layer.name.profile === undefined + ) ?? + null + ); +} + +async function readCodexConfig(cwd, includeLayers) { + return callCodexAppServer({ + cwd, + method: "config/read", + params: { + includeLayers, + cwd, + }, + }); +} + +export async function ensureCodexWritableRoots(cwd, writableRoots) { + const requestedRoots = uniquePaths(writableRoots); + if (requestedRoots.length === 0) { + return false; + } + + for (let attempt = 0; attempt < MAX_CONFIG_WRITE_ATTEMPTS; attempt++) { + const response = await readCodexConfig(cwd, true); + const effectiveRoots = Array.isArray( + response?.config?.sandbox_workspace_write?.writable_roots + ) + ? response.config.sandbox_workspace_write.writable_roots + : []; + const userLayer = selectWritableUserLayer(response?.layers); + const userRoots = Array.isArray( + userLayer?.config?.sandbox_workspace_write?.writable_roots + ) + ? userLayer.config.sandbox_workspace_write.writable_roots + : []; + const persisted = requestedRoots.every((root) => includesPath(userRoots, root)); + const effective = requestedRoots.every((root) => includesPath(effectiveRoots, root)); + if (persisted && effective) { + return false; + } + if (persisted) { + throw new Error( + "Plugin data roots are present in user config but are overridden by a higher-precedence Codex config layer." + ); + } + + try { + await callCodexAppServer({ + cwd, + method: "config/batchWrite", + params: { + edits: [ + { + keyPath: WRITABLE_ROOTS_KEY, + value: uniquePaths([...userRoots, ...requestedRoots]), + mergeStrategy: "replace", + }, + ], + filePath: userLayer?.name?.file ?? null, + expectedVersion: userLayer?.version ?? null, + reloadUserConfig: true, + }, + }); + const verifiedResponse = await readCodexConfig(cwd, false); + const verifiedRoots = Array.isArray( + verifiedResponse?.config?.sandbox_workspace_write?.writable_roots + ) + ? verifiedResponse.config.sandbox_workspace_write.writable_roots + : []; + if (!requestedRoots.every((root) => includesPath(verifiedRoots, root))) { + throw new Error( + "Codex accepted the user-config update, but a higher-precedence config layer still overrides the required plugin data roots." + ); + } + return true; + } catch (error) { + const conflict = + /ConfigVersionConflict|modified since last read/i.test( + error instanceof Error ? error.message : String(error) + ); + if (!conflict || attempt === MAX_CONFIG_WRITE_ATTEMPTS - 1) { + throw error; + } + } + } + return false; +} + +export async function ensureCodexWritableRoot(cwd, writableRoot) { + return ensureCodexWritableRoots(cwd, [writableRoot]); +} + +export async function removeCodexWritableRoots(cwd, writableRoots) { + const requestedRoots = uniquePaths(writableRoots); + if (requestedRoots.length === 0) { + return false; + } + + for (let attempt = 0; attempt < MAX_CONFIG_WRITE_ATTEMPTS; attempt++) { + const response = await readCodexConfig(cwd, true); + const userLayer = selectWritableUserLayer(response?.layers); + const userRoots = Array.isArray( + userLayer?.config?.sandbox_workspace_write?.writable_roots + ) + ? validPathStrings( + userLayer.config.sandbox_workspace_write.writable_roots + ) + : []; + const nextRoots = userRoots.filter( + (candidate) => + !requestedRoots.some((requested) => samePath(candidate, requested)) + ); + if (nextRoots.length === userRoots.length) { + return false; + } + + try { + await callCodexAppServer({ + cwd, + method: "config/batchWrite", + params: { + edits: [ + { + keyPath: WRITABLE_ROOTS_KEY, + value: nextRoots, + mergeStrategy: "replace", + }, + ], + filePath: userLayer?.name?.file ?? null, + expectedVersion: userLayer?.version ?? null, + reloadUserConfig: true, + }, + }); + return true; + } catch (error) { + const conflict = + /ConfigVersionConflict|modified since last read/i.test( + error instanceof Error ? error.message : String(error) + ); + if (!conflict || attempt === MAX_CONFIG_WRITE_ATTEMPTS - 1) { + throw error; + } + } + } + return false; +} export function ensureNativePluginHooksEnabled(content) { const lines = String(content ?? "").split("\n"); diff --git a/scripts/lib/codex-paths.mjs b/scripts/lib/codex-paths.mjs index 773e782..12d013b 100644 --- a/scripts/lib/codex-paths.mjs +++ b/scripts/lib/codex-paths.mjs @@ -2,19 +2,31 @@ * Copyright 2026 Sendbird, Inc. * SPDX-License-Identifier: Apache-2.0 */ +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; export const PLUGIN_DATA_NAMESPACE = "cc"; -export const LEGACY_PLUGIN_DATA_NAMESPACES = ["claude-code"]; +export const LEGACY_PLUGIN_DATA_NAMESPACES = ["cc", "claude-code"]; + +const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); export function normalizePathSlashes(value) { return value.replace(/\\/g, "/"); } +function canonicalPath(candidate) { + try { + return fs.realpathSync.native(candidate); + } catch { + return path.resolve(candidate); + } +} + export function samePath(a, b, platform = process.platform) { - const left = path.resolve(a); - const right = path.resolve(b); + const left = canonicalPath(a); + const right = canonicalPath(b); return platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right; @@ -28,14 +40,93 @@ export function resolvePluginsDataRoot() { return path.join(resolveCodexHome(), "plugins", "data"); } -export function resolvePluginDataRoot(namespace = PLUGIN_DATA_NAMESPACE) { - return path.join(resolvePluginsDataRoot(), namespace); +export function resolvePluginCacheInstallInfo( + pluginRoot = PLUGIN_ROOT, + codexHome = resolveCodexHome() +) { + const cacheRoot = path.join(codexHome, "plugins", "cache"); + const relativePath = path.relative( + canonicalPath(cacheRoot), + canonicalPath(pluginRoot) + ); + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + return null; + } + const [marketplaceName, pluginName, version, ...rest] = relativePath + .split(path.sep) + .filter(Boolean); + if ( + !marketplaceName || + !/^[A-Za-z0-9_-]+$/.test(marketplaceName) || + pluginName !== PLUGIN_DATA_NAMESPACE || + !version || + rest.length > 0 + ) { + return null; + } + return { + marketplaceName, + pluginName, + version, + pluginId: `${pluginName}@${marketplaceName}`, + }; +} + +export function resolveMarketplacePluginDataRoot(marketplaceName) { + if (!/^[A-Za-z0-9_-]+$/.test(marketplaceName)) { + throw new Error(`Invalid marketplace name: ${marketplaceName}`); + } + return path.join(resolvePluginsDataRoot(), `${PLUGIN_DATA_NAMESPACE}-${marketplaceName}`); +} + +export function resolveExpectedPluginDataRoot( + pluginRoot = PLUGIN_ROOT, + codexHome = resolveCodexHome() +) { + const cacheInstall = resolvePluginCacheInstallInfo(pluginRoot, codexHome); + const namespace = cacheInstall + ? `${PLUGIN_DATA_NAMESPACE}-${cacheInstall.marketplaceName}` + : PLUGIN_DATA_NAMESPACE; + return path.join(codexHome, "plugins", "data", namespace); +} + +export function resolvePluginDataRoot(namespace) { + if (namespace !== undefined) { + return path.join(resolvePluginsDataRoot(), namespace); + } + + const injectedRoot = + process.env.PLUGIN_DATA?.trim() || process.env.CLAUDE_PLUGIN_DATA?.trim(); + if (injectedRoot) { + const resolvedInjectedRoot = path.resolve(injectedRoot); + const expectedRoot = resolveExpectedPluginDataRoot(); + if (samePath(resolvedInjectedRoot, expectedRoot)) { + return resolvedInjectedRoot; + } + } + + return resolveExpectedPluginDataRoot(); +} + +export function resolveWritablePluginDataRoots( + primaryRoot = resolveExpectedPluginDataRoot() +) { + const roots = [ + primaryRoot, + ...LEGACY_PLUGIN_DATA_NAMESPACES.map((namespace) => + resolvePluginDataRoot(namespace) + ), + ]; + return roots.filter( + (candidate, index) => + !roots.slice(0, index).some((previous) => samePath(previous, candidate)) + ); } -export function resolvePluginStateRoot(namespace = PLUGIN_DATA_NAMESPACE) { +export function resolvePluginStateRoot(namespace) { return path.join(resolvePluginDataRoot(namespace), "state"); } -export function resolvePluginRuntimeRoot(namespace = PLUGIN_DATA_NAMESPACE) { +export function resolvePluginRuntimeRoot(namespace) { return path.join(resolvePluginDataRoot(namespace), "runtime"); } diff --git a/scripts/lib/render.mjs b/scripts/lib/render.mjs index 5407dbb..ed77c32 100644 --- a/scripts/lib/render.mjs +++ b/scripts/lib/render.mjs @@ -234,7 +234,14 @@ export function renderSetupReport(report) { `- auth: ${report.auth.detail}`, `- hooks: ${report.hooks.detail}`, ...(report.hookTrust ? [`- hook trust: ${report.hookTrust.detail}`] : []), - `- review gate: ${report.reviewGateEnabled ? "enabled" : "disabled"}`, + ...(report.pluginState ? [`- plugin state: ${report.pluginState.detail}`] : []), + `- review gate: ${ + report.reviewGateEnabled == null + ? "check after restart" + : report.reviewGateEnabled + ? "enabled" + : "disabled" + }`, "", ]; if (report.actionsTaken.length > 0) { diff --git a/scripts/lib/state.mjs b/scripts/lib/state.mjs index 689c2cc..065ef3a 100644 --- a/scripts/lib/state.mjs +++ b/scripts/lib/state.mjs @@ -22,6 +22,7 @@ import { resolvePluginDataRoot, resolvePluginStateRoot, resolvePluginsDataRoot, + samePath, } from "./codex-paths.mjs"; import { resolveWorkspaceRoot } from "./workspace.mjs"; import { isProcessAlive, validateProcessIdentity, getProcessIdentity } from "./process.mjs"; @@ -31,6 +32,8 @@ let ensuredPluginDataRoot = null; const CONFIG_FILE_NAME = "config.json"; const JOBS_DIR_NAME = "jobs"; const CURRENT_SESSION_FILE_NAME = "current-session.json"; +const CURRENT_SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; +const CURRENT_SESSION_MAX_CLOCK_SKEW_MS = 5 * 60 * 1000; const STOP_REVIEW_LAST_FILE_NAME = "stop-review-last.json"; const STOP_REVIEW_HISTORY_FILE_NAME = "stop-review-history.jsonl"; const TURN_BASELINE_FILE_PREFIX = "turn-baseline"; @@ -77,6 +80,14 @@ function removeIfEmpty(dirPath) { } catch {} } +function canonicalPath(candidate) { + try { + return fs.realpathSync.native(candidate); + } catch { + return path.resolve(candidate); + } +} + function mergeDirectory(sourceDir, destinationDir) { fs.mkdirSync(destinationDir, { recursive: true, mode: 0o700 }); @@ -116,7 +127,10 @@ function migrateLegacyPluginDataRoots() { for (const legacyNamespace of LEGACY_PLUGIN_DATA_NAMESPACES) { const legacyRoot = resolvePluginDataRoot(legacyNamespace); - if (!fs.existsSync(legacyRoot) || legacyRoot === destinationRoot) { + if ( + !fs.existsSync(legacyRoot) || + samePath(canonicalPath(legacyRoot), canonicalPath(destinationRoot)) + ) { continue; } @@ -266,6 +280,16 @@ export function getCurrentSession(cwd) { const filePath = resolveCurrentSessionFile(cwd); try { const payload = JSON.parse(fs.readFileSync(filePath, "utf8")); + const updatedAt = Date.parse(payload.updatedAt); + const ageMs = Date.now() - updatedAt; + if ( + !Number.isFinite(updatedAt) || + ageMs > CURRENT_SESSION_MAX_AGE_MS || + ageMs < -CURRENT_SESSION_MAX_CLOCK_SKEW_MS + ) { + fs.unlinkSync(filePath); + return null; + } return sanitizeId(payload.sessionId, "session ID"); } catch { return null; diff --git a/skills/adversarial-review/SKILL.md b/skills/adversarial-review/SKILL.md index 99f25ba..eb16dd0 100644 --- a/skills/adversarial-review/SKILL.md +++ b/skills/adversarial-review/SKILL.md @@ -71,6 +71,7 @@ Background flow: - Before spawning the built-in child, capture the review job id plus routing context in one call: `node "/scripts/claude-companion.mjs" background-routing-context --kind review --json` - If that helper returns a non-empty `jobId`, pass it into the companion command as an internal `--job-id ` routing flag. +- Whenever forwarding that reserved `--job-id`, also pass `--cwd ` using `workspaceRoot` from the same helper response. Reserved job ids are workspace-scoped. - If that helper returns a non-empty `ownerSessionId`, include `--owner-session-id ` in the companion command. - If it returns an empty `ownerSessionId`, omit `--owner-session-id` entirely. Never leave an empty placeholder such as `--owner-session-id --job-id`. - If that helper returns a non-empty `parentThreadId`, pass it into the child prompt as the parent thread id for one-shot completion notification. @@ -95,6 +96,7 @@ Background flow: - if the available shell tool is `exec_command`, call it once in non-interactive mode and wait for command exit in that same call - include `--owner-session-id ` only when the parent resolved a non-empty owner session id - include `--job-id ` when the parent reserved one + - include the matching `--cwd ` whenever the command includes that reserved `--job-id` - never leave an empty routing placeholder such as `--owner-session-id --job-id` - return only that command's stdout exactly, with no added commentary - ignore stderr progress chatter such as `[cc] ...` lines and preserve only the final stdout-equivalent result text diff --git a/skills/rescue/SKILL.md b/skills/rescue/SKILL.md index df51fc0..b446f4f 100644 --- a/skills/rescue/SKILL.md +++ b/skills/rescue/SKILL.md @@ -74,6 +74,7 @@ Subagent launch: - If that helper returns a non-empty `ownerSessionId`, include `--owner-session-id ` in the companion command so tracked Claude Code jobs stay attached to the user-facing parent session for `$cc:status` / `$cc:result`. - If it returns an empty `ownerSessionId`, omit `--owner-session-id` entirely. Never leave an empty routing placeholder such as `--owner-session-id --job-id`. - If that helper returns a non-empty `jobId`, pass it into the companion command as an internal `--job-id ` routing flag. +- Whenever forwarding that reserved `--job-id`, also pass `--cwd ` using `workspaceRoot` from the same helper response. Reserved job ids are workspace-scoped. - Add an internal companion routing flag that reflects whether the user will see this result in the current turn: - Foreground rescue must add `--view-state on-success` - Background rescue must add `--view-state defer` @@ -141,6 +142,7 @@ Subagent launch: - for background rescue, use that same steering message as the child's own final assistant message instead of echoing the raw companion result - tell the child not to inspect the repository, read files, grep, or do the task directly - tell the child not to reinterpret routing flags that were already resolved by the parent + - include the matching `--cwd ` whenever the command includes that reserved `--job-id` - tell the child to copy the resolved rescue task text byte-for-byte into that exact command after parent-side routing flags are removed - explicitly forbid appending terminal punctuation, adding quotes, dropping prefixes such as `completed:`, or stripping leading slash commands such as `/simplify` - include one short exact-output example such as `completed:/simplify make the output compact` diff --git a/skills/review/SKILL.md b/skills/review/SKILL.md index ea5c0af..fa0ee17 100644 --- a/skills/review/SKILL.md +++ b/skills/review/SKILL.md @@ -69,6 +69,7 @@ Background flow: - Before spawning the built-in child, capture the review job id plus routing context in one call: `node "/scripts/claude-companion.mjs" background-routing-context --kind review --json` - If that helper returns a non-empty `jobId`, pass it into the companion command as an internal `--job-id ` routing flag. +- Whenever forwarding that reserved `--job-id`, also pass `--cwd ` using `workspaceRoot` from the same helper response. Reserved job ids are workspace-scoped. - If that helper returns a non-empty `ownerSessionId`, include `--owner-session-id ` in the companion command. - If it returns an empty `ownerSessionId`, omit `--owner-session-id` entirely. Never leave an empty placeholder such as `--owner-session-id --job-id`. - If that helper returns a non-empty `parentThreadId`, pass it into the child prompt as the parent thread id for one-shot completion notification. @@ -93,6 +94,7 @@ Background flow: - if the available shell tool is `exec_command`, call it once in non-interactive mode and wait for command exit in that same call - include `--owner-session-id ` only when the parent resolved a non-empty owner session id - include `--job-id ` when the parent reserved one + - include the matching `--cwd ` whenever the command includes that reserved `--job-id` - never leave an empty routing placeholder such as `--owner-session-id --job-id` - return only that command's stdout exactly, with no added commentary - ignore stderr progress chatter such as `[cc] ...` lines and preserve only the final stdout-equivalent result text diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md index 3b98074..1582ab1 100644 --- a/skills/setup/SKILL.md +++ b/skills/setup/SKILL.md @@ -20,6 +20,7 @@ Workflow: - If the user agrees, run `npm install -g @anthropic-ai/claude-code` and rerun setup. - If Claude Code is already installed or `npm` is unavailable, do not ask about installation. - If setup reports missing native plugin hook features or hook trust, rerun setup once. The companion repairs `[features].hooks`, `[features].plugin_hooks`, and this plugin's native hook trust hashes itself. +- If setup adds the plugin-data destination or legacy migration roots to the writable-root list, do not retry in the same Codex session. Tell the user to restart Codex and rerun the same setup command; any requested review-gate change is deliberately deferred until that restart. - After the decision flow is complete, run the final user-facing command without `--json`: `node "/scripts/claude-companion.mjs" setup $ARGUMENTS` diff --git a/tests/codex-config.test.mjs b/tests/codex-config.test.mjs new file mode 100644 index 0000000..8481b78 --- /dev/null +++ b/tests/codex-config.test.mjs @@ -0,0 +1,158 @@ +/** + * Copyright 2026 Sendbird, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, it } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { ensureCodexWritableRoot } from "../scripts/lib/codex-config.mjs"; + +const originalExecutable = process.env.CC_PLUGIN_CODEX_EXECUTABLE; +const originalArgs = process.env.CC_PLUGIN_CODEX_APP_SERVER_ARGS_JSON; +const tempRoots = []; + +afterEach(() => { + if (originalExecutable === undefined) { + delete process.env.CC_PLUGIN_CODEX_EXECUTABLE; + } else { + process.env.CC_PLUGIN_CODEX_EXECUTABLE = originalExecutable; + } + if (originalArgs === undefined) { + delete process.env.CC_PLUGIN_CODEX_APP_SERVER_ARGS_JSON; + } else { + process.env.CC_PLUGIN_CODEX_APP_SERVER_ARGS_JSON = originalArgs; + } + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }); + } +}); + +it("retries writable-root updates without losing a concurrent config change", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cc-codex-config-")); + tempRoots.push(root); + const statePath = path.join(root, "state.json"); + const serverPath = path.join(root, "fake-app-server.mjs"); + fs.writeFileSync( + statePath, + JSON.stringify({ + writableRoots: ["/existing", 42], + version: "sha256:0", + conflictInjected: false, + }), + "utf8" + ); + fs.writeFileSync( + serverPath, + `import fs from "node:fs"; +import readline from "node:readline"; +const statePath = ${JSON.stringify(statePath)}; +const expectedCwd = ${JSON.stringify(root)}; +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +const write = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +rl.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") { + write({ jsonrpc: "2.0", id: message.id, result: {} }); + return; + } + const state = JSON.parse(fs.readFileSync(statePath, "utf8")); + if (message.method === "config/read") { + if (message.params.cwd !== expectedCwd) { + write({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32602, message: "config/read cwd mismatch" }, + }); + return; + } + write({ + jsonrpc: "2.0", + id: message.id, + result: { + config: { + sandbox_workspace_write: { + writable_roots: [...state.writableRoots, "/project-only"], + }, + }, + origins: {}, + layers: [{ + name: { type: "user", file: "/tmp/config.toml", profile: null }, + version: state.version, + config: { + sandbox_workspace_write: { + writable_roots: state.writableRoots, + }, + }, + }], + }, + }); + return; + } + if (message.method === "config/batchWrite") { + if ( + message.params.expectedVersion !== state.version || + message.params.filePath !== "/tmp/config.toml" + ) { + write({ + jsonrpc: "2.0", + id: message.id, + error: { + code: -32600, + message: "ConfigVersionConflict: Configuration was modified since last read", + }, + }); + return; + } + } + if (message.method === "config/batchWrite" && !state.conflictInjected) { + fs.writeFileSync(statePath, JSON.stringify({ + writableRoots: [...state.writableRoots, "/concurrent"], + version: "sha256:1", + conflictInjected: true, + }), "utf8"); + write({ + jsonrpc: "2.0", + id: message.id, + error: { + code: -32600, + message: "ConfigVersionConflict: Configuration was modified since last read", + }, + }); + return; + } + if (message.method === "config/batchWrite") { + const edit = message.params.edits.find( + (candidate) => candidate.keyPath === "sandbox_workspace_write.writable_roots" + ); + fs.writeFileSync(statePath, JSON.stringify({ + writableRoots: edit.value, + version: "sha256:2", + conflictInjected: true, + }), "utf8"); + write({ jsonrpc: "2.0", id: message.id, result: { status: "ok" } }); + } +}); +`, + "utf8" + ); + + process.env.CC_PLUGIN_CODEX_EXECUTABLE = process.execPath; + process.env.CC_PLUGIN_CODEX_APP_SERVER_ARGS_JSON = JSON.stringify([serverPath]); + + assert.equal(await ensureCodexWritableRoot(root, "/target"), true); + assert.deepEqual(JSON.parse(fs.readFileSync(statePath, "utf8")).writableRoots, [ + "/existing", + "/concurrent", + "/target", + ]); + assert.equal(await ensureCodexWritableRoot(root, "/target"), false); +}); diff --git a/tests/codex-paths.test.mjs b/tests/codex-paths.test.mjs new file mode 100644 index 0000000..9932d67 --- /dev/null +++ b/tests/codex-paths.test.mjs @@ -0,0 +1,177 @@ +/** + * Copyright 2026 Sendbird, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + resolveExpectedPluginDataRoot, + resolvePluginCacheInstallInfo, + resolvePluginDataRoot, + resolveMarketplacePluginDataRoot, + resolveWritablePluginDataRoots, +} from "../scripts/lib/codex-paths.mjs"; + +const originalPluginData = process.env.PLUGIN_DATA; +const originalClaudePluginData = process.env.CLAUDE_PLUGIN_DATA; +const originalCodexHome = process.env.CODEX_HOME; +const tempRoots = []; +const PROJECT_VERSION = JSON.parse( + fs.readFileSync(new URL("../package.json", import.meta.url), "utf8") +).version; + +afterEach(() => { + if (originalPluginData === undefined) { + delete process.env.PLUGIN_DATA; + } else { + process.env.PLUGIN_DATA = originalPluginData; + } + if (originalClaudePluginData === undefined) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = originalClaudePluginData; + } + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("plugin data paths", () => { + it("accepts Codex's injected plugin data root when it matches the install identity", () => { + const expectedRoot = resolveExpectedPluginDataRoot(); + process.env.PLUGIN_DATA = expectedRoot; + process.env.CLAUDE_PLUGIN_DATA = "/tmp/codex/plugins/data/cc-other"; + + assert.equal(resolvePluginDataRoot(), expectedRoot); + }); + + it("uses CLAUDE_PLUGIN_DATA when PLUGIN_DATA is absent", () => { + const expectedRoot = resolveExpectedPluginDataRoot(); + delete process.env.PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = expectedRoot; + + assert.equal(resolvePluginDataRoot(), expectedRoot); + }); + + it("ignores an injected plugin data root outside the install identity", () => { + process.env.PLUGIN_DATA = "/"; + + assert.equal(resolvePluginDataRoot(), resolveExpectedPluginDataRoot()); + }); + + it("accepts a symlink-equivalent injected plugin data root", () => { + const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "cc-paths-")); + tempRoots.push(codexHome); + const actualRoot = path.join(codexHome, "actual-data"); + const expectedRoot = path.join(codexHome, "plugins", "data", "cc"); + fs.mkdirSync(actualRoot, { recursive: true }); + fs.mkdirSync(path.dirname(expectedRoot), { recursive: true }); + fs.symlinkSync( + actualRoot, + expectedRoot, + process.platform === "win32" ? "junction" : "dir" + ); + process.env.CODEX_HOME = codexHome; + process.env.PLUGIN_DATA = actualRoot; + + assert.equal(resolvePluginDataRoot(), actualRoot); + }); + + it("keeps explicit legacy namespaces independent of injected paths", () => { + process.env.PLUGIN_DATA = "/tmp/codex/plugins/data/cc-sendbird"; + + assert.match(resolvePluginDataRoot("cc"), /plugins[/\\]data[/\\]cc$/); + }); + + it("derives marketplace identity from a managed cache root", () => { + const codexHome = path.resolve("/tmp/codex-home"); + const pluginRoot = path.join( + codexHome, + "plugins", + "cache", + "sendbird", + "cc", + PROJECT_VERSION + ); + + assert.deepEqual(resolvePluginCacheInstallInfo(pluginRoot, codexHome), { + marketplaceName: "sendbird", + pluginName: "cc", + version: PROJECT_VERSION, + pluginId: "cc@sendbird", + }); + }); + + it("rejects invalid marketplace names derived from cache paths", () => { + const codexHome = path.resolve("/tmp/codex-home"); + assert.equal( + resolvePluginCacheInstallInfo( + path.join( + codexHome, + "plugins", + "cache", + "bad name", + "cc", + PROJECT_VERSION + ), + codexHome + ), + null + ); + }); + + it("builds the official marketplace-qualified data root", () => { + assert.match( + resolveMarketplacePluginDataRoot("sendbird"), + /plugins[/\\]data[/\\]cc-sendbird$/ + ); + assert.throws( + () => resolveMarketplacePluginDataRoot("../outside"), + /Invalid marketplace name/ + ); + }); + + it("includes legacy namespaces needed to migrate marketplace plugin state", () => { + const primaryRoot = resolveMarketplacePluginDataRoot("sendbird"); + + assert.deepEqual(resolveWritablePluginDataRoots(primaryRoot), [ + primaryRoot, + resolvePluginDataRoot("cc"), + resolvePluginDataRoot("claude-code"), + ]); + }); + + it("does not trust injected environment paths for persistent writable roots", () => { + process.env.PLUGIN_DATA = "/"; + const codexHome = path.resolve("/tmp/codex-home"); + + assert.equal( + resolveExpectedPluginDataRoot("/tmp/source-checkout", codexHome), + path.join(codexHome, "plugins", "data", "cc") + ); + assert.equal( + resolveExpectedPluginDataRoot( + path.join( + codexHome, + "plugins", + "cache", + "sendbird", + "cc", + PROJECT_VERSION + ), + codexHome + ), + path.join(codexHome, "plugins", "data", "cc-sendbird") + ); + }); +}); diff --git a/tests/hooks.test.mjs b/tests/hooks.test.mjs index adf6793..facf1f3 100644 --- a/tests/hooks.test.mjs +++ b/tests/hooks.test.mjs @@ -16,11 +16,13 @@ import { REVIEW_MCP_SERVER_NAME, SANDBOX_STOP_REVIEW_TOOLS, } from "../scripts/lib/claude-cli.mjs"; -import { SESSION_ID_ENV } from "../scripts/lib/tracked-jobs.mjs"; const PROJECT_ROOT = path.resolve( fileURLToPath(new URL("../", import.meta.url)) ); +const PROJECT_VERSION = JSON.parse( + fs.readFileSync(path.join(PROJECT_ROOT, "package.json"), "utf8") +).version; const SESSION_HOOK = path.join( PROJECT_ROOT, "hooks", @@ -36,6 +38,7 @@ const UNREAD_HOOK = path.join( "hooks", "unread-result-hook.mjs" ); +const HOOKS_JSON = path.join(PROJECT_ROOT, "hooks", "hooks.json"); const PLUGIN_CONFIG_BLOCK = '[plugins."cc@local-plugins"]\nenabled = true\n'; function createFakeClaudeBinary(binDir) { @@ -97,6 +100,14 @@ if (process.env.CLAUDE_MCP_CONFIG_FILE) { }) + "\\n"); process.exit(0); } + if (process.env.CLAUDE_LONG_BLOCK_RESULT === "1") { + process.stdout.write(JSON.stringify({ + type: "result", + session_id: "hook-session-result", + result: "BLOCK: " + "x".repeat(3000) + }) + "\\n"); + process.exit(0); + } process.stdout.write(JSON.stringify({ type: "result", session_id: "hook-session-result", @@ -178,22 +189,51 @@ function createHookEnvironment(options = {}) { }; } +function installCachedPlugin(testEnv) { + const pluginRoot = path.join( + testEnv.homeDir, + ".codex", + "plugins", + "cache", + "sendbird", + "cc", + PROJECT_VERSION + ); + fs.mkdirSync(pluginRoot, { recursive: true }); + for (const entry of ["hooks", "prompts", "scripts"]) { + fs.cpSync(path.join(PROJECT_ROOT, entry), path.join(pluginRoot, entry), { + recursive: true, + }); + } + fs.writeFileSync( + path.join(testEnv.homeDir, ".codex", "config.toml"), + '[plugins."cc@sendbird"]\nenabled = true\n', + "utf8" + ); + return { + pluginDataRoot: path.join( + testEnv.homeDir, + ".codex", + "plugins", + "data", + "cc-sendbird" + ), + stopHook: path.join(pluginRoot, "hooks", "stop-review-gate-hook.mjs"), + }; +} + function cleanupHookEnvironment(testEnv) { fs.rmSync(testEnv.rootDir, { recursive: true, force: true }); } -function stateDirFor(homeDir, workspaceDir) { +function stateDirFor(homeDir, workspaceDir, pluginDataRoot = null) { const realWorkspace = fs.realpathSync.native(workspaceDir); const workspaceHash = createHash("sha256") .update(realWorkspace) .digest("hex") .slice(0, 12); return path.join( - homeDir, - ".codex", - "plugins", - "data", - "cc", + pluginDataRoot ?? path.join(homeDir, ".codex", "plugins", "data", "cc"), "state", workspaceHash ); @@ -268,6 +308,29 @@ function writeTurnBaselineSnapshot(testEnv, sessionId, fingerprint) { } describe("hooks", () => { + it("native plugin hook events stay within upstream Codex hook event names", () => { + const upstreamHookEventNames = new Set([ + "PreToolUse", + "PermissionRequest", + "PostToolUse", + "PreCompact", + "PostCompact", + "SessionStart", + "UserPromptSubmit", + "Stop", + ]); + const hooksConfig = JSON.parse(fs.readFileSync(HOOKS_JSON, "utf8")); + const pluginHookEvents = Object.keys(hooksConfig.hooks ?? {}); + + assert.ok(pluginHookEvents.length > 0); + for (const eventName of pluginHookEvents) { + assert.ok( + upstreamHookEventNames.has(eventName), + `${eventName} is not an upstream Codex hook event` + ); + } + }); + it("stop-review hook uses read-only sandbox and git MCP when review gate is enabled", () => { const testEnv = createHookEnvironment(); @@ -373,6 +436,85 @@ describe("hooks", () => { } }); + it("writes cached marketplace hook state under Codex's injected PLUGIN_DATA root", () => { + const testEnv = createHookEnvironment({ + createClaude: false, + initGit: false, + }); + + try { + const { pluginDataRoot, stopHook } = installCachedPlugin(testEnv); + runHook( + stopHook, + [], + { + cwd: testEnv.workspaceDir, + session_id: "hook-session", + last_assistant_message: "review me", + }, + { + ...testEnv.env, + PLUGIN_DATA: pluginDataRoot, + } + ); + + const snapshotFile = path.join( + stateDirFor(testEnv.homeDir, testEnv.workspaceDir, pluginDataRoot), + "stop-review-last.json" + ); + assert.equal(fs.existsSync(snapshotFile), true); + assert.equal( + fs.existsSync( + path.join( + stateDirFor(testEnv.homeDir, testEnv.workspaceDir), + "stop-review-last.json" + ) + ), + false + ); + } finally { + cleanupHookEnvironment(testEnv); + } + }); + + it("ignores a PLUGIN_DATA root that does not match the installed marketplace", () => { + const testEnv = createHookEnvironment({ + createClaude: false, + initGit: false, + }); + + try { + const { pluginDataRoot, stopHook } = installCachedPlugin(testEnv); + const unexpectedRoot = path.join(testEnv.rootDir, "unexpected-plugin-data"); + runHook( + stopHook, + [], + { + cwd: testEnv.workspaceDir, + session_id: "hook-session", + last_assistant_message: "review me", + }, + { + ...testEnv.env, + PLUGIN_DATA: unexpectedRoot, + } + ); + + assert.equal( + fs.existsSync( + path.join( + stateDirFor(testEnv.homeDir, testEnv.workspaceDir, pluginDataRoot), + "stop-review-last.json" + ) + ), + true + ); + assert.equal(fs.existsSync(unexpectedRoot), false); + } finally { + cleanupHookEnvironment(testEnv); + } + }); + it("stop-review hook skips Claude when the latest turn made no net edits", async () => { const testEnv = createHookEnvironment(); @@ -420,44 +562,49 @@ describe("hooks", () => { } }); - it("session lifecycle hook resolves queued session jobs on SessionEnd", () => { + it("unread-result hook reaps stale running jobs on UserPromptSubmit", () => { const testEnv = createHookEnvironment(); try { - writeStateJob(testEnv, "queued-hook-job", { - id: "queued-hook-job", - status: "queued", + writeStateJob(testEnv, "stale-running-job", { + id: "stale-running-job", + status: "running", sessionId: "hook-session", workspaceRoot: testEnv.workspaceDir, createdAt: "2026-04-04T01:00:00Z", + startedAt: "2026-04-04T01:00:01Z", + pid: 99999999, }); runHook( - SESSION_HOOK, - ["SessionEnd"], + UNREAD_HOOK, + [], { + hook_event_name: "UserPromptSubmit", cwd: testEnv.workspaceDir, session_id: "hook-session", + prompt: "continue", }, testEnv.env ); - const job = readStateJob(testEnv, "queued-hook-job"); - assert.equal(job.status, "cancelled"); - assert.equal(job.phase, "cancelled"); + const job = readStateJob(testEnv, "stale-running-job"); + assert.equal(job.status, "failed"); + assert.equal(job.phase, "failed"); assert.equal(job.pid, null); - assert.match(job.errorMessage ?? "", /session ended/i); + assert.match(job.errorMessage ?? "", /Auto-reaped/i); + assert.equal(readCurrentSessionMarker(testEnv).sessionId, "hook-session"); } finally { cleanupHookEnvironment(testEnv); } }); - it("session lifecycle hook refuses to kill a stored PID without a matching identity", () => { + it("unread-result hook does not cancel live jobs during UserPromptSubmit", () => { const testEnv = createHookEnvironment(); try { - writeStateJob(testEnv, "untrusted-running-job", { - id: "untrusted-running-job", + writeStateJob(testEnv, "live-running-job", { + id: "live-running-job", status: "running", sessionId: "hook-session", workspaceRoot: testEnv.workspaceDir, @@ -467,20 +614,20 @@ describe("hooks", () => { }); runHook( - SESSION_HOOK, - ["SessionEnd"], + UNREAD_HOOK, + [], { + hook_event_name: "UserPromptSubmit", cwd: testEnv.workspaceDir, session_id: "hook-session", + prompt: "continue", }, testEnv.env ); - const job = readStateJob(testEnv, "untrusted-running-job"); - assert.equal(job.status, "cancel_failed"); - assert.equal(job.phase, "cancel_failed"); + const job = readStateJob(testEnv, "live-running-job"); + assert.equal(job.status, "running"); assert.equal(job.pid, process.pid); - assert.match(job.errorMessage ?? "", /without a matching PID identity/i); } finally { cleanupHookEnvironment(testEnv); } @@ -658,6 +805,49 @@ describe("hooks", () => { } }); + it("stop-review hook caps emitted block reasons while preserving raw output", () => { + const testEnv = createHookEnvironment(); + + try { + const stateDir = stateDirFor(testEnv.homeDir, testEnv.workspaceDir); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "config.json"), + JSON.stringify({ version: 1, stopReviewGate: true }, null, 2) + "\n", + "utf8" + ); + + const result = runHook( + STOP_HOOK, + [], + { + cwd: testEnv.workspaceDir, + session_id: "hook-session", + last_assistant_message: "review me", + }, + { + ...testEnv.env, + CLAUDE_LONG_BLOCK_RESULT: "1", + } + ); + + const payload = JSON.parse(result.stdout); + assert.equal(payload.decision, "block"); + assert.ok( + payload.reason.length <= 1_600, + `expected bounded reason, got ${payload.reason.length} chars` + ); + assert.match(payload.reason, /Full stop-review output was saved/); + + const snapshot = readStopReviewSnapshot(testEnv); + assert.equal(snapshot.status, "blocked"); + assert.ok(snapshot.reason.length > payload.reason.length); + assert.equal(snapshot.rawOutput, `BLOCK: ${"x".repeat(3000)}`); + } finally { + cleanupHookEnvironment(testEnv); + } + }); + it("stop-review hook accepts an ALLOW contract after streamed prefix chatter", () => { const testEnv = createHookEnvironment(); @@ -798,103 +988,4 @@ describe("hooks", () => { } }); - it("session lifecycle hook falls back to the current-session marker on SessionEnd", () => { - const testEnv = createHookEnvironment(); - - try { - const stateDir = stateDirFor(testEnv.homeDir, testEnv.workspaceDir); - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync( - path.join(stateDir, "current-session.json"), - JSON.stringify( - { sessionId: "hook-session", updatedAt: "2026-04-04T01:00:00Z" }, - null, - 2 - ) + "\n", - "utf8" - ); - writeStateJob(testEnv, "queued-hook-job", { - id: "queued-hook-job", - status: "queued", - sessionId: "hook-session", - workspaceRoot: testEnv.workspaceDir, - createdAt: "2026-04-04T01:00:00Z", - }); - - runHook( - SESSION_HOOK, - ["SessionEnd"], - { - cwd: testEnv.workspaceDir, - }, - testEnv.env - ); - - const job = readStateJob(testEnv, "queued-hook-job"); - assert.equal(job.status, "cancelled"); - assert.ok( - !fs.existsSync(path.join(stateDir, "current-session.json")), - "SessionEnd fallback should clear the current-session marker" - ); - } finally { - cleanupHookEnvironment(testEnv); - } - }); - - it("session lifecycle hook ignores fallback lookup errors on SessionEnd", () => { - const testEnv = createHookEnvironment(); - - try { - const missingDir = path.join(testEnv.rootDir, "missing-workspace"); - const result = runHook( - SESSION_HOOK, - ["SessionEnd"], - { - cwd: missingDir, - }, - testEnv.env - ); - - assert.equal(result.stdout.trim(), ""); - assert.equal(result.stderr.trim(), ""); - } finally { - cleanupHookEnvironment(testEnv); - } - }); - - it("nested SessionEnd does not cancel jobs owned by the parent session", () => { - const testEnv = createHookEnvironment(); - - try { - writeStateJob(testEnv, "parent-owned-job", { - id: "parent-owned-job", - status: "running", - sessionId: "parent-session", - workspaceRoot: testEnv.workspaceDir, - createdAt: "2026-04-04T01:00:00Z", - startedAt: "2026-04-04T01:00:01Z", - pid: 999999, - }); - - runHook( - SESSION_HOOK, - ["SessionEnd"], - { - cwd: testEnv.workspaceDir, - session_id: "child-session", - }, - { - ...testEnv.env, - [SESSION_ID_ENV]: "parent-session", - } - ); - - const job = readStateJob(testEnv, "parent-owned-job"); - assert.equal(job.status, "running"); - assert.equal(job.sessionId, "parent-session"); - assert.equal(job.pid, 999999); - } finally { - cleanupHookEnvironment(testEnv); - } - }); }); diff --git a/tests/installer-cli.test.mjs b/tests/installer-cli.test.mjs index ebed4a5..3510eec 100644 --- a/tests/installer-cli.test.mjs +++ b/tests/installer-cli.test.mjs @@ -329,6 +329,7 @@ import readline from "node:readline"; const codexHome = ${JSON.stringify(codexHome)}; const logPath = ${JSON.stringify(logPath)}; +const writableRootsPath = path.join(codexHome, "fake-writable-roots.json"); const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); function readConfig(configPath) { @@ -435,6 +436,55 @@ rl.on("line", (line) => { return; } + if (message.method === "config/read") { + const writableRoots = fs.existsSync(writableRootsPath) + ? JSON.parse(fs.readFileSync(writableRootsPath, "utf8")) + : []; + process.stdout.write(JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { + config: { + sandbox_workspace_write: { + writable_roots: writableRoots, + }, + }, + origins: {}, + layers: [ + { + name: { + type: "user", + file: "/tmp/config.toml", + profile: null, + }, + version: "sha256:installer-config", + config: { + sandbox_workspace_write: { + writable_roots: writableRoots, + }, + }, + }, + ], + }, + }) + "\\n"); + return; + } + + if (message.method === "config/batchWrite") { + const edit = message.params.edits.find( + (candidate) => candidate.keyPath === "sandbox_workspace_write.writable_roots" + ); + if (edit) { + fs.writeFileSync(writableRootsPath, JSON.stringify(edit.value), "utf8"); + } + process.stdout.write(JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { status: "ok" }, + }) + "\\n"); + return; + } + process.stdout.write( JSON.stringify({ jsonrpc: "2.0", @@ -782,7 +832,7 @@ describe("installer-cli", () => { runInstaller("install", homeDir, sourceRoot, { ...fakeCodex.env, CC_PLUGIN_CODEX_MARKETPLACE_SOURCE: marketplaceRoot, - CC_PLUGIN_CODEX_MARKETPLACE_NAME: "sendbird", + CC_PLUGIN_CODEX_MARKETPLACE_NAME: "stale-config-name", }); const configFile = path.join(homeDir, ".codex", "config.toml"); @@ -806,6 +856,28 @@ describe("installer-cli", () => { requests.some((request) => request.method === "marketplace/add"), "installer should call Codex marketplace/add" ); + const writableRootRequest = requests.find( + (request) => + request.method === "config/batchWrite" && + request.params.edits.some( + (edit) => edit.keyPath === "sandbox_workspace_write.writable_roots" + ) + ); + assert.deepEqual(writableRootRequest?.params.edits, [ + { + keyPath: "sandbox_workspace_write.writable_roots", + value: [ + path.join(homeDir, ".codex", "plugins", "data", "cc-sendbird"), + path.join(homeDir, ".codex", "plugins", "data", "cc"), + path.join(homeDir, ".codex", "plugins", "data", "claude-code"), + ], + mergeStrategy: "replace", + }, + ]); + assert.equal( + writableRootRequest?.params.expectedVersion, + "sha256:installer-config" + ); assert.equal( pluginInstallRequest?.params?.marketplacePath, path.join(homeDir, ".codex", "marketplaces", "sendbird", ".agents", "plugins", "marketplace.json") @@ -1033,6 +1105,15 @@ describe("installer-cli", () => { assert.doesNotMatch(config, /\[plugins\."cc@local-plugins"\]/); assert.doesNotMatch(config, /\[plugins\."cc@sendbird"\]/); assert.equal(hooks.hooks.SessionStart[0].hooks[0].command, "echo custom-hook"); + assert.deepEqual( + JSON.parse( + fs.readFileSync( + path.join(codexDir, "fake-writable-roots.json"), + "utf8" + ) + ), + [] + ); }); it("removes versioned marketplace cache entries during uninstall", () => { diff --git a/tests/integration/claude-companion.test.mjs b/tests/integration/claude-companion.test.mjs index a087d6f..3f09d55 100644 --- a/tests/integration/claude-companion.test.mjs +++ b/tests/integration/claude-companion.test.mjs @@ -168,6 +168,7 @@ function createTestEnvironment() { ...process.env, HOME: homeDir, USERPROFILE: homeDir, + CODEX_HOME: path.join(homeDir, ".codex"), PATH: `${binDir}${path.delimiter}${process.env.PATH || ""}`, }, }; @@ -183,12 +184,28 @@ import readline from "node:readline"; const hooks = ${JSON.stringify(hooks, null, 2)}; const logPath = ${JSON.stringify(logPath)}; +const configStatePath = ${JSON.stringify( + path.join(testEnv.rootDir, "fake-codex-config-state.json") + )}; const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); function write(message) { process.stdout.write(JSON.stringify(message) + "\\n"); } +function readConfigState() { + return fs.existsSync(configStatePath) + ? JSON.parse(fs.readFileSync(configStatePath, "utf8")) + : { + writableRoots: [${JSON.stringify(testEnv.workspaceDir)}], + version: "sha256:config-0", + }; +} + +function writeConfigState(state) { + fs.writeFileSync(configStatePath, JSON.stringify(state), "utf8"); +} + rl.on("line", (line) => { const message = JSON.parse(line); fs.appendFileSync(logPath, JSON.stringify(message) + "\\n", "utf8"); @@ -211,7 +228,67 @@ rl.on("line", (line) => { }); return; } + if (message.method === "config/read") { + const state = readConfigState(); + write({ + jsonrpc: "2.0", + id: message.id, + result: { + config: { + sandbox_workspace_write: { + writable_roots: state.writableRoots, + }, + }, + origins: {}, + layers: [ + { + name: { + type: "user", + file: "/tmp/config.toml", + profile: null, + }, + version: state.version, + config: { + sandbox_workspace_write: { + writable_roots: state.writableRoots, + }, + }, + }, + ], + }, + }); + return; + } if (message.method === "config/batchWrite") { + const state = readConfigState(); + if ( + message.params.expectedVersion != null && + message.params.expectedVersion !== state.version + ) { + write({ + jsonrpc: "2.0", + id: message.id, + error: { + code: -32600, + message: "ConfigVersionConflict: Configuration was modified since last read", + }, + }); + return; + } + const writableRootsEdit = message.params.edits.find( + (edit) => edit.keyPath === "sandbox_workspace_write.writable_roots" + ); + if (writableRootsEdit) { + writeConfigState({ + writableRoots: writableRootsEdit.value, + version: "sha256:config-" + (Number(state.version.split("-").at(-1)) + 1), + }); + } else { + writeConfigState({ + ...state, + version: "sha256:config-" + (Number(state.version.split("-").at(-1)) + 1), + }); + } write({ jsonrpc: "2.0", id: message.id, result: { status: "ok" } }); return; } @@ -290,7 +367,7 @@ function writeCurrentSessionMarker(testEnv, sessionId) { fs.mkdirSync(stateDir, { recursive: true }); fs.writeFileSync( path.join(stateDir, "current-session.json"), - JSON.stringify({ sessionId, updatedAt: "2026-04-03T12:00:00Z" }, null, 2) + "\n", + JSON.stringify({ sessionId, updatedAt: new Date().toISOString() }, null, 2) + "\n", "utf8" ); } @@ -588,35 +665,45 @@ function assertCompletedReviewPayload(payload) { describe("claude-companion integration", () => { it("setup toggles the review gate on and off for the current workspace", () => { const testEnv = createTestEnvironment(); + const fakeCodex = createFakeCodexAppServer(testEnv, []); + const setupEnv = { + ...testEnv.env, + CC_PLUGIN_CODEX_EXECUTABLE: process.execPath, + CC_PLUGIN_CODEX_APP_SERVER_ARGS_JSON: JSON.stringify([fakeCodex.serverPath]), + }; try { const initial = runCompanionJson( ["setup", "--cwd", testEnv.workspaceDir, "--json"], - { env: testEnv.env } + { env: setupEnv } + ); + assert.equal(initial.reviewGateEnabled, null); + assert.match( + initial.nextSteps.join("\n"), + /Restart Codex.*rerun `\$cc:setup`/ ); - assert.equal(initial.reviewGateEnabled, false); const enabled = runCompanion( ["setup", "--cwd", testEnv.workspaceDir, "--enable-review-gate"], - { env: testEnv.env } + { env: setupEnv } ); assert.match(enabled.stdout, /review gate: enabled/i); const afterEnable = runCompanionJson( ["setup", "--cwd", testEnv.workspaceDir, "--json"], - { env: testEnv.env } + { env: setupEnv } ); assert.equal(afterEnable.reviewGateEnabled, true); const disabled = runCompanion( ["setup", "--cwd", testEnv.workspaceDir, "--disable-review-gate"], - { env: testEnv.env } + { env: setupEnv } ); assert.match(disabled.stdout, /review gate: disabled/i); const afterDisable = runCompanionJson( ["setup", "--cwd", testEnv.workspaceDir, "--json"], - { env: testEnv.env } + { env: setupEnv } ); assert.equal(afterDisable.reviewGateEnabled, false); } finally { @@ -624,6 +711,36 @@ describe("claude-companion integration", () => { } }); + it("setup reports plugin-state repair guidance when the app-server is unavailable", () => { + const testEnv = createTestEnvironment(); + + try { + const report = runCompanionJson( + ["setup", "--cwd", testEnv.workspaceDir, "--json"], + { + env: { + ...testEnv.env, + CC_PLUGIN_CODEX_EXECUTABLE: path.join( + testEnv.rootDir, + "missing-codex" + ), + }, + } + ); + + assert.equal(report.ready, false); + assert.equal(report.reviewGateEnabled, null); + assert.equal(report.pluginState.ready, false); + assert.match(report.pluginState.detail, /unable to configure/i); + assert.match( + report.nextSteps.join("\n"), + /sandbox_workspace_write\.writable_roots/ + ); + } finally { + cleanupTestEnvironment(testEnv); + } + }); + it("setup trusts current native plugin hooks through Codex hooks/list", () => { const testEnv = createTestEnvironment(); const sourcePath = path.join(PROJECT_ROOT, "hooks", "hooks.json"); @@ -701,7 +818,7 @@ describe("claude-companion integration", () => { try { const report = runCompanionJson( - ["setup", "--cwd", testEnv.workspaceDir, "--json"], + ["setup", "--cwd", testEnv.workspaceDir, "--json", "--enable-review-gate"], { env: { ...testEnv.env, @@ -712,14 +829,19 @@ describe("claude-companion integration", () => { } ); - assert.equal(report.ready, true); + assert.equal(report.ready, false); + assert.equal(report.reviewGateEnabled, null); assert.equal(report.hookTrust.ready, true); assert.equal(report.hookTrust.found, 3); assert.equal(report.hookTrust.trusted, 2); assert.match(report.hookTrust.detail, /trusted 2 native plugin hooks/i); const requests = readJsonLines(fakeCodex.logPath); - const writeRequest = requests.find((request) => request.method === "config/batchWrite"); + const writeRequest = requests.find( + (request) => + request.method === "config/batchWrite" && + request.params.edits.some((edit) => edit.keyPath === "hooks.state") + ); assert.ok(writeRequest, "expected setup to write hook trust state"); assert.deepEqual(writeRequest.params.edits, [ { @@ -736,6 +858,50 @@ describe("claude-companion integration", () => { }, ]); assert.equal(writeRequest.params.reloadUserConfig, true); + + const writableRootRequest = requests.find( + (request) => + request.method === "config/batchWrite" && + request.params.edits.some( + (edit) => edit.keyPath === "sandbox_workspace_write.writable_roots" + ) + ); + assert.ok(writableRootRequest, "expected setup to allow plugin data writes"); + assert.deepEqual(writableRootRequest.params.edits, [ + { + keyPath: "sandbox_workspace_write.writable_roots", + value: [ + testEnv.workspaceDir, + path.join(testEnv.homeDir, ".codex", "plugins", "data", "cc"), + path.join( + testEnv.homeDir, + ".codex", + "plugins", + "data", + "claude-code" + ), + ], + mergeStrategy: "replace", + }, + ]); + assert.equal(writableRootRequest.params.expectedVersion, "sha256:config-1"); + assert.match( + report.actionsTaken.join("\n"), + /Restart Codex so existing sessions use the updated writable roots/ + ); + assert.match( + report.actionsTaken.join("\n"), + /Deferred the review-gate change until plugin state write access is ready/ + ); + assert.match( + report.nextSteps.join("\n"), + /Restart Codex.*rerun `\$cc:setup`/ + ); + assert.equal( + fs.existsSync(path.join(stateDirFor(testEnv), "config.json")), + false, + "setup must not touch plugin state until the new sandbox root is active" + ); } finally { cleanupTestEnvironment(testEnv); } diff --git a/tests/render.test.mjs b/tests/render.test.mjs index ab3ee85..8530739 100644 --- a/tests/render.test.mjs +++ b/tests/render.test.mjs @@ -173,12 +173,26 @@ describe("renderSetupReport", () => { assert.ok(output.includes("- hook trust: trusted 3 native plugin hooks")); }); + it("includes plugin state details when present", () => { + const output = renderSetupReport({ + ...baseReport, + pluginState: { ready: false, detail: "restart required" }, + }); + assert.ok(output.includes("- plugin state: restart required")); + }); + it("shows review gate status", () => { const enabled = renderSetupReport({ ...baseReport, reviewGateEnabled: true }); assert.ok(enabled.includes("review gate: enabled")); const disabled = renderSetupReport(baseReport); assert.ok(disabled.includes("review gate: disabled")); + + const pendingRestart = renderSetupReport({ + ...baseReport, + reviewGateEnabled: null, + }); + assert.ok(pendingRestart.includes("review gate: check after restart")); }); it("includes actions taken", () => { diff --git a/tests/skills-contracts.test.mjs b/tests/skills-contracts.test.mjs index 5267419..032b4ea 100644 --- a/tests/skills-contracts.test.mjs +++ b/tests/skills-contracts.test.mjs @@ -16,6 +16,28 @@ function read(relativePath) { return fs.readFileSync(path.join(PROJECT_ROOT, relativePath), "utf8"); } +test("built-in child commands preserve the workspace for reserved job ids", () => { + const skills = [ + ["rescue", "skills/rescue/SKILL.md"], + ["review", "skills/review/SKILL.md"], + ["adversarial review", "skills/adversarial-review/SKILL.md"], + ]; + + for (const [name, skillPath] of skills) { + const skill = read(skillPath); + assert.match( + skill, + /Whenever forwarding that reserved `--job-id`, also pass `--cwd ` using `workspaceRoot` from the same helper response/i, + `${name} must keep reserved job ids in their workspace`, + ); + assert.match( + skill, + /include the matching `--cwd ` whenever the (?:exact )?command (?:contains|includes) (?:that )?reserved `--job-id/i, + `${name} child command must forward the reserved job workspace`, + ); + } +}); + test("internal runtime references keep the active-root and notification invariants", () => { const reviewRuntime = read("internal-skills/review-runtime/runtime.md"); const rescueRuntime = read("internal-skills/cli-runtime/runtime.md"); @@ -329,6 +351,8 @@ test("setup skill repairs native plugin hook feature gates before the final setu assert.match(setup, /\[features\]\.hooks/i); assert.match(setup, /\[features\]\.plugin_hooks/i); assert.match(setup, /native hook trust hashes/i); + assert.match(setup, /plugin-data destination .* writable-root list/i); + assert.match(setup, /restart Codex and rerun the same setup command/i); assert.doesNotMatch(setup, /install-hooks\.mjs/i); }); diff --git a/tests/state.test.mjs b/tests/state.test.mjs index 3716b8d..008aa6c 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -9,7 +9,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { createHash } from "node:crypto"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; // State paths are workspace-hash based and resolveWorkspaceRoot() shells out to // git, so most tests use a real git repo cwd. A dedicated subprocess test below @@ -45,7 +45,24 @@ import { // We'll use the project root as a known git-repo cwd for workspace resolution. const PROJECT_CWD = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const STATE_MODULE_URL = new URL("../scripts/lib/state.mjs", import.meta.url).href; +const PROJECT_VERSION = JSON.parse( + fs.readFileSync(path.join(PROJECT_CWD, "package.json"), "utf8") +).version; +function installCachedStateModule(codexHome) { + const pluginRoot = path.join( + codexHome, + "plugins", + "cache", + "sendbird", + "cc", + PROJECT_VERSION + ); + fs.mkdirSync(pluginRoot, { recursive: true }); + fs.cpSync(path.join(PROJECT_CWD, "scripts"), path.join(pluginRoot, "scripts"), { + recursive: true, + }); + return pathToFileURL(path.join(pluginRoot, "scripts", "lib", "state.mjs")).href; +} function createTempGitRepo() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-test-")); @@ -161,7 +178,7 @@ describe("loadConfig / saveConfig", () => { assert.equal(cfg.stopReviewGate, true); }); - it("migrates legacy claude-code plugin state into the cc plugin namespace and prunes old armed markers", () => { + it("migrates legacy plugin state into Codex's injected data root and prunes old armed markers", () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-state-migrate-")); const codexHome = path.join(homeDir, ".codex"); const repoDir = createTempGitRepo(); @@ -172,7 +189,15 @@ describe("loadConfig / saveConfig", () => { .update(realWorkspace) .digest("hex") .slice(0, 12); - const legacyStateDir = path.join( + const legacyCcStateDir = path.join( + codexHome, + "plugins", + "data", + "cc", + "state", + workspaceHash + ); + const legacyClaudeStateDir = path.join( codexHome, "plugins", "data", @@ -184,18 +209,20 @@ describe("loadConfig / saveConfig", () => { codexHome, "plugins", "data", - "cc", + "cc-sendbird", "state", workspaceHash ); + const cachedStateModuleUrl = installCachedStateModule(codexHome); - fs.mkdirSync(legacyStateDir, { recursive: true }); + fs.mkdirSync(legacyCcStateDir, { recursive: true }); fs.writeFileSync( - path.join(legacyStateDir, "config.json"), + path.join(legacyCcStateDir, "config.json"), JSON.stringify({ version: 1, stopReviewGate: true }, null, 2) + "\n", "utf8" ); - fs.writeFileSync(path.join(legacyStateDir, "armed-old-session"), "", "utf8"); + fs.mkdirSync(legacyClaudeStateDir, { recursive: true }); + fs.writeFileSync(path.join(legacyClaudeStateDir, "armed-old-session"), "", "utf8"); const result = spawnSync( process.execPath, @@ -203,7 +230,7 @@ describe("loadConfig / saveConfig", () => { "--input-type=module", "-e", ` - const mod = await import(${JSON.stringify(STATE_MODULE_URL)}); + const mod = await import(${JSON.stringify(cachedStateModuleUrl)}); const cwd = ${JSON.stringify(repoDir)}; console.log(JSON.stringify({ stateDir: mod.resolveStateDir(cwd), @@ -217,6 +244,7 @@ describe("loadConfig / saveConfig", () => { HOME: homeDir, USERPROFILE: homeDir, CODEX_HOME: codexHome, + PLUGIN_DATA: path.join(homeDir, "unexpected-plugin-data"), }, encoding: "utf8", } @@ -227,13 +255,71 @@ describe("loadConfig / saveConfig", () => { assert.equal(payload.stateDir, nextStateDir); assert.equal(payload.config.stopReviewGate, true); assert.equal(fs.existsSync(path.join(nextStateDir, "config.json")), true); - assert.equal(fs.existsSync(path.join(legacyStateDir, "config.json")), false); + assert.equal(fs.existsSync(path.join(legacyCcStateDir, "config.json")), false); + assert.equal(fs.existsSync(legacyClaudeStateDir), false); assert.equal(fs.existsSync(path.join(nextStateDir, "armed-old-session")), false); } finally { fs.rmSync(homeDir, { recursive: true, force: true }); fs.rmSync(repoDir, { recursive: true, force: true }); } }); + + it("does not migrate a legacy root into an injected symlink to itself", () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-state-alias-")); + const codexHome = path.join(homeDir, ".codex"); + const repoDir = createTempGitRepo(); + const legacyRoot = path.join(codexHome, "plugins", "data", "cc"); + const expectedRoot = path.join(codexHome, "plugins", "data", "cc-sendbird"); + + try { + const cachedStateModuleUrl = installCachedStateModule(codexHome); + const workspaceHash = createHash("sha256") + .update(fs.realpathSync.native(repoDir)) + .digest("hex") + .slice(0, 12); + const stateDir = path.join(legacyRoot, "state", workspaceHash); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "config.json"), + JSON.stringify({ version: 1, stopReviewGate: true }) + "\n", + "utf8" + ); + fs.symlinkSync( + legacyRoot, + expectedRoot, + process.platform === "win32" ? "junction" : "dir" + ); + + const result = spawnSync( + process.execPath, + [ + "--input-type=module", + "-e", + ` + const mod = await import(${JSON.stringify(cachedStateModuleUrl)}); + console.log(JSON.stringify(mod.getConfig(${JSON.stringify(repoDir)}))); + `, + ], + { + env: { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + CODEX_HOME: codexHome, + PLUGIN_DATA: expectedRoot, + }, + encoding: "utf8", + } + ); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(JSON.parse(result.stdout).stopReviewGate, true); + assert.equal(fs.existsSync(path.join(stateDir, "config.json")), true); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); }); // --------------------------------------------------------------------------- @@ -520,6 +606,38 @@ describe("current session marker", () => { clearCurrentSession(repoDir, sessionId); assert.equal(getCurrentSession(repoDir), "newer-session"); }); + + it("expires a stale current-session fallback marker", () => { + setCurrentSession(repoDir, sessionId); + const markerFile = path.join(resolveStateDir(repoDir), "current-session.json"); + fs.writeFileSync( + markerFile, + JSON.stringify({ + sessionId, + updatedAt: "2020-01-01T00:00:00.000Z", + }), + "utf8" + ); + + assert.equal(getCurrentSession(repoDir), null); + assert.equal(fs.existsSync(markerFile), false); + }); + + it("expires a current-session marker with excessive future clock skew", () => { + setCurrentSession(repoDir, sessionId); + const markerFile = path.join(resolveStateDir(repoDir), "current-session.json"); + fs.writeFileSync( + markerFile, + JSON.stringify({ + sessionId, + updatedAt: "9999-01-01T00:00:00.000Z", + }), + "utf8" + ); + + assert.equal(getCurrentSession(repoDir), null); + assert.equal(fs.existsSync(markerFile), false); + }); }); // ---------------------------------------------------------------------------