From c4eae42bc923ef7a41d6495c116c7ae7ce2caba2 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:04:07 +0300 Subject: [PATCH 1/4] fix: harden Claude compatibility and test gates --- .codex-plugin/plugin.json | 2 +- .github/workflows/ci.yml | 54 +- .github/workflows/mutation.yml | 53 ++ .gitignore | 3 + CHANGELOG.md | 28 + README.md | 24 +- hooks/lib/plugin-install-guard.mjs | 57 +- package-lock.json | 452 ++++++++++++- package.json | 49 +- scripts/install-hooks.mjs | 18 +- scripts/installer-cli.mjs | 168 ++++- scripts/lib/claude-cli.mjs | 4 +- scripts/lib/managed-global-integration.mjs | 237 +++++-- stryker.config.mjs | 9 +- stryker.critical.config.mjs | 24 + stryker.shard.config.mjs | 67 ++ tests/claude-cli.test.mjs | 39 +- tests/e2e/codex-skills-e2e.test.mjs | 18 +- tests/hooks.test.mjs | 173 ++++- tests/install-hooks.test.mjs | 27 + tests/installer-cli.test.mjs | 674 +++++++++++++++++++- tests/integration/claude-companion.test.mjs | 1 + tests/job-control.test.mjs | 174 +++++ tests/mutation-config.test.mjs | 53 ++ tests/plugin-install-guard.test.mjs | 501 +++++++++++++++ tests/process.test.mjs | 10 +- tests/prompts.test.mjs | 4 +- tests/skills-contracts.test.mjs | 612 +++++++----------- tests/state.test.mjs | 10 +- tests/test-env-isolation.test.mjs | 44 ++ tests/test-env.mjs | 20 + tests/unread-result-hook.test.mjs | 2 + tsconfig.tests.json | 10 + 33 files changed, 3108 insertions(+), 513 deletions(-) create mode 100644 .github/workflows/mutation.yml create mode 100644 stryker.critical.config.mjs create mode 100644 stryker.shard.config.mjs create mode 100644 tests/mutation-config.test.mjs create mode 100644 tests/plugin-install-guard.test.mjs create mode 100644 tests/test-env-isolation.test.mjs create mode 100644 tests/test-env.mjs create mode 100644 tsconfig.tests.json diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index fc0d488..92d2853 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cc", - "version": "1.5.1", + "version": "1.5.2", "description": "Claude Code Plugin for Codex. Delegate code reviews, investigations, tracked tasks, and transcript transfers from inside Codex.", "author": { "name": "CBEPX", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83e2bd0..3acd1db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,20 +1,20 @@ name: CI +permissions: + contents: read + on: + workflow_dispatch: push: branches: - main pull_request: jobs: - core-cross-platform: - name: Core checks (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: - - windows-latest + windows-unit: + name: Unit (windows-latest) + runs-on: windows-latest + timeout-minutes: 15 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 @@ -26,11 +26,13 @@ jobs: - run: npm run check:changelog - run: npm run lint - run: npm run typecheck + - run: npm run typecheck:tests - run: npm run test:cross-platform macos-full: name: Full CI (macos-latest) runs-on: macos-latest + timeout-minutes: 20 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 @@ -40,17 +42,12 @@ jobs: - run: npm install -g @openai/codex - run: codex --version - run: npm ci - - run: npm run check:version-sync - - run: npm run check:changelog - - run: npm run lint - - run: npm run typecheck - - run: npm run test - - run: npm run test:integration - - run: npm run test:e2e + - run: npm run check - linux-full: - name: Full CI (ubuntu-latest) + linux-coverage: + name: Coverage (ubuntu-latest) runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 @@ -64,6 +61,27 @@ jobs: - run: npm run check:changelog - run: npm run lint - run: npm run typecheck + - run: npm run typecheck:tests + - run: npm run test:coverage + - if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: coverage + path: reports/coverage/ + if-no-files-found: error + retention-days: 14 + + node18-runtime: + name: Runtime compatibility (Node 18) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 18 + cache: npm + - run: npm ci + - run: node --version - run: npm run test - run: npm run test:integration - - run: npm run test:e2e diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 0000000..25f90f4 --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,53 @@ +name: Mutation + +permissions: + contents: read + +on: + workflow_dispatch: + pull_request: + schedule: + - cron: "0 3 * * 0" + +jobs: + pull-request: + if: github.event_name == 'pull_request' + name: Pull-request mutation + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run test:mutation:pr:force + - if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: mutation-pull-request + path: reports/mutation/ + if-no-files-found: error + retention-days: 14 + + full: + if: github.event_name != 'pull_request' + name: Full mutation + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run test:mutation:full:force + - if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: mutation-full + path: reports/mutation/ + if-no-files-found: error + retention-days: 14 diff --git a/.gitignore b/.gitignore index 7b73a65..31e221c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,11 @@ .claude/ +.serena/ node_modules/ .stryker-tmp/ reports/mutation/ +reports/coverage/ reports/stryker-incremental.json +reports/stryker-*-incremental.json stryker.log *.log .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 8767fd4..839cd81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ ## [Unreleased] +## v1.5.2 + +### Added + +- Isolate every test process behind a temporary `CODEX_HOME` preload and add a sentinel regression test proving production state helpers cannot write into the user's real Codex state. +- Add enforced C8 coverage gates with a 89/89/79/96 ratchet, test-source type checking, full macOS/Linux and Node.js 18 unit coverage, a curated Windows-safe suite, and retained CI reports. +- Gate pull requests with Stryker's parser contracts at an 80% break threshold plus managed-cleanup and installer shards at 55%; run all seven shards on the weekly/manual job and fail if line-range scopes drift away from their named functions. + +### Changed + +- Replace broad prose snapshots with focused executable skill-contract checks while retaining workspace, foreground execution, empty-placeholder, model-inheritance, notification, routing, raw-CLI fallback, and background-launch invariants. +- Expand regression coverage for current Claude Code behavior, Opus aliases, hook block/error paths, installer RPC failures, job selection, managed cleanup, and the existing normalized `contextWindow` JSON contract. + +### Fixed + +- Refuse managed global cleanup when `hooks.json` is malformed or has an invalid shape, preserving hook and wrapper data instead of partially deleting it. +- Preserve unrelated hook document keys and entries while removing only plugin-managed hooks and wrappers after confirmed official uninstall signals. +- Validate marketplace and hook documents before mutation, tolerate unavailable or unrecognized Codex `plugin/uninstall` failures, and keep explicit RPC permission/auth refusals fail-closed unless the recovery override is set. +- Derive uninstall targets from observed config/cache state, attempt every installed marketplace even when an earlier uninstall RPC is unavailable, and continue validated local cleanup when stale Codex state or future RPC wording would otherwise make uninstall permanently unrepeatable. +- Match only anchored plugin-absence RPC messages so unexpected errors containing words such as `unknown` or `not found` are handled by the explicit recovery policy instead of being misclassified as confirmed absence. +- Keep install/update/uninstall recoverable when foreign hook data is malformed, with an explicit escape hatch that preserves risky legacy hook files while still removing official plugin config and cache state. +- Rewrite retained global `hooks.json`, shared `config.toml`, and personal `marketplace.json` documents through synced same-directory temporary files and atomic renames when inode identity can change; follow existing symlinks, preserve file modes, and recover hard-linked in-place rewrites from synced backups. +- Defer destructive legacy-install cleanup until the replacement Codex marketplace/plugin install succeeds, so an unavailable remote update leaves the working legacy install intact. +- Stop obsolete hooks after confirmed official uninstall even when malformed hook data blocks cleanup, with a resettable one-time repair warning. +- Keep refusal-marker cleanup best-effort so permissions or Windows file locking cannot fail healthy native hook invocations or an otherwise completed uninstall. +- Preserve foreign hook shapes and empty entries, and make the shipped legacy hook installer fail before changing config when cleanup is unsafe. +- Match managed hook paths case-insensitively on Windows, exercise cleanup and line-range guards in Windows CI, and validate complete function spans for mutation scopes. + ## v1.5.1 ### Fixed diff --git a/README.md b/README.md index 8631cc9..1b6dc1e 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ It follows the shape of [openai/codex-plugin-cc](https://github.com/openai/codex Install the fork release from the CBEPX marketplace snapshot: ```bash -codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.5.1 +codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.5.2 codex plugin add cc@cbepx ``` @@ -59,13 +59,15 @@ The optional `npx` helper can install this fork release and enable the required ```bash CC_PLUGIN_CODEX_MARKETPLACE_NAME=cbepx \ CC_PLUGIN_CODEX_MARKETPLACE_SOURCE=CBEPX/cc-plugin-codex \ -CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.5.1 \ -npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.5.1/cc-plugin-codex-1.5.1.tgz install +CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.5.2 \ +npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.5.2/cc-plugin-codex-1.5.2.tgz install ``` On Windows, prefer the marketplace path or the `npx` helper. The shell-script helper below is POSIX-only. Codex CLI's official guidance still treats Windows support as experimental and recommends a WSL workspace for the best Codex experience. Claude Code supports both native Windows and WSL. +If install/update/uninstall reports an invalid global `hooks.json` while a legacy cc install is present, repair that JSON before retrying. To continue without touching risky legacy hook files, set `CC_PLUGIN_CODEX_SKIP_LEGACY_CLEANUP=1`; uninstall still removes official plugin config/cache state but deliberately leaves the legacy files for manual repair. An explicit Codex RPC permission/auth refusal remains fail-closed; use `CC_PLUGIN_CODEX_IGNORE_UNINSTALL_RPC=1` only when you intentionally want local uninstall cleanup despite that refusal. + > **Prerequisites:** Node.js 18+, Codex with hook support, and `claude` CLI installed and authenticated. > If you don't have the Claude CLI yet: > ```bash @@ -332,7 +334,7 @@ The review gate is an **optional** stop-time hook. When enabled, pressing Ctrl+C Install from the fork's marketplace snapshot: ```bash -codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.5.1 +codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.5.2 codex plugin add cc@cbepx ``` @@ -353,8 +355,8 @@ This fork does not install from the upstream Sendbird marketplace. Use the CBEPX ```bash CC_PLUGIN_CODEX_MARKETPLACE_NAME=cbepx \ CC_PLUGIN_CODEX_MARKETPLACE_SOURCE=CBEPX/cc-plugin-codex \ -CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.5.1 \ -npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.5.1/cc-plugin-codex-1.5.1.tgz install +CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.5.2 \ +npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.5.2/cc-plugin-codex-1.5.2.tgz install ``` After install, run: @@ -384,7 +386,7 @@ $cc:setup Re-run the fork marketplace install flow, pinned to the release you want: ```bash -codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.5.1 +codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.5.2 codex plugin add cc@cbepx ``` @@ -442,9 +444,13 @@ Run the normal project gate before publishing or opening a PR: ```bash npm run check +npm run test:coverage ``` -Mutation testing is available as an advisory local signal for the high-risk CLI parsing and prompt-rendering modules: +`npm run check` requires a working `codex` executable for its real E2E suite. Constrained local environments may opt out explicitly with `CC_PLUGIN_ALLOW_E2E_SKIP=1 npm run check`; CI never uses that opt-out. +Coverage stays a separate instrumented run because `npm run check` already executes the full unit, integration, and Codex E2E suites. CI fails below 89% lines/statements, 79% branches, or 96% functions. + +Mutation testing enforces the same pull-request profile used in CI: ```bash npm run test:mutation:dry-run @@ -452,7 +458,7 @@ npm run test:mutation npm run test:mutation:force ``` -Stryker runs through the native `node:test` command runner, so coverage analysis is disabled and the mutation score does not fail the build. The generated report is written under `reports/mutation/`. Use `test:mutation:force` after changing only tests because command-runner incremental mode cannot reliably detect that. Do not mass-disable surviving mutants; either improve the focused tests or use a `// Stryker disable ...: reason` comment for an intentional equivalent mutant. +`test:mutation` checks the critical parser contracts plus managed cleanup and installer orchestration. `test:mutation:force` runs all seven shards, matching the weekly/manual workflow. Scores below each configured break threshold fail the command, and reports are written under `reports/mutation/`. Use the force variant after changing only tests because command-runner incremental mode cannot reliably detect that. Do not mass-disable surviving mutants; improve the focused tests or use a `// Stryker disable ...: reason` comment only for an intentional equivalent mutant. Mutation testing requires Node.js 20+ because Stryker 9 has a newer development-time engine requirement. The plugin runtime still supports the Node.js version listed in the prerequisites. diff --git a/hooks/lib/plugin-install-guard.mjs b/hooks/lib/plugin-install-guard.mjs index 966d71d..1501afb 100644 --- a/hooks/lib/plugin-install-guard.mjs +++ b/hooks/lib/plugin-install-guard.mjs @@ -3,27 +3,76 @@ * SPDX-License-Identifier: Apache-2.0 */ +import fs from "node:fs"; +import path from "node:path"; import process from "node:process"; import { cleanupManagedGlobalIntegrations, getManagedPluginSignals, } from "../../scripts/lib/managed-global-integration.mjs"; +import { resolveCodexHome } from "../../scripts/lib/codex-paths.mjs"; -export function cleanupAfterOfficialUninstall(pluginRoot) { - const signals = getManagedPluginSignals(); +function clearRefusalMarker(refusalMarker) { + try { + fs.rmSync(refusalMarker, { recursive: true, force: true }); + } catch { + // A cosmetic warning marker must never make a native hook fail. + } +} + +export function cleanupAfterOfficialUninstall(pluginRoot, codexHome) { + const resolvedCodexHome = codexHome ?? resolveCodexHome(); + const signals = getManagedPluginSignals(resolvedCodexHome); + const refusalMarker = path.join( + resolvedCodexHome, + "plugins", + "data", + "cc", + "managed-cleanup-refused" + ); if (signals.configState === "active") { + clearRefusalMarker(refusalMarker); return false; } if (signals.configState !== "inactive" || signals.cachePresent) { + if (signals.cachePresent) { + clearRefusalMarker(refusalMarker); + } return false; } + const cleaned = cleanupManagedGlobalIntegrations( + pluginRoot, + resolvedCodexHome, + { reportRefusal: false } + ); + if (!cleaned) { + const refusalReason = `${signals.reason}\n`; + let previousReason = null; + try { + previousReason = fs.readFileSync(refusalMarker, "utf8"); + } catch {} + if (previousReason !== refusalReason) { + process.stderr.write( + `[cc] managed hook cleanup refused after explicit uninstall signals (${signals.reason}, cache missing); repair ${path.join(resolvedCodexHome, "hooks.json")}\n` + ); + try { + clearRefusalMarker(refusalMarker); + fs.mkdirSync(path.dirname(refusalMarker), { recursive: true }); + fs.writeFileSync(refusalMarker, refusalReason, "utf8"); + } catch { + // The hook still exits early even when the warning marker cannot be persisted. + } + } + return true; + } + + clearRefusalMarker(refusalMarker); process.stderr.write( - `[cc] removing managed hooks after explicit uninstall signals (${signals.reason}, cache missing)\n` + `[cc] removed managed hooks after explicit uninstall signals (${signals.reason}, cache missing)\n` ); - cleanupManagedGlobalIntegrations(pluginRoot); return true; } diff --git a/package-lock.json b/package-lock.json index 75098e5..776ac6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cc-plugin-codex", - "version": "1.5.1", + "version": "1.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cc-plugin-codex", - "version": "1.5.1", + "version": "1.5.2", "license": "Apache-2.0", "bin": { "cc-plugin-codex": "scripts/installer-cli.mjs" @@ -15,6 +15,7 @@ "@eslint/js": "^10.0.1", "@stryker-mutator/core": "^9.6.1", "@types/node": "^25.6.0", + "c8": "12.0.0", "eslint": "^10.2.0", "globals": "^17.5.0", "typescript": "^6.0.2" @@ -554,6 +555,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1078,6 +1089,16 @@ } } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1287,6 +1308,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1354,6 +1382,32 @@ "node": ">= 14" } }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1424,6 +1478,40 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/c8": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz", + "integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^18.0.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1506,6 +1594,39 @@ "node": ">= 12" } }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -1977,6 +2098,23 @@ "dev": true, "license": "ISC" }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1997,6 +2135,29 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -2053,6 +2214,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2092,6 +2271,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2118,6 +2307,13 @@ "node": ">= 0.4" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/human-signals": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", @@ -2241,6 +2437,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-md4": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", @@ -2366,6 +2601,22 @@ "yallist": "^3.0.2" } }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2399,6 +2650,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2596,6 +2857,33 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2828,6 +3116,39 @@ "node": ">= 12" } }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/strip-final-newline": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", @@ -2841,6 +3162,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -2990,6 +3339,21 @@ "punycode": "^2.1.0" } }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/weapon-regex": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/weapon-regex/-/weapon-regex-1.3.6.tgz", @@ -3023,6 +3387,52 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -3030,6 +3440,44 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 82c66fc..85b2443 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-plugin-codex", - "version": "1.5.1", + "version": "1.5.2", "description": "Claude Code Plugin for Codex (CBEPX fork)", "type": "module", "author": { @@ -50,22 +50,48 @@ "scripts": { "lint": "eslint .", "typecheck": "tsc -p tsconfig.json", + "typecheck:tests": "tsc -p tsconfig.tests.json", "check:changelog": "node scripts/check-changelog.mjs", "check:version-sync": "node scripts/check-version-sync.mjs", "sync:plugin-version": "node scripts/sync-plugin-version.mjs", - "check": "npm run check:version-sync && npm run check:changelog && npm run lint && npm run typecheck && npm run test && npm run test:integration && npm run test:e2e", + "check": "npm run check:version-sync && npm run check:changelog && npm run lint && npm run typecheck && npm run typecheck:tests && npm run test && npm run test:integration && npm run test:e2e", "install:codex": "node scripts/installer-cli.mjs install", "prepack": "npm run check:version-sync && npm run check:changelog", "setup:git-hooks": "node scripts/setup-git-hooks.mjs", - "test": "node --test tests/*.test.mjs", - "test:coverage": "node --test --experimental-test-coverage tests/*.test.mjs tests/integration/*.test.mjs", - "test:cross-platform": "node --test tests/args.test.mjs tests/changelog.test.mjs tests/claude-cli.test.mjs tests/fs.test.mjs tests/prompts.test.mjs tests/render.test.mjs tests/sandbox-modes.test.mjs tests/skills-contracts.test.mjs tests/structured-output.test.mjs tests/version-sync.test.mjs", - "test:integration": "node --test tests/integration/*.test.mjs", - "test:mutation": "stryker run", - "test:mutation:dry-run": "stryker run --dryRunOnly", - "test:mutation:force": "stryker run --force", - "test:mutation:unit": "node --test tests/args.test.mjs tests/structured-output.test.mjs tests/render.test.mjs tests/claude-cli.test.mjs", - "test:e2e": "node --test tests/e2e/*.test.mjs", + "test": "node --import ./tests/test-env.mjs --test tests/*.test.mjs", + "test:coverage": "c8 --all --include='scripts/**/*.mjs' --include='hooks/**/*.mjs' --reporter=text --reporter=json-summary --reporter=lcov --reports-dir=reports/coverage --check-coverage --lines=89 --statements=89 --branches=79 --functions=96 node --import ./tests/test-env.mjs --test tests/*.test.mjs tests/integration/*.test.mjs tests/e2e/*.test.mjs", + "test:cross-platform": "node --import ./tests/test-env.mjs --test tests/args.test.mjs tests/changelog.test.mjs tests/claude-cli.test.mjs tests/fs.test.mjs tests/install-hooks.test.mjs tests/mutation-config.test.mjs tests/plugin-install-guard.test.mjs tests/prompts.test.mjs tests/render.test.mjs tests/sandbox-modes.test.mjs tests/skills-contracts.test.mjs tests/structured-output.test.mjs tests/version-sync.test.mjs", + "test:integration": "node --import ./tests/test-env.mjs --test tests/integration/*.test.mjs", + "test:mutation": "npm run test:mutation:pr", + "test:mutation:pr": "npm run test:mutation:critical && npm run test:mutation:shard:managed && npm run test:mutation:shard:installer", + "test:mutation:pr:force": "npm run test:mutation:critical:force && npm run test:mutation:shard:managed:force && npm run test:mutation:shard:installer:force", + "test:mutation:critical": "stryker run stryker.critical.config.mjs", + "test:mutation:critical:force": "stryker run stryker.critical.config.mjs --force", + "test:mutation:critical:unit": "node --import ./tests/test-env.mjs --test tests/args.test.mjs tests/structured-output.test.mjs", + "test:mutation:dry-run": "stryker run stryker.critical.config.mjs --dryRunOnly", + "test:mutation:force": "npm run test:mutation:full:force", + "test:mutation:full": "npm run test:mutation:critical && npm run test:mutation:shard:render && npm run test:mutation:shard:claude-cli && npm run test:mutation:shard:state && npm run test:mutation:shard:job-control && npm run test:mutation:shard:managed && npm run test:mutation:shard:installer", + "test:mutation:full:force": "npm run test:mutation:critical:force && npm run test:mutation:shard:render:force && npm run test:mutation:shard:claude-cli:force && npm run test:mutation:shard:state:force && npm run test:mutation:shard:job-control:force && npm run test:mutation:shard:managed:force && npm run test:mutation:shard:installer:force", + "test:mutation:shard:render": "CC_MUTATION_SHARD=render stryker run stryker.shard.config.mjs", + "test:mutation:shard:render:force": "CC_MUTATION_SHARD=render stryker run stryker.shard.config.mjs --force", + "test:mutation:shard:claude-cli": "CC_MUTATION_SHARD=claude-cli stryker run stryker.shard.config.mjs", + "test:mutation:shard:claude-cli:force": "CC_MUTATION_SHARD=claude-cli stryker run stryker.shard.config.mjs --force", + "test:mutation:shard:state": "CC_MUTATION_SHARD=state stryker run stryker.shard.config.mjs", + "test:mutation:shard:state:force": "CC_MUTATION_SHARD=state stryker run stryker.shard.config.mjs --force", + "test:mutation:shard:job-control": "CC_MUTATION_SHARD=job-control stryker run stryker.shard.config.mjs", + "test:mutation:shard:job-control:force": "CC_MUTATION_SHARD=job-control stryker run stryker.shard.config.mjs --force", + "test:mutation:shard:managed": "CC_MUTATION_SHARD=managed stryker run stryker.shard.config.mjs", + "test:mutation:shard:managed:force": "CC_MUTATION_SHARD=managed stryker run stryker.shard.config.mjs --force", + "test:mutation:shard:installer": "CC_MUTATION_SHARD=installer stryker run stryker.shard.config.mjs", + "test:mutation:shard:installer:force": "CC_MUTATION_SHARD=installer stryker run stryker.shard.config.mjs --force", + "test:mutation:render:unit": "node --import ./tests/test-env.mjs --test tests/render.test.mjs", + "test:mutation:claude-cli:unit": "node --import ./tests/test-env.mjs --test tests/claude-cli.test.mjs", + "test:mutation:state:unit": "node --import ./tests/test-env.mjs --test tests/state.test.mjs", + "test:mutation:job-control:unit": "node --import ./tests/test-env.mjs --test tests/job-control.test.mjs", + "test:mutation:managed:unit": "node --import ./tests/test-env.mjs --test tests/plugin-install-guard.test.mjs", + "test:mutation:installer:unit": "node --import ./tests/test-env.mjs --test tests/installer-cli.test.mjs", + "test:mutation:unit": "npm run test:mutation:critical:unit", + "test:e2e": "node --import ./tests/test-env.mjs --test tests/e2e/*.test.mjs", "uninstall:codex": "node scripts/installer-cli.mjs uninstall", "update:codex": "node scripts/installer-cli.mjs update", "version": "npm run sync:plugin-version && npm run check:version-sync && npm run check:changelog" @@ -74,6 +100,7 @@ "@eslint/js": "^10.0.1", "@stryker-mutator/core": "^9.6.1", "@types/node": "^25.6.0", + "c8": "12.0.0", "eslint": "^10.2.0", "globals": "^17.5.0", "typescript": "^6.0.2" diff --git a/scripts/install-hooks.mjs b/scripts/install-hooks.mjs index 20186c2..d3bcbbd 100644 --- a/scripts/install-hooks.mjs +++ b/scripts/install-hooks.mjs @@ -18,7 +18,10 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { ensureNativePluginHooksEnabled } from "./lib/codex-config.mjs"; import { resolveCodexHome } from "./lib/codex-paths.mjs"; -import { removeManagedHooks } from "./lib/managed-global-integration.mjs"; +import { + removeManagedHooks, + writeTextAtomic, +} from "./lib/managed-global-integration.mjs"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = path.resolve(SCRIPT_DIR, ".."); @@ -36,16 +39,11 @@ function readTextFile(filePath) { return fs.readFileSync(filePath, "utf8"); } -function writeTextFile(filePath, content) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, content, "utf8"); -} - function configureNativePluginHooks() { const existing = readTextFile(CODEX_CONFIG_TOML) ?? ""; const { changed, content } = ensureNativePluginHooksEnabled(existing); if (changed || !fs.existsSync(CODEX_CONFIG_TOML)) { - writeTextFile(CODEX_CONFIG_TOML, content); + writeTextAtomic(CODEX_CONFIG_TOML, content); } return changed; } @@ -55,8 +53,12 @@ function configureNativePluginHooks() { // --------------------------------------------------------------------------- function main() { + if (!removeManagedHooks(PLUGIN_ROOT)) { + throw new Error( + `Cannot safely remove legacy hooks while ${path.join(CODEX_DIR, "hooks.json")} is invalid.` + ); + } const nativeHooksChanged = configureNativePluginHooks(); - removeManagedHooks(PLUGIN_ROOT); if (nativeHooksChanged) { console.log("Enabled native Codex plugin hooks in ~/.codex/config.toml."); diff --git a/scripts/installer-cli.mjs b/scripts/installer-cli.mjs index 3d7dc48..a1fa925 100755 --- a/scripts/installer-cli.mjs +++ b/scripts/installer-cli.mjs @@ -14,6 +14,7 @@ import { callCodexAppServer } from "./lib/codex-app-server.mjs"; import { ensureNativePluginHooksEnabled } from "./lib/codex-config.mjs"; import { resolveCodexHome } from "./lib/codex-paths.mjs"; import { + getManagedPluginSignals, LEGACY_MARKETPLACE_NAME, listManagedPluginCacheEntries, pluginIdForMarketplace, @@ -21,7 +22,8 @@ import { } from "./lib/plugin-identity.mjs"; import { cleanupManagedGlobalIntegrations, - removeManagedSkillWrappers, + validateManagedHooks, + writeTextAtomic, } from "./lib/managed-global-integration.mjs"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); @@ -54,11 +56,6 @@ function readText(filePath) { return fs.readFileSync(filePath, "utf8"); } -function writeText(filePath, content) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, content, "utf8"); -} - function removeIfEmpty(dirPath) { if (!fs.existsSync(dirPath)) { return; @@ -91,16 +88,26 @@ function configureNativePluginHooks() { const existing = readText(CODEX_CONFIG_FILE); const { changed, content } = ensureNativePluginHooksEnabled(existing); if (changed || !fs.existsSync(CODEX_CONFIG_FILE)) { - writeText(CODEX_CONFIG_FILE, content); + writeTextAtomic(CODEX_CONFIG_FILE, content); } return changed; } -function removePersonalMarketplaceCcEntries() { +function readPersonalMarketplace() { if (!fs.existsSync(PERSONAL_MARKETPLACE_FILE)) { - return; + return null; } - const parsed = JSON.parse(fs.readFileSync(PERSONAL_MARKETPLACE_FILE, "utf8")); + try { + return JSON.parse(fs.readFileSync(PERSONAL_MARKETPLACE_FILE, "utf8")); + } catch (error) { + throw new Error( + `Cannot update invalid marketplace JSON at ${PERSONAL_MARKETPLACE_FILE}: ${error.message}` + ); + } +} + +function removePersonalMarketplaceCcEntries() { + const parsed = readPersonalMarketplace(); if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.plugins)) { return; } @@ -115,7 +122,7 @@ function removePersonalMarketplaceCcEntries() { return; } parsed.plugins = nextPlugins; - writeText(PERSONAL_MARKETPLACE_FILE, `${JSON.stringify(parsed, null, 2)}\n`); + writeTextAtomic(PERSONAL_MARKETPLACE_FILE, `${JSON.stringify(parsed, null, 2)}\n`); } function normalizeTrailingNewline(text) { @@ -147,18 +154,85 @@ function removeManagedPluginConfigSections() { } if (changed) { - writeText(CODEX_CONFIG_FILE, normalizeTrailingNewline(kept.join("\n").replace(/\n{3,}/g, "\n\n"))); + writeTextAtomic( + CODEX_CONFIG_FILE, + normalizeTrailingNewline(kept.join("\n").replace(/\n{3,}/g, "\n\n")) + ); + } +} + +function prepareLegacyLocalCleanup({ + allowInvalidHooksWithoutLegacyInstall = false, + allowSkipLegacyCleanup = false, +} = {}) { + if ( + allowSkipLegacyCleanup && + process.env.CC_PLUGIN_CODEX_SKIP_LEGACY_CLEANUP === "1" + ) { + process.stderr.write( + "[cc] skipping legacy cleanup by explicit CC_PLUGIN_CODEX_SKIP_LEGACY_CLEANUP=1\n" + ); + return null; + } + + readPersonalMarketplace(); + const cacheEntries = listManagedPluginCacheEntries(CODEX_HOME); + if (!validateManagedHooks(CODEX_HOME)) { + if ( + allowInvalidHooksWithoutLegacyInstall && + !fs.existsSync(LEGACY_INSTALL_DIR) && + cacheEntries.length === 0 + ) { + process.stderr.write( + `[cc] ${path.join(CODEX_HOME, "hooks.json")} is invalid; installing without legacy managed-hook cleanup\n` + ); + return []; + } + throw new Error( + `Cannot safely remove managed integrations while ${path.join(CODEX_HOME, "hooks.json")} is invalid.` + + (allowSkipLegacyCleanup + ? " Repair it or retry with CC_PLUGIN_CODEX_SKIP_LEGACY_CLEANUP=1." + : "") + ); } + return [...new Set([ + LEGACY_INSTALL_DIR, + PACKAGE_ROOT, + ...cacheEntries.map((entry) => entry.cachePath), + ])]; } -function cleanupLegacyLocalInstall() { - cleanupManagedGlobalIntegrations(LEGACY_INSTALL_DIR); - cleanupManagedGlobalIntegrations(PACKAGE_ROOT); - removeManagedSkillWrappers(); +function cleanupLegacyLocalInstall(pluginRoots) { + if (pluginRoots === null) { + return; + } + for (const pluginRoot of pluginRoots) { + if (!cleanupManagedGlobalIntegrations(pluginRoot, CODEX_HOME)) { + throw new Error( + `Managed integration cleanup was refused for ${path.join(CODEX_HOME, "hooks.json")}.` + ); + } + } removePersonalMarketplaceCcEntries(); fs.rmSync(LEGACY_INSTALL_DIR, { recursive: true, force: true }); } +function isPluginAlreadyAbsent(error) { + const message = String(error?.rpcMessage ?? "").trim(); + const pluginId = String.raw`["']?cc@[\w.-]+["']?`; + return new RegExp( + String.raw`^(?:(?:unknown|no such)\s+plugin\s*:?\s*${pluginId}|plugin\s+${pluginId}\s+(?:is\s+)?(?:not\s+(?:currently\s+)?installed|not\s+found|does\s+not\s+exist)|plugin\s+(?:not\s+found|does\s+not\s+exist)\s*:?\s*${pluginId})\.?$`, + "i" + ).test(message); +} + +function isPluginUninstallRefused(error) { + const message = String(error?.rpcMessage ?? ""); + return /\b(?:(?:permission|access)\s+denied|unauthorized|forbidden|not\s+authorized)\b/i.test( + message + ); +} + async function addMarketplaceThroughCodex({ source, refName, sparsePaths }) { const params = { source }; if (refName) { @@ -200,8 +274,11 @@ async function uninstallPluginThroughCodex(marketplaceName) { async function installOrUpdate() { const marketplaceConfig = resolveInstallerMarketplaceConfig(); + const legacyPluginRoots = prepareLegacyLocalCleanup({ + allowInvalidHooksWithoutLegacyInstall: true, + allowSkipLegacyCleanup: true, + }); const hooksChanged = configureNativePluginHooks(); - cleanupLegacyLocalInstall(); const marketplace = await addMarketplaceThroughCodex(marketplaceConfig); const marketplacePath = path.join( @@ -211,6 +288,7 @@ async function installOrUpdate() { "marketplace.json" ); await installPluginThroughCodex(marketplacePath); + cleanupLegacyLocalInstall(legacyPluginRoots); console.log(`Installed ${PLUGIN_NAME} from ${marketplaceConfig.source} into the Codex plugin cache.`); if (hooksChanged) { @@ -221,19 +299,51 @@ async function installOrUpdate() { async function uninstall() { const marketplaceConfig = resolveInstallerMarketplaceConfig(); - cleanupLegacyLocalInstall(); + const legacyPluginRoots = prepareLegacyLocalCleanup({ + allowSkipLegacyCleanup: true, + }); + const ignoreUninstallRpc = + process.env.CC_PLUGIN_CODEX_IGNORE_UNINSTALL_RPC === "1"; + const signals = getManagedPluginSignals(CODEX_HOME); + const observedMarketplaces = new Set([ + ...signals.sections.map((section) => section.marketplaceName), + ...signals.cacheEntries.map((entry) => entry.marketplaceName), + ]); + const marketplaceNames = observedMarketplaces.size > 0 + ? observedMarketplaces + : new Set([ + marketplaceConfig.marketplaceName, + DEFAULT_MARKETPLACE_NAME, + LEGACY_MARKETPLACE_NAME, + ]); - for (const marketplaceName of [ - marketplaceConfig.marketplaceName, - DEFAULT_MARKETPLACE_NAME, - LEGACY_MARKETPLACE_NAME, - ]) { + for (const marketplaceName of marketplaceNames) { try { await uninstallPluginThroughCodex(marketplaceName); - } catch { - // Continue local cleanup across historical install modes. + } catch (error) { + if (isPluginAlreadyAbsent(error)) { + continue; + } + if (isPluginUninstallRefused(error) && !ignoreUninstallRpc) { + throw new Error( + `${error.message}\n` + + "Retry with CC_PLUGIN_CODEX_IGNORE_UNINSTALL_RPC=1 only if local cleanup is intended." + ); + } + if (error?.rpcCode == null || error.rpcCode === -32601) { + process.stderr.write( + `[cc] Codex plugin/uninstall is unavailable; continuing with validated local cleanup: ${error.message}\n` + ); + continue; + } + process.stderr.write( + `[cc] plugin/uninstall failed for ${pluginIdForMarketplace(marketplaceName)}; ` + + `continuing with validated local cleanup: ${error.message}\n` + ); } } + + cleanupLegacyLocalInstall(legacyPluginRoots); removeManagedPluginConfigSections(); for (const cacheEntry of listManagedPluginCacheEntries(CODEX_HOME)) { @@ -248,6 +358,14 @@ async function uninstall() { } removeIfEmpty(cacheDir); } + try { + fs.rmSync( + path.join(CODEX_HOME, "plugins", "data", "cc", "managed-cleanup-refused"), + { recursive: true, force: true } + ); + } catch { + // Removing a warning marker must not turn a completed uninstall into a failure. + } console.log(`Uninstalled ${PLUGIN_NAME} from Codex plugin cache and removed legacy local installs.`); } diff --git a/scripts/lib/claude-cli.mjs b/scripts/lib/claude-cli.mjs index 21337c3..4c34304 100644 --- a/scripts/lib/claude-cli.mjs +++ b/scripts/lib/claude-cli.mjs @@ -228,7 +228,7 @@ function extractClaudeLimitResetText(text) { const CLAUDE_FINAL_MESSAGE_LIMIT_RE = /(?:you(?:'|’)?ve|you have)\s+hit\s+your\s+.*limit|\b(?:session|usage)\s+limit\b.{0,120}\bresets(?:\s+at)?\b|\b(?:session|usage)\s+limit\s+reached\b/i; const CLAUDE_ERROR_LIMIT_RE = - /(?:you(?:'|’)?ve|you have)\s+hit\s+your\s+.*limit|\b(?:session|usage)\s+limit\b|rate[_ -]?limit|apierrorstatus"?\s*:?\s*429|\b429\b/i; + /(?:you(?:'|’)?ve|you have)\s+hit\s+your\s+.*limit|\b(?:session|usage)\s+limit\b|rate[_ -]?limit|\b429\b/i; const CLAUDE_USAGE_LIMIT_EPOCH_RE = /\b(?:claude\s+ai\s+)?(?:session|usage)\s+limit\s+reached\|(\d{10}|\d{13})\b/i; const CLAUDE_USAGE_LIMIT_EPOCH_GLOBAL_RE = @@ -236,7 +236,7 @@ const CLAUDE_USAGE_LIMIT_EPOCH_GLOBAL_RE = const CLAUDE_LIMIT_RESET_TEXT_RE = /(?:(?:you(?:'|’)?ve|you have)\s+hit\s+your\s+[^\r\n.]*?limit|\b(?:session|usage)\s+limit(?:\s+reached)?\b)[^\r\n.]*?\bresets(?:\s+at)?\s+([^\r\n.]+)/gi; const CLAUDE_ERROR_RESET_TEXT_RE = - /(?:rate[_ -]?limit|apierrorstatus"?\s*:?\s*429|\b429\b)[^\r\n.]{0,120}?\bresets\s+at\s+([^\r\n.]+)/gi; + /(?:rate[_ -]?limit|\b429\b)[^\r\n.]{0,120}?\bresets\s+at\s+([^\r\n.]+)/gi; function formatClaudeLimitEpoch(rawEpoch) { const epoch = String(rawEpoch ?? ""); diff --git a/scripts/lib/managed-global-integration.mjs b/scripts/lib/managed-global-integration.mjs index 9a4914f..65b9718 100644 --- a/scripts/lib/managed-global-integration.mjs +++ b/scripts/lib/managed-global-integration.mjs @@ -3,9 +3,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { randomBytes } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import process from "node:process"; import { normalizePathSlashes, resolveCodexHome } from "./codex-paths.mjs"; import { getManagedPluginSignals as getManagedPluginSignalsBase, @@ -14,11 +16,7 @@ import { PLUGIN_NAME, } from "./plugin-identity.mjs"; -const CODEX_HOME = resolveCodexHome(); const HOME_DIR = os.homedir(); -const CODEX_HOOKS_FILE = path.join(CODEX_HOME, "hooks.json"); -const CODEX_SKILLS_DIR = path.join(CODEX_HOME, "skills"); -const CODEX_PROMPTS_DIR = path.join(CODEX_HOME, "prompts"); const MANAGED_WRAPPER_SKILLS = [ "review", "adversarial-review", @@ -36,9 +34,103 @@ function readText(filePath) { return fs.readFileSync(filePath, "utf8"); } -function writeText(filePath, content) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, content, "utf8"); +export function writeTextAtomic(filePath, content) { + let targetFile = filePath; + let linkStats = null; + try { + linkStats = fs.lstatSync(filePath); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + if (linkStats?.isSymbolicLink()) { + targetFile = fs.realpathSync(filePath); + } + + fs.mkdirSync(path.dirname(targetFile), { recursive: true }); + let targetStats = null; + try { + targetStats = fs.statSync(targetFile); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + const mode = targetStats ? targetStats.mode & 0o777 : 0o600; + if (targetStats?.nlink > 1) { + // ponytail: atomic rename cannot preserve hard-link identity; back up the in-place rewrite. + const backupFile = + `${targetFile}.bak.${process.pid}.${Date.now().toString(36)}.${randomBytes(4).toString("hex")}`; + const originalContent = fs.readFileSync(targetFile); + let preserveBackup = false; + const rewriteInPlace = (data) => { + const descriptor = fs.openSync(targetFile, "w"); + try { + fs.writeFileSync(descriptor, data); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + }; + try { + const backupDescriptor = fs.openSync(backupFile, "wx", 0o600); + try { + fs.writeFileSync(backupDescriptor, originalContent); + fs.fchmodSync(backupDescriptor, 0o600); + fs.fsyncSync(backupDescriptor); + } finally { + fs.closeSync(backupDescriptor); + } + + try { + rewriteInPlace(content); + } catch (writeError) { + try { + rewriteInPlace(originalContent); + } catch (restoreError) { + preserveBackup = true; + throw new AggregateError( + [writeError, restoreError], + `Failed to rewrite ${targetFile}; original content retained at ${backupFile}` + ); + } + throw writeError; + } + } catch (error) { + if (!preserveBackup) { + try { + fs.rmSync(backupFile, { force: true }); + } catch { + // Preserve the original rewrite failure. + } + } + throw error; + } + fs.rmSync(backupFile, { force: true }); + return; + } + + const temporaryFile = + `${targetFile}.tmp.${process.pid}.${Date.now().toString(36)}.${randomBytes(4).toString("hex")}`; + try { + const descriptor = fs.openSync(temporaryFile, "wx", mode); + try { + fs.writeFileSync(descriptor, content, "utf8"); + fs.fchmodSync(descriptor, mode); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + fs.renameSync(temporaryFile, targetFile); + } catch (error) { + try { + fs.rmSync(temporaryFile, { force: true }); + } catch { + // Preserve the original write failure. + } + throw error; + } } function removeIfEmpty(dirPath) { @@ -50,82 +142,153 @@ function removeIfEmpty(dirPath) { } } -export function removeManagedHooks(pluginRoot) { - const raw = readText(CODEX_HOOKS_FILE); +function readManagedHooksDocument(codexHome) { + const hooksFile = path.join(codexHome, "hooks.json"); + const raw = readText(hooksFile); if (!raw) { - return; + return { hooksFile, parsed: null, refusal: null }; + } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { hooksFile, parsed: null, refusal: "invalid JSON" }; + } + if ( + !parsed || + typeof parsed !== "object" || + Array.isArray(parsed) || + (parsed.hooks != null && + (typeof parsed.hooks !== "object" || + Array.isArray(parsed.hooks))) + ) { + return { hooksFile, parsed: null, refusal: "invalid hooks data" }; + } + + return { hooksFile, parsed, refusal: null }; +} + +export function validateManagedHooks(codexHome = resolveCodexHome()) { + return readManagedHooksDocument(codexHome).refusal === null; +} + +export function removeManagedHooks( + pluginRoot, + codexHome = resolveCodexHome(), + { reportRefusal = true, platform = process.platform } = {} +) { + const { hooksFile, parsed, refusal } = readManagedHooksDocument(codexHome); + if (refusal) { + if (reportRefusal) { + process.stderr.write(`[cc] refusing managed hook cleanup: ${refusal} in ${hooksFile}\n`); + } + return false; + } + if (!parsed) { + return true; } - const parsed = JSON.parse(raw); const nextHooks = {}; let changed = false; + const normalizeForComparison = (value) => { + const normalized = normalizePathSlashes(String(value)); + return platform === "win32" ? normalized.toLowerCase() : normalized; + }; const hookPrefixes = [ - normalizePathSlashes(path.join(pluginRoot, "hooks")) + "/", - ...listManagedPluginCacheEntries().map( - (cacheEntry) => normalizePathSlashes(path.join(cacheEntry.cachePath, "hooks")) + "/" + normalizeForComparison(path.join(pluginRoot, "hooks")) + "/", + ...listManagedPluginCacheEntries(codexHome).map( + (cacheEntry) => normalizeForComparison(path.join(cacheEntry.cachePath, "hooks")) + "/" ), ]; for (const [eventName, entries] of Object.entries(parsed.hooks ?? {})) { + if (!Array.isArray(entries)) { + nextHooks[eventName] = entries; + continue; + } + const keptEntries = []; - for (const entry of entries ?? []) { - const keptNested = (entry.hooks ?? []).filter((hook) => { - const command = normalizePathSlashes(String(hook?.command ?? "")); + for (const entry of entries) { + if ( + !entry || + typeof entry !== "object" || + Array.isArray(entry) || + !Array.isArray(entry.hooks) + ) { + keptEntries.push(entry); + continue; + } + + const keptNested = entry.hooks.filter((hook) => { + const command = normalizeForComparison(hook?.command ?? ""); const shouldRemove = hookPrefixes.some((hookPrefix) => command.includes(hookPrefix)); - changed ||= shouldRemove; return !shouldRemove; }); - if (keptNested.length > 0) { + const removed = keptNested.length !== entry.hooks.length; + changed ||= removed; + if (!removed || keptNested.length > 0) { keptEntries.push({ ...entry, hooks: keptNested }); } } - if (keptEntries.length > 0) { + if (keptEntries.length > 0 || entries.length === 0) { nextHooks[eventName] = keptEntries; } } if (!changed) { - return; + return true; } - if (Object.keys(nextHooks).length === 0) { - fs.rmSync(CODEX_HOOKS_FILE, { force: true }); - return; + const otherKeys = Object.keys(parsed).filter((key) => key !== "hooks"); + if (Object.keys(nextHooks).length === 0 && otherKeys.length === 0) { + fs.rmSync(hooksFile, { force: true }); + return true; } - writeText(CODEX_HOOKS_FILE, `${JSON.stringify({ hooks: nextHooks }, null, 2)}\n`); + writeTextAtomic(hooksFile, `${JSON.stringify({ ...parsed, hooks: nextHooks }, null, 2)}\n`); + return true; } function formatWrapperName(skillName) { return `${PLUGIN_NAME}-${skillName}`; } -export function removeManagedSkillWrappers() { +export function removeManagedSkillWrappers(codexHome = resolveCodexHome()) { + const skillsDir = path.join(codexHome, "skills"); + const promptsDir = path.join(codexHome, "prompts"); for (const skillName of MANAGED_WRAPPER_SKILLS) { - fs.rmSync(path.join(CODEX_SKILLS_DIR, formatWrapperName(skillName)), { + fs.rmSync(path.join(skillsDir, formatWrapperName(skillName)), { recursive: true, force: true, }); - fs.rmSync(path.join(CODEX_PROMPTS_DIR, `${formatWrapperName(skillName)}.md`), { + fs.rmSync(path.join(promptsDir, `${formatWrapperName(skillName)}.md`), { force: true, }); } - removeIfEmpty(CODEX_SKILLS_DIR); - removeIfEmpty(CODEX_PROMPTS_DIR); + removeIfEmpty(skillsDir); + removeIfEmpty(promptsDir); } -export function getManagedPluginSignals() { - return getManagedPluginSignalsBase(); +export function getManagedPluginSignals(codexHome = resolveCodexHome()) { + return getManagedPluginSignalsBase(codexHome); } -export function isCodexPluginActive() { - return getManagedPluginSignals().configState === "active"; +export function isCodexPluginActive(codexHome = resolveCodexHome()) { + return getManagedPluginSignals(codexHome).configState === "active"; } -export function cleanupManagedGlobalIntegrations(pluginRoot) { - removeManagedHooks(pluginRoot); - removeManagedSkillWrappers(); +export function cleanupManagedGlobalIntegrations( + pluginRoot, + codexHome = resolveCodexHome(), + options = {} +) { + if (!removeManagedHooks(pluginRoot, codexHome, options)) { + return false; + } + removeManagedSkillWrappers(codexHome); + return true; } export function resolveManagedMarketplacePluginPath(pluginRoot) { diff --git a/stryker.config.mjs b/stryker.config.mjs index d4f6a3d..3415642 100644 --- a/stryker.config.mjs +++ b/stryker.config.mjs @@ -1,14 +1,12 @@ export default { testRunner: "command", commandRunner: { - command: "npm run test:mutation:unit", + command: "npm run test:mutation:critical:unit", }, coverageAnalysis: "off", mutate: [ "scripts/lib/args.mjs", "scripts/lib/structured-output.mjs", - "scripts/lib/render.mjs", - "scripts/lib/claude-cli.mjs", ], reporters: ["progress", "clear-text", "html", "json"], clearTextReporter: { @@ -19,9 +17,10 @@ export default { }, thresholds: { high: 80, - low: 60, - break: null, + low: 55, + break: 55, }, + concurrency: 4, incremental: true, incrementalFile: "reports/stryker-incremental.json", htmlReporter: { diff --git a/stryker.critical.config.mjs b/stryker.critical.config.mjs new file mode 100644 index 0000000..51ad06c --- /dev/null +++ b/stryker.critical.config.mjs @@ -0,0 +1,24 @@ +import fullConfig from "./stryker.config.mjs"; + +export default { + ...fullConfig, + commandRunner: { + command: "npm run test:mutation:critical:unit", + }, + mutate: [ + "scripts/lib/args.mjs", + "scripts/lib/structured-output.mjs", + ], + thresholds: { + high: 90, + low: 80, + break: 80, + }, + incrementalFile: "reports/stryker-critical-incremental.json", + htmlReporter: { + fileName: "reports/mutation/critical.html", + }, + jsonReporter: { + fileName: "reports/mutation/critical.json", + }, +}; diff --git a/stryker.shard.config.mjs b/stryker.shard.config.mjs new file mode 100644 index 0000000..a4cccfa --- /dev/null +++ b/stryker.shard.config.mjs @@ -0,0 +1,67 @@ +import baseConfig from "./stryker.config.mjs"; + +const shardName = process.env.CC_MUTATION_SHARD; +const shards = { + render: { + command: "npm run test:mutation:render:unit", + mutate: ["scripts/lib/render.mjs"], + }, + "claude-cli": { + command: "npm run test:mutation:claude-cli:unit", + mutate: ["scripts/lib/claude-cli.mjs"], + }, + state: { + command: "npm run test:mutation:state:unit", + mutate: [ + // Persistence lifecycle, session lookup, and terminal job transitions. + "scripts/lib/state.mjs:156-196", + "scripts/lib/state.mjs:319-367", + "scripts/lib/state.mjs:695-745", + ], + }, + "job-control": { + command: "npm run test:mutation:job-control:unit", + // Public selection and cancellation paths; process mechanics are covered separately. + mutate: ["scripts/lib/job-control.mjs:144-247"], + }, + managed: { + command: "npm run test:mutation:managed:unit", + mutate: [ + "scripts/lib/managed-global-integration.mjs", + "hooks/lib/plugin-install-guard.mjs", + ], + }, + installer: { + command: "npm run test:mutation:installer:unit", + mutate: [ + // Marketplace validation/config cleanup and the complete uninstall orchestration. + "scripts/installer-cli.mjs:96-234", + "scripts/installer-cli.mjs:275-371", + ], + }, +}; + +const shard = shards[shardName]; +if (!shard) { + throw new Error(`Unknown mutation shard: ${shardName || ""}`); +} + +export default { + ...baseConfig, + commandRunner: { + command: shard.command, + }, + mutate: shard.mutate, + thresholds: { + high: 80, + low: 55, + break: 55, + }, + incrementalFile: `reports/stryker-${shardName}-incremental.json`, + htmlReporter: { + fileName: `reports/mutation/${shardName}.html`, + }, + jsonReporter: { + fileName: `reports/mutation/${shardName}.json`, + }, +}; diff --git a/tests/claude-cli.test.mjs b/tests/claude-cli.test.mjs index f1bcbf3..be37fd2 100644 --- a/tests/claude-cli.test.mjs +++ b/tests/claude-cli.test.mjs @@ -430,7 +430,7 @@ describe("StreamParser", () => { }, ]; const events = parser.feed( - subagentEvents.map(JSON.stringify).join("\n") + "\n" + subagentEvents.map((event) => JSON.stringify(event)).join("\n") + "\n" ); assert.equal(events.length, 7); @@ -527,7 +527,7 @@ describe("StreamParser", () => { }, ]; const events = parser.feed( - subagentEvents.map(JSON.stringify).join("\n") + "\n" + subagentEvents.map((event) => JSON.stringify(event)).join("\n") + "\n" ); assert.equal(events.length, 0); @@ -1194,6 +1194,41 @@ describe("classifyClaudeFailure", () => { ); }); + it("accepts the supported spacing and separator variants", () => { + const finalMessages = [ + "youve hit your weekly limit", + "you have hit your limit", + "session limit after a cooldown resets tomorrow", + "usage limit reached", + ]; + for (const finalMessage of finalMessages) { + assert.equal( + classifyClaudeFailure({ + finalMessage, + finalMessageHasLimitSignal: true, + })?.kind, + "claude_rate_limit", + finalMessage + ); + } + + for (const stderr of ["ratelimit exceeded", "rate limit exceeded", "rate-limit exceeded"]) { + assert.equal(classifyClaudeFailure({ stderr })?.kind, "claude_rate_limit", stderr); + } + }); + + it("uses source order across textual and canonical reset markers", () => { + const failure = classifyClaudeFailure({ + finalMessage: + "You've hit your session limit · resets 4:50pm (Europe/Moscow). " + + "Claude AI usage limit reached|1751558400", + finalMessageHasLimitSignal: true, + }); + + assert.equal(failure.kind, "claude_rate_limit"); + assert.equal(failure.resetText, "2025-07-03T16:00:00.000Z"); + }); + it("ignores loose rate-limit markers from final model output", () => { assert.equal( classifyClaudeFailure({ diff --git a/tests/e2e/codex-skills-e2e.test.mjs b/tests/e2e/codex-skills-e2e.test.mjs index 27466ed..ace9f14 100644 --- a/tests/e2e/codex-skills-e2e.test.mjs +++ b/tests/e2e/codex-skills-e2e.test.mjs @@ -31,6 +31,14 @@ function codexAvailable() { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); + if ( + result.status !== 0 && + process.env.CC_PLUGIN_ALLOW_E2E_SKIP !== "1" + ) { + throw new Error( + "codex CLI is required for E2E tests; set CC_PLUGIN_ALLOW_E2E_SKIP=1 for an explicit local opt-out" + ); + } return result.status === 0; } @@ -735,7 +743,11 @@ function startDirectSkillProvider({ requests, listen() { return new Promise((resolve) => { - server.listen(0, "127.0.0.1", () => resolve(server.address().port)); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + assert.ok(address && typeof address !== "string"); + resolve(address.port); + }); }); }, close() { @@ -1085,7 +1097,9 @@ function startMockProvider({ listen() { return new Promise((resolve) => { server.listen(0, "127.0.0.1", () => { - resolve(server.address().port); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + resolve(address.port); }); }); }, diff --git a/tests/hooks.test.mjs b/tests/hooks.test.mjs index cbb6e94..d10edc6 100644 --- a/tests/hooks.test.mjs +++ b/tests/hooks.test.mjs @@ -48,6 +48,34 @@ if (process.env.CLAUDE_ARGS_FILE) { if (process.env.CLAUDE_SILENT_FAIL === "1") { process.exit(7); } + if (process.env.CLAUDE_UNAUTHENTICATED === "1") { + process.stderr.write("Not logged in. Run claude auth login.\\n"); + process.exit(1); + } + if (process.env.CLAUDE_EMPTY_RESULT === "1") { + process.stdout.write(JSON.stringify({ + type: "result", + session_id: "hook-session-result", + result: "" + }) + "\\n"); + process.exit(0); + } + if (process.env.CLAUDE_BLOCK_RESULT === "1") { + process.stdout.write(JSON.stringify({ + type: "result", + session_id: "hook-session-result", + result: "BLOCK: fix the failing regression" + }) + "\\n"); + process.exit(0); + } + if (process.env.CLAUDE_PREFIXED_BLOCK_RESULT === "1") { + process.stdout.write(JSON.stringify({ + type: "result", + session_id: "hook-session-result", + result: "Review complete.\\nBLOCK: fix the failing regression" + }) + "\\n"); + process.exit(0); + } if (process.env.CLAUDE_PREFIXED_ALLOW_RESULT === "1") { process.stdout.write(JSON.stringify({ type: "stream_event", @@ -96,7 +124,7 @@ if (process.env.CLAUDE_ARGS_FILE) { } if (args[0] === "--version") { - process.stdout.write("2.1.90 (Claude Code)\\n"); + process.stdout.write("2.1.220 (Claude Code)\\n"); process.exit(0); } @@ -157,12 +185,14 @@ function createHookEnvironment(options = {}) { return { rootDir, + binDir, homeDir, workspaceDir, env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir, + CODEX_HOME: path.join(homeDir, ".codex"), PATH: `${binDir}${path.delimiter}${process.env.PATH || ""}`, }, }; @@ -204,6 +234,16 @@ function runHook(scriptPath, args, input, env) { return result; } +function enableReviewGate(testEnv) { + 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" + ); +} + function readCurrentSessionMarker(testEnv) { return JSON.parse( fs.readFileSync( @@ -702,6 +742,112 @@ describe("hooks", () => { } }); + it("stop-review hook blocks BLOCK contracts with or without prefix chatter", async (t) => { + for (const [name, envName] of [ + ["direct", "CLAUDE_BLOCK_RESULT"], + ["prefixed", "CLAUDE_PREFIXED_BLOCK_RESULT"], + ]) { + await t.test(name, () => { + const testEnv = createHookEnvironment(); + try { + enableReviewGate(testEnv); + const result = runHook( + STOP_HOOK, + [], + { + cwd: testEnv.workspaceDir, + session_id: "hook-session", + last_assistant_message: "review me", + }, + { + ...testEnv.env, + [envName]: "1", + } + ); + + const payload = JSON.parse(result.stdout); + assert.equal(payload.decision, "block"); + assert.match(payload.reason, /fix the failing regression/); + const snapshot = readStopReviewSnapshot(testEnv); + assert.equal(snapshot.firstLine, "BLOCK: fix the failing regression"); + assert.equal(snapshot.status, "blocked"); + } finally { + cleanupHookEnvironment(testEnv); + } + }); + } + }); + + it("stop-review hook blocks empty and unauthenticated Claude results", async (t) => { + for (const scenario of [ + { + name: "empty result", + envName: "CLAUDE_EMPTY_RESULT", + reason: /returned no output/i, + }, + { + name: "unauthenticated", + envName: "CLAUDE_UNAUTHENTICATED", + reason: /Not logged in|review failed/i, + }, + ]) { + await t.test(scenario.name, () => { + const testEnv = createHookEnvironment(); + try { + enableReviewGate(testEnv); + const result = runHook( + STOP_HOOK, + [], + { + cwd: testEnv.workspaceDir, + session_id: "hook-session", + last_assistant_message: "review me", + }, + { + ...testEnv.env, + [scenario.envName]: "1", + } + ); + + const payload = JSON.parse(result.stdout); + assert.equal(payload.decision, "block"); + assert.match(payload.reason, scenario.reason); + assert.equal(readStopReviewSnapshot(testEnv).status, "blocked"); + } finally { + cleanupHookEnvironment(testEnv); + } + }); + } + }); + + it("stop-review hook records a setup-required skip when Claude is missing", () => { + const testEnv = createHookEnvironment({ createClaude: false }); + try { + enableReviewGate(testEnv); + const result = runHook( + STOP_HOOK, + [], + { + cwd: testEnv.workspaceDir, + session_id: "hook-session", + last_assistant_message: "review me", + }, + { + ...testEnv.env, + PATH: `${testEnv.binDir}${path.delimiter}/usr/bin:/bin`, + } + ); + + assert.equal(result.stdout, ""); + assert.match(result.stderr, /claude CLI not found in PATH/i); + const snapshot = readStopReviewSnapshot(testEnv); + assert.equal(snapshot.status, "skipped_claude_not_ready"); + assert.equal(snapshot.claudeInvoked, false); + } finally { + cleanupHookEnvironment(testEnv); + } + }); + it("stop-review hook accepts an ALLOW contract after streamed prefix chatter", () => { const testEnv = createHookEnvironment(); @@ -842,6 +988,31 @@ describe("hooks", () => { } }); + it("hook input parser accepts empty input and rejects malformed JSON", () => { + const testEnv = createHookEnvironment(); + try { + const empty = spawnSync(process.execPath, [UNREAD_HOOK], { + cwd: PROJECT_ROOT, + env: testEnv.env, + input: "", + encoding: "utf8", + }); + assert.equal(empty.status, 0, empty.stderr); + assert.equal(empty.stdout, ""); + + const malformed = spawnSync(process.execPath, [UNREAD_HOOK], { + cwd: PROJECT_ROOT, + env: testEnv.env, + input: "{invalid\n", + encoding: "utf8", + }); + assert.notEqual(malformed.status, 0); + assert.match(malformed.stderr, /Invalid hook input JSON/i); + } finally { + cleanupHookEnvironment(testEnv); + } + }); + it("session lifecycle hook falls back to the current-session marker on SessionEnd", () => { const testEnv = createHookEnvironment(); diff --git a/tests/install-hooks.test.mjs b/tests/install-hooks.test.mjs index 61b96db..502fe04 100644 --- a/tests/install-hooks.test.mjs +++ b/tests/install-hooks.test.mjs @@ -26,6 +26,7 @@ function runInstallHooks(homeDir, scriptPath = SCRIPT_PATH, cwd = PROJECT_ROOT) ...process.env, HOME: homeDir, USERPROFILE: homeDir, + CODEX_HOME: path.join(homeDir, ".codex"), }, encoding: "utf8", }); @@ -156,4 +157,30 @@ describe("install-hooks.mjs", () => { const hooks = JSON.parse(fs.readFileSync(path.join(codexDir, "hooks.json"), "utf8")); assert.equal(hooks.hooks.SessionStart[0].hooks[0].command, "echo custom-hook"); }); + + it("fails before changing config when global hooks JSON is malformed", () => { + const homeDir = makeTempHome(); + tempHomes.push(homeDir); + const codexDir = path.join(homeDir, ".codex"); + const hooksFile = path.join(codexDir, "hooks.json"); + const configFile = path.join(codexDir, "config.toml"); + fs.mkdirSync(codexDir, { recursive: true }); + fs.writeFileSync(hooksFile, "{invalid\n", "utf8"); + + const result = spawnSync(process.execPath, [SCRIPT_PATH], { + cwd: PROJECT_ROOT, + env: { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + CODEX_HOME: codexDir, + }, + encoding: "utf8", + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Cannot safely remove legacy hooks/); + assert.equal(fs.readFileSync(hooksFile, "utf8"), "{invalid\n"); + assert.equal(fs.existsSync(configFile), false); + }); }); diff --git a/tests/installer-cli.test.mjs b/tests/installer-cli.test.mjs index 0103d8f..149140b 100644 --- a/tests/installer-cli.test.mjs +++ b/tests/installer-cli.test.mjs @@ -111,8 +111,8 @@ function copyMarketplaceFixture(sourceRoot, marketplaceName = "sendbird") { return marketplaceRoot; } -function runInstaller(command, homeDir, sourceRoot, extraEnv = {}) { - const result = spawnSync( +function spawnInstaller(command, homeDir, sourceRoot, extraEnv = {}) { + return spawnSync( process.execPath, [path.join(sourceRoot, "scripts", "installer-cli.mjs"), command], { @@ -121,12 +121,34 @@ function runInstaller(command, homeDir, sourceRoot, extraEnv = {}) { ...process.env, HOME: homeDir, USERPROFILE: homeDir, + CODEX_HOME: path.join(homeDir, ".codex"), ...extraEnv, }, encoding: "utf8", } ); +} +function spawnProjectInstaller(command, homeDir, extraEnv = {}) { + return spawnSync( + process.execPath, + [path.join(PROJECT_ROOT, "scripts", "installer-cli.mjs"), command], + { + cwd: PROJECT_ROOT, + env: { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + CODEX_HOME: path.join(homeDir, ".codex"), + ...extraEnv, + }, + encoding: "utf8", + } + ); +} + +function runInstaller(command, homeDir, sourceRoot, extraEnv = {}) { + const result = spawnInstaller(command, homeDir, sourceRoot, extraEnv); assert.equal(result.status, 0, result.stderr || result.stdout); return result; } @@ -141,6 +163,7 @@ function runLocalPluginInstaller(command, pluginRoot, homeDir, extraEnv = {}) { ...process.env, HOME: homeDir, USERPROFILE: homeDir, + CODEX_HOME: path.join(homeDir, ".codex"), ...extraEnv, }, encoding: "utf8", @@ -161,6 +184,7 @@ function runLocalPluginInstallerExpectFailure(command, pluginRoot, homeDir, extr ...process.env, HOME: homeDir, USERPROFILE: homeDir, + CODEX_HOME: path.join(homeDir, ".codex"), ...extraEnv, }, encoding: "utf8", @@ -367,6 +391,13 @@ function appendPluginSection(configPath, pluginId) { fs.writeFileSync(configPath, (base ? base + "\\n\\n" : "") + next + "\\n", "utf8"); } +function clearPluginSection(configPath, pluginId) { + const header = '[plugins."' + pluginId + '"]'; + const next = removeSection(readConfig(configPath), header); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, next, "utf8"); +} + function copyPlugin(sourceRoot, destinationRoot) { fs.rmSync(destinationRoot, { recursive: true, force: true }); fs.mkdirSync(path.dirname(destinationRoot), { recursive: true }); @@ -408,6 +439,16 @@ function handleInstall(params) { }; } +function handleUninstall(params) { + const [pluginName, marketplaceName] = String(params.pluginId).split("@"); + fs.rmSync( + path.join(codexHome, "plugins", "cache", marketplaceName, pluginName), + { recursive: true, force: true } + ); + clearPluginSection(path.join(codexHome, "config.toml"), params.pluginId); + return {}; +} + function logMessage(message) { fs.appendFileSync(logPath, JSON.stringify(message) + "\\n", "utf8"); } @@ -435,6 +476,11 @@ rl.on("line", (line) => { return; } + if (message.method === "plugin/uninstall") { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: handleUninstall(message.params) }) + "\\n"); + return; + } + process.stdout.write( JSON.stringify({ jsonrpc: "2.0", @@ -455,7 +501,12 @@ rl.on("line", (line) => { }; } -function createMethodNotFoundCodex(homeDir, codexHome = path.join(homeDir, ".codex")) { +function createRpcErrorCodex( + homeDir, + rpcMessage = "Method not found", + rpcCode = -32601, + codexHome = path.join(homeDir, ".codex") +) { const scriptPath = makeTempHelper("fake-codex-app-server-method-not-found"); const logPath = path.join(codexHome, "fake-codex-requests.log"); fs.mkdirSync(path.dirname(logPath), { recursive: true }); @@ -464,7 +515,7 @@ function createMethodNotFoundCodex(homeDir, codexHome = path.join(homeDir, ".cod String.raw`import fs from "node:fs"; import readline from "node:readline"; -const [, , codexHome, logPath] = process.argv; +const [, , codexHome, logPath, rpcMessage, rpcCode] = process.argv; const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); function logMessage(message) { @@ -489,7 +540,7 @@ rl.on("line", (line) => { JSON.stringify({ jsonrpc: "2.0", id: message.id, - error: { code: -32601, message: "Method not found" }, + error: { code: Number(rpcCode), message: rpcMessage }, }) + "\n" ); });`, @@ -499,12 +550,58 @@ rl.on("line", (line) => { return { env: { CC_PLUGIN_CODEX_EXECUTABLE: process.execPath, - CC_PLUGIN_CODEX_APP_SERVER_ARGS_JSON: JSON.stringify([scriptPath, codexHome, logPath]), + CC_PLUGIN_CODEX_APP_SERVER_ARGS_JSON: JSON.stringify([ + scriptPath, + codexHome, + logPath, + rpcMessage, + String(rpcCode), + ]), }, logPath, }; } +function createProcessErrorCodex( + homeDir, + stderrMessage, + codexHome = path.join(homeDir, ".codex") +) { + const scriptPath = makeTempHelper("fake-codex-app-server-process-error"); + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync( + scriptPath, + String.raw`import readline from "node:readline"; + +const [, , stderrMessage] = process.argv; +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + +rl.on("line", (line) => { + if (!line.trim()) { + return; + } + const message = JSON.parse(line); + if (message.method === "initialize") { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { ok: true } }) + "\n"); + return; + } + process.stderr.write(stderrMessage + "\n"); + process.exit(1); +});`, + "utf8" + ); + + return { + env: { + CC_PLUGIN_CODEX_EXECUTABLE: process.execPath, + CC_PLUGIN_CODEX_APP_SERVER_ARGS_JSON: JSON.stringify([ + scriptPath, + stderrMessage, + ]), + }, + }; +} + function createHangingCodex(homeDir, codexHome = path.join(homeDir, ".codex")) { const scriptPath = makeTempHelper("fake-codex-app-server-hang"); const logPath = path.join(codexHome, "fake-codex-requests.log"); @@ -550,7 +647,11 @@ rl.on("line", (line) => { }; } -function createUninstallOrderCodex(homeDir, codexHome = path.join(homeDir, ".codex")) { +function createUninstallOrderCodex( + homeDir, + codexHome = path.join(homeDir, ".codex"), + corruptHooks = false +) { const scriptPath = makeTempHelper("fake-codex-app-server-uninstall-order"); const logPath = path.join(codexHome, "fake-codex-requests.log"); const inspectPath = path.join(codexHome, "uninstall-order.json"); @@ -561,7 +662,7 @@ function createUninstallOrderCodex(homeDir, codexHome = path.join(homeDir, ".cod import path from "node:path"; import readline from "node:readline"; -const [, , codexHome, logPath, inspectPath] = process.argv; +const [, , codexHome, logPath, inspectPath, corruptHooks] = process.argv; const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); function writeJson(filePath, value) { @@ -650,6 +751,9 @@ function handleUninstall(params) { hooksText.includes("stop-review-gate-hook.mjs") || hooksText.includes("unread-result-hook.mjs"), }); + if (corruptHooks === "true") { + fs.writeFileSync(hooksPath, "{invalid\n", "utf8"); + } const [pluginName, marketplaceName] = String(params.pluginId).split("@"); const cacheRoot = path.join(codexHome, "plugins", "cache", marketplaceName, pluginName); @@ -704,6 +808,7 @@ rl.on("line", (line) => { codexHome, logPath, inspectPath, + String(corruptHooks), ]), }, logPath, @@ -741,6 +846,7 @@ function runShellWrapper(scriptName, homeDir, sourceRoot, extraEnv = {}) { ...process.env, HOME: homeDir, USERPROFILE: homeDir, + CODEX_HOME: path.join(homeDir, ".codex"), CC_PLUGIN_CODEX_TARBALL_URL: `file://${tarballPath}`, ...extraEnv, }, @@ -819,9 +925,15 @@ describe("installer-cli", () => { it("does not fall back to local config activation when marketplace/add is unavailable", () => { const homeDir = makeTempHome(); const sourceRoot = makeTempSource(); - const fakeCodex = createMethodNotFoundCodex(homeDir); + const fakeCodex = createRpcErrorCodex(homeDir); copyFixture(sourceRoot); const marketplaceRoot = copyMarketplaceFixture(sourceRoot); + const legacyInstallDir = path.join(homeDir, ".codex", "plugins", "cc"); + const staleSkillPath = path.join(homeDir, ".codex", "skills", "cc-review", "SKILL.md"); + fs.mkdirSync(legacyInstallDir, { recursive: true }); + fs.writeFileSync(path.join(legacyInstallDir, "keep.txt"), "keep\n", "utf8"); + fs.mkdirSync(path.dirname(staleSkillPath), { recursive: true }); + fs.writeFileSync(staleSkillPath, "stale wrapper\n", "utf8"); const result = spawnSync( process.execPath, @@ -832,6 +944,7 @@ describe("installer-cli", () => { ...process.env, HOME: homeDir, USERPROFILE: homeDir, + CODEX_HOME: path.join(homeDir, ".codex"), ...fakeCodex.env, CC_PLUGIN_CODEX_MARKETPLACE_SOURCE: marketplaceRoot, CC_PLUGIN_CODEX_MARKETPLACE_NAME: "sendbird", @@ -844,7 +957,8 @@ describe("installer-cli", () => { assert.notEqual(result.status, 0, "marketplace/add failure should fail install"); assert.doesNotMatch(config, /\[plugins\."cc@sendbird"\]/); - assert.ok(!fs.existsSync(path.join(homeDir, ".codex", "skills", "cc-review", "SKILL.md"))); + assert.ok(fs.existsSync(path.join(legacyInstallDir, "keep.txt"))); + assert.ok(fs.existsSync(staleSkillPath)); assert.ok(!fs.existsSync(path.join(homeDir, ".agents", "plugins", "marketplace.json"))); }); @@ -880,6 +994,35 @@ describe("installer-cli", () => { assert.ok(fs.existsSync(path.join(cacheDir, "scripts", "installer-cli.mjs"))); }); + it("updates a symlinked config.toml target without replacing the link or mode", () => { + const homeDir = makeTempHome(); + const sourceRoot = makeTempSource(); + const fakeCodex = createMarketplaceAwareCodex(homeDir); + copyFixture(sourceRoot); + const marketplaceRoot = copyMarketplaceFixture(sourceRoot); + const codexHome = path.join(homeDir, ".codex"); + const configFile = path.join(codexHome, "config.toml"); + const managedConfig = path.join(homeDir, "dotfiles", "config.toml"); + fs.mkdirSync(path.dirname(managedConfig), { recursive: true }); + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(managedConfig, "[features]\nhooks = false\n", "utf8"); + fs.chmodSync(managedConfig, 0o644); + fs.symlinkSync(managedConfig, configFile); + + runInstaller("install", homeDir, sourceRoot, { + ...fakeCodex.env, + CC_PLUGIN_CODEX_MARKETPLACE_SOURCE: marketplaceRoot, + CC_PLUGIN_CODEX_MARKETPLACE_NAME: "sendbird", + }); + + assert.equal(fs.lstatSync(configFile).isSymbolicLink(), true); + assert.equal(fs.statSync(managedConfig).mode & 0o777, 0o644); + const config = fs.readFileSync(managedConfig, "utf8"); + assert.match(config, /hooks = true/); + assert.match(config, /plugin_hooks = true/); + assert.match(config, /\[plugins\."cc@sendbird"\]/); + }); + it("removes stale fallback skill wrappers and legacy global hooks when official install succeeds", () => { const homeDir = makeTempHome(); const sourceRoot = makeTempSource(); @@ -1129,7 +1272,7 @@ describe("installer-cli", () => { assert.ok(!fs.existsSync(hooksFile), "uninstall should remove managed hooks even when they point at a versioned cache root"); }); - it("removes legacy managed hooks before calling Codex plugin/uninstall", () => { + it("does not mutate managed hooks before Codex plugin/uninstall succeeds", () => { const homeDir = makeTempHome(); const sourceRoot = makeTempSource(); const fakeCodex = createUninstallOrderCodex(homeDir); @@ -1164,8 +1307,513 @@ describe("installer-cli", () => { const inspect = JSON.parse(fs.readFileSync(fakeCodex.inspectPath, "utf8")); assert.equal( inspect.managedHooksPresentAtUninstallCall, - false, - "managed hooks should be removed before plugin/uninstall deactivates the plugin config" + true, + "managed hooks must remain intact until Codex accepts plugin/uninstall" + ); + const uninstallIds = readFakeCodexLog(fakeCodex.logPath) + .filter((message) => message.method === "plugin/uninstall") + .map((message) => message.params.pluginId); + assert.deepEqual(uninstallIds, ["cc@sendbird"]); + assert.equal(fs.existsSync(hooksFile), false); + }); + + it("does not delete legacy files when hooks become invalid during uninstall", () => { + const homeDir = makeTempHome(); + const sourceRoot = makeTempSource(); + const codexHome = path.join(homeDir, ".codex"); + const legacyDir = path.join(codexHome, "plugins", "cc"); + const hooksFile = path.join(codexHome, "hooks.json"); + const fakeCodex = createUninstallOrderCodex(homeDir, codexHome, true); + copyFixture(sourceRoot); + fs.mkdirSync(path.join(legacyDir, "hooks"), { recursive: true }); + fs.writeFileSync(path.join(legacyDir, "keep.txt"), "keep\n", "utf8"); + fs.writeFileSync( + hooksFile, + `${JSON.stringify({ + hooks: { + SessionStart: [{ + hooks: [{ + command: `node "${path.join(legacyDir, "hooks", "session-lifecycle-hook.mjs")}"`, + }], + }], + }, + })}\n`, + "utf8" + ); + + const result = spawnInstaller("uninstall", homeDir, sourceRoot, fakeCodex.env); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /cleanup was refused/); + assert.doesNotMatch(result.stdout, /Uninstalled cc/); + assert.equal(fs.existsSync(path.join(legacyDir, "keep.txt")), true); + assert.equal(fs.readFileSync(hooksFile, "utf8"), "{invalid\n"); + + const retry = spawnInstaller("uninstall", homeDir, sourceRoot, { + ...fakeCodex.env, + CC_PLUGIN_CODEX_SKIP_LEGACY_CLEANUP: "1", + }); + + assert.equal(retry.status, 0, retry.stderr || retry.stdout); + assert.match(retry.stderr, /skipping legacy cleanup/); + assert.match(retry.stdout, /Uninstalled cc/); + assert.equal(fs.existsSync(path.join(legacyDir, "keep.txt")), true); + assert.equal(fs.readFileSync(hooksFile, "utf8"), "{invalid\n"); + }); + + it("fails without local mutation when Codex explicitly refuses uninstall", () => { + const homeDir = makeTempHome(); + const codexHome = path.join(homeDir, ".codex"); + const legacyDir = path.join(codexHome, "plugins", "cc"); + const configFile = path.join(codexHome, "config.toml"); + const fakeCodex = createRpcErrorCodex( + homeDir, + "Permission denied", + -32000 + ); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(path.join(legacyDir, "keep.txt"), "keep\n", "utf8"); + fs.writeFileSync(configFile, '[plugins."cc@cbepx"]\nenabled = true\n', "utf8"); + + const result = spawnProjectInstaller("uninstall", homeDir, fakeCodex.env); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Permission denied/); + assert.match(result.stderr, /CC_PLUGIN_CODEX_IGNORE_UNINSTALL_RPC=1/); + assert.doesNotMatch(result.stdout, /Uninstalled cc/); + assert.equal(fs.existsSync(path.join(legacyDir, "keep.txt")), true); + assert.match(fs.readFileSync(configFile, "utf8"), /cc@cbepx/); + }); + + it("recognizes explicit permission and authorization refusals", () => { + for (const message of [ + "Permission denied", + "Access denied", + "Unauthorized", + "Forbidden", + "Plugin uninstall is not authorized", + ]) { + const homeDir = makeTempHome(); + const codexHome = path.join(homeDir, ".codex"); + const fakeCodex = createRpcErrorCodex(homeDir, message, -32000); + fs.writeFileSync( + path.join(codexHome, "config.toml"), + '[plugins."cc@cbepx"]\nenabled = true\n', + "utf8" + ); + + const result = spawnProjectInstaller("uninstall", homeDir, fakeCodex.env); + + assert.notEqual(result.status, 0, message); + assert.match(result.stderr, /CC_PLUGIN_CODEX_IGNORE_UNINSTALL_RPC=1/); + } + }); + + it("does not mistake process-level permission stderr for an uninstall RPC refusal", () => { + const homeDir = makeTempHome(); + const codexHome = path.join(homeDir, ".codex"); + const legacyDir = path.join(codexHome, "plugins", "cc"); + const configFile = path.join(codexHome, "config.toml"); + const fakeCodex = createProcessErrorCodex(homeDir, "Permission denied by policy"); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(path.join(legacyDir, "keep.txt"), "keep\n", "utf8"); + fs.writeFileSync(configFile, '[plugins."cc@cbepx"]\nenabled = true\n', "utf8"); + + const result = spawnProjectInstaller("uninstall", homeDir, fakeCodex.env); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stderr, /Permission denied by policy/); + assert.match(result.stderr, /plugin\/uninstall is unavailable/); + assert.doesNotMatch(result.stderr, /CC_PLUGIN_CODEX_IGNORE_UNINSTALL_RPC=1/); + assert.equal(fs.existsSync(legacyDir), false); + assert.doesNotMatch(fs.readFileSync(configFile, "utf8"), /cc@cbepx/); + }); + + it("continues local cleanup after an unrecognized uninstall error", () => { + const homeDir = makeTempHome(); + const codexHome = path.join(homeDir, ".codex"); + const legacyDir = path.join(codexHome, "plugins", "cc"); + const configFile = path.join(codexHome, "config.toml"); + const fakeCodex = createRpcErrorCodex( + homeDir, + "plugin cc@cbepx: unknown error", + -32000 + ); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(path.join(legacyDir, "keep.txt"), "keep\n", "utf8"); + fs.writeFileSync(configFile, '[plugins."cc@cbepx"]\nenabled = true\n', "utf8"); + + const result = spawnProjectInstaller("uninstall", homeDir, fakeCodex.env); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stderr, /unknown error/); + assert.match(result.stderr, /continuing with validated local cleanup/); + assert.match(result.stdout, /Uninstalled cc/); + assert.equal(fs.existsSync(legacyDir), false); + assert.doesNotMatch(fs.readFileSync(configFile, "utf8"), /cc@cbepx/); + }); + + it("allows explicit recovery after a permission refusal", () => { + const homeDir = makeTempHome(); + const codexHome = path.join(homeDir, ".codex"); + const legacyDir = path.join(codexHome, "plugins", "cc"); + const configFile = path.join(codexHome, "config.toml"); + const fakeCodex = createRpcErrorCodex(homeDir, "Permission denied", -32000); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(path.join(legacyDir, "keep.txt"), "keep\n", "utf8"); + fs.writeFileSync(configFile, '[plugins."cc@cbepx"]\nenabled = true\n', "utf8"); + + const result = spawnProjectInstaller("uninstall", homeDir, { + ...fakeCodex.env, + CC_PLUGIN_CODEX_IGNORE_UNINSTALL_RPC: "1", + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stderr, /Permission denied/); + assert.match(result.stdout, /Uninstalled cc/); + assert.equal(fs.existsSync(legacyDir), false); + assert.doesNotMatch(fs.readFileSync(configFile, "utf8"), /cc@cbepx/); + }); + + it("uses the legacy-cleanup escape hatch without retaining plugin config or cache", () => { + const homeDir = makeTempHome(); + const codexHome = path.join(homeDir, ".codex"); + const legacyDir = path.join(codexHome, "plugins", "cc"); + const cacheDir = path.join(codexHome, "plugins", "cache", "cbepx", "cc", "1.5.1"); + const configFile = path.join(codexHome, "config.toml"); + const hooksFile = path.join(codexHome, "hooks.json"); + const fakeCodex = createRpcErrorCodex( + homeDir, + "Plugin cc@cbepx is not installed", + -32004 + ); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(path.join(legacyDir, "keep.txt"), "keep\n", "utf8"); + fs.writeFileSync(configFile, '[plugins."cc@cbepx"]\nenabled = true\n', "utf8"); + fs.writeFileSync(hooksFile, "{invalid\n", "utf8"); + + const result = spawnProjectInstaller("uninstall", homeDir, { + ...fakeCodex.env, + CC_PLUGIN_CODEX_SKIP_LEGACY_CLEANUP: "1", + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stderr, /skipping legacy cleanup/); + assert.equal(fs.existsSync(legacyDir), true); + assert.equal(fs.readFileSync(hooksFile, "utf8"), "{invalid\n"); + assert.equal(fs.existsSync(cacheDir), false); + assert.doesNotMatch(fs.readFileSync(configFile, "utf8"), /cc@cbepx/); + }); + + it("completes validated local cleanup when Codex is unavailable", () => { + const homeDir = makeTempHome(); + const codexHome = path.join(homeDir, ".codex"); + const legacyDir = path.join(codexHome, "plugins", "cc"); + const hooksFile = path.join(codexHome, "hooks.json"); + const configFile = path.join(codexHome, "config.toml"); + fs.mkdirSync(path.join(legacyDir, "hooks"), { recursive: true }); + fs.writeFileSync(configFile, '[plugins."cc@cbepx"]\nenabled = true\n', "utf8"); + fs.writeFileSync( + hooksFile, + `${JSON.stringify({ + hooks: { + SessionStart: [{ + hooks: [{ + command: `node "${path.join(legacyDir, "hooks", "session-lifecycle-hook.mjs")}"`, + }], + }], + }, + })}\n`, + "utf8" + ); + + const result = spawnProjectInstaller("uninstall", homeDir, { + CC_PLUGIN_CODEX_EXECUTABLE: path.join(homeDir, "missing-codex"), + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stderr, /continuing with validated local cleanup/); + assert.equal(fs.existsSync(legacyDir), false); + assert.equal(fs.existsSync(hooksFile), false); + assert.doesNotMatch(fs.readFileSync(configFile, "utf8"), /cc@cbepx/); + }); + + it("supports Codex versions without plugin/uninstall", () => { + const homeDir = makeTempHome(); + const codexHome = path.join(homeDir, ".codex"); + const legacyDir = path.join(codexHome, "plugins", "cc"); + const configFile = path.join(codexHome, "config.toml"); + const fakeCodex = createRpcErrorCodex(homeDir); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(configFile, '[plugins."cc@cbepx"]\nenabled = true\n', "utf8"); + + const result = spawnProjectInstaller("uninstall", homeDir, fakeCodex.env); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stderr, /Codex plugin\/uninstall is unavailable/); + assert.equal(fs.existsSync(legacyDir), false); + assert.doesNotMatch(fs.readFileSync(configFile, "utf8"), /cc@cbepx/); + }); + + it("continues attempting observed marketplaces when plugin/uninstall is unavailable", () => { + const homeDir = makeTempHome(); + const codexHome = path.join(homeDir, ".codex"); + const configFile = path.join(codexHome, "config.toml"); + const fakeCodex = createRpcErrorCodex(homeDir); + fs.writeFileSync( + configFile, + [ + '[plugins."cc@sendbird"]', + "enabled = true", + "", + '[plugins."cc@cbepx"]', + "enabled = true", + "", + ].join("\n"), + "utf8" + ); + + const result = spawnProjectInstaller("uninstall", homeDir, fakeCodex.env); + + assert.equal(result.status, 0, result.stderr || result.stdout); + const uninstallIds = readFakeCodexLog(fakeCodex.logPath) + .filter((message) => message.method === "plugin/uninstall") + .map((message) => message.params.pluginId); + assert.deepEqual(uninstallIds, ["cc@sendbird", "cc@cbepx"]); + }); + + it("keeps uninstall idempotent when Codex confirms the plugin is absent", () => { + const homeDir = makeTempHome(); + const fakeCodex = createRpcErrorCodex( + homeDir, + "Plugin cc@cbepx is not installed", + -32004 + ); + + const result = spawnProjectInstaller("uninstall", homeDir, fakeCodex.env); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.doesNotMatch(result.stderr, /plugin\/uninstall failed/); + assert.match(result.stdout, /Uninstalled cc/); + }); + + it("ignores unrecognized absent errors for unobserved fallback plugin ids", () => { + const homeDir = makeTempHome(); + const legacyDir = path.join(homeDir, ".codex", "plugins", "cc"); + const fakeCodex = createRpcErrorCodex( + homeDir, + "Unknown plugin: cc@cbepx", + -32004 + ); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(path.join(legacyDir, "keep.txt"), "keep\n", "utf8"); + + const result = spawnProjectInstaller("uninstall", homeDir, fakeCodex.env); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(legacyDir), false); + assert.match(result.stdout, /Uninstalled cc/); + }); + + it("attempts every marketplace observed in config or cache", () => { + const homeDir = makeTempHome(); + const codexHome = path.join(homeDir, ".codex"); + const configFile = path.join(codexHome, "config.toml"); + const refusalMarker = path.join( + codexHome, + "plugins", + "data", + "cc", + "managed-cleanup-refused" + ); + const fakeCodex = createFakeCodex(homeDir); + fs.mkdirSync( + path.join(codexHome, "plugins", "cache", "sendbird", "cc", "1.0.0"), + { recursive: true } + ); + fs.mkdirSync( + path.join(codexHome, "plugins", "cache", "cbepx", "cc", "1.5.1"), + { recursive: true } + ); + fs.mkdirSync(path.dirname(refusalMarker), { recursive: true }); + fs.writeFileSync(refusalMarker, "old-reason\n", "utf8"); + fs.writeFileSync( + configFile, + [ + '[plugins."cc@sendbird"]', + "enabled = true", + "", + '[plugins."cc@cbepx"]', + "enabled = true", + "", + ].join("\n"), + "utf8" + ); + + const result = spawnProjectInstaller("uninstall", homeDir, fakeCodex.env); + + assert.equal(result.status, 0, result.stderr || result.stdout); + const uninstallIds = readFakeCodexLog(fakeCodex.logPath) + .filter((message) => message.method === "plugin/uninstall") + .map((message) => message.params.pluginId); + assert.deepEqual(uninstallIds, ["cc@sendbird", "cc@cbepx"]); + assert.equal(fs.existsSync(refusalMarker), false); + }); + + it("does not clean managed files when personal marketplace JSON is invalid", () => { + const homeDir = makeTempHome(); + const sourceRoot = makeTempSource(); + copyFixture(sourceRoot); + + const marketplaceDir = path.join(homeDir, ".agents", "plugins"); + const codexHome = path.join(homeDir, ".codex"); + const hooksFile = path.join(codexHome, "hooks.json"); + const wrapperDir = path.join(codexHome, "skills", "cc-review"); + const hooksText = `${JSON.stringify({ + hooks: { + SessionStart: [ + { + hooks: [ + { + type: "command", + command: `node "${path.join(sourceRoot, "hooks", "session-lifecycle-hook.mjs")}"`, + }, + ], + }, + ], + }, + })}\n`; + + fs.mkdirSync(marketplaceDir, { recursive: true }); + fs.mkdirSync(wrapperDir, { recursive: true }); + fs.writeFileSync(path.join(marketplaceDir, "marketplace.json"), "{invalid\n", "utf8"); + fs.writeFileSync(hooksFile, hooksText, "utf8"); + + const result = spawnInstaller("uninstall", homeDir, sourceRoot); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Cannot update invalid marketplace JSON/); + assert.doesNotMatch(result.stdout, /Uninstalled cc/); + assert.equal(fs.readFileSync(hooksFile, "utf8"), hooksText); + assert.equal(fs.existsSync(wrapperDir), true); + }); + + it("refuses install and uninstall when malformed hooks put legacy data at risk", () => { + for (const command of ["install", "uninstall"]) { + const homeDir = makeTempHome(); + const codexHome = path.join(homeDir, ".codex"); + const legacyDir = path.join(codexHome, "plugins", "cc"); + const hooksFile = path.join(codexHome, "hooks.json"); + const wrapperDir = path.join(codexHome, "skills", "cc-review"); + const configFile = path.join(codexHome, "config.toml"); + const fakeCodex = createFakeCodex(homeDir); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.mkdirSync(wrapperDir, { recursive: true }); + fs.writeFileSync(path.join(legacyDir, "keep.txt"), "keep\n", "utf8"); + fs.writeFileSync(hooksFile, "{invalid\n", "utf8"); + fs.writeFileSync(configFile, '[plugins."cc@cbepx"]\nenabled = true\n', "utf8"); + + const result = spawnProjectInstaller(command, homeDir, fakeCodex.env); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /hooks\.json.*invalid/i); + assert.doesNotMatch(result.stdout, /Installed cc|Uninstalled cc/); + assert.equal(fs.existsSync(path.join(legacyDir, "keep.txt")), true); + assert.equal(fs.existsSync(wrapperDir), true); + assert.match(fs.readFileSync(configFile, "utf8"), /cc@cbepx/); + assert.equal(fs.existsSync(fakeCodex.logPath), false); + } + }); + + it("installs with malformed hooks JSON when no legacy managed install exists", () => { + const homeDir = makeTempHome(); + const sourceRoot = makeTempSource(); + const codexHome = path.join(homeDir, ".codex"); + const hooksFile = path.join(codexHome, "hooks.json"); + const fakeCodex = createMarketplaceAwareCodex(homeDir); + copyFixture(sourceRoot); + const marketplaceRoot = copyMarketplaceFixture(sourceRoot); + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(hooksFile, "{invalid\n", "utf8"); + + const result = spawnInstaller("install", homeDir, sourceRoot, { + ...fakeCodex.env, + CC_PLUGIN_CODEX_MARKETPLACE_SOURCE: marketplaceRoot, + CC_PLUGIN_CODEX_MARKETPLACE_NAME: "sendbird", + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stderr, /installing without legacy managed-hook cleanup/); + assert.equal(fs.readFileSync(hooksFile, "utf8"), "{invalid\n"); + assert.equal( + fs.existsSync( + path.join(codexHome, "plugins", "cache", "sendbird", "cc", "local") + ), + true + ); + }); + + it("allows install to preserve risky legacy data through the explicit escape hatch", () => { + const homeDir = makeTempHome(); + const sourceRoot = makeTempSource(); + const codexHome = path.join(homeDir, ".codex"); + const legacyDir = path.join(codexHome, "plugins", "cc"); + const hooksFile = path.join(codexHome, "hooks.json"); + const fakeCodex = createMarketplaceAwareCodex(homeDir); + copyFixture(sourceRoot); + const marketplaceRoot = copyMarketplaceFixture(sourceRoot); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(path.join(legacyDir, "keep.txt"), "keep\n", "utf8"); + fs.writeFileSync(hooksFile, "{invalid\n", "utf8"); + + const result = spawnInstaller("install", homeDir, sourceRoot, { + ...fakeCodex.env, + CC_PLUGIN_CODEX_MARKETPLACE_SOURCE: marketplaceRoot, + CC_PLUGIN_CODEX_MARKETPLACE_NAME: "sendbird", + CC_PLUGIN_CODEX_SKIP_LEGACY_CLEANUP: "1", + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stderr, /skipping legacy cleanup/); + assert.equal(fs.existsSync(path.join(legacyDir, "keep.txt")), true); + assert.equal(fs.readFileSync(hooksFile, "utf8"), "{invalid\n"); + }); + + it("preserves config.toml when its atomic replacement fails", () => { + const homeDir = makeTempHome(); + const codexHome = path.join(homeDir, ".codex"); + const configFile = path.join(codexHome, "config.toml"); + const preload = makeTempHelper("fail-atomic-config-rename"); + const original = "[features]\nhooks = false\n\n[custom]\nkeep = true\n"; + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(configFile, original, "utf8"); + fs.writeFileSync( + preload, + String.raw`import fs from "node:fs"; + +const target = process.env.CC_PLUGIN_ATOMIC_RENAME_FAIL_PATH; +const renameSync = fs.renameSync; +fs.renameSync = (source, destination) => { + if (destination === target && String(source).startsWith(target + ".tmp.")) { + throw new Error("simulated config.toml atomic replace failure"); + } + return renameSync(source, destination); +};`, + "utf8" + ); + + const result = spawnProjectInstaller("install", homeDir, { + NODE_OPTIONS: `--import=${preload}`, + CC_PLUGIN_ATOMIC_RENAME_FAIL_PATH: configFile, + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /simulated config\.toml atomic replace failure/); + assert.equal(fs.readFileSync(configFile, "utf8"), original); + assert.deepEqual( + fs.readdirSync(codexHome).filter((name) => name.startsWith("config.toml.tmp.")), + [] ); }); diff --git a/tests/integration/claude-companion.test.mjs b/tests/integration/claude-companion.test.mjs index 4447ef9..6f154ce 100644 --- a/tests/integration/claude-companion.test.mjs +++ b/tests/integration/claude-companion.test.mjs @@ -228,6 +228,7 @@ function createTestEnvironment() { ...process.env, HOME: homeDir, USERPROFILE: homeDir, + CODEX_HOME: path.join(homeDir, ".codex"), PATH: `${binDir}${path.delimiter}${process.env.PATH || ""}`, }, }; diff --git a/tests/job-control.test.mjs b/tests/job-control.test.mjs index 6f246dd..b2c6f53 100644 --- a/tests/job-control.test.mjs +++ b/tests/job-control.test.mjs @@ -15,6 +15,7 @@ import { enrichJob, readJobProgressPreview, buildStatusSnapshot, + buildSingleJobSnapshot, resolveResultJob, DEFAULT_MAX_STATUS_JOBS, DEFAULT_MAX_PROGRESS_LINES, @@ -39,6 +40,22 @@ function createTempGitRepo() { return repoDir; } +function withTempJobRepo(run) { + const repoDir = createTempGitRepo(); + try { + return run(repoDir); + } finally { + clearCurrentSession(repoDir); + fs.rmSync(resolveJobsDir(repoDir), { recursive: true, force: true }); + fs.rmSync(repoDir, { recursive: true, force: true }); + } +} + +function writeJobAt(repoDir, payload) { + const jobFile = writeJobFile(repoDir, payload.id, payload); + fs.writeFileSync(jobFile, JSON.stringify(payload), "utf8"); +} + // --------------------------------------------------------------------------- // sortJobsNewestFirst // --------------------------------------------------------------------------- @@ -161,6 +178,47 @@ describe("buildStatusSnapshot", () => { fs.rmSync(repoDir, { recursive: true, force: true }); } }); + + it("separates running/latest/recent jobs and honors display limits", () => { + withTempJobRepo((repoDir) => { + const jobs = [ + { id: "run", status: "running", updatedAt: "2026-04-03T12:00:00Z" }, + { id: "latest", status: "completed", updatedAt: "2026-04-03T11:00:00Z" }, + { id: "older", status: "failed", updatedAt: "2026-04-03T10:00:00Z" }, + { id: "oldest", status: "cancelled", updatedAt: "2026-04-03T09:00:00Z" }, + ]; + for (const job of jobs) { + writeJobAt(repoDir, { + ...job, + jobClass: "task", + sessionId: "session-a", + workspaceRoot: repoDir, + createdAt: job.updatedAt, + }); + } + setCurrentSession(repoDir, "session-a"); + fs.writeFileSync( + resolveJobLogFile(repoDir, "run"), + "[t1] first\n[t2] second\n[t3] third\n", + "utf8" + ); + + const limited = buildStatusSnapshot(repoDir, { + maxJobs: 2, + maxProgressLines: 1, + }); + assert.deepEqual(limited.running.map((job) => job.id), ["run"]); + assert.deepEqual(limited.running[0].progressPreview, ["third"]); + assert.equal(limited.latestFinished.id, "latest"); + assert.deepEqual(limited.recent.map((job) => job.id), ["older"]); + + const defaults = buildStatusSnapshot(repoDir); + assert.deepEqual(defaults.recent.map((job) => job.id), ["older", "oldest"]); + + const all = buildStatusSnapshot(repoDir, { all: true, maxJobs: 1 }); + assert.deepEqual(all.recent.map((job) => job.id), ["older", "oldest"]); + }); + }); }); // --------------------------------------------------------------------------- @@ -334,6 +392,51 @@ describe("enrichJob", () => { }); }); +describe("buildSingleJobSnapshot", () => { + it("resolves newest, exact, and unique-prefix references", () => { + withTempJobRepo((repoDir) => { + for (const [id, updatedAt] of [ + ["review-alpha", "2026-04-03T10:00:00Z"], + ["review-beta", "2026-04-03T11:00:00Z"], + ]) { + writeJobAt(repoDir, { + id, + status: "completed", + jobClass: "review", + createdAt: updatedAt, + updatedAt, + }); + } + + assert.equal(buildSingleJobSnapshot(repoDir).job.id, "review-beta"); + assert.equal(buildSingleJobSnapshot(repoDir, "review-alpha").job.id, "review-alpha"); + assert.equal(buildSingleJobSnapshot(repoDir, "review-a").job.id, "review-alpha"); + }); + }); + + it("rejects ambiguous and missing references with actionable errors", () => { + withTempJobRepo((repoDir) => { + for (const id of ["review-alpha", "review-beta"]) { + writeJobAt(repoDir, { + id, + status: "completed", + createdAt: "2026-04-03T10:00:00Z", + updatedAt: "2026-04-03T10:00:00Z", + }); + } + + assert.throws( + () => buildSingleJobSnapshot(repoDir, "review-"), + /Job reference "review-" is ambiguous\. Use a longer job id\./ + ); + assert.throws( + () => buildSingleJobSnapshot(repoDir, "missing"), + /No job found for "missing"\. Run status to list known jobs\./ + ); + }); + }); +}); + // --------------------------------------------------------------------------- // resolveResultJob // --------------------------------------------------------------------------- @@ -382,4 +485,75 @@ describe("resolveResultJob", () => { assert.equal(resolved.job.id, jobIds[1]); assert.equal(resolved.job.status, "queued"); }); + + it("returns terminal state for an explicit completed job", () => { + withTempJobRepo((repoDir) => { + writeJobAt(repoDir, { + id: "finished", + status: "completed", + jobClass: "review", + createdAt: "2026-04-03T09:00:00Z", + updatedAt: "2026-04-03T09:01:00Z", + }); + + const resolved = resolveResultJob(repoDir, "finished"); + assert.equal(resolved.state, "terminal"); + assert.equal(resolved.job.id, "finished"); + }); + }); + + it("selects the latest finished job from the current session", () => { + withTempJobRepo((repoDir) => { + for (const job of [ + { + id: "mine", + status: "failed", + sessionId: "session-a", + updatedAt: "2026-04-03T10:00:00Z", + }, + { + id: "other", + status: "completed", + sessionId: "session-b", + updatedAt: "2026-04-03T12:00:00Z", + }, + { + id: "active", + status: "running", + sessionId: "session-a", + updatedAt: "2026-04-03T11:00:00Z", + }, + ]) { + writeJobAt(repoDir, { + ...job, + createdAt: job.updatedAt, + }); + } + setCurrentSession(repoDir, "session-a"); + + const resolved = resolveResultJob(repoDir); + assert.equal(resolved.state, "terminal"); + assert.equal(resolved.job.id, "mine"); + }); + }); + + it("rejects unsupported states and an empty finished-job history", () => { + withTempJobRepo((repoDir) => { + writeJobAt(repoDir, { + id: "paused", + status: "paused", + createdAt: "2026-04-03T09:00:00Z", + updatedAt: "2026-04-03T09:00:00Z", + }); + + assert.throws( + () => resolveResultJob(repoDir, "paused"), + /Job paused is paused\. Check status for more details\./ + ); + assert.throws( + () => resolveResultJob(repoDir), + /No finished Claude Code jobs found for this repository yet\./ + ); + }); + }); }); diff --git a/tests/mutation-config.test.mjs b/tests/mutation-config.test.mjs new file mode 100644 index 0000000..cd4d7f5 --- /dev/null +++ b/tests/mutation-config.test.mjs @@ -0,0 +1,53 @@ +/** + * Copyright 2026 Sendbird, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const PROJECT_ROOT = path.resolve(fileURLToPath(new URL("../", import.meta.url))); +/** @type {Array<[string, string[]]>} */ +const expectations = [ + ["scripts/lib/state.mjs:156-196", ["ensurePluginDataLayout", "resolveWorkspaceHash", "ensureStateDir"]], + ["scripts/lib/state.mjs:319-367", ["writeJobFile", "normalizeStoredJob"]], + ["scripts/lib/state.mjs:695-745", ["casJobStatus", "transitionJob", "writeAtomic"]], + ["scripts/lib/job-control.mjs:144-247", ["matchJobReference", "buildStatusSnapshot", "resolveCancelableJob"]], + ["scripts/installer-cli.mjs:96-234", ["readPersonalMarketplace", "prepareLegacyLocalCleanup", "isPluginAlreadyAbsent", "isPluginUninstallRefused"]], + ["scripts/installer-cli.mjs:275-371", ["installOrUpdate", "uninstall"]], +]; + +test("mutation line ranges still contain their intended complete functions", () => { + const config = fs.readFileSync(path.join(PROJECT_ROOT, "stryker.shard.config.mjs"), "utf8"); + for (const [spec, functionNames] of expectations) { + assert.ok(config.includes(`"${spec}"`), `missing mutation range ${spec}`); + const [, file, start, end] = spec.match(/^(.*):(\d+)-(\d+)$/); + const source = fs.readFileSync(path.join(PROJECT_ROOT, file), "utf8"); + const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true); + const spans = functionNames.map((functionName) => { + const declaration = sourceFile.statements.find( + (node) => ts.isFunctionDeclaration(node) && node.name?.text === functionName + ); + assert.ok(declaration, `${file} no longer declares ${functionName}`); + const firstLine = + sourceFile.getLineAndCharacterOfPosition(declaration.getStart(sourceFile)).line + 1; + const lastLine = + sourceFile.getLineAndCharacterOfPosition(declaration.end).line + 1; + assert.ok( + firstLine >= Number(start) && lastLine <= Number(end), + `${spec} excludes part of ${functionName} (${firstLine}-${lastLine})` + ); + return { firstLine, lastLine }; + }); + assert.equal(spans[0].firstLine, Number(start), `${spec} has a stale start boundary`); + assert.equal( + spans.at(-1).lastLine, + Number(end), + `${spec} has a stale end boundary` + ); + } +}); diff --git a/tests/plugin-install-guard.test.mjs b/tests/plugin-install-guard.test.mjs new file mode 100644 index 0000000..19cd4b3 --- /dev/null +++ b/tests/plugin-install-guard.test.mjs @@ -0,0 +1,501 @@ +/** + * Copyright 2026 Sendbird, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, it } from "node:test"; + +import { cleanupAfterOfficialUninstall } from "../hooks/lib/plugin-install-guard.mjs"; +import { + isCodexPluginActive, + removeManagedHooks, + removeManagedSkillWrappers, + resolveManagedMarketplacePluginPath, +} from "../scripts/lib/managed-global-integration.mjs"; + +describe("cleanupAfterOfficialUninstall", () => { + let codexHome; + let hooksFile; + let pluginRoot; + let rootDir; + let wrapperDir; + + beforeEach(() => { + rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-install-guard-")); + codexHome = path.join(rootDir, "codex"); + hooksFile = path.join(codexHome, "hooks.json"); + pluginRoot = path.join(rootDir, "plugin"); + wrapperDir = path.join(codexHome, "skills", "cc-review"); + + fs.mkdirSync(wrapperDir, { recursive: true }); + fs.mkdirSync(path.join(codexHome, "skills", "unrelated"), { recursive: true }); + fs.writeFileSync( + path.join(codexHome, "config.toml"), + "[features]\nhooks = true\n", + "utf8" + ); + }); + + afterEach(() => { + fs.rmSync(rootDir, { recursive: true, force: true }); + }); + + it("removes managed entries after confirmed uninstall and preserves unrelated data", () => { + const refusalMarker = path.join( + codexHome, + "plugins", + "data", + "cc", + "managed-cleanup-refused" + ); + fs.writeFileSync( + hooksFile, + `${JSON.stringify( + { + version: 1, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: "command", + command: `node "${path.join(pluginRoot, "hooks", "unread-result-hook.mjs")}"`, + }, + { + type: "command", + command: "/usr/local/bin/unrelated-hook", + }, + ], + }, + ], + }, + }, + null, + 2 + )}\n`, + "utf8" + ); + fs.mkdirSync(refusalMarker, { recursive: true }); + + assert.equal(cleanupAfterOfficialUninstall(pluginRoot, codexHome), true); + + const hooks = JSON.parse(fs.readFileSync(hooksFile, "utf8")); + assert.equal(hooks.version, 1); + assert.deepEqual( + hooks.hooks.UserPromptSubmit[0].hooks.map((hook) => hook.command), + ["/usr/local/bin/unrelated-hook"] + ); + assert.equal(fs.existsSync(wrapperDir), false); + assert.equal( + fs.existsSync(path.join(codexHome, "skills", "unrelated")), + true + ); + assert.equal(fs.existsSync(refusalMarker), false); + }); + + it("refuses all managed cleanup when hooks JSON is invalid", () => { + const invalidHooks = "{not-json\n"; + const refusalMarker = path.join( + codexHome, + "plugins", + "data", + "cc", + "managed-cleanup-refused" + ); + fs.writeFileSync(hooksFile, invalidHooks, "utf8"); + + const originalWrite = process.stderr.write; + let stderr = ""; + process.stderr.write = (chunk) => { + stderr += String(chunk); + return true; + }; + try { + assert.equal(cleanupAfterOfficialUninstall(pluginRoot, codexHome), true); + assert.equal(cleanupAfterOfficialUninstall(pluginRoot, codexHome), true); + } finally { + process.stderr.write = originalWrite; + } + + assert.equal(fs.readFileSync(hooksFile, "utf8"), invalidHooks); + assert.equal(fs.existsSync(wrapperDir), true); + assert.equal(fs.existsSync(refusalMarker), true); + assert.equal(stderr.match(/managed hook cleanup refused/g)?.length, 1); + assert.doesNotMatch(stderr, /refusing managed hook cleanup:/); + }); + + it("preserves managed files while the plugin is active", () => { + const refusalMarker = path.join( + codexHome, + "plugins", + "data", + "cc", + "managed-cleanup-refused" + ); + fs.mkdirSync(path.dirname(refusalMarker), { recursive: true }); + fs.writeFileSync(refusalMarker, "old-reason\n", "utf8"); + fs.writeFileSync( + path.join(codexHome, "config.toml"), + '[plugins."cc@cbepx"]\nenabled = true\n', + "utf8" + ); + + assert.equal(cleanupAfterOfficialUninstall(pluginRoot, codexHome), false); + assert.equal(fs.existsSync(wrapperDir), true); + assert.equal(fs.existsSync(refusalMarker), false); + }); + + it("does not fail a healthy hook when the refusal marker cannot be removed", (t) => { + const refusalMarker = path.join( + codexHome, + "plugins", + "data", + "cc", + "managed-cleanup-refused" + ); + fs.writeFileSync( + path.join(codexHome, "config.toml"), + '[plugins."cc@cbepx"]\nenabled = true\n', + "utf8" + ); + const rmSync = fs.rmSync; + t.mock.method(fs, "rmSync", (target, options) => { + if (target === refusalMarker) { + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + } + return rmSync(target, options); + }); + + assert.equal(cleanupAfterOfficialUninstall(pluginRoot, codexHome), false); + assert.equal(fs.existsSync(wrapperDir), true); + }); + + it("preserves managed files while a plugin cache entry remains", () => { + const refusalMarker = path.join( + codexHome, + "plugins", + "data", + "cc", + "managed-cleanup-refused" + ); + fs.mkdirSync(path.dirname(refusalMarker), { recursive: true }); + fs.writeFileSync(refusalMarker, "old-reason\n", "utf8"); + fs.writeFileSync( + path.join(codexHome, "config.toml"), + '[plugins."cc@cbepx"]\nenabled = false\n', + "utf8" + ); + fs.mkdirSync(path.join(codexHome, "plugins", "cache", "cbepx", "cc", "test-version"), { + recursive: true, + }); + + assert.equal(cleanupAfterOfficialUninstall(pluginRoot, codexHome), false); + assert.equal(fs.existsSync(wrapperDir), true); + assert.equal(fs.existsSync(refusalMarker), false); + }); +}); + +describe("managed global integration cleanup", () => { + let codexHome; + let hooksFile; + let pluginRoot; + let rootDir; + + beforeEach(() => { + rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-managed-cleanup-")); + codexHome = path.join(rootDir, "codex"); + hooksFile = path.join(codexHome, "hooks.json"); + pluginRoot = path.join(rootDir, "plugin"); + fs.mkdirSync(codexHome, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(rootDir, { recursive: true, force: true }); + }); + + function prepareHardLinkedHooks() { + const linkedHooksFile = path.join(rootDir, "linked-hooks.json"); + const raw = `${JSON.stringify({ + hooks: { + SessionStart: [{ + hooks: [ + { + command: `node "${path.join(pluginRoot, "hooks", "session-lifecycle-hook.mjs")}"`, + }, + { command: "/usr/local/bin/unrelated-hook" }, + ], + }], + }, + })}\n`; + fs.writeFileSync(hooksFile, raw, "utf8"); + fs.linkSync(hooksFile, linkedHooksFile); + return { linkedHooksFile, raw, inode: fs.statSync(hooksFile).ino }; + } + + it("accepts a missing hooks file without creating it", () => { + assert.equal(removeManagedHooks(pluginRoot, codexHome), true); + assert.equal(fs.existsSync(hooksFile), false); + }); + + it("rejects invalid hooks document shapes without rewriting them", () => { + for (const value of [null, [], { hooks: [] }, { hooks: "invalid" }]) { + const raw = `${JSON.stringify(value)}\n`; + fs.writeFileSync(hooksFile, raw, "utf8"); + assert.equal(removeManagedHooks(pluginRoot, codexHome), false); + assert.equal(fs.readFileSync(hooksFile, "utf8"), raw); + } + }); + + it("preserves foreign hook shapes and empty entries while removing managed hooks", () => { + const foreignEvent = { futureSchema: true }; + const foreignEntry = { matcher: "Future", hooks: "future-schema" }; + const emptyEntry = { matcher: "Bash", hooks: [] }; + fs.writeFileSync( + hooksFile, + `${JSON.stringify({ + hooks: { + FutureEvent: foreignEvent, + SessionStart: [ + foreignEntry, + emptyEntry, + { + matcher: "", + hooks: [{ + command: `node "${path.join(pluginRoot, "hooks", "session-lifecycle-hook.mjs")}"`, + }], + }, + ], + }, + })}\n`, + "utf8" + ); + + assert.equal(removeManagedHooks(pluginRoot, codexHome), true); + + const parsed = JSON.parse(fs.readFileSync(hooksFile, "utf8")); + assert.deepEqual(parsed.hooks.FutureEvent, foreignEvent); + assert.deepEqual(parsed.hooks.SessionStart, [foreignEntry, emptyEntry]); + }); + + it("preserves the original hooks document when the atomic replace fails", (t) => { + const raw = `${JSON.stringify({ + hooks: { + SessionStart: [{ + hooks: [{ + command: `node "${path.join(pluginRoot, "hooks", "session-lifecycle-hook.mjs")}"`, + }], + }], + Stop: [{ + hooks: [{ command: "/usr/local/bin/unrelated-hook" }], + }], + }, + })}\n`; + fs.writeFileSync(hooksFile, raw, "utf8"); + + const renameSync = fs.renameSync; + t.mock.method(fs, "renameSync", (source, destination) => { + if (destination === hooksFile) { + throw new Error("simulated atomic replace failure"); + } + return renameSync(source, destination); + }); + + assert.throws( + () => removeManagedHooks(pluginRoot, codexHome), + /simulated atomic replace failure/ + ); + assert.equal(fs.readFileSync(hooksFile, "utf8"), raw); + assert.deepEqual( + fs.readdirSync(codexHome).filter((name) => name.startsWith("hooks.json.tmp.")), + [] + ); + }); + + it("preserves hard-link identity while rewriting managed hooks", () => { + const { inode, linkedHooksFile } = prepareHardLinkedHooks(); + + assert.equal(removeManagedHooks(pluginRoot, codexHome), true); + + assert.equal(fs.statSync(hooksFile).ino, inode); + assert.equal(fs.statSync(linkedHooksFile).ino, inode); + const rewritten = fs.readFileSync(linkedHooksFile, "utf8"); + assert.doesNotMatch(rewritten, /session-lifecycle-hook/); + assert.match(rewritten, /unrelated-hook/); + }); + + it("restores hard-linked hooks when the completed rewrite fails to sync", (t) => { + const { inode, linkedHooksFile, raw } = prepareHardLinkedHooks(); + const fsyncSync = fs.fsyncSync; + let syncs = 0; + t.mock.method(fs, "fsyncSync", (descriptor) => { + if (++syncs === 2) { + throw new Error("simulated hard-link sync failure"); + } + return fsyncSync(descriptor); + }); + + assert.throws( + () => removeManagedHooks(pluginRoot, codexHome), + /simulated hard-link sync failure/ + ); + assert.equal(fs.statSync(hooksFile).ino, inode); + assert.equal(fs.statSync(linkedHooksFile).ino, inode); + assert.equal(fs.readFileSync(hooksFile, "utf8"), raw); + assert.equal(fs.readFileSync(linkedHooksFile, "utf8"), raw); + assert.deepEqual( + fs.readdirSync(codexHome).filter((name) => name.startsWith("hooks.json.bak.")), + [] + ); + }); + + it("restores hard-linked hooks after a partial in-place write", (t) => { + const { inode, linkedHooksFile, raw } = prepareHardLinkedHooks(); + const writeFileSync = fs.writeFileSync; + let descriptorWrites = 0; + t.mock.method(fs, "writeFileSync", (destination, data, options) => { + if (typeof destination === "number" && ++descriptorWrites === 2) { + const partial = Buffer.from(String(data), "utf8").subarray(0, 8); + fs.writeSync(destination, partial, 0, partial.length, null); + throw new Error("simulated partial hard-link write"); + } + return writeFileSync(destination, data, options); + }); + + assert.throws( + () => removeManagedHooks(pluginRoot, codexHome), + /simulated partial hard-link write/ + ); + assert.equal(fs.statSync(hooksFile).ino, inode); + assert.equal(fs.statSync(linkedHooksFile).ino, inode); + assert.equal(fs.readFileSync(hooksFile, "utf8"), raw); + assert.equal(fs.readFileSync(linkedHooksFile, "utf8"), raw); + assert.deepEqual( + fs.readdirSync(codexHome).filter((name) => name.startsWith("hooks.json.bak.")), + [] + ); + }); + + it("retains the hard-link backup when restoration cannot be synced", (t) => { + const { inode, linkedHooksFile, raw } = prepareHardLinkedHooks(); + const fsyncSync = fs.fsyncSync; + let syncs = 0; + t.mock.method(fs, "fsyncSync", (descriptor) => { + if (++syncs >= 2) { + throw new Error("simulated restore sync failure"); + } + return fsyncSync(descriptor); + }); + + assert.throws( + () => removeManagedHooks(pluginRoot, codexHome), + (error) => error instanceof AggregateError && + /original content retained/.test(error.message) + ); + assert.equal(fs.statSync(hooksFile).ino, inode); + assert.equal(fs.statSync(linkedHooksFile).ino, inode); + const backups = fs.readdirSync(codexHome) + .filter((name) => name.startsWith("hooks.json.bak.")); + assert.equal(backups.length, 1); + assert.equal( + fs.readFileSync(path.join(codexHome, backups[0]), "utf8"), + raw + ); + }); + + it("matches managed Windows hook paths case-insensitively", () => { + const windowsPluginRoot = "C:\\Users\\Test\\plugins\\cc"; + fs.writeFileSync( + hooksFile, + `${JSON.stringify({ + hooks: { + SessionStart: [{ + hooks: [{ + command: `node "${path.win32.join( + "c:\\users\\test\\plugins\\cc", + "hooks", + "session-lifecycle-hook.mjs" + )}"`, + }], + }], + }, + })}\n`, + "utf8" + ); + + assert.equal( + removeManagedHooks(windowsPluginRoot, codexHome, { platform: "win32" }), + true + ); + assert.equal(fs.existsSync(hooksFile), false); + }); + + it("deletes a hooks file that contains only a managed hook", () => { + fs.writeFileSync( + hooksFile, + JSON.stringify({ + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + command: `node "${path.join(pluginRoot, "hooks", "unread-result-hook.mjs")}"`, + }, + ], + }, + ], + }, + }), + "utf8" + ); + + assert.equal(removeManagedHooks(pluginRoot, codexHome), true); + assert.equal(fs.existsSync(hooksFile), false); + }); + + it("removes every managed skill and prompt wrapper", () => { + const wrapperNames = [ + "review", + "adversarial-review", + "rescue", + "status", + "result", + "cancel", + "setup", + ]; + for (const name of wrapperNames) { + const skillDir = path.join(codexHome, "skills", `cc-${name}`); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), name, "utf8"); + fs.mkdirSync(path.join(codexHome, "prompts"), { recursive: true }); + fs.writeFileSync(path.join(codexHome, "prompts", `cc-${name}.md`), name, "utf8"); + } + + removeManagedSkillWrappers(codexHome); + + assert.equal(fs.existsSync(path.join(codexHome, "skills")), false); + assert.equal(fs.existsSync(path.join(codexHome, "prompts")), false); + }); + + it("reports active plugin state and resolves a personal marketplace path", () => { + fs.writeFileSync( + path.join(codexHome, "config.toml"), + '[plugins."cc@cbepx"]\nenabled = true\n', + "utf8" + ); + assert.equal(isCodexPluginActive(codexHome), true); + + const homeDir = os.homedir(); + assert.equal( + resolveManagedMarketplacePluginPath(path.join(homeDir, "plugins", "cc")), + "./plugins/cc" + ); + assert.throws( + () => resolveManagedMarketplacePluginPath(homeDir), + /Plugin root must not be the marketplace root itself/ + ); + }); +}); diff --git a/tests/process.test.mjs b/tests/process.test.mjs index b6514b6..a38be62 100644 --- a/tests/process.test.mjs +++ b/tests/process.test.mjs @@ -60,6 +60,7 @@ describe("runCommand", () => { }); it("does not route commands through a shell", () => { + /** @type {{ shell?: boolean } | null} */ let capturedOptions = null; const result = runCommand("echo", ["hello"], { spawnSyncImpl: (_command, _args, options) => { @@ -79,6 +80,7 @@ describe("runCommand", () => { }); it("passes maxBuffer through to spawnSync", () => { + /** @type {{ maxBuffer?: number } | null} */ let capturedOptions = null; const result = runCommand("echo", ["hello"], { maxBuffer: 1234, @@ -120,7 +122,9 @@ describe("runCommandChecked", () => { it("throws the actual Error for ENOENT", () => { assert.throws( () => runCommandChecked("no-such-binary-xyz"), - (err) => err.code === "ENOENT" + (err) => + err instanceof Error && + /** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT" ); }); }); @@ -234,7 +238,7 @@ describe("terminateProcessTree", () => { platform: "linux", killImpl: (pid, sig) => { if (pid < 0) { - const err = new Error("EPERM"); + const err = /** @type {NodeJS.ErrnoException} */ (new Error("EPERM")); err.code = "EPERM"; throw err; } @@ -250,7 +254,7 @@ describe("terminateProcessTree", () => { const result = terminateProcessTree(12345, { platform: "linux", killImpl: () => { - const err = new Error("ESRCH"); + const err = /** @type {NodeJS.ErrnoException} */ (new Error("ESRCH")); err.code = "ESRCH"; throw err; }, diff --git a/tests/prompts.test.mjs b/tests/prompts.test.mjs index 14a8661..d93a21a 100644 --- a/tests/prompts.test.mjs +++ b/tests/prompts.test.mjs @@ -110,7 +110,9 @@ describe("loadPromptTemplate", () => { it("throws for non-existent prompt", () => { assert.throws( () => loadPromptTemplate(tmpRoot, "nonexistent"), - (err) => err.code === "ENOENT" + (err) => + err instanceof Error && + /** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT" ); }); diff --git a/tests/skills-contracts.test.mjs b/tests/skills-contracts.test.mjs index 9f7f642..be720c1 100644 --- a/tests/skills-contracts.test.mjs +++ b/tests/skills-contracts.test.mjs @@ -2,414 +2,296 @@ * Copyright 2026 Sendbird, Inc. * SPDX-License-Identifier: Apache-2.0 */ +import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; import test from "node:test"; -import assert from "node:assert/strict"; import { fileURLToPath } from "node:url"; import { MODEL_ALIASES } from "../scripts/lib/claude-cli.mjs"; -const PROJECT_ROOT = path.resolve( - fileURLToPath(new URL("../", import.meta.url)) -); +const PROJECT_ROOT = path.resolve(fileURLToPath(new URL("../", import.meta.url))); +const SKILL_NAMES = [ + "adversarial-review", + "cancel", + "mcp-diagnose", + "rescue", + "result", + "review", + "setup", + "status", + "transfer", +]; function read(relativePath) { return fs.readFileSync(path.join(PROJECT_ROOT, relativePath), "utf8"); } -function escapeRegex(value) { - return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +function frontmatter(text, label) { + const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); + assert.ok(match, `${label}: missing frontmatter`); + return match[1]; } -test("README model alias docs match runtime aliases", () => { - const readme = read("README.md"); +function assertIncludesAll(text, expected, label) { + for (const value of expected) { + assert.ok(text.includes(value), `${label}: missing ${value}`); + } +} - for (const alias of MODEL_ALIASES.keys()) { - assert.match(readme, new RegExp(`\\\`${escapeRegex(alias)}\\\``)); +test("public skills expose stable frontmatter and keep workspace roots separate", () => { + for (const name of SKILL_NAMES) { + const label = `skills/${name}/SKILL.md`; + const skill = read(label); + const metadata = frontmatter(skill, label); + + assert.match(metadata, new RegExp(`^name: ${name}$`, "m"), label); + assert.match(metadata, /^description:\s+.+$/m, label); + assertIncludesAll( + skill, + [ + "Resolve `` as two directories above this `SKILL.md` file", + '/scripts/claude-companion.mjs', + "Keep the shell tool in the active Codex user workspace", + "never set its working directory to ``", + ], + label + ); + assert.doesNotMatch(skill, /--cwd ""|--cwd "\$PWD"|/i, label); } - assert.match(readme, /Claude Code resolves aliases to the current model/i); - assert.match(readme, /full model ID to pin a version/i); - assert.match(readme, /requestedModel.*forwarded alias or full ID/i); - assert.match(readme, /terminal `contextWindow` reported in `modelUsage`/i); - assert.match(readme, /does not infer a context limit from a floating alias/i); }); -test("public skills keep plugin and user workspace roots separate", () => { - const skillPaths = [ - "skills/adversarial-review/SKILL.md", - "skills/cancel/SKILL.md", - "skills/mcp-diagnose/SKILL.md", - "skills/rescue/SKILL.md", - "skills/result/SKILL.md", - "skills/review/SKILL.md", - "skills/setup/SKILL.md", - "skills/status/SKILL.md", - "skills/transfer/SKILL.md", - ]; +test("README model documentation follows runtime aliases", () => { + const readme = read("README.md"); - for (const skillPath of skillPaths) { - const skillText = read(skillPath); - assert.match(skillText, /Keep the shell tool in the active Codex user workspace/i, skillPath); - assert.match(skillText, /never set its working directory to ``/i, skillPath); - assert.doesNotMatch(skillText, /--cwd ""/i, skillPath); - assert.doesNotMatch(skillText, /--cwd "\$PWD"/i, skillPath); - assert.doesNotMatch(skillText, //i, skillPath); + for (const alias of MODEL_ALIASES.keys()) { + assert.ok(readme.includes(`\`${alias}\``), alias); } + assertIncludesAll( + readme, + [ + "--model ", + "a full ID pins a version", + "requestedModel", + "finalModel", + "contextWindow", + ], + "README.md" + ); }); -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"); - const activeRootPattern = /\/scripts\/claude-companion\.mjs/i; +test("simple skills keep their executable companion commands", () => { + const commands = { + cancel: "cancel $ARGUMENTS", + "mcp-diagnose": "mcp-diagnose $ARGUMENTS", + result: "result $ARGUMENTS", + status: "status $ARGUMENTS", + transfer: "transfer $ARGUMENTS", + }; - assert.match(reviewRuntime, /resolved the active plugin root/i); - assert.match(reviewRuntime, activeRootPattern); - assert.match(reviewRuntime, /Never derive the workspace from the plugin root/i); - assert.match(reviewRuntime, /--cwd ""/i); - assert.match(reviewRuntime, /Never emit an empty routing placeholder such as `--owner-session-id {2}--job-id`/i); - assert.match(reviewRuntime, /blocking foreground shell-tool call, not as a background terminal\/session/i); - assert.match(reviewRuntime, /Do not request a shell session id, poll a shell session later, or return before the companion command exits/i); - assert.match(reviewRuntime, /if the available shell tool is `exec_command`, call it once in non-interactive mode and wait for command exit in that same call/i); - assert.match(reviewRuntime, /mention the tool name `send_input` literally/i); - assert.match(reviewRuntime, /exact tool shape `send_input\(\{ target: , message: \}\)`/i); - assert.match(reviewRuntime, /do not silently drop the completion notification path when the parent provided a non-empty parent thread id/i); - assert.match(reviewRuntime, /Use that same steering message as the child's own final assistant message for background mode/i); - assert.match(reviewRuntime, /Use the implicit default role and omit `agent_type` when it is optional or absent/i); - assert.match(reviewRuntime, /If the runtime schema marks `agent_type` required, pass `agent_type: "default"`/i); - assert.match(reviewRuntime, /Omit `model` so the child inherits the current Codex runtime model/i); - assert.match(reviewRuntime, /Do not add a fixed-version model fallback/i); - assert.doesNotMatch(reviewRuntime, /gpt-5\.\d+/i); + for (const [name, command] of Object.entries(commands)) { + const skill = read(`skills/${name}/SKILL.md`); + assert.ok( + skill.includes(`node "/scripts/claude-companion.mjs" ${command}`), + name + ); + } - assert.match(rescueRuntime, /resolved the active plugin root/i); - assert.match(rescueRuntime, activeRootPattern); - assert.match(rescueRuntime, /Never derive the workspace from the plugin root/i); - assert.match(rescueRuntime, /--cwd ""/i); - assert.match(rescueRuntime, /Never emit an empty routing placeholder such as `--owner-session-id {2}--job-id`/i); - assert.match(rescueRuntime, /Do not add `--quiet-progress` by default/i); - assert.match(rescueRuntime, /slash command as literal Claude Code task text/i); - assert.match(rescueRuntime, /blocking foreground shell-tool call, not as a background terminal\/session/i); - assert.match(rescueRuntime, /Do not request a shell session id, poll a shell session later, or return before the companion command exits/i); - assert.match(rescueRuntime, /if the available shell tool is `exec_command`, call it once in non-interactive mode and wait for command exit in that same call/i); - assert.match(rescueRuntime, /allow at most one success-only `send_input` notification before finishing/i); - assert.match(rescueRuntime, /Mention the tool name `send_input` literally/i); - assert.match(rescueRuntime, /exact tool shape `send_input\(\{ target: , message: \}\)`/i); - assert.match(rescueRuntime, /Use steering messages that point the parent at `\$cc:result` or `\$cc:status` instead of embedding the raw Claude result/i); - assert.match(rescueRuntime, /use that same steering message as the child's own final assistant message instead of echoing the raw companion result/i); + const transfer = read("skills/transfer/SKILL.md"); + assertIncludesAll(transfer, ["--source ", "codex resume "], "transfer"); + assert.match( + read("skills/mcp-diagnose/SKILL.md"), + /Do not print raw MCP server configs or secrets/i + ); }); -test("review skills keep background execution outside the companion command", () => { - const review = read("skills/review/SKILL.md"); - const adversarial = read("skills/adversarial-review/SKILL.md"); - const activeRootPattern = /\/scripts\/claude-companion\.mjs/i; - - assert.match(review, /Resolve `` as two directories above this `SKILL\.md` file/i); - assert.match(review, /Use `\$cc:review` as the default when the user asks for code review, asks you to have Claude review something, or wants a second review pass without explicitly asking for stronger adversarial scrutiny/i); - assert.match(review, /If the user asks for stronger challenge on design, tradeoffs, rollout risk, migration risk, configuration behavior, or provides custom review focus text, route to `\$cc:adversarial-review` instead/i); - assert.match(review, /If the user wants Claude Code to investigate, validate by changing code, or actually fix\/implement something, route to `\$cc:rescue` instead/i); - assert.match(review, /If the overall request is "you review it too, also ask Claude to review in the background, then you aggregate and fix it", keep the delegated Claude part on `\$cc:review` unless the user explicitly asks for a harsher or more adversarial review/i); - assert.match(review, /`\$cc:review` does not accept custom focus text/i); - assert.match(review, activeRootPattern); - assert.match(review, /Treat `--wait` and `--background` as Codex-side execution controls only/i); - assert.match(review, /Strip them before calling the companion command/i); - assert.match(review, /The companion review process itself always runs in the foreground/i); - assert.match(review, /internal runtime reference at `\.\.\/\.\.\/internal-skills\/review-runtime\/runtime\.md`/i); - assert.match(review, /It is an internal reference document, not a public skill to invoke/i); - assert.match(review, /review --view-state on-success/i); - assert.match(review, /Foreground review belongs to the main Codex thread/i); - assert.match(review, /Do not spawn a review subagent/i); - assert.match(review, /do not invoke a generic review-runner role/i); - assert.match(review, /Do not fall back to raw `claude`, `claude-code`, `claude review`, `bash -lc \.\.\.claude\.\.\.`/i); - assert.match(review, /If the .*companion command fails, surface that failure/i); - assert.match(review, /For background review, use Codex's built-in `default` subagent/i); - assert.match(review, /Do not satisfy background review by using a generic `claude_review_runner`-style helper role/i); - assert.match(review, /Never satisfy background review by running the companion command itself with shell backgrounding/i); - assert.match(review, /Background here means "spawn the forwarding child via `spawn_agent` and do not wait in the parent turn\."/i); - assert.match(review, /background-routing-context --kind review --json/i); - assert.match(review, /helper's non-empty `workspaceRoot` as the canonical workspace/i); - assert.match(review, /review --cwd "" --view-state defer/i); - assert.match(review, /internal `--job-id ` routing flag/i); - assert.match(review, /non-empty `ownerSessionId`/i); - assert.match(review, /omit `--owner-session-id` entirely/i); - assert.match(review, /spawn_agent/i); - assert.match(review, /`fork_context: false`/i); - assert.match(review, /`reasoning_effort: "medium"`/i); - assert.match(review, /Use the built-in default role implicitly and omit `agent_type` when it is optional or absent/i); - assert.match(review, /If the runtime schema marks `agent_type` required, pass `agent_type: "default"`/i); - assert.match(review, /Omit `model` so the forwarding child inherits the current Codex runtime model/i); - assert.match(review, /Prefer a self-contained child message over inheriting parent history/i); - assert.match(review, /Only consider `fork_context: true` as a last resort/i); - assert.match(review, /Do not retry with an explicit model override if spawning fails/i); - assert.doesNotMatch(review, /gpt-5\.\d+/i); - assert.match(review, /review --cwd "" --view-state defer/i); - assert.match(review, /include `--owner-session-id ` only when the parent resolved a non-empty owner session id/i); - assert.match(review, /never leave an empty routing placeholder such as `--owner-session-id {2}--job-id`/i); - assert.match(review, /blocking foreground shell-tool call, not as a background terminal\/session/i); - assert.match(review, /Do not request a shell session id, poll a shell session later, or return before the companion command exits/i); - assert.match(review, /if the available shell tool is `exec_command`, call it once in non-interactive mode and wait for command exit in that same call/i); - assert.match(review, /allow one extra `send_input` call after a successful shell result/i); - assert.match(review, /must mention the tool name `send_input` literally/i); - assert.match(review, /must target the provided parent thread id/i); - assert.match(review, /exact tool shape `send_input\(\{ target: , message: \}\)`/i); - assert.match(review, /do not silently drop the completion notification path from the child prompt/i); - assert.match(review, /Background Claude Code review finished\. Open it with \$cc:result \./i); - assert.match(review, /that `send_input` message should use one of those exact steering messages/i); - assert.match(review, /use these steering messages instead of embedding the raw review result in the notification/i); - assert.match(review, /do not embed the raw Claude result inside the notification message/i); - assert.match(review, /do not include any other prose in that notification message/i); - assert.match(review, /use that same steering message as the child's own final assistant message instead of echoing the raw review result/i); - assert.match(review, /Check the subagent session or \$cc:status for progress, and once it's done, we will let you know to see the results\./i); - assert.doesNotMatch(review, /claude-companion\.mjs" review --background/i); - assert.doesNotMatch(review, /claude-companion\.mjs" review \$ARGUMENTS/i); +test("review skills preserve foreground/background routing contracts", () => { + const variants = [ + { + name: "review", + notification: + "Background Claude Code review finished. Open it with $cc:result .", + }, + { + name: "adversarial-review", + notification: + "Background Claude Code adversarial review finished. Open it with $cc:result .", + }, + ]; - assert.match(adversarial, /Resolve `` as two directories above this `SKILL\.md` file/i); - assert.match(adversarial, /Do not treat `\$cc:adversarial-review` as the default review path/i); - assert.match(adversarial, /Good triggers include requests to challenge the design, challenge tradeoffs, pressure-test a risky change, question whether a migration\/config\/template change really removed the risk, or honor custom focus text that asks for harsher review/i); - assert.match(adversarial, /If the user wants Claude Code to go beyond review and perform investigation, validation edits, or implementation work, route to `\$cc:rescue` instead/i); - assert.match(adversarial, /If the user asks for a local review plus a separate Claude background review and then wants the main Codex thread to aggregate the findings and apply fixes, keep the delegated Claude portion on `\$cc:review` unless the user explicitly asks for the adversarial angle/i); - assert.match(adversarial, /Unlike `\$cc:review`, this skill accepts custom focus text after the flags/i); - assert.match(adversarial, activeRootPattern); - assert.match(adversarial, /Treat `--wait` and `--background` as Codex-side execution controls only/i); - assert.match(adversarial, /Strip them before calling the companion command/i); - assert.match(adversarial, /The companion review process itself always runs in the foreground/i); - assert.match(adversarial, /internal runtime reference at `\.\.\/\.\.\/internal-skills\/review-runtime\/runtime\.md`/i); - assert.match(adversarial, /It is an internal reference document, not a public skill to invoke/i); - assert.match(adversarial, /adversarial-review --view-state on-success/i); - assert.match(adversarial, /Foreground adversarial review belongs to the main Codex thread/i); - assert.match(adversarial, /Do not spawn a review subagent/i); - assert.match(adversarial, /do not invoke a generic review-runner role/i); - assert.match(adversarial, /Do not fall back to raw `claude`, `claude-code`, `claude review`, `bash -lc \.\.\.claude\.\.\.`/i); - assert.match(adversarial, /If the .*companion command fails, surface that failure/i); - assert.match(adversarial, /For background adversarial review, use Codex's built-in `default` subagent/i); - assert.match(adversarial, /Do not satisfy background adversarial review by using a generic `claude_review_runner`-style helper role/i); - assert.match(adversarial, /Never satisfy background adversarial review by running the companion command itself with shell backgrounding/i); - assert.match(adversarial, /Background here means "spawn the forwarding child via `spawn_agent` and do not wait in the parent turn\."/i); - assert.match(adversarial, /background-routing-context --kind review --json/i); - assert.match(adversarial, /helper's non-empty `workspaceRoot` as the canonical workspace/i); - assert.match(adversarial, /adversarial-review --cwd "" --view-state defer/i); - assert.match(adversarial, /internal `--job-id ` routing flag/i); - assert.match(adversarial, /non-empty `ownerSessionId`/i); - assert.match(adversarial, /omit `--owner-session-id` entirely/i); - assert.match(adversarial, /spawn_agent/i); - assert.match(adversarial, /`fork_context: false`/i); - assert.match(adversarial, /`reasoning_effort: "medium"`/i); - assert.match(adversarial, /Use the built-in default role implicitly and omit `agent_type` when it is optional or absent/i); - assert.match(adversarial, /If the runtime schema marks `agent_type` required, pass `agent_type: "default"`/i); - assert.match(adversarial, /Omit `model` so the forwarding child inherits the current Codex runtime model/i); - assert.match(adversarial, /Prefer a self-contained child message over inheriting parent history/i); - assert.match(adversarial, /Only consider `fork_context: true` as a last resort/i); - assert.match(adversarial, /Do not retry with an explicit model override if spawning fails/i); - assert.doesNotMatch(adversarial, /gpt-5\.\d+/i); - assert.match(adversarial, /adversarial-review --cwd "" --view-state defer/i); - assert.match(adversarial, /include `--owner-session-id ` only when the parent resolved a non-empty owner session id/i); - assert.match(adversarial, /never leave an empty routing placeholder such as `--owner-session-id {2}--job-id`/i); - assert.match(adversarial, /blocking foreground shell-tool call, not as a background terminal\/session/i); - assert.match(adversarial, /Do not request a shell session id, poll a shell session later, or return before the companion command exits/i); - assert.match(adversarial, /if the available shell tool is `exec_command`, call it once in non-interactive mode and wait for command exit in that same call/i); - assert.match(adversarial, /allow one extra `send_input` call after a successful shell result/i); - assert.match(adversarial, /must mention the tool name `send_input` literally/i); - assert.match(adversarial, /must target the provided parent thread id/i); - assert.match(adversarial, /exact tool shape `send_input\(\{ target: , message: \}\)`/i); - assert.match(adversarial, /do not silently drop the completion notification path from the child prompt/i); - assert.match(adversarial, /Background Claude Code adversarial review finished\. Open it with \$cc:result \./i); - assert.match(adversarial, /that `send_input` message should use one of those exact steering messages/i); - assert.match(adversarial, /use these steering messages instead of embedding the raw review result in the notification/i); - assert.match(adversarial, /do not embed the raw Claude result inside the notification message/i); - assert.match(adversarial, /do not include any other prose in that notification message/i); - assert.match(adversarial, /use that same steering message as the child's own final assistant message instead of echoing the raw review result/i); - assert.match(adversarial, /Check the subagent session or \$cc:status for progress, and once it's done, we will let you know to see the results\./i); - assert.doesNotMatch(adversarial, /claude-companion\.mjs" adversarial-review --background/i); - assert.doesNotMatch(adversarial, /claude-companion\.mjs" adversarial-review \$ARGUMENTS/i); + for (const { name, notification } of variants) { + const skill = read(`skills/${name}/SKILL.md`); + assertIncludesAll( + skill, + [ + `claude-companion.mjs" ${name} ...`, + `${name} --view-state on-success`, + "background-routing-context --kind review --json", + `${name} --cwd "" --view-state defer`, + "--owner-session-id ", + "--job-id ", + "spawn_agent", + "`fork_context: false`", + '`reasoning_effort: "medium"`', + "Omit `model` so the forwarding child inherits the current Codex runtime model.", + "run that command as one blocking foreground shell-tool call, not as a background terminal/session", + "do not request a shell session id, poll a shell session later, or return before the companion command exits", + "never leave an empty routing placeholder such as `--owner-session-id --job-id`", + "send_input({ target: , message: })", + notification, + ], + name + ); + assert.doesNotMatch( + skill, + new RegExp(`claude-companion\\.mjs" ${name} --background`, "i"), + name + ); + assert.doesNotMatch(skill, /gpt-5\.\d+/i, name); + } }); -test("rescue skill keeps --background and --wait as host-side controls only", () => { - const rescue = read("skills/rescue/SKILL.md"); - const activeRootPattern = /\/scripts\/claude-companion\.mjs/i; +test("review and rescue skills retain negative routing guards", () => { + for (const name of ["review", "adversarial-review"]) { + assertIncludesAll( + read(`skills/${name}/SKILL.md`), + [ + "Do not spawn a review subagent", + "do not invoke a generic review-runner role", + "Do not fall back to raw `claude`", + "generic `claude_review_runner`-style helper role", + "shell backgrounding such as `&`, `nohup`, detached `spawn`", + "Only consider `fork_context: true` as a last resort", + "Do not retry with an explicit model override if spawning fails", + ], + name + ); + } - assert.match(rescue, /Resolve `` as two directories above this `SKILL\.md` file/i); - assert.match(rescue, /Prefer `\$cc:rescue` when the user wants Claude Code to diagnose the issue, validate a risky change by actually editing or testing, apply fixes from a prior review, or carry a task forward across multiple steps/i); - assert.match(rescue, /Do not use rescue for "just review this diff" unless the user also wants follow-through work beyond review findings/i); - assert.match(rescue, /Do not use rescue merely because the main Codex thread plans to fix things after combining its own review with a separate Claude review/i); - assert.match(rescue, activeRootPattern); - assert.match(rescue, /`--background` and `--wait` are Codex-side execution controls only/i); - assert.match(rescue, /Never satisfy background rescue by launching `claude-companion\.mjs task` itself as a detached shell process/i); - assert.match(rescue, /Never forward either flag to `claude-companion\.mjs task`/i); - assert.match(rescue, /The main Codex thread owns that execution-mode choice/i); - assert.match(rescue, /If the user explicitly passed `--background`, run the rescue subagent in the background/i); - assert.match(rescue, /If neither flag is present and the rescue request is small, clearly bounded, or likely to finish quickly, prefer foreground/i); - assert.match(rescue, /If neither flag is present and the request looks complicated, open-ended, multi-step, or likely to keep Claude Code running for a while, prefer background execution for the subagent/i); - assert.match(rescue, /This size-and-scope heuristic belongs to the main Codex thread/i); - assert.match(rescue, /If the user task text itself begins with a slash command such as `\/simplify`/i); - assert.match(rescue, /Remove `--background` and `--wait` before spawning the subagent/i); - assert.match(rescue, /If the free-text task begins with `\/`, preserve it verbatim/i); - assert.match(rescue, /background-routing-context --kind task --json/i); - assert.match(rescue, /helper's non-empty `workspaceRoot` as the canonical workspace/i); - assert.match(rescue, /--cwd ""/i); - assert.match(rescue, /non-empty `ownerSessionId`/i); - assert.match(rescue, /omit `--owner-session-id` entirely/i); - assert.match(rescue, /internal `--job-id ` routing flag/i); - assert.match(rescue, /Foreground rescue must add `--view-state on-success`/i); - assert.match(rescue, /Background rescue must add `--view-state defer`/i); - assert.match(rescue, /Background: spawn the rescue subagent without waiting for it in this turn/i); - assert.match(rescue, /The subagent still runs the companion `task` command in the foreground/i); - assert.match(rescue, /tell the user `Claude Code rescue started in the background\. Check the subagent session or \$cc:status for progress, and once it's done, we will let you know to see the results\.`/i); + assertIncludesAll( + read("skills/review/SKILL.md"), + [ + "Use `$cc:review` as the default", + "route to `$cc:adversarial-review` instead", + "route to `$cc:rescue` instead", + ], + "review routing" + ); + assertIncludesAll( + read("skills/adversarial-review/SKILL.md"), + [ + "Do not treat `$cc:adversarial-review` as the default", + "route to `$cc:rescue` instead", + "keep the delegated Claude portion on `$cc:review`", + ], + "adversarial routing" + ); + assertIncludesAll( + read("skills/rescue/SKILL.md"), + [ + "This size-and-scope heuristic belongs to the main Codex thread", + "If the user task text itself begins with a slash command", + "Never satisfy background rescue by launching", + "Prefer `fork_context: false`", + "Only consider `fork_context: true` as a last resort", + "Do not retry with an explicit model override if spawning fails", + ], + "rescue routing" + ); }); -test("rescue skill documents the experimental built-in-agent forwarding path", () => { +test("rescue keeps host execution controls out of the companion task", () => { const rescue = read("skills/rescue/SKILL.md"); - const rescueAgentMeta = read("skills/rescue/agents/openai.yaml"); - const frontmatter = rescue.split("---")[1] ?? ""; - const supportedArgumentsLine = - rescue - .split("\n") - .find((line) => line.startsWith("Supported arguments:")) ?? ""; + const metadata = frontmatter(rescue, "rescue"); + const agentMetadata = read("skills/rescue/agents/openai.yaml"); - assert.doesNotMatch(frontmatter, /--builtin-agent/i); - assert.doesNotMatch(supportedArgumentsLine, /--builtin-agent/i); - assert.doesNotMatch(rescueAgentMeta, /--builtin-agent/i); - assert.doesNotMatch(frontmatter, /--notify-parent-on-complete/i); - assert.doesNotMatch(supportedArgumentsLine, /--notify-parent-on-complete/i); - assert.doesNotMatch(rescueAgentMeta, /--notify-parent-on-complete/i); - assert.match(rescue, /By default, hand this skill off through Codex's built-in `default` subagent/i); - assert.match(rescue, /legacy request still includes `--builtin-agent`/i); - assert.match(rescue, /legacy request still includes `--notify-parent-on-complete`/i); - assert.match(rescue, /compatibility alias for the default built-in path/i); - assert.match(rescue, /Prefer `fork_context: false` for the built-in rescue child/i); - assert.match(rescue, /Only consider `fork_context: true` as a last resort/i); - assert.match(rescue, /implicit default role and omit `agent_type` when it is optional or absent/i); - assert.match(rescue, /If the runtime schema marks `agent_type` required, pass `agent_type: "default"`/i); - assert.match(rescue, /must omit `model` and set `reasoning_effort: "medium"` on `spawn_agent`/i); - assert.match(rescue, /inherits the current Codex runtime model/i); - assert.match(rescue, /Do not retry with an explicit model override if spawning fails/i); + assertIncludesAll( + rescue, + [ + "task-resume-candidate --json", + "background-routing-context --kind task --json", + '--cwd ""', + "--view-state on-success", + "--view-state defer", + "--owner-session-id ", + "--job-id ", + "--prompt-file", + "send_input({ target: , message: })", + "Background Claude Code rescue finished. Open it with $cc:result .", + "../../internal-skills/cli-runtime/runtime.md", + "../../internal-skills/task-prompt-shaping/prompt-shaping.md", + ], + "rescue" + ); + assert.match(rescue, /Never forward either flag to `claude-companion\.mjs task`/i); + assert.doesNotMatch(rescue, /claude-companion\.mjs" task --(?:background|wait)/i); assert.doesNotMatch(rescue, /gpt-5\.\d+/i); - assert.match(rescue, /non-empty `parentThreadId`/i); - assert.match(rescue, /pass it into the child prompt as the parent thread id/i); - assert.match(rescue, /allow one extra `send_input` call after a successful shell result/i); - assert.match(rescue, /must mention the tool name `send_input` literally/i); - assert.match(rescue, /must target the provided parent thread id/i); - assert.match(rescue, /exact tool shape `send_input\(\{ target: , message: \}\)`/i); - assert.match(rescue, /do not silently drop the completion notification path from the child prompt/i); - assert.match(rescue, /short user-facing template that steers the parent toward explicit result retrieval instead of inlining the raw result/i); - assert.match(rescue, /Background Claude Code rescue finished\. Open it with \$cc:result \./i); - assert.match(rescue, /fall back to:/i); - assert.match(rescue, /Background Claude Code rescue finished\. Inspect it with \$cc:status first, then use \$cc:result for the finished job you want to open\./i); - assert.match(rescue, /blocking foreground shell-tool call, not as a background terminal\/session/i); - assert.match(rescue, /Do not request a shell session id, poll a shell session later, or return before the companion command exits/i); - assert.match(rescue, /if the available shell tool is `exec_command`, call it once in non-interactive mode and wait for command exit in that same call/i); - assert.match(rescue, /prefer these steering messages over embedding the raw result text/i); - assert.match(rescue, /do not embed the raw Claude result inside the notification message/i); - assert.match(rescue, /do not include any other prose in that notification message/i); - assert.match(rescue, /for background rescue, use that same steering message as the child's own final assistant message instead of echoing the raw companion result/i); - assert.match(rescue, /background built-in rescue now attempts parent wake-up by default/i); - assert.match(rescue, /default for background built-in rescue on persistent Codex\/Desktop threads/i); - assert.match(rescue, /silently degrade on one-shot `codex exec` runs/i); - assert.match(rescue, /the parent thread owns prompt shaping/i); - assert.match(rescue, /If the built-in rescue request is vague, chatty, or a follow-up, the parent may tighten only the task text/i); - assert.match(rescue, /Prefer passing a small structured `` block instead of forked thread history/i); - assert.match(rescue, /internal runtime reference at `\.\.\/\.\.\/internal-skills\/cli-runtime\/runtime\.md`/i); - assert.match(rescue, /It is an internal reference document, not a public skill to invoke/i); - assert.match(rescue, /internal prompt-shaping reference at `\.\.\/\.\.\/internal-skills\/task-prompt-shaping\/prompt-shaping\.md`/i); - assert.match(rescue, /It is an internal reference document, not a public skill to invoke/i); - assert.match(rescue, /If the request is already concrete, keep it literal/i); - assert.match(rescue, /If the request names a concrete file, path, or artifact such as `README\.md`/i); - assert.match(rescue, /Do not compress it into a shorter delta/i); - assert.match(rescue, /materialize it into a temporary prompt file first and use `--prompt-file` instead of embedding the task directly/i); - assert.match(rescue, /multi-line task text/i); - assert.match(rescue, /single quotes, backticks, or XML-style blocks/i); - assert.match(rescue, /absolute `--prompt-file` path/i); - assert.match(rescue, /temporary path outside the repository checkout/i); - assert.match(rescue, /normal file-write tool or other structured write path/i); - assert.match(rescue, /rewrite it into a short delta that names the next thing Claude Code should change or inspect/i); - assert.match(rescue, /preserve the language mix and only tighten the execution intent/i); - assert.match(rescue, /make that output contract explicit instead of broadening the task/i); - assert.match(rescue, /For `--resume`, `--resume-last`, vague follow-ups, or ambiguous continuation requests, prefer adding a compact `` block/i); - assert.match(rescue, /Keep `` small and structured/i); - assert.match(rescue, /`mode` \(`fresh` or `resume`\)/i); - assert.match(rescue, /`job_id` when the parent reserved one/i); - assert.match(rescue, /`claude_session` when a resumable Claude session is already known/i); - assert.match(rescue, /`next_delta` for the exact next objective/i); - assert.match(rescue, /Do not use `` for already-clear fresh tasks unless it adds real value/i); - assert.match(rescue, /Do not turn it into a free-form summary of the whole parent thread/i); - assert.match(rescue, /prefer a short delta instruction for resume follow-ups/i); - assert.match(rescue, /The child must not do an additional interpretation pass/i); - assert.match(rescue, /prefer `--resume` or `--resume-last` with a short delta instruction/i); - assert.match(rescue, /compact strict forwarding message/i); - assert.match(rescue, /transient forwarding worker for Claude Code rescue/i); - assert.match(rescue, /include exactly one shell command to run/i); - assert.match(rescue, /ignore stderr progress chatter such as `\[cc\] \.\.\.` lines/i); - assert.match(rescue, /not to inspect the repository, read files, grep, or do the task directly/i); - assert.match(rescue, /for foreground rescue only, tell the child to return that command's stdout text exactly/i); - assert.match(rescue, /copy the resolved rescue task text byte-for-byte/i); - assert.match(rescue, /forbid appending terminal punctuation, adding quotes, dropping prefixes such as `completed:`/i); - assert.match(rescue, /completed:\/simplify make the output compact/i); -}); -test("rescue runtime guidance forbids task --background", () => { - const runtimeSkill = read("internal-skills/cli-runtime/runtime.md"); - - assert.match(runtimeSkill, /`--background` and `--wait` are parent-side execution controls only/i); - assert.match(runtimeSkill, /Strip both before building the `task` command/i); - assert.match(runtimeSkill, /Never call `task --background` or invent `task --wait`\./i); - assert.match(runtimeSkill, /The companion task command always runs in the foreground/i); - assert.match(runtimeSkill, /`--owner-session-id`, and `--job-id` as routing controls/i); - assert.match(runtimeSkill, /If the free-text task begins with `\/`, treat that slash command as literal Claude Code task text/i); - assert.match(runtimeSkill, /Do not add `--quiet-progress` by default for built-in rescue forwarding/i); - assert.match(runtimeSkill, /Let companion stderr progress remain available in the spawned agent thread/i); - assert.match(runtimeSkill, /prefer staging it in a temporary prompt file and pass it through `--prompt-file` instead of inlining it in one shell string/i); - assert.match(runtimeSkill, /prefer a temporary path outside the repository checkout/i); - assert.match(runtimeSkill, /Use a structured file-write path to create that prompt file/i); - assert.match(runtimeSkill, /ignore the progress chatter and preserve only the final stdout-equivalent result text/i); - assert.match(runtimeSkill, /It does not change the companion command you build/i); - assert.match(runtimeSkill, /`--view-state on-success` means the user will see this companion result in the current turn/i); - assert.match(runtimeSkill, /`--view-state defer` means the parent is not waiting/i); - assert.match(runtimeSkill, /`--owner-session-id ` is an internal parent-session routing control/i); + for (const legacyFlag of ["--builtin-agent", "--notify-parent-on-complete"]) { + assert.ok(!metadata.includes(legacyFlag), legacyFlag); + assert.ok(!agentMetadata.includes(legacyFlag), legacyFlag); + } }); -test("rescue parent skill owns resume-candidate exploration", () => { - const rescue = read("skills/rescue/SKILL.md"); - const runtimeSkill = read("internal-skills/cli-runtime/runtime.md"); +test("internal runtime references preserve executable routing invariants", () => { + const reviewRuntime = read("internal-skills/review-runtime/runtime.md"); + const rescueRuntime = read("internal-skills/cli-runtime/runtime.md"); - assert.match(rescue, /task-resume-candidate --json/i); - assert.match(rescue, /Continue current Claude Code thread/i); - assert.match(rescue, /Start a new Claude Code thread/i); + assertIncludesAll( + reviewRuntime, + [ + 'node "/scripts/claude-companion.mjs" review ...', + 'node "/scripts/claude-companion.mjs" adversarial-review ...', + "review --view-state on-success", + "adversarial-review --view-state on-success", + "background-routing-context --kind review --json", + "Never derive the workspace from the plugin root", + "Never emit an empty routing placeholder such as `--owner-session-id --job-id`", + 'review --cwd "" --view-state defer', + 'adversarial-review --cwd "" --view-state defer', + "run the companion command as one blocking foreground shell-tool call, not as a background terminal/session", + "do not request a shell session id, poll a shell session later, or return before the companion command exits", + "Omit `model` so the child inherits the current Codex runtime model.", + "Do not add a fixed-version model fallback.", + "send_input({ target: , message: })", + "Background Claude Code review finished. Open it with $cc:result .", + "Background Claude Code adversarial review finished. Open it with $cc:result .", + ], + "review runtime" + ); + assert.doesNotMatch(reviewRuntime, /gpt-5\.\d+/i); - assert.doesNotMatch(runtimeSkill, /task-resume-candidate --json/i); - assert.doesNotMatch(runtimeSkill, /Continue current Claude Code thread/i); - assert.doesNotMatch(runtimeSkill, /Start a new Claude Code thread/i); - assert.match(runtimeSkill, /The parent rescue skill already owns that choice/i); + assertIncludesAll( + rescueRuntime, + [ + 'node "/scripts/claude-companion.mjs" task --cwd ""', + "Never derive the workspace from the plugin root", + "Never emit an empty routing placeholder such as `--owner-session-id --job-id`", + "Run the companion command as one blocking foreground shell-tool call, not as a background terminal/session.", + "Do not request a shell session id, poll a shell session later, or return before the companion command exits.", + "Never call `task --background` or invent `task --wait`.", + "--owner-session-id ", + "--job-id", + "--prompt-file", + "Never call `task-resume-candidate` from the rescue forwarder.", + "send_input({ target: , message: })", + ], + "rescue runtime" + ); }); -test("setup skill repairs native plugin hook feature gates before the final setup report", () => { +test("setup keeps native hook repair in the companion flow", () => { const setup = read("skills/setup/SKILL.md"); - assert.match(setup, /Resolve `` as two directories above this `SKILL\.md` file/i); - assert.match(setup, /\/scripts\/claude-companion\.mjs/i); - assert.match(setup, /setup --json/i); - assert.match(setup, /missing native plugin hook features/i); - assert.match(setup, /hook trust/i); - assert.match(setup, /\[features\]\.hooks/i); - assert.match(setup, /\[features\]\.plugin_hooks/i); - assert.match(setup, /native hook trust hashes/i); + assertIncludesAll( + setup, + [ + 'claude-companion.mjs" setup --json', + "[features].hooks", + "[features].plugin_hooks", + "native hook trust hashes", + ], + "setup" + ); assert.doesNotMatch(setup, /install-hooks\.mjs/i); }); - -test("simple runtime skills resolve the active plugin root from the skill path", () => { - const status = read("skills/status/SKILL.md"); - const result = read("skills/result/SKILL.md"); - const cancel = read("skills/cancel/SKILL.md"); - const transfer = read("skills/transfer/SKILL.md"); - const mcpDiagnose = read("skills/mcp-diagnose/SKILL.md"); - const activeRootPattern = /\/scripts\/claude-companion\.mjs/i; - - for (const skillText of [status, result, cancel, transfer, mcpDiagnose]) { - assert.match(skillText, /Resolve `` as two directories above this `SKILL\.md` file/i); - assert.match(skillText, activeRootPattern); - assert.doesNotMatch(skillText, //i); - } - - assert.match(transfer, /claude-companion\.mjs" transfer \$ARGUMENTS/i); - assert.match(transfer, /codex resume /i); - assert.match(transfer, /--source /i); - assert.match(mcpDiagnose, /claude-companion\.mjs" mcp-diagnose \$ARGUMENTS/i); - assert.match(mcpDiagnose, /Do not print raw MCP server configs or secrets/i); -}); diff --git a/tests/state.test.mjs b/tests/state.test.mjs index b182814..83fb94b 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -812,21 +812,23 @@ describe("cleanupOldJobs", () => { fs.utimesSync(badReservation, twoHoursAgo / 1000, twoHoursAgo / 1000); fs.utimesSync(staleReservation, twoHoursAgo / 1000, twoHoursAgo / 1000); - fs.statSync = (targetPath, ...args) => { + Reflect.set(fs, "statSync", (targetPath, ...args) => { if (targetPath === badReservation) { - const error = new Error("synthetic stat failure"); + const error = /** @type {NodeJS.ErrnoException} */ ( + new Error("synthetic stat failure") + ); error.code = "EIO"; throw error; } return originalStatSync(targetPath, ...args); - }; + }); cleanupOldJobs(repoDir); assert.equal(fs.existsSync(badReservation), true); assert.equal(fs.existsSync(staleReservation), false); } finally { - fs.statSync = originalStatSync; + Reflect.set(fs, "statSync", originalStatSync); fs.rmSync(resolveStateDir(repoDir), { recursive: true, force: true }); fs.rmSync(repoDir, { recursive: true, force: true }); } diff --git a/tests/test-env-isolation.test.mjs b/tests/test-env-isolation.test.mjs new file mode 100644 index 0000000..8a603e4 --- /dev/null +++ b/tests/test-env-isolation.test.mjs @@ -0,0 +1,44 @@ +/** + * Copyright 2026 Sendbird, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { it } from "node:test"; + +import { + resolveStateDir, + saveConfig, +} from "../scripts/lib/state.mjs"; + +it("routes state writes away from the original CODEX_HOME", () => { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-test-env-repo-")); + const init = spawnSync("git", ["init", "-q"], { + cwd: repoDir, + encoding: "utf8", + }); + assert.equal(init.status, 0, init.stderr); + + try { + const isolatedStateDir = resolveStateDir(repoDir); + const originalStateDir = isolatedStateDir.replace( + process.env.CODEX_HOME, + process.env.CC_TEST_ORIGINAL_CODEX_HOME + ); + + assert.notEqual(process.env.CODEX_HOME, process.env.CC_TEST_ORIGINAL_CODEX_HOME); + assert.ok(isolatedStateDir.startsWith(`${process.env.CODEX_HOME}${path.sep}`)); + assert.equal(fs.existsSync(originalStateDir), false); + + saveConfig(repoDir, { stopReviewGate: true }); + + assert.equal(fs.existsSync(path.join(isolatedStateDir, "config.json")), true); + assert.equal(fs.existsSync(originalStateDir), false); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } +}); diff --git a/tests/test-env.mjs b/tests/test-env.mjs new file mode 100644 index 0000000..9edf6db --- /dev/null +++ b/tests/test-env.mjs @@ -0,0 +1,20 @@ +/** + * 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"; + +process.env.CC_TEST_ORIGINAL_CODEX_HOME ??= + process.env.CODEX_HOME || path.join(os.homedir(), ".codex"); + +const testCodexHome = fs.mkdtempSync( + path.join(os.tmpdir(), "cc-plugin-codex-test-") +); +process.env.CODEX_HOME = testCodexHome; + +process.once("exit", () => { + fs.rmSync(testCodexHome, { recursive: true, force: true }); +}); diff --git a/tests/unread-result-hook.test.mjs b/tests/unread-result-hook.test.mjs index b854be7..9c8f8bc 100644 --- a/tests/unread-result-hook.test.mjs +++ b/tests/unread-result-hook.test.mjs @@ -89,6 +89,7 @@ function runHook(testEnv, payload) { ...process.env, HOME: testEnv.homeDir, USERPROFILE: testEnv.homeDir, + CODEX_HOME: path.join(testEnv.homeDir, ".codex"), }, input: JSON.stringify(payload), encoding: "utf8", @@ -440,6 +441,7 @@ test("skips unread-result announcements when nested-session hook suppression is ...process.env, HOME: testEnv.homeDir, USERPROFILE: testEnv.homeDir, + CODEX_HOME: path.join(testEnv.homeDir, ".codex"), CLAUDE_COMPANION_SKIP_INTERACTIVE_HOOKS: "1", }, input: JSON.stringify({ diff --git a/tsconfig.tests.json b/tsconfig.tests.json new file mode 100644 index 0000000..d8ee885 --- /dev/null +++ b/tsconfig.tests.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "tests/**/*.mjs" + ], + "exclude": [ + "node_modules", + "tasks" + ] +} From 6e1da5935e096fef4d0e80144640eba988973480 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:13:55 +0300 Subject: [PATCH 2/4] test: make Claude launch fixtures Windows-safe --- tests/claude-cli.test.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/claude-cli.test.mjs b/tests/claude-cli.test.mjs index be37fd2..4bb2aee 100644 --- a/tests/claude-cli.test.mjs +++ b/tests/claude-cli.test.mjs @@ -1266,13 +1266,13 @@ describe("runClaudeTurn", () => { if (process.platform === "win32") { fs.writeFileSync( path.join(tmpDir, "claude.cmd"), - `@echo off\r\n"${process.execPath}" "%~dp0fake-claude.mjs" %*\r\n` + `@echo off\r\n"${process.execPath}" "%~dp0fake-claude.mjs"\r\n` ); } else { const launcher = path.join(tmpDir, "claude"); fs.writeFileSync( launcher, - `#!/bin/sh\nDIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\nexec "${process.execPath}" "$DIR/fake-claude.mjs" "$@"\n` + `#!/bin/sh\nDIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\nexec "${process.execPath}" "$DIR/fake-claude.mjs"\n` ); fs.chmodSync(launcher, 0o755); } @@ -1306,13 +1306,13 @@ describe("runClaudeTurn", () => { if (process.platform === "win32") { fs.writeFileSync( path.join(tmpDir, "claude.cmd"), - `@echo off\r\n"${process.execPath}" "%~dp0fake-claude.mjs" %*\r\n` + `@echo off\r\n"${process.execPath}" "%~dp0fake-claude.mjs"\r\n` ); } else { const launcher = path.join(tmpDir, "claude"); fs.writeFileSync( launcher, - `#!/bin/sh\nDIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\nexec "${process.execPath}" "$DIR/fake-claude.mjs" "$@"\n` + `#!/bin/sh\nDIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\nexec "${process.execPath}" "$DIR/fake-claude.mjs"\n` ); fs.chmodSync(launcher, 0o755); } From ff29e96b9c18854a12cdb6a0dbaf2741de3a7a77 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Tue, 28 Jul 2026 06:29:42 +0300 Subject: [PATCH 3/4] fix: close Claude lifecycle race conditions --- CHANGELOG.md | 6 + hooks/hooks.json | 1 + hooks/session-lifecycle-hook.mjs | 58 +- package.json | 6 +- scripts/claude-companion.mjs | 21 +- scripts/lib/claude-cli.mjs | 228 ++++- scripts/lib/process.mjs | 363 +++++++- scripts/lib/render.mjs | 58 +- scripts/lib/state.mjs | 412 +++++++-- scripts/lib/tracked-jobs.mjs | 97 +- stryker.shard.config.mjs | 20 +- tests/cancel-command.test.mjs | 91 ++ tests/claude-cli.test.mjs | 583 +++++++++++- tests/fixtures/swap-job-after-read.mjs | 65 ++ tests/hooks.test.mjs | 185 +++- tests/mutation-config.test.mjs | 16 +- tests/process.test.mjs | 673 +++++++++++++- tests/render.test.mjs | 110 ++- tests/state.test.mjs | 1118 +++++++++++++++++++++++- tests/tracked-jobs.test.mjs | 263 +++++- 20 files changed, 4150 insertions(+), 224 deletions(-) create mode 100644 tests/cancel-command.test.mjs create mode 100644 tests/fixtures/swap-job-after-read.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 839cd81..1a38b7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,12 @@ - Keep refusal-marker cleanup best-effort so permissions or Windows file locking cannot fail healthy native hook invocations or an otherwise completed uninstall. - Preserve foreign hook shapes and empty entries, and make the shipped legacy hook installer fail before changing config when cleanup is unsafe. - Match managed hook paths case-insensitively on Windows, exercise cleanup and line-range guards in Windows CI, and validate complete function spans for mutation scopes. +- Launch both legacy JavaScript and current native Claude Code npm shims directly on Windows, skipping stale or unsupported shims when a later PATH entry is usable, avoiding Node.js `.cmd` spawn failures without routing prompts through a command shell. +- Record Windows process identities through CIM and atomically compare the stored identity before dispatching `taskkill`, re-checking failed terminations so processes that exit during cancellation are reported accurately. +- Keep Windows hooks responsive with a time-bounded, half-open circuit breaker for read-only CIM probes while bypassing it for required spawn-time identity capture, persist failed reaper-probe throttling across one-shot hook processes with a two-second read-only timeout, always attempt atomic identity-checked cancellation for every job, distinguish pre-check absence, CIM failure, and exit during `taskkill`, grant five-minute identity leases only after successful verification, retain the first unavailable-check timestamp without refreshing it, fail open when that timestamp cannot be persisted, stop treating the job as active after a fifteen-minute unverifiable ceiling while preserving late successful results and requiring explicit CIM verification before rendering any destructive Windows cleanup command, bound cancellation identity and termination calls to ten seconds, cap aggregate SessionEnd process cleanup at twenty seconds before preserving remaining jobs for manual recovery, and hide every spawned command window. +- Resolve lock-owner identity before publication, stage the complete ownership record privately and atomically hard-link it into place, fall back once per process to exclusive-create publication on filesystems that reject hard links, use per-owner tokens so stale holders cannot remove replacement locks, retry tagged lock contention without rewriting successful executions as raw filesystem failures, clean up only crash-orphaned staging files whose names exactly match the writer's format, protect legacy malformed locks with a fifteen-second grace period, and recover otherwise unverifiable locks after a two-minute hard ceiling. +- Keep unverifiable live-owner locks fail-closed within that ceiling, treat identity lookup races and timeouts as unverifiable instead of PID mismatches, deliberately keep POSIX job reaping fail-open when identity lookup is unavailable, treat `EPERM` liveness probes as proof that POSIX processes and process groups still exist, preserve recovery PIDs when POSIX cancellation cannot verify a live process group, re-check a surviving leader's identity before SIGKILL while still escalating orphaned child groups after their leader exits, classify already-exited POSIX and Windows process trees accurately, and render platform-correct manual cleanup commands against the same PID that was verified. +- Run the process lifecycle suite in Windows CI with platform-neutral Node.js fixtures, including a real CIM lookup and identity-checked child-process tree termination, and expand mutation shards across the complete changed process, reaper, and lock-recovery functions. ## v1.5.1 diff --git a/hooks/hooks.json b/hooks/hooks.json index fddbb1b..6121289 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -19,6 +19,7 @@ { "type": "command", "command": "node \"$PLUGIN_ROOT/hooks/session-lifecycle-hook.mjs\" SessionEnd", + "timeout": 45, "statusMessage": "Cleaning up Claude Code bridge jobs" } ] diff --git a/hooks/session-lifecycle-hook.mjs b/hooks/session-lifecycle-hook.mjs index 15184a5..badd540 100644 --- a/hooks/session-lifecycle-hook.mjs +++ b/hooks/session-lifecycle-hook.mjs @@ -22,7 +22,7 @@ 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 { terminateProcessTreeIfIdentityMatches } from "../scripts/lib/process.mjs"; import { ACTIVE_JOB_STATUSES, clearCurrentSession, @@ -39,6 +39,7 @@ export { SESSION_ID_ENV }; const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA"; const SKIP_INTERACTIVE_HOOKS_ENV = "CLAUDE_COMPANION_SKIP_INTERACTIVE_HOOKS"; const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SESSION_CLEANUP_SOFT_BUDGET_MS = 20_000; function shellEscape(value) { return `'${String(value).replace(/'/g, `'\"'\"'`)}'`; @@ -80,24 +81,45 @@ function cleanupSessionJobs(cwd, sessionId) { return; } + const cleanupStartedAt = Date.now(); for (const job of sessionJobs) { const stillRunning = ACTIVE_JOB_STATUSES.has(job.status); if (!stillRunning) { continue; } const hasPid = Number.isFinite(job.pid); - const hasTrustedPid = + let canSafelyCancel = !hasPid; + let cancellationFailure = + "Refused to terminate a stored process without a matching PID identity."; + const cleanupBudgetExhausted = + hasPid && + Date.now() - cleanupStartedAt >= SESSION_CLEANUP_SOFT_BUDGET_MS; + if (cleanupBudgetExhausted) { + cancellationFailure = + "Skipped process-tree termination because the SessionEnd cleanup budget was exhausted."; + } else if ( hasPid && typeof job.pidIdentity === "string" && - job.pidIdentity && - validateProcessIdentity(job.pid, job.pidIdentity); - const canSafelyCancel = !hasPid || hasTrustedPid; - try { - if (hasTrustedPid) { - terminateProcessTree(job.pid); + job.pidIdentity + ) { + try { + const result = terminateProcessTreeIfIdentityMatches( + job.pid, + job.pidIdentity + ); + canSafelyCancel = + result.delivered || + result.reason === "process-missing" || + result.reason === "identity-mismatch"; + if (!canSafelyCancel) { + cancellationFailure = + "Identity-checked process-tree termination did not complete."; + } + } catch (error) { + canSafelyCancel = false; + const detail = error instanceof Error ? error.message : String(error); + cancellationFailure = `Failed to terminate the stored process tree: ${detail}`; } - } catch { - // Ignore teardown failures during session shutdown. } try { transitionJob( @@ -106,14 +128,14 @@ function cleanupSessionJobs(cwd, sessionId) { [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", - } + completedAt: nowIso(), + errorMessage: canSafelyCancel + ? "Cancelled when the Codex session ended." + : cancellationFailure, + 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. diff --git a/package.json b/package.json index 85b2443..a13d661 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "setup:git-hooks": "node scripts/setup-git-hooks.mjs", "test": "node --import ./tests/test-env.mjs --test tests/*.test.mjs", "test:coverage": "c8 --all --include='scripts/**/*.mjs' --include='hooks/**/*.mjs' --reporter=text --reporter=json-summary --reporter=lcov --reports-dir=reports/coverage --check-coverage --lines=89 --statements=89 --branches=79 --functions=96 node --import ./tests/test-env.mjs --test tests/*.test.mjs tests/integration/*.test.mjs tests/e2e/*.test.mjs", - "test:cross-platform": "node --import ./tests/test-env.mjs --test tests/args.test.mjs tests/changelog.test.mjs tests/claude-cli.test.mjs tests/fs.test.mjs tests/install-hooks.test.mjs tests/mutation-config.test.mjs tests/plugin-install-guard.test.mjs tests/prompts.test.mjs tests/render.test.mjs tests/sandbox-modes.test.mjs tests/skills-contracts.test.mjs tests/structured-output.test.mjs tests/version-sync.test.mjs", + "test:cross-platform": "node --import ./tests/test-env.mjs --test tests/args.test.mjs tests/cancel-command.test.mjs tests/changelog.test.mjs tests/claude-cli.test.mjs tests/fs.test.mjs tests/install-hooks.test.mjs tests/mutation-config.test.mjs tests/plugin-install-guard.test.mjs tests/process.test.mjs tests/prompts.test.mjs tests/render.test.mjs tests/sandbox-modes.test.mjs tests/skills-contracts.test.mjs tests/structured-output.test.mjs tests/version-sync.test.mjs", "test:integration": "node --import ./tests/test-env.mjs --test tests/integration/*.test.mjs", "test:mutation": "npm run test:mutation:pr", "test:mutation:pr": "npm run test:mutation:critical && npm run test:mutation:shard:managed && npm run test:mutation:shard:installer", @@ -85,8 +85,8 @@ "test:mutation:shard:installer": "CC_MUTATION_SHARD=installer stryker run stryker.shard.config.mjs", "test:mutation:shard:installer:force": "CC_MUTATION_SHARD=installer stryker run stryker.shard.config.mjs --force", "test:mutation:render:unit": "node --import ./tests/test-env.mjs --test tests/render.test.mjs", - "test:mutation:claude-cli:unit": "node --import ./tests/test-env.mjs --test tests/claude-cli.test.mjs", - "test:mutation:state:unit": "node --import ./tests/test-env.mjs --test tests/state.test.mjs", + "test:mutation:claude-cli:unit": "node --import ./tests/test-env.mjs --test tests/claude-cli.test.mjs tests/process.test.mjs", + "test:mutation:state:unit": "node --import ./tests/test-env.mjs --test tests/state.test.mjs tests/tracked-jobs.test.mjs", "test:mutation:job-control:unit": "node --import ./tests/test-env.mjs --test tests/job-control.test.mjs", "test:mutation:managed:unit": "node --import ./tests/test-env.mjs --test tests/plugin-install-guard.test.mjs", "test:mutation:installer:unit": "node --import ./tests/test-env.mjs --test tests/installer-cli.test.mjs", diff --git a/scripts/claude-companion.mjs b/scripts/claude-companion.mjs index d81975d..c6afd22 100644 --- a/scripts/claude-companion.mjs +++ b/scripts/claude-companion.mjs @@ -61,7 +61,10 @@ import { ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs"; -import { binaryAvailable, getProcessIdentity } from "./lib/process.mjs"; +import { + binaryAvailable, + getSpawnedProcessIdentity, +} from "./lib/process.mjs"; import { callCodexAppServer } from "./lib/codex-app-server.mjs"; import { importExternalAgentSession, @@ -1558,7 +1561,7 @@ function enqueueBackgroundReview(cwd, job, request) { if (child.pid != null) { let pidIdentity = null; try { - pidIdentity = getProcessIdentity(child.pid); + pidIdentity = getSpawnedProcessIdentity(child.pid); } catch {} patchJob(job.workspaceRoot, job.id, { pid: child.pid, @@ -1817,7 +1820,7 @@ function enqueueDetachedTask(cwd, job, request, options = {}) { if (child.pid != null) { let pidIdentity = null; try { - pidIdentity = getProcessIdentity(child.pid); + pidIdentity = getSpawnedProcessIdentity(child.pid); } catch {} patchJob(job.workspaceRoot, job.id, { pid: child.pid, @@ -2664,7 +2667,6 @@ async function handleCancel(argv) { const cwd = resolveCommandCwd(options); const reference = positionals[0] ?? ""; const { workspaceRoot, job } = resolveCancelableJob(cwd, reference); - const existing = readStoredJob(workspaceRoot, job.id) ?? {}; // CAS: running/queued → cancelling const transition = transitionJob( @@ -2674,17 +2676,18 @@ async function handleCancel(argv) { "cancelling" ); if (!transition.transitioned) { + const currentStatus = transition.job?.status ?? job.status; outputCommandResult( - { jobId: job.id, status: job.status }, - `Job ${job.id} is already ${job.status}.\n`, + { jobId: job.id, status: currentStatus }, + `Job ${job.id} is already ${currentStatus}.\n`, options.json ); return; } // Cancel via process group kill with PID identity verification - const pid = existing.pid ?? job.pid; - const pidIdentity = existing.pidIdentity ?? null; + const pid = transition.job.pid ?? null; + const pidIdentity = transition.job.pidIdentity ?? null; /** @type {{ cancelled: boolean, note?: string }} */ let cancelResult = { cancelled: true, note: "No PID to cancel" }; const jobLogFile = resolveJobLogFile(workspaceRoot, job.id); @@ -2737,7 +2740,7 @@ async function handleCancel(argv) { appendLogLine(jobLogFile, `Cancel result: ${effectiveStatus}`); cleanupOldJobs(workspaceRoot); - const nextJob = { ...job, status: effectiveStatus, phase: effectiveStatus }; + const nextJob = finalTransition.job; const payload = { jobId: job.id, status: effectiveStatus, diff --git a/scripts/lib/claude-cli.mjs b/scripts/lib/claude-cli.mjs index 4c34304..67ea9d9 100644 --- a/scripts/lib/claude-cli.mjs +++ b/scripts/lib/claude-cli.mjs @@ -14,7 +14,13 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { normalizePathSlashes, resolvePluginRuntimeRoot } from "./codex-paths.mjs"; -import { getProcessIdentity, validateProcessIdentity } from "./process.mjs"; +import { + getProcessIdentity, + getSpawnedProcessIdentity, + isProcessAlive, + isProcessGroupAlive, + terminateProcessTreeIfIdentityMatches, +} from "./process.mjs"; const CLAUDE_BIN = "claude"; export const MAX_STREAM_PARSER_UNKNOWN_EVENTS = 50; @@ -35,6 +41,79 @@ const MODEL_FIELD_NAMES = [ "selectedModel", ]; +function resolveClaudeNpmShim(shimPath) { + let source; + try { + source = fs.readFileSync(shimPath, "utf8"); + } catch { + return null; + } + + const match = source.match( + /"%(?:dp0%|~dp0)[\\/]([^"\r\n]*@anthropic-ai[\\/]claude-code[\\/](?:cli\.js|bin[\\/]claude\.exe))"/iu + ); + if (!match) { + return null; + } + + const parts = match[1].split(/[\\/]/u); + const target = path.resolve(path.dirname(shimPath), ...parts); + try { + if (!fs.statSync(target).isFile()) { + return null; + } + } catch { + return null; + } + + return target.toLowerCase().endsWith(".exe") + ? { executable: target, prefixArgs: [] } + : { executable: process.execPath, prefixArgs: [target] }; +} + +export function resolveClaudeCommand(platform = process.platform, env = process.env) { + if (platform !== "win32") { + return { executable: CLAUDE_BIN, prefixArgs: [] }; + } + + const searchPath = env.PATH ?? env.Path ?? ""; + let firstShimError = null; + for (const entry of searchPath.split(";")) { + const directory = entry.trim().replace(/^"(.*)"$/u, "$1"); + if (!directory) { + continue; + } + + const nativeExecutable = path.join(directory, `${CLAUDE_BIN}.exe`); + try { + if (fs.statSync(nativeExecutable).isFile()) { + return { executable: nativeExecutable, prefixArgs: [] }; + } + } catch { + // Keep searching PATH. + } + + const npmShim = path.join(directory, `${CLAUDE_BIN}.cmd`); + const resolvedShim = resolveClaudeNpmShim(npmShim); + if (resolvedShim) { + return resolvedShim; + } + try { + if (fs.statSync(npmShim).isFile()) { + firstShimError ??= { + executable: null, + prefixArgs: [], + error: `Found Claude command shim at ${npmShim}, but its target could not be resolved safely`, + }; + } + } catch { + // Keep searching PATH. + } + } + + return firstShimError ?? { executable: CLAUDE_BIN, prefixArgs: [] }; +} + function pushBoundedTail(list, value, maxEntries) { list.push(value); if (list.length > maxEntries) { @@ -427,10 +506,15 @@ export function areModelIdsEquivalent(left, right) { export function getClaudeAvailability(cwd) { try { - const result = spawnSync(CLAUDE_BIN, ["--version"], { + const command = resolveClaudeCommand(); + if (command.error) { + return { available: false, detail: command.error }; + } + const result = spawnSync(command.executable, [...command.prefixArgs, "--version"], { cwd, encoding: "utf8", timeout: 10_000, + windowsHide: true, }); if (result.status !== 0) throw new Error("non-zero exit"); return { available: true, detail: (result.stdout ?? "").trim() }; @@ -444,10 +528,15 @@ export function getClaudeAuthStatus(cwd) { return { available: true, loggedIn: true, detail: "API key configured" }; } try { - const result = spawnSync(CLAUDE_BIN, ["auth", "status"], { + const command = resolveClaudeCommand(); + if (command.error) { + return { available: false, loggedIn: false, detail: command.error }; + } + const result = spawnSync(command.executable, [...command.prefixArgs, "auth", "status"], { cwd, encoding: "utf8", timeout: 10_000, + windowsHide: true, }); if (result.status !== 0) throw new Error("not authenticated"); return { available: true, loggedIn: true, detail: "authenticated" }; @@ -1192,11 +1281,33 @@ export async function runClaudeTurn(cwd, prompt, options = {}) { ...options, }); const requestedModel = options.model ? resolveModel(options.model) : null; + const command = resolveClaudeCommand(); + if (command.error) { + return { + status: "failed", + exitCode: -1, + sessionId: null, + finalMessage: "", + structuredOutput: null, + toolUses: [], + touchedFiles: [], + requestedModel, + finalModel: null, + contextWindow: null, + modelEvents: [], + failure: classifyClaudeFailure({ stderr: command.error }), + stderr: command.error, + pid: null, + pidIdentity: null, + }; + } + const executableArgs = [...command.prefixArgs, ...args]; return new Promise((resolve, reject) => { - const proc = spawn(CLAUDE_BIN, args, { + const proc = spawn(command.executable, executableArgs, { cwd, detached: true, // new process group for safe cancellation + windowsHide: true, stdio: ["ignore", "pipe", "pipe"], // stdin ignored — prompt is passed as CLI arg env: { ...process.env, @@ -1209,7 +1320,7 @@ export async function runClaudeTurn(cwd, prompt, options = {}) { let pidIdentity = null; try { - pidIdentity = getProcessIdentity(proc.pid); + pidIdentity = getSpawnedProcessIdentity(proc.pid); } catch { // Best-effort — may fail on some platforms } @@ -1378,41 +1489,119 @@ export async function runClaudeAdversarialReview( * Cancel a running Claude Code process. * Uses process group kill with PID identity verification. */ -export async function cancelClaudeProcess(pid, pidIdentity) { +export async function cancelClaudeProcess(pid, pidIdentity, options = {}) { + const platform = options.platform ?? process.platform; + + if (platform === "win32") { + try { + const terminate = + options.terminateProcessTreeIfIdentityMatchesImpl ?? + terminateProcessTreeIfIdentityMatches; + const result = terminate(pid, pidIdentity, { platform }); + if (result.delivered) { + return { cancelled: true }; + } + if (result.reason === "process-missing") { + return { cancelled: true, note: "Process already exited" }; + } + if (result.reason === "identity-mismatch") { + return { + cancelled: true, + note: "Process already exited (PID recycled)", + }; + } + return { + cancelled: false, + note: "Refused to terminate process tree without a matching PID identity", + }; + } catch (error) { + return { + cancelled: false, + note: `Failed to terminate process tree: ${error.message}`, + }; + } + } + + if (!pidIdentity) { + return { + cancelled: false, + note: "Refused to terminate process group without a matching PID identity", + }; + } + + const getIdentity = options.getProcessIdentityImpl ?? getProcessIdentity; + const isAlive = options.isProcessAliveImpl ?? isProcessAlive; + const isGroupAlive = + options.isProcessGroupAliveImpl ?? isProcessGroupAlive; + // Verify PID identity to prevent killing recycled PIDs - if (pidIdentity && !validateProcessIdentity(pid, pidIdentity)) { + let actualIdentity; + try { + actualIdentity = getIdentity(pid); + } catch (error) { + if (isAlive(pid) || isGroupAlive(pid)) { + const detail = error instanceof Error ? error.message : String(error); + return { + cancelled: false, + note: `Unable to verify process identity: ${detail}`, + }; + } + return { cancelled: true, note: "Process already exited" }; + } + if (actualIdentity !== pidIdentity) { return { cancelled: true, note: "Process already exited (PID recycled)", }; } + const kill = options.killImpl ?? process.kill.bind(process); + const wait = options.waitForProcessGroupImpl ?? waitForProcessGroup; + // SIGTERM to entire process group try { - process.kill(-pid, "SIGTERM"); - } catch { - return { cancelled: true, note: "Process not found" }; + kill(-pid, "SIGTERM"); + } catch (error) { + return error?.code === "ESRCH" + ? { cancelled: true, note: "Process not found" } + : { cancelled: false, note: `Failed to send SIGTERM: ${error.message}` }; } // Wait for process group to die - const dead = await waitForProcessGroup(pid, 5000); + const dead = await wait(pid, 5000); if (dead) { return { cancelled: true }; } - // Escalate to SIGKILL - if (pidIdentity && !validateProcessIdentity(pid, pidIdentity)) { + // The leader can exit while children remain in the original process group. + if (!isGroupAlive(pid)) { return { cancelled: true, note: "Process exited during SIGTERM wait", }; } + if (isAlive(pid)) { + try { + if (getIdentity(pid) !== pidIdentity) { + return { + cancelled: true, + note: "Process exited during SIGTERM wait (PID recycled)", + }; + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { + cancelled: false, + note: `Unable to re-verify process identity before SIGKILL: ${detail}`, + }; + } + } try { - process.kill(-pid, "SIGKILL"); + kill(-pid, "SIGKILL"); } catch {} - const killedDead = await waitForProcessGroup(pid, 3000); + const killedDead = await wait(pid, 3000); if (killedDead) { return { cancelled: true }; } @@ -1423,15 +1612,6 @@ export async function cancelClaudeProcess(pid, pidIdentity) { }; } -function isProcessGroupAlive(pgid) { - try { - process.kill(-pgid, 0); - return true; - } catch { - return false; - } -} - async function waitForProcessGroup(pgid, timeoutMs) { const start = Date.now(); while (Date.now() - start < timeoutMs) { diff --git a/scripts/lib/process.mjs b/scripts/lib/process.mjs index ba532df..d5edc8d 100644 --- a/scripts/lib/process.mjs +++ b/scripts/lib/process.mjs @@ -15,13 +15,15 @@ export function runCommand(command, args = [], options = {}) { input: options.input, maxBuffer: options.maxBuffer, stdio: options.stdio ?? "pipe", + timeout: options.timeout, + windowsHide: options.windowsHide ?? true, shell: false }); return { command, args, - status: result.status ?? 0, + status: result.status ?? null, signal: result.signal ?? null, stdout: result.stdout ?? "", stderr: result.stderr ?? "", @@ -31,11 +33,22 @@ export function runCommand(command, args = [], options = {}) { export function runCommandChecked(command, args = [], options = {}) { const result = runCommand(command, args, options); + if (isCommandTimeout(result, options.timeout)) { + const error = result.error ?? new Error(formatCommandFailure(result)); + if (!/** @type {NodeJS.ErrnoException} */ (error).code) { + /** @type {NodeJS.ErrnoException} */ (error).code = "ETIMEDOUT"; + } + throw error; + } if (result.error) { throw result.error; } if (result.status !== 0) { - throw new Error(formatCommandFailure(result)); + const error = /** @type {Error & { status?: number }} */ ( + new Error(formatCommandFailure(result)) + ); + error.status = result.status; + throw error; } return result; } @@ -59,6 +72,39 @@ function looksLikeMissingProcessMessage(text) { return /not found|no running instance|cannot find|does not exist|no such process/i.test(text); } +function isCommandTimeout(result, timeout) { + return ( + result.error?.code === "ETIMEDOUT" || + (Number.isFinite(timeout) && timeout > 0 && result.signal === "SIGTERM") + ); +} + +const WINDOWS_PROCESS_MISSING_EXIT = 241; +const WINDOWS_PROCESS_IDENTITY_MISMATCH_EXIT = 242; +const WINDOWS_PROCESS_TERMINATION_FAILED_EXIT = 243; +const WINDOWS_PROCESS_IDENTITY_UNAVAILABLE_EXIT = 244; +const WINDOWS_PROCESS_EXITED_DURING_TERMINATION_EXIT = 245; +const WINDOWS_PROCESS_COMMAND_TIMEOUT_MS = 10_000; +const WINDOWS_IDENTITY_CIRCUIT_RETRY_MS = 60_000; +const currentProcessIdentityCache = new Map(); +const windowsIdentityUnavailableError = Object.assign( + new Error("Windows process identity service is unavailable"), + { code: "ETIMEDOUT" } +); +let windowsIdentityUnavailableAt = 0; + +function isWindowsIdentityCircuitOpen() { + return ( + windowsIdentityUnavailableAt > 0 && + Date.now() - windowsIdentityUnavailableAt < + WINDOWS_IDENTITY_CIRCUIT_RETRY_MS + ); +} + +function tripWindowsIdentityCircuit() { + windowsIdentityUnavailableAt = Date.now(); +} + export function terminateProcessTree(pid, options = {}) { if (!Number.isFinite(pid)) { return { attempted: false, delivered: false, method: null }; @@ -112,16 +158,214 @@ export function terminateProcessTree(pid, options = {}) { return { attempted: true, delivered: true, method: "process" }; } catch (innerError) { if (innerError?.code === "ESRCH") { - return { attempted: true, delivered: false, method: "process" }; + return { + attempted: true, + delivered: false, + method: "process", + reason: "process-missing", + }; } throw innerError; } } - return { attempted: true, delivered: false, method: "process-group" }; + return { + attempted: true, + delivered: false, + method: "process-group", + reason: "process-missing", + }; } } +/** + * Terminate a stored process only while its stable identity still matches. + * Windows performs the check and taskkill dispatch inside one PowerShell turn. + */ +export function terminateProcessTreeIfIdentityMatches( + pid, + expectedIdentity, + options = {} +) { + if ( + !Number.isInteger(pid) || + pid <= 0 || + typeof expectedIdentity !== "string" || + expectedIdentity.length === 0 + ) { + return { + attempted: false, + delivered: false, + method: null, + reason: "identity-unavailable", + }; + } + + const platform = options.platform ?? process.platform; + if (platform !== "win32") { + const getIdentity = options.getProcessIdentityImpl ?? getProcessIdentity; + const isAlive = options.isProcessAliveImpl ?? isProcessAlive; + let actualIdentity; + try { + actualIdentity = getIdentity(pid); + } catch { + return { + attempted: false, + delivered: false, + method: null, + reason: isAlive(pid) ? "identity-unavailable" : "process-missing", + }; + } + if (actualIdentity !== expectedIdentity) { + return { + attempted: false, + delivered: false, + method: null, + reason: "identity-mismatch", + }; + } + const terminate = options.terminateProcessTreeImpl ?? terminateProcessTree; + return terminate(pid, options); + } + + if (!/^\d+$/u.test(expectedIdentity)) { + return { + attempted: false, + delivered: false, + method: null, + reason: "identity-unavailable", + }; + } + + const runCommandImpl = options.runCommandImpl ?? runCommand; + const usesDefaultCommand = options.runCommandImpl === undefined; + const script = [ + `try { $target = Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' -ErrorAction Stop } catch { exit ${WINDOWS_PROCESS_IDENTITY_UNAVAILABLE_EXIT} }`, + `if ($null -eq $target) { exit ${WINDOWS_PROCESS_MISSING_EXIT} }`, + `$creationTime = [DateTime]$target.CreationDate`, + `if ($creationTime.ToFileTimeUtc().ToString() -ne '${expectedIdentity}') { exit ${WINDOWS_PROCESS_IDENTITY_MISMATCH_EXIT} }`, + `& taskkill.exe /PID ${pid} /T /F | Out-Null`, + `if ($LASTEXITCODE -ne 0) { try { $remaining = Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' -ErrorAction Stop } catch { exit ${WINDOWS_PROCESS_IDENTITY_UNAVAILABLE_EXIT} }; if ($null -eq $remaining) { exit ${WINDOWS_PROCESS_EXITED_DURING_TERMINATION_EXIT} }; exit ${WINDOWS_PROCESS_TERMINATION_FAILED_EXIT} }`, + ].join("; "); + const timeout = options.timeout ?? WINDOWS_PROCESS_COMMAND_TIMEOUT_MS; + const result = runCommandImpl( + "powershell.exe", + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], + { + cwd: options.cwd, + env: options.env, + timeout, + windowsHide: true, + } + ); + + if (isCommandTimeout(result, timeout)) { + if (usesDefaultCommand) { + tripWindowsIdentityCircuit(); + } + return { + attempted: true, + delivered: false, + method: "identity-checked-taskkill", + reason: "identity-unavailable", + result, + }; + } + if (!result.error && result.status === 0) { + if (usesDefaultCommand) { + windowsIdentityUnavailableAt = 0; + } + return { + attempted: true, + delivered: true, + method: "identity-checked-taskkill", + result, + }; + } + if (!result.error && result.status === WINDOWS_PROCESS_MISSING_EXIT) { + if (result.stderr.trim()) { + if (usesDefaultCommand) { + tripWindowsIdentityCircuit(); + } + return { + attempted: true, + delivered: false, + method: "identity-checked-taskkill", + reason: "identity-unavailable", + result, + }; + } + if (usesDefaultCommand) { + windowsIdentityUnavailableAt = 0; + } + return { + attempted: true, + delivered: false, + method: "identity-checked-taskkill", + reason: "process-missing", + result, + }; + } + if (!result.error && result.status === WINDOWS_PROCESS_IDENTITY_MISMATCH_EXIT) { + if (usesDefaultCommand) { + windowsIdentityUnavailableAt = 0; + } + return { + attempted: true, + delivered: false, + method: "identity-checked-taskkill", + reason: "identity-mismatch", + result, + }; + } + if ( + !result.error && + result.status === WINDOWS_PROCESS_EXITED_DURING_TERMINATION_EXIT + ) { + if (usesDefaultCommand) { + windowsIdentityUnavailableAt = 0; + } + return { + attempted: true, + delivered: false, + method: "identity-checked-taskkill", + reason: "process-missing", + result, + }; + } + if (!result.error && result.status === WINDOWS_PROCESS_IDENTITY_UNAVAILABLE_EXIT) { + if (usesDefaultCommand) { + tripWindowsIdentityCircuit(); + } + return { + attempted: true, + delivered: false, + method: "identity-checked-taskkill", + reason: "identity-unavailable", + result, + }; + } + if (result.error) { + if (usesDefaultCommand) { + tripWindowsIdentityCircuit(); + } + throw result.error; + } + if ( + usesDefaultCommand && + result.status === WINDOWS_PROCESS_TERMINATION_FAILED_EXIT + ) { + windowsIdentityUnavailableAt = 0; + } + if ( + usesDefaultCommand && + result.status !== WINDOWS_PROCESS_TERMINATION_FAILED_EXIT + ) { + tripWindowsIdentityCircuit(); + } + throw new Error(formatCommandFailure(result)); +} + export function formatCommandFailure(result) { const parts = [`${result.command} ${result.args.join(" ")}`.trim()]; if (result.signal) { @@ -143,31 +387,114 @@ export function formatCommandFailure(result) { * Get stable process identity for PID reuse detection. * Returns a string that is immutable for the process lifetime. */ -export function getProcessIdentity(pid) { - if (process.platform === 'darwin') { - const row = runCommandChecked('ps', ['-o', 'lstart=,comm=', '-p', String(pid)]); - return row.stdout.trim(); +export function getProcessIdentity(pid, options = {}) { + if (!Number.isInteger(pid) || pid <= 0) { + throw new TypeError("PID must be a positive integer"); + } + + const platform = options.platform ?? process.platform; + const runCommandCheckedImpl = options.runCommandCheckedImpl ?? runCommandChecked; + const readFileSyncImpl = options.readFileSyncImpl ?? readFileSync; + const usesDefaultSources = + options.runCommandCheckedImpl === undefined && + options.readFileSyncImpl === undefined; + const cacheKey = + usesDefaultSources && pid === process.pid ? `${platform}:${pid}` : null; + if (cacheKey && currentProcessIdentityCache.has(cacheKey)) { + return currentProcessIdentityCache.get(cacheKey); + } + + let identity; + if (platform === "win32") { + if ( + usesDefaultSources && + !options.bypassWindowsIdentityCircuit && + isWindowsIdentityCircuitOpen() + ) { + throw windowsIdentityUnavailableError; + } + let row; + try { + row = runCommandCheckedImpl( + "powershell.exe", + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + `try { $process = Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' -ErrorAction Stop } catch { exit ${WINDOWS_PROCESS_IDENTITY_UNAVAILABLE_EXIT} }; if ($null -eq $process) { exit 3 }; $creationTime = [DateTime]$process.CreationDate; $creationTime.ToFileTimeUtc()`, + ], + { + timeout: options.timeout ?? WINDOWS_PROCESS_COMMAND_TIMEOUT_MS, + windowsHide: true, + } + ); + } catch (error) { + const code = /** @type {NodeJS.ErrnoException} */ (error).code; + const status = /** @type {Error & { status?: number }} */ (error).status; + if ( + usesDefaultSources && + (status === WINDOWS_PROCESS_IDENTITY_UNAVAILABLE_EXIT || + ["ETIMEDOUT", "ENOENT", "EAGAIN", "ENOMEM"].includes(code ?? "")) + ) { + tripWindowsIdentityCircuit(); + } + throw error; + } + if (usesDefaultSources) { + windowsIdentityUnavailableAt = 0; + } + identity = row.stdout.trim(); + if (!/^\d+$/u.test(identity)) { + throw new Error("Windows process creation time was unavailable"); + } + } else if (platform === "darwin") { + const row = runCommandCheckedImpl("ps", ["-o", "lstart=,comm=", "-p", String(pid)]); + identity = row.stdout.trim(); } else { - const stat = readFileSync(`/proc/${pid}/stat`, 'utf8'); - const closeParen = stat.lastIndexOf(')'); - const fields = stat.slice(closeParen + 2).split(' '); - return fields[19]; // starttime field + const stat = readFileSyncImpl(`/proc/${pid}/stat`, "utf8"); + const closeParen = stat.lastIndexOf(")"); + const fields = stat.slice(closeParen + 2).split(" "); + identity = fields[19]; // starttime field } + + if (cacheKey) { + currentProcessIdentityCache.set(cacheKey, identity); + } + return identity; } -export function validateProcessIdentity(pid, expectedIdentity) { +export function getSpawnedProcessIdentity(pid, options = {}) { + return getProcessIdentity(pid, { + ...options, + bypassWindowsIdentityCircuit: true, + }); +} + +export function validateProcessIdentity(pid, expectedIdentity, options = {}) { try { - return getProcessIdentity(pid) === expectedIdentity; + return getProcessIdentity(pid, options) === expectedIdentity; } catch { return false; } } -export function isProcessAlive(pid) { +export function isProcessAlive(pid, options = {}) { + const killImpl = options.killImpl ?? process.kill.bind(process); try { - process.kill(pid, 0); + killImpl(pid, 0); return true; - } catch { - return false; + } catch (error) { + return /** @type {NodeJS.ErrnoException} */ (error).code === "EPERM"; + } +} + +export function isProcessGroupAlive(pgid, options = {}) { + const killImpl = options.killImpl ?? process.kill.bind(process); + try { + killImpl(-pgid, 0); + return true; + } catch (error) { + return /** @type {NodeJS.ErrnoException} */ (error).code === "EPERM"; } } diff --git a/scripts/lib/render.mjs b/scripts/lib/render.mjs index 294b2b4..344d40f 100644 --- a/scripts/lib/render.mjs +++ b/scripts/lib/render.mjs @@ -9,6 +9,7 @@ */ import path from "node:path"; +import process from "node:process"; import { parseStructuredOutput } from "./structured-output.mjs"; function severityRank(severity) { @@ -411,7 +412,17 @@ export function renderStatusReport(report) { return renderStatusTable(rows); } -export function renderJobStatusReport(job) { +function resolveManualCleanupPid(job) { + return job.pgid ?? job.pid; +} + +function formatManualCleanupCommand(pid, platform = process.platform) { + return platform === "win32" + ? `taskkill /PID ${pid} /T /F` + : `kill -9 -${pid}`; +} + +export function renderJobStatusReport(job, platform = process.platform) { const lines = ["# Claude Code Job Status", "", "| Field | Value |", "| --- | --- |"]; pushKeyValueTableRow(lines, "Job", `\`${job.id}\``, { raw: true }); pushKeyValueTableRow(lines, "Kind", job.kindLabel ?? job.kind ?? ""); @@ -442,11 +453,32 @@ export function renderJobStatusReport(job) { } else { pushKeyValueTableRow(lines, "Result", `\`${formatClaudeSkillCommand("result", job.id)}\``, { raw: true }); } - if (job.status === "cancel_failed") { + const cleanupPid = resolveManualCleanupPid(job); + const needsWindowsPidVerification = + platform === "win32" && + cleanupPid && + (job.status === "failed" || job.status === "cancel_failed"); + if (needsWindowsPidVerification) { + pushKeyValueTableRow( + lines, + "Verify process", + `\`powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter 'ProcessId = ${cleanupPid}'"\``, + { raw: true } + ); + if (job.pidIdentity) { + pushKeyValueTableRow(lines, "Recorded process identity", job.pidIdentity); + } + pushKeyValueTableRow( + lines, + "Manual cleanup (after verification)", + `\`${formatManualCleanupCommand(cleanupPid, platform)}\``, + { raw: true } + ); + } else if (job.status === "cancel_failed" && cleanupPid) { pushKeyValueTableRow( lines, "Manual cleanup", - `\`kill -9 -${job.pgid ?? job.pid}\``, + `\`${formatManualCleanupCommand(cleanupPid, platform)}\``, { raw: true } ); } @@ -495,11 +527,27 @@ export function renderStoredJobResult(job, storedJob) { return `${lines.join("\n").trimEnd()}\n`; } -export function renderCancelReport(job) { +export function renderCancelReport(job, platform = process.platform) { const lines = ["# Claude Code Cancel", "", `Cancelled ${job.id}.`, ""]; if (job.title) lines.push(`- Title: ${job.title}`); if (job.summary) lines.push(`- Summary: ${job.summary}`); - if (job.status === "cancel_failed") lines.push(`- Warning: Process group may still be alive. Manual cleanup: kill -9 -${job.pgid ?? job.pid}`); + if (job.status === "cancel_failed") { + const cleanupPid = resolveManualCleanupPid(job); + const cleanup = formatManualCleanupCommand(cleanupPid, platform); + if (!cleanupPid) { + lines.push("- Warning: Process may still be alive, but no cleanup PID was recorded."); + } else if (platform === "win32") { + lines.push( + `- Verify process before cleanup: \`powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter 'ProcessId = ${cleanupPid}'"\`` + ); + if (job.pidIdentity) { + lines.push(`- Recorded process identity: ${job.pidIdentity}`); + } + lines.push(`- Manual cleanup (after verification): ${cleanup}`); + } else { + lines.push(`- Warning: Process group may still be alive. Manual cleanup: ${cleanup}`); + } + } lines.push("- Check `$cc:status` for the updated queue."); return `${lines.join("\n").trimEnd()}\n`; } diff --git a/scripts/lib/state.mjs b/scripts/lib/state.mjs index e2a0766..8935927 100644 --- a/scripts/lib/state.mjs +++ b/scripts/lib/state.mjs @@ -16,6 +16,7 @@ import { createHash, randomBytes } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import process from "node:process"; import { LEGACY_PLUGIN_DATA_NAMESPACES, @@ -24,7 +25,10 @@ import { resolvePluginsDataRoot, } from "./codex-paths.mjs"; import { resolveWorkspaceRoot } from "./workspace.mjs"; -import { isProcessAlive, validateProcessIdentity, getProcessIdentity } from "./process.mjs"; +import { + getProcessIdentity, + isProcessAlive, +} from "./process.mjs"; const STATE_VERSION = 1; let ensuredPluginDataRoot = null; @@ -37,8 +41,26 @@ const TURN_BASELINE_FILE_PREFIX = "turn-baseline"; const MAX_TERMINAL_JOBS_PER_SESSION = 100; export const MAX_STOP_REVIEW_HISTORY_ENTRIES = 200; const REAP_GRACE_MS = 2_000; +const WINDOWS_IDENTITY_RECHECK_MS = 5 * 60 * 1000; +const WINDOWS_IDENTITY_UNVERIFIABLE_MAX_MS = + WINDOWS_IDENTITY_RECHECK_MS * 3; +const WINDOWS_IDENTITY_CHECK_SUFFIX = ".identity-check"; +const WINDOWS_IDENTITY_PROBE_SUFFIX = ".identity-probe"; +const WINDOWS_IDENTITY_UNAVAILABLE_SUFFIX = ".identity-unavailable"; +const WINDOWS_REAPER_IDENTITY_TIMEOUT_MS = 2_000; const QUEUED_WITHOUT_PID_REAP_GRACE_MS = 30_000; +const INVALID_LOCK_STALE_MS = 15_000; +const LIVE_LOCK_LEASE_MS = 30_000; +const LOCK_HARD_STALE_MS = LIVE_LOCK_LEASE_MS * 4; const RESERVED_JOB_FILE_MAX_AGE_MS = 60 * 60 * 1000; +const HARD_LINK_UNSUPPORTED_CODES = new Set([ + "EPERM", + "ENOSYS", + "EOPNOTSUPP", + "ENOTSUP", + "EXDEV", +]); +let hardLinksUnsupported = false; export const JOB_RESERVATION_SUFFIX = ".reserve"; export const ACTIVE_JOB_STATUSES = new Set(["queued", "running", "cancelling"]); const NO_SESSION_RETENTION_BUCKET = "__no-session__"; @@ -460,7 +482,12 @@ function isWithinReapGracePeriod(job, now = Date.now(), graceMs = REAP_GRACE_MS) * Detect zombie jobs whose PID has died and auto-transition them to "failed". * Called from listJobs() so every job-reading path benefits automatically. */ -export function reapStaleJobs(cwd, jobs) { +export function reapStaleJobs(cwd, jobs, options = {}) { + const platform = options.platform ?? process.platform; + const isProcessAliveImpl = options.isProcessAliveImpl ?? isProcessAlive; + const getProcessIdentityImpl = + options.getProcessIdentityImpl ?? getProcessIdentity; + return jobs.map((job) => { if (isWithinReapGracePeriod(job)) return job; if (!REAPABLE_STATUSES.has(job.status)) return job; @@ -488,24 +515,186 @@ export function reapStaleJobs(cwd, jobs) { } } - // Use pidIdentity if available (PID-reuse safe), otherwise fall back to isProcessAlive - const alive = job.pidIdentity - ? validateProcessIdentity(job.pid, job.pidIdentity) - : isProcessAlive(job.pid); - if (alive) return job; + const now = Date.now(); + const processExists = isProcessAliveImpl(job.pid); + let withinWindowsIdentityLease = false; + let windowsIdentityCheckFile = null; + let windowsIdentityProbeFile = null; + let windowsIdentityUnavailableFile = null; + let windowsIdentityUnavailableExists = false; + let windowsIdentityUnavailableAgeMs = null; + if (platform === "win32") { + windowsIdentityCheckFile = + resolveJobFile(cwd, job.id) + WINDOWS_IDENTITY_CHECK_SUFFIX; + windowsIdentityProbeFile = + resolveJobFile(cwd, job.id) + WINDOWS_IDENTITY_PROBE_SUFFIX; + windowsIdentityUnavailableFile = + resolveJobFile(cwd, job.id) + WINDOWS_IDENTITY_UNAVAILABLE_SUFFIX; + let windowsIdentityCheckAgeMs = null; + try { + windowsIdentityCheckAgeMs = + now - fs.statSync(windowsIdentityCheckFile).mtimeMs; + } catch {} + let windowsIdentityProbeAgeMs = windowsIdentityCheckAgeMs; + try { + windowsIdentityProbeAgeMs = + now - fs.statSync(windowsIdentityProbeFile).mtimeMs; + } catch {} + try { + windowsIdentityUnavailableAgeMs = + now - fs.statSync(windowsIdentityUnavailableFile).mtimeMs; + windowsIdentityUnavailableExists = true; + } catch {} + if (Number.isFinite(windowsIdentityProbeAgeMs)) { + withinWindowsIdentityLease = + windowsIdentityProbeAgeMs < WINDOWS_IDENTITY_RECHECK_MS; + } else { + withinWindowsIdentityLease = isWithinReapGracePeriod( + job, + now, + WINDOWS_IDENTITY_RECHECK_MS + ); + } + } + const needsIdentityCheck = + processExists && + job.pidIdentity && + (platform !== "win32" || !withinWindowsIdentityLease); + let identityMatches = true; + let identityUnavailable = false; + if (needsIdentityCheck) { + try { + identityMatches = + getProcessIdentityImpl( + job.pid, + platform === "win32" + ? { timeout: WINDOWS_REAPER_IDENTITY_TIMEOUT_MS } + : undefined + ) === job.pidIdentity; + } catch { + identityMatches = false; + identityUnavailable = true; + if ( + windowsIdentityUnavailableFile && + !windowsIdentityUnavailableExists + ) { + let inheritedLegacyMarker = false; + try { + if ( + windowsIdentityCheckFile && + fs.readFileSync(windowsIdentityCheckFile, "utf8") === + "unavailable\n" + ) { + windowsIdentityUnavailableAgeMs = + now - fs.statSync(windowsIdentityCheckFile).mtimeMs; + inheritedLegacyMarker = true; + } + } catch {} + try { + if (!inheritedLegacyMarker) { + fs.writeFileSync( + windowsIdentityUnavailableFile, + "unavailable\n", + { + mode: 0o600, + flag: "wx", + } + ); + windowsIdentityUnavailableExists = true; + windowsIdentityUnavailableAgeMs = 0; + } + } catch { + try { + windowsIdentityUnavailableAgeMs = + now - fs.statSync(windowsIdentityUnavailableFile).mtimeMs; + windowsIdentityUnavailableExists = true; + } catch {} + } + } + if (windowsIdentityProbeFile) { + try { + fs.writeFileSync(windowsIdentityProbeFile, "unavailable\n", { + mode: 0o600, + }); + } catch {} + } + } + } + // POSIX stays fail-open: a restricted `ps` is indistinguishable from a + // transient identity lookup failure and is not evidence of PID reuse. + const identityUnavailableTooLong = + platform === "win32" && + identityUnavailable && + Number.isFinite(windowsIdentityUnavailableAgeMs) && + windowsIdentityUnavailableAgeMs >= + WINDOWS_IDENTITY_UNVERIFIABLE_MAX_MS; + const alive = + processExists && + (!needsIdentityCheck || + identityMatches || + (identityUnavailable && !identityUnavailableTooLong)); + if (alive) { + if ( + windowsIdentityCheckFile && + needsIdentityCheck && + identityMatches && + !identityUnavailable + ) { + try { + fs.writeFileSync( + windowsIdentityCheckFile, + "verified\n", + { mode: 0o600 } + ); + } catch {} + if (windowsIdentityProbeFile) { + try { + fs.writeFileSync(windowsIdentityProbeFile, "verified\n", { + mode: 0o600, + }); + } catch {} + } + if (windowsIdentityUnavailableFile) { + try { + fs.unlinkSync(windowsIdentityUnavailableFile); + } catch {} + } + } + return job; + } // Process is dead — transition via CAS try { - const nextStatus = job.status === "cancelling" ? "cancelled" : "failed"; - const transitioned = transitionJob(cwd, job.id, [job.status], nextStatus, { - errorMessage: job.status === "cancelling" - ? "Cancelled by user. Auto-reaped after process exit." - : `Process ${job.pid} died without completing. Auto-reaped.`, - completedAt: nowIso(), - pid: null, - pidIdentity: null, - phase: nextStatus === "cancelled" ? "cancelled" : "failed", - }); + const nextStatus = identityUnavailableTooLong + ? (job.status === "cancelling" ? "cancel_failed" : "failed") + : (job.status === "cancelling" ? "cancelled" : "failed"); + const terminalData = identityUnavailableTooLong + ? { + errorMessage: + `Process ${job.pid} identity remained unverifiable beyond the bounded Windows recheck window. Manual cleanup may be required.`, + completedAt: nowIso(), + phase: nextStatus, + reapedUnverifiable: true, + ...(nextStatus === "cancel_failed" + ? { pgid: job.pgid ?? job.pid } + : {}), + } + : { + errorMessage: job.status === "cancelling" + ? "Cancelled by user. Auto-reaped after process exit." + : `Process ${job.pid} died without completing. Auto-reaped.`, + completedAt: nowIso(), + pid: null, + pidIdentity: null, + phase: nextStatus === "cancelled" ? "cancelled" : "failed", + }; + const transitioned = transitionJob( + cwd, + job.id, + [job.status], + nextStatus, + terminalData + ); if (transitioned.transitioned) { return readJobFile(cwd, job.id) ?? job; } @@ -564,67 +753,142 @@ function sleepSync(ms) { } } +function unlinkLockIfUnchanged(lockFile, expectedSource) { + try { + if (fs.readFileSync(lockFile, "utf8") === expectedSource) { + fs.unlinkSync(lockFile); + } + } catch {} +} + function recoverStaleLock(lockFile) { - if (!fs.existsSync(lockFile)) { + let lockSource; + let lockFileAgeMs; + try { + lockSource = fs.readFileSync(lockFile, "utf8"); + lockFileAgeMs = Date.now() - fs.statSync(lockFile).mtimeMs; + } catch { return; } + + if (lockFileAgeMs >= LOCK_HARD_STALE_MS) { + unlinkLockIfUnchanged(lockFile, lockSource); + return; + } + + let lockData; try { - const lockData = JSON.parse(fs.readFileSync(lockFile, "utf8")); - const ownerMatch = validateProcessIdentity(lockData.pid, lockData.identity); - if (!ownerMatch) { - fs.unlinkSync(lockFile); - } + lockData = JSON.parse(lockSource); } catch { - try { - fs.unlinkSync(lockFile); - } catch {} + if (lockFileAgeMs >= INVALID_LOCK_STALE_MS) { + unlinkLockIfUnchanged(lockFile, lockSource); + } + return; + } + + const pid = lockData?.pid; + if (!Number.isInteger(pid) || pid <= 0) { + if (lockFileAgeMs >= INVALID_LOCK_STALE_MS) { + unlinkLockIfUnchanged(lockFile, lockSource); + } + return; + } + + if (!isProcessAlive(pid)) { + unlinkLockIfUnchanged(lockFile, lockSource); + return; + } + + const timestamp = Number(lockData.timestamp); + const liveLockLeaseExpired = + Number.isFinite(timestamp) && + Date.now() - timestamp >= LIVE_LOCK_LEASE_MS; + if (!liveLockLeaseExpired) { + return; + } + if (typeof lockData.identity !== "string" || !lockData.identity) { + return; } -} -function writeLockOwnership(lockFile) { - let myIdentity = null; try { - myIdentity = getProcessIdentity(process.pid); + if (getProcessIdentity(pid) !== lockData.identity) { + unlinkLockIfUnchanged(lockFile, lockSource); + } } catch {} - fs.writeFileSync( - lockFile, - JSON.stringify({ - pid: process.pid, - identity: myIdentity, - timestamp: Date.now(), - }), - { mode: 0o600 } - ); } function acquireJobLock(lockFile) { - for (let attempt = 0; attempt < CAS_MAX_RETRIES; attempt++) { - recoverStaleLock(lockFile); - try { - const fd = fs.openSync(lockFile, "wx"); - writeLockOwnership(lockFile); - return fd; - } catch (err) { - if (err.code === "EEXIST" && attempt < CAS_MAX_RETRIES - 1) { - const delay = - CAS_RETRY_DELAY_MS + Math.random() * CAS_RETRY_DELAY_MS; - sleepSync(delay); - continue; + let lockOwnerIdentity = null; + try { + lockOwnerIdentity = getProcessIdentity(process.pid); + } catch {} + const token = randomBytes(16).toString("hex"); + const stagedLockFile = + `${lockFile}.publish.${process.pid}.${token}`; + const lockSource = JSON.stringify({ + pid: process.pid, + identity: lockOwnerIdentity, + timestamp: Date.now(), + token, + }); + + try { + fs.writeFileSync( + stagedLockFile, + lockSource, + { encoding: "utf8", mode: 0o600, flag: "wx" } + ); + + for (let attempt = 0; ; attempt += 1) { + recoverStaleLock(lockFile); + try { + if (hardLinksUnsupported) { + fs.writeFileSync(lockFile, lockSource, { + encoding: "utf8", + mode: 0o600, + flag: "wx", + }); + } else { + fs.linkSync(stagedLockFile, lockFile); + } + return token; + } catch (err) { + if ( + !hardLinksUnsupported && + HARD_LINK_UNSUPPORTED_CODES.has(err.code) + ) { + hardLinksUnsupported = true; + attempt -= 1; + continue; + } + if (err.code === "EEXIST" && attempt < CAS_MAX_RETRIES - 1) { + const delay = + CAS_RETRY_DELAY_MS + Math.random() * CAS_RETRY_DELAY_MS; + sleepSync(delay); + continue; + } + if (err.code === "EEXIST") { + throw Object.assign( + new Error(`Job state lock remained busy: ${lockFile}`), + { code: "ELOCKBUSY", cause: err } + ); + } + throw err; } - throw err; } - } - return null; -} - -function releaseJobLock(lockFile, fd) { - if (fd != null) { + } finally { try { - fs.closeSync(fd); + fs.unlinkSync(stagedLockFile); } catch {} } +} + +function releaseJobLock(lockFile, token) { try { - fs.unlinkSync(lockFile); + const current = JSON.parse(fs.readFileSync(lockFile, "utf8")); + if (current.token === token) { + fs.unlinkSync(lockFile); + } } catch {} } @@ -702,7 +966,7 @@ export function transitionJob(cwd, jobId, expectedStatuses, next, extra = {}) { const expectedList = Array.isArray(expectedStatuses) ? expectedStatuses : [expectedStatuses]; - const fd = acquireJobLock(lockFile); + const lockToken = acquireJobLock(lockFile); try { const job = JSON.parse(fs.readFileSync(jobFile, "utf8")); @@ -727,7 +991,7 @@ export function transitionJob(cwd, jobId, expectedStatuses, next, extra = {}) { job: updatedJob, }; } finally { - releaseJobLock(lockFile, fd); + releaseJobLock(lockFile, lockToken); } } @@ -756,6 +1020,15 @@ export function cleanupOldJobs(cwd) { try { fs.unlinkSync(jobFile); } catch {} + try { + fs.unlinkSync(jobFile + WINDOWS_IDENTITY_CHECK_SUFFIX); + } catch {} + try { + fs.unlinkSync(jobFile + WINDOWS_IDENTITY_PROBE_SUFFIX); + } catch {} + try { + fs.unlinkSync(jobFile + WINDOWS_IDENTITY_UNAVAILABLE_SUFFIX); + } catch {} const defaultLogFile = resolveJobLogFile(cwd, job.id); try { fs.unlinkSync(defaultLogFile); @@ -765,16 +1038,21 @@ export function cleanupOldJobs(cwd) { const jobsDir = resolveJobsDir(cwd); try { for (const entry of fs.readdirSync(jobsDir, { withFileTypes: true })) { - if (!entry.isFile() || !entry.name.endsWith(JOB_RESERVATION_SUFFIX)) { + const maxAgeMs = entry.name.endsWith(JOB_RESERVATION_SUFFIX) + ? RESERVED_JOB_FILE_MAX_AGE_MS + : /\.json\.lock\.publish\.\d+\.[0-9a-f]{32}$/u.test(entry.name) + ? LOCK_HARD_STALE_MS + : null; + if (!entry.isFile() || maxAgeMs === null) { continue; } try { - const reservationPath = path.join(jobsDir, entry.name); - const stat = fs.statSync(reservationPath); - if (Date.now() - stat.mtimeMs <= RESERVED_JOB_FILE_MAX_AGE_MS) { + const transientPath = path.join(jobsDir, entry.name); + const stat = fs.statSync(transientPath); + if (Date.now() - stat.mtimeMs <= maxAgeMs) { continue; } - fs.unlinkSync(reservationPath); + fs.unlinkSync(transientPath); } catch { continue; } diff --git a/scripts/lib/tracked-jobs.mjs b/scripts/lib/tracked-jobs.mjs index 04779a6..221fbeb 100644 --- a/scripts/lib/tracked-jobs.mjs +++ b/scripts/lib/tracked-jobs.mjs @@ -21,6 +21,22 @@ export const SESSION_ID_ENV = "CLAUDE_COMPANION_SESSION_ID"; export const MAX_JOB_LOG_BYTES = 1024 * 1024; export const MAX_JOB_MODEL_FALLBACK_EVENTS = 50; const LOG_TRUNCATION_MARKER = "[... earlier log output truncated ...]\n"; +const TRACKED_JOB_TRANSITION_RETRIES = 3; + +function transitionTrackedJob(...args) { + for (let attempt = 0; ; attempt += 1) { + try { + return transitionJob(...args); + } catch (error) { + if ( + error?.code !== "ELOCKBUSY" || + attempt >= TRACKED_JOB_TRANSITION_RETRIES - 1 + ) { + throw error; + } + } + } +} function sliceTextTailByBytes(text, maxBytes) { const normalized = typeof text === "string" ? text : String(text ?? ""); @@ -347,21 +363,47 @@ export async function runTrackedJob(job, runner, options = {}) { pidIdentity: job.pidIdentity ?? null, logFile: options.logFile ?? job.logFile ?? null }; - writeJobFile(job.workspaceRoot, job.id, runningRecord); - - // onSpawn callback: persist Claude child PID/identity at spawn time - // Guarded by status check — only write if job is still running (cancel may have won) - const onSpawn = ({ pid, pidIdentity }) => { - const transition = transitionJob( + const storedJob = readJobFile(job.workspaceRoot, job.id); + if (storedJob) { + const started = transitionTrackedJob( job.workspaceRoot, job.id, - ["running"], + ["queued", "running"], "running", { - pid, - pidIdentity, + startedAt: runningRecord.startedAt, + phase: runningRecord.phase, + logFile: runningRecord.logFile, } ); + if (!started.transitioned) { + throw new Error( + `Job ${job.id} left the queue before execution started (${started.job?.status ?? "unknown"}).` + ); + } + } else { + writeJobFile(job.workspaceRoot, job.id, runningRecord); + } + + // onSpawn callback: persist Claude child PID/identity at spawn time + // Guarded by status check — only write if job is still running (cancel may have won) + const onSpawn = ({ pid, pidIdentity }) => { + let transition; + try { + transition = transitionTrackedJob( + job.workspaceRoot, + job.id, + ["running"], + "running", + { + pid, + pidIdentity, + } + ); + } catch (error) { + try { terminateProcessTree(pid); } catch {} + throw error; + } if (!transition.transitioned) { // Job already left running state (cancel won the race) — kill the child immediately try { terminateProcessTree(pid); } catch {} @@ -389,13 +431,30 @@ export async function runTrackedJob(job, runner, options = {}) { ...(modelFallbacks.length > 0 ? { modelFallbacks } : {}), }; - const transitioned = transitionJob( + let transitioned = transitionTrackedJob( job.workspaceRoot, job.id, ["running"], completionStatus, terminalData ); + if ( + !transitioned.transitioned && + transitioned.previousStatus === "failed" && + transitioned.job?.reapedUnverifiable === true + ) { + transitioned = transitionTrackedJob( + job.workspaceRoot, + job.id, + ["failed"], + completionStatus, + { + ...terminalData, + errorMessage: null, + reapedUnverifiable: false, + } + ); + } // If CAS failed, another actor (cancel) already moved the job to a different state — respect that appendLogBlock(options.logFile ?? job.logFile ?? null, "Final output", execution.rendered); @@ -406,14 +465,16 @@ export async function runTrackedJob(job, runner, options = {}) { const completedAt = nowIso(); // Use CAS: running → failed - transitionJob(job.workspaceRoot, job.id, ["running"], "failed", { - errorMessage, - pid: null, - pidIdentity: null, - phase: "failed", - completedAt, - logFile: options.logFile ?? job.logFile ?? null - }); + if (error?.code !== "ELOCKBUSY") { + transitionTrackedJob(job.workspaceRoot, job.id, ["running"], "failed", { + errorMessage, + pid: null, + pidIdentity: null, + phase: "failed", + completedAt, + logFile: options.logFile ?? job.logFile ?? null + }); + } cleanupOldJobs(job.workspaceRoot); throw error; diff --git a/stryker.shard.config.mjs b/stryker.shard.config.mjs index a4cccfa..40ad041 100644 --- a/stryker.shard.config.mjs +++ b/stryker.shard.config.mjs @@ -8,15 +8,27 @@ const shards = { }, "claude-cli": { command: "npm run test:mutation:claude-cli:unit", - mutate: ["scripts/lib/claude-cli.mjs"], + mutate: [ + "scripts/lib/claude-cli.mjs", + "scripts/lib/process.mjs:9-54", + "scripts/lib/process.mjs:75-106", + "scripts/lib/process.mjs:108-179", + "scripts/lib/process.mjs:185-367", + "scripts/lib/process.mjs:390-500", + ], }, state: { command: "npm run test:mutation:state:unit", mutate: [ // Persistence lifecycle, session lookup, and terminal job transitions. - "scripts/lib/state.mjs:156-196", - "scripts/lib/state.mjs:319-367", - "scripts/lib/state.mjs:695-745", + "scripts/lib/state.mjs:178-218", + "scripts/lib/state.mjs:341-389", + "scripts/lib/state.mjs:466-707", + "scripts/lib/state.mjs:756-893", + "scripts/lib/state.mjs:959-1009", + "scripts/lib/state.mjs:1015-1061", + "scripts/lib/tracked-jobs.mjs:26-39", + "scripts/lib/tracked-jobs.mjs:356-482", ], }, "job-control": { diff --git a/tests/cancel-command.test.mjs b/tests/cancel-command.test.mjs new file mode 100644 index 0000000..51bc090 --- /dev/null +++ b/tests/cancel-command.test.mjs @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { afterEach, test } from "node:test"; + +import { getProcessIdentity } from "../scripts/lib/process.mjs"; +import { + readJobFile, + resolveJobFile, + writeJobFile, +} from "../scripts/lib/state.mjs"; + +const ROOT = path.resolve(fileURLToPath(new URL("..", import.meta.url))); +const COMPANION = path.join(ROOT, "scripts", "claude-companion.mjs"); +const SWAP_PRELOAD = path.join( + ROOT, + "tests", + "fixtures", + "swap-job-after-read.mjs" +); +const cleanup = []; + +afterEach(() => { + while (cleanup.length > 0) cleanup.pop()(); +}); + +function runCancelSnapshotRace(id, stalePid) { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "cc-cancel-race-")); + cleanup.push(() => fs.rmSync(cwd, { recursive: true, force: true })); + const init = spawnSync("git", ["init", "-q"], { cwd, encoding: "utf8" }); + assert.equal(init.status, 0, init.stderr); + + const createdAt = new Date().toISOString(); + const snapshot = { + id, + status: "running", + pid: stalePid, + pidIdentity: stalePid == null ? null : "stale-snapshot-identity", + createdAt, + updatedAt: new Date(Date.now() + 60_000).toISOString(), + }; + const authoritative = { + ...snapshot, + pid: process.pid, + pidIdentity: getProcessIdentity(process.pid), + updatedAt: createdAt, + }; + writeJobFile(cwd, id, snapshot); + const cancelRecord = path.join(cwd, "cancel-attempt.json"); + + const result = spawnSync( + process.execPath, + ["--import", SWAP_PRELOAD, COMPANION, "cancel", id, "--cwd", cwd], + { + encoding: "utf8", + env: { + ...process.env, + CC_TEST_SWAP_JOB_FILE: resolveJobFile(cwd, id), + CC_TEST_SWAP_JOB_JSON: `${JSON.stringify(authoritative, null, 2)}\n`, + CC_TEST_SWAP_JOB_AFTER_READ: "1", + CC_TEST_CANCEL_PID: String(process.pid), + CC_TEST_CANCEL_RECORD_FILE: cancelRecord, + }, + } + ); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, new RegExp(String(process.pid))); + if (stalePid != null) { + assert.doesNotMatch(result.stdout, new RegExp(String(stalePid))); + } + assert.deepEqual(JSON.parse(fs.readFileSync(cancelRecord, "utf8")), { + pid: process.pid, + }); + const stored = readJobFile(cwd, id); + assert.equal(stored.status, "cancel_failed"); + assert.equal(stored.pid, process.pid); + assert.equal(stored.pgid, process.pid); +} + +test("cancel uses the in-lock PID when the list snapshot is stale", () => { + runCancelSnapshotRace("cancel-stale-pid-race", 987_654_321); +}); + +test("cancel does not silently succeed when the list snapshot has no PID", () => { + runCancelSnapshotRace("cancel-missing-pid-race", null); +}); diff --git a/tests/claude-cli.test.mjs b/tests/claude-cli.test.mjs index 4bb2aee..93df101 100644 --- a/tests/claude-cli.test.mjs +++ b/tests/claude-cli.test.mjs @@ -33,9 +33,40 @@ import { MAX_STREAM_PARSER_TOUCHED_FILES, MAX_STREAM_PARSER_MODEL_EVENTS, MAX_STDERR_BYTES, + getClaudeAvailability, + getClaudeAuthStatus, + resolveClaudeCommand, + cancelClaudeProcess, runClaudeTurn, } from "../scripts/lib/claude-cli.mjs"; +function createFakeClaudeCommand(tmpDir, source) { + const packageRoot = path.join( + tmpDir, + "node_modules", + "@anthropic-ai", + "claude-code" + ); + const fakeClaude = path.join(packageRoot, "cli.js"); + fs.mkdirSync(packageRoot, { recursive: true }); + fs.writeFileSync(fakeClaude, source); + + fs.writeFileSync( + path.join(tmpDir, "claude.cmd"), + `@ECHO off\r\n"%_prog%" "%dp0%\\node_modules\\@anthropic-ai\\claude-code\\cli.js" %*\r\n` + ); + if (process.platform !== "win32") { + const launcher = path.join(tmpDir, "claude"); + fs.writeFileSync( + launcher, + `#!/bin/sh\nexec "${process.execPath}" "${fakeClaude}" "$@"\n` + ); + fs.chmodSync(launcher, 0o755); + } + + return fakeClaude; +} + // =========================================================================== // StreamParser // =========================================================================== @@ -1257,26 +1288,10 @@ describe("runClaudeTurn", () => { const oldPath = process.env.PATH ?? ""; try { const longStderr = `DROP-ME\n${"x".repeat(MAX_STDERR_BYTES + 32)}\nKEEP-ME`; - const fakeClaude = path.join(tmpDir, "fake-claude.mjs"); - fs.writeFileSync( - fakeClaude, + createFakeClaudeCommand( + tmpDir, `process.stderr.write(${JSON.stringify(longStderr)}, () => process.exit(1));\n` ); - - if (process.platform === "win32") { - fs.writeFileSync( - path.join(tmpDir, "claude.cmd"), - `@echo off\r\n"${process.execPath}" "%~dp0fake-claude.mjs"\r\n` - ); - } else { - const launcher = path.join(tmpDir, "claude"); - fs.writeFileSync( - launcher, - `#!/bin/sh\nDIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\nexec "${process.execPath}" "$DIR/fake-claude.mjs"\n` - ); - fs.chmodSync(launcher, 0o755); - } - process.env.PATH = `${tmpDir}${path.delimiter}${oldPath}`; const result = await runClaudeTurn(process.cwd(), "prompt"); @@ -1297,41 +1312,531 @@ describe("runClaudeTurn", () => { const oldFlag = process.env.CLAUDE_CODE_FORWARD_SUBAGENT_TEXT; delete process.env.CLAUDE_CODE_FORWARD_SUBAGENT_TEXT; try { - const fakeClaude = path.join(tmpDir, "fake-claude.mjs"); - fs.writeFileSync( - fakeClaude, - `const out = JSON.stringify({ type: "result", result: "env:" + (process.env.CLAUDE_CODE_FORWARD_SUBAGENT_TEXT ?? "unset"), session_id: "sess-env" });\nprocess.stdout.write(out + "\\n", () => process.exit(0));\n` + createFakeClaudeCommand( + tmpDir, + `const result = JSON.stringify({ env: process.env.CLAUDE_CODE_FORWARD_SUBAGENT_TEXT ?? "unset", argv: process.argv.slice(2) });\nconst out = JSON.stringify({ type: "result", result, session_id: "sess-env" });\nprocess.stdout.write(out + "\\n", () => process.exit(0));\n` ); + process.env.PATH = `${tmpDir}${path.delimiter}${oldPath}`; - if (process.platform === "win32") { - fs.writeFileSync( - path.join(tmpDir, "claude.cmd"), - `@echo off\r\n"${process.execPath}" "%~dp0fake-claude.mjs"\r\n` - ); + const options = { + model: "claude-opus-5", + effort: "xhigh", + permissionMode: "dontAsk", + }; + const result = await runClaudeTurn(process.cwd(), "prompt", options); + const payload = JSON.parse(result.finalMessage); + + assert.equal(payload.env, "1"); + assert.deepEqual( + payload.argv, + buildArgs("prompt", { outputFormat: "stream-json", ...options }) + ); + } finally { + process.env.PATH = oldPath; + if (oldFlag === undefined) { + delete process.env.CLAUDE_CODE_FORWARD_SUBAGENT_TEXT; } else { - const launcher = path.join(tmpDir, "claude"); - fs.writeFileSync( - launcher, - `#!/bin/sh\nDIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\nexec "${process.execPath}" "$DIR/fake-claude.mjs"\n` - ); - fs.chmodSync(launcher, 0o755); + process.env.CLAUDE_CODE_FORWARD_SUBAGENT_TEXT = oldFlag; } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("uses the same PATH-resolved Claude command for availability and auth", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-claude-status-")); + const oldPath = process.env.PATH ?? ""; + const oldApiKey = process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_API_KEY; + try { + createFakeClaudeCommand( + tmpDir, + `const args = process.argv.slice(2);\nif (args[0] === "--version") process.stdout.write("2.1.220\\n");\nprocess.exit(args[0] === "--version" || (args[0] === "auth" && args[1] === "status") ? 0 : 1);\n` + ); process.env.PATH = `${tmpDir}${path.delimiter}${oldPath}`; - const result = await runClaudeTurn(process.cwd(), "prompt"); - - assert.equal(result.finalMessage, "env:1"); + assert.deepEqual(getClaudeAvailability(process.cwd()), { + available: true, + detail: "2.1.220", + }); + assert.deepEqual(getClaudeAuthStatus(process.cwd()), { + available: true, + loggedIn: true, + detail: "authenticated", + }); } finally { process.env.PATH = oldPath; - if (oldFlag === undefined) { - delete process.env.CLAUDE_CODE_FORWARD_SUBAGENT_TEXT; + if (oldApiKey === undefined) { + delete process.env.ANTHROPIC_API_KEY; } else { - process.env.CLAUDE_CODE_FORWARD_SUBAGENT_TEXT = oldFlag; + process.env.ANTHROPIC_API_KEY = oldApiKey; } fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + + it("resolves legacy and current npm shims without a command shell", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-claude-shim-")); + try { + const legacyTarget = createFakeClaudeCommand(tmpDir, ""); + assert.deepEqual(resolveClaudeCommand("win32", { PATH: tmpDir }), { + executable: process.execPath, + prefixArgs: [legacyTarget], + }); + + const nativeTarget = path.join( + tmpDir, + "node_modules", + "@anthropic-ai", + "claude-code", + "bin", + "claude.exe" + ); + fs.mkdirSync(path.dirname(nativeTarget), { recursive: true }); + fs.writeFileSync(nativeTarget, ""); + fs.writeFileSync( + path.join(tmpDir, "claude.cmd"), + `@ECHO off\r\n"%dp0%\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*\r\n` + ); + + assert.deepEqual(resolveClaudeCommand("win32", { PATH: tmpDir }), { + executable: nativeTarget, + prefixArgs: [], + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("resolves a quoted native PATH entry after misses and honors Path casing", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-claude-native-")); + try { + const missingDir = path.join(tmpDir, "missing"); + const nativeDir = path.join(tmpDir, "native claude"); + const nativeExecutable = path.join(nativeDir, "claude.exe"); + fs.mkdirSync(missingDir); + fs.mkdirSync(nativeDir); + fs.writeFileSync(nativeExecutable, ""); + + assert.deepEqual( + resolveClaudeCommand("win32", { + Path: `; ${missingDir} ; "${nativeDir}" `, + }), + { + executable: nativeExecutable, + prefixArgs: [], + } + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("resolves a local npm shim that uses the %~dp0 form", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-claude-local-")); + try { + const binDir = path.join(tmpDir, "node_modules", ".bin"); + const target = path.join( + tmpDir, + "node_modules", + "@anthropic-ai", + "claude-code", + "cli.js" + ); + fs.mkdirSync(binDir, { recursive: true }); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, ""); + fs.writeFileSync( + path.join(binDir, "claude.cmd"), + `@echo off\r\nnode "%~dp0\\..\\@anthropic-ai\\claude-code\\cli.js" %*\r\n` + ); + + assert.deepEqual(resolveClaudeCommand("win32", { PATH: binDir }), { + executable: process.execPath, + prefixArgs: [target], + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("skips an unsupported shim when a later native Claude executable exists", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-claude-invalid-")); + try { + const firstDir = path.join(tmpDir, "first"); + const secondDir = path.join(tmpDir, "second"); + fs.mkdirSync(firstDir); + fs.mkdirSync(secondDir); + fs.writeFileSync(path.join(firstDir, "claude.cmd"), "@echo off\r\nother-cli %*\r\n"); + fs.writeFileSync(path.join(secondDir, "claude.exe"), ""); + + const resolved = resolveClaudeCommand("win32", { + PATH: `${firstDir};${secondDir}`, + }); + assert.equal(resolved.executable, path.join(secondDir, "claude.exe")); + assert.deepEqual(resolved.prefixArgs, []); + assert.equal(resolved.error, undefined); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("rejects a recognized shim with a missing target and preserves non-Windows resolution", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-claude-missing-")); + try { + fs.writeFileSync( + path.join(tmpDir, "claude.cmd"), + `@echo off\r\nnode "%dp0%\\node_modules\\@anthropic-ai\\claude-code\\cli.js" %*\r\n` + ); + + const windowsResult = resolveClaudeCommand("win32", { PATH: tmpDir }); + assert.equal(windowsResult.executable, null); + assert.match(windowsResult.error, /target could not be resolved safely/iu); + assert.deepEqual(resolveClaudeCommand("linux", { PATH: tmpDir }), { + executable: "claude", + prefixArgs: [], + }); + assert.deepEqual(resolveClaudeCommand("win32", { PATH: "" }), { + executable: "claude", + prefixArgs: [], + }); + + const targetDirectory = path.join( + tmpDir, + "node_modules", + "@anthropic-ai", + "claude-code", + "cli.js" + ); + fs.mkdirSync(targetDirectory, { recursive: true }); + const directoryResult = resolveClaudeCommand("win32", { PATH: tmpDir }); + assert.equal(directoryResult.executable, null); + assert.match(directoryResult.error, /target could not be resolved safely/iu); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe("cancelClaudeProcess", () => { + it("uses atomic Windows identity-checked process-tree termination", async () => { + let terminatedPid = null; + let terminatedIdentity = null; + const result = await cancelClaudeProcess(12345, "identity", { + platform: "win32", + terminateProcessTreeIfIdentityMatchesImpl: (pid, identity) => { + terminatedPid = pid; + terminatedIdentity = identity; + return { + attempted: true, + delivered: true, + method: "identity-checked-taskkill", + }; + }, + }); + + assert.equal(terminatedPid, 12345); + assert.equal(terminatedIdentity, "identity"); + assert.deepEqual(result, { cancelled: true }); + }); + + it("treats an already-exited Windows process as cancelled", async () => { + const result = await cancelClaudeProcess(12345, "identity", { + platform: "win32", + terminateProcessTreeIfIdentityMatchesImpl: () => ({ + attempted: true, + delivered: false, + method: "identity-checked-taskkill", + reason: "process-missing", + }), + }); + + assert.deepEqual(result, { + cancelled: true, + note: "Process already exited", + }); + }); + + it("reports Windows and POSIX termination errors as failures", async () => { + const windowsResult = await cancelClaudeProcess(12345, "identity", { + platform: "win32", + terminateProcessTreeIfIdentityMatchesImpl: () => { + throw new Error("access denied"); + }, + }); + const permissionError = Object.assign(new Error("operation not permitted"), { + code: "EPERM", + }); + const posixResult = await cancelClaudeProcess(12345, "identity", { + platform: "linux", + getProcessIdentityImpl: () => "identity", + killImpl: () => { + throw permissionError; + }, + }); + + assert.deepEqual(windowsResult, { + cancelled: false, + note: "Failed to terminate process tree: access denied", + }); + assert.deepEqual(posixResult, { + cancelled: false, + note: "Failed to send SIGTERM: operation not permitted", + }); + }); + + it("does not terminate a recycled Windows PID", async () => { + const result = await cancelClaudeProcess(12345, "old-identity", { + platform: "win32", + terminateProcessTreeIfIdentityMatchesImpl: () => ({ + attempted: true, + delivered: false, + method: "identity-checked-taskkill", + reason: "identity-mismatch", + }), + }); + + assert.deepEqual(result, { + cancelled: true, + note: "Process already exited (PID recycled)", + }); + }); + + it("refuses Windows termination without a stable identity", async () => { + const result = await cancelClaudeProcess(12345, null, { + platform: "win32", + terminateProcessTreeIfIdentityMatchesImpl: () => ({ + attempted: false, + delivered: false, + method: null, + reason: "identity-unavailable", + }), + }); + + assert.deepEqual(result, { + cancelled: false, + note: "Refused to terminate process tree without a matching PID identity", + }); + }); + + it("distinguishes a missing POSIX process from other signal errors", async () => { + const missingError = Object.assign(new Error("missing"), { code: "ESRCH" }); + const result = await cancelClaudeProcess(12345, "identity", { + platform: "linux", + getProcessIdentityImpl: () => "identity", + killImpl: () => { + throw missingError; + }, + }); + + assert.deepEqual(result, { + cancelled: true, + note: "Process not found", + }); + }); + + it("stops after SIGTERM when the POSIX process group exits", async () => { + const signals = []; + const waits = []; + const result = await cancelClaudeProcess(12345, "identity", { + platform: "linux", + getProcessIdentityImpl: () => "identity", + killImpl: (pid, signal) => signals.push([pid, signal]), + waitForProcessGroupImpl: async (pid, timeout) => { + waits.push([pid, timeout]); + return true; + }, + }); + + assert.deepEqual(signals, [[-12345, "SIGTERM"]]); + assert.deepEqual(waits, [[12345, 5000]]); + assert.deepEqual(result, { cancelled: true }); + }); + + it("fails closed when POSIX identity lookup is unavailable for a live group", async () => { + const signals = []; + let leaderChecks = 0; + const result = await cancelClaudeProcess(12345, "identity", { + platform: "linux", + getProcessIdentityImpl: () => { + throw Object.assign(new Error("lookup unavailable"), { code: "EAGAIN" }); + }, + isProcessAliveImpl: () => { + leaderChecks += 1; + return false; + }, + isProcessGroupAliveImpl: () => true, + killImpl: (pid, signal) => signals.push([pid, signal]), + }); + + assert.equal(leaderChecks, 1); + assert.deepEqual(signals, []); + assert.deepEqual(result, { + cancelled: false, + note: "Unable to verify process identity: lookup unavailable", + }); + }); + + it("distinguishes a missing POSIX process, recycled PID, and missing identity", async () => { + const missing = await cancelClaudeProcess(12345, "identity", { + platform: "linux", + getProcessIdentityImpl: () => { + throw new Error("lookup failed"); + }, + isProcessAliveImpl: () => false, + isProcessGroupAliveImpl: () => false, + killImpl: () => assert.fail("missing process must not be signalled"), + }); + const recycled = await cancelClaudeProcess(12345, "identity", { + platform: "linux", + getProcessIdentityImpl: () => "different-identity", + killImpl: () => assert.fail("recycled PID must not be signalled"), + }); + const noIdentity = await cancelClaudeProcess(12345, null, { + platform: "linux", + killImpl: () => assert.fail("unverified process must not be signalled"), + }); + + assert.deepEqual(missing, { + cancelled: true, + note: "Process already exited", + }); + assert.deepEqual(recycled, { + cancelled: true, + note: "Process already exited (PID recycled)", + }); + assert.deepEqual(noIdentity, { + cancelled: false, + note: "Refused to terminate process group without a matching PID identity", + }); + }); + + it("skips SIGKILL when the process group exits after the wait timeout", async () => { + const signals = []; + const result = await cancelClaudeProcess(12345, "identity", { + platform: "linux", + getProcessIdentityImpl: () => "identity", + isProcessGroupAliveImpl: () => false, + killImpl: (pid, signal) => signals.push([pid, signal]), + waitForProcessGroupImpl: async () => false, + }); + + assert.deepEqual(signals, [[-12345, "SIGTERM"]]); + assert.deepEqual(result, { + cancelled: true, + note: "Process exited during SIGTERM wait", + }); + }); + + it("escalates when the group survives SIGTERM after its leader exits", async () => { + let identityLookups = 0; + let waits = 0; + const signals = []; + const result = await cancelClaudeProcess(12345, "identity", { + platform: "linux", + getProcessIdentityImpl: () => { + identityLookups += 1; + return "identity"; + }, + isProcessGroupAliveImpl: () => true, + killImpl: (pid, signal) => signals.push([pid, signal]), + waitForProcessGroupImpl: async () => { + waits += 1; + return waits === 2; + }, + }); + + assert.equal(identityLookups, 1); + assert.deepEqual(signals, [ + [-12345, "SIGTERM"], + [-12345, "SIGKILL"], + ]); + assert.deepEqual(result, { cancelled: true }); + }); + + it("escalates to SIGKILL and reports whether the process group died", async () => { + const successfulSignals = []; + let successfulWaits = 0; + const killed = await cancelClaudeProcess(12345, "identity", { + platform: "linux", + getProcessIdentityImpl: () => "identity", + isProcessAliveImpl: () => true, + isProcessGroupAliveImpl: () => true, + killImpl: (pid, signal) => successfulSignals.push([pid, signal]), + waitForProcessGroupImpl: async () => { + successfulWaits += 1; + return successfulWaits === 2; + }, + }); + const aliveSignals = []; + const stillAlive = await cancelClaudeProcess(54321, "identity", { + platform: "linux", + getProcessIdentityImpl: () => "identity", + isProcessAliveImpl: () => true, + isProcessGroupAliveImpl: () => true, + killImpl: (pid, signal) => aliveSignals.push([pid, signal]), + waitForProcessGroupImpl: async () => false, + }); + + assert.deepEqual(successfulSignals, [ + [-12345, "SIGTERM"], + [-12345, "SIGKILL"], + ]); + assert.deepEqual(killed, { cancelled: true }); + assert.deepEqual(aliveSignals, [ + [-54321, "SIGTERM"], + [-54321, "SIGKILL"], + ]); + assert.deepEqual(stillAlive, { + cancelled: false, + note: "Process group 54321 still alive after SIGKILL", + }); + }); + + it("refuses SIGKILL when the group leader PID is recycled during the wait", async () => { + const signals = []; + let identityLookups = 0; + const result = await cancelClaudeProcess(12345, "identity", { + platform: "linux", + getProcessIdentityImpl: () => { + identityLookups += 1; + return identityLookups === 1 ? "identity" : "recycled-identity"; + }, + isProcessAliveImpl: () => true, + isProcessGroupAliveImpl: () => true, + killImpl: (pid, signal) => signals.push([pid, signal]), + waitForProcessGroupImpl: async () => false, + }); + + assert.equal(identityLookups, 2); + assert.deepEqual(signals, [[-12345, "SIGTERM"]]); + assert.deepEqual(result, { + cancelled: true, + note: "Process exited during SIGTERM wait (PID recycled)", + }); + }); + + it("fails closed when identity cannot be re-verified before SIGKILL", async () => { + const signals = []; + let identityLookups = 0; + const result = await cancelClaudeProcess(12345, "identity", { + platform: "linux", + getProcessIdentityImpl: () => { + identityLookups += 1; + if (identityLookups === 1) return "identity"; + throw new Error("second lookup failed"); + }, + isProcessAliveImpl: () => true, + isProcessGroupAliveImpl: () => true, + killImpl: (pid, signal) => signals.push([pid, signal]), + waitForProcessGroupImpl: async () => false, + }); + + assert.deepEqual(signals, [[-12345, "SIGTERM"]]); + assert.deepEqual(result, { + cancelled: false, + note: "Unable to re-verify process identity before SIGKILL: second lookup failed", + }); + }); }); // =========================================================================== diff --git a/tests/fixtures/swap-job-after-read.mjs b/tests/fixtures/swap-job-after-read.mjs new file mode 100644 index 0000000..7c1929b --- /dev/null +++ b/tests/fixtures/swap-job-after-read.mjs @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import childProcess from "node:child_process"; +import { syncBuiltinESMExports } from "node:module"; + +const target = path.resolve(process.env.CC_TEST_SWAP_JOB_FILE ?? ""); +const replacement = process.env.CC_TEST_SWAP_JOB_JSON ?? ""; +const swapAfterRead = Number(process.env.CC_TEST_SWAP_JOB_AFTER_READ ?? "1"); +const cancelPid = Number(process.env.CC_TEST_CANCEL_PID); +const cancelRecord = path.resolve( + process.env.CC_TEST_CANCEL_RECORD_FILE ?? "" +); +const readFileSync = fs.readFileSync; +let swapped = false; +let targetReads = 0; + +fs.readFileSync = function patchedReadFileSync(filePath, ...args) { + const result = readFileSync.call(this, filePath, ...args); + if (path.resolve(String(filePath)) === target) { + targetReads += 1; + if (!swapped && targetReads === swapAfterRead) { + swapped = true; + fs.writeFileSync(target, replacement, { + encoding: "utf8", + mode: 0o600, + }); + } + } + return result; +}; + +if (Number.isInteger(cancelPid) && cancelPid > 0 && cancelRecord !== path.resolve("")) { + if (process.platform === "win32") { + const spawnSync = childProcess.spawnSync; + childProcess.spawnSync = function patchedSpawnSync(command, args, options) { + if ( + command === "powershell.exe" && + args?.join(" ").includes(`taskkill.exe /PID ${cancelPid}`) + ) { + fs.writeFileSync(cancelRecord, `${JSON.stringify({ pid: cancelPid })}\n`); + return { + status: 1, + signal: null, + stdout: "", + stderr: "test cancellation failure", + error: null, + }; + } + return spawnSync.call(this, command, args, options); + }; + syncBuiltinESMExports(); + } else { + const kill = process.kill.bind(process); + process.kill = function patchedKill(pid, signal) { + if (pid === -cancelPid) { + fs.writeFileSync(cancelRecord, `${JSON.stringify({ pid: cancelPid })}\n`); + throw Object.assign(new Error("test cancellation failure"), { + code: "EPERM", + }); + } + return kill(pid, signal); + }; + } +} diff --git a/tests/hooks.test.mjs b/tests/hooks.test.mjs index d10edc6..6c9f53a 100644 --- a/tests/hooks.test.mjs +++ b/tests/hooks.test.mjs @@ -4,14 +4,15 @@ */ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { SANDBOX_STOP_REVIEW_TOOLS } from "../scripts/lib/claude-cli.mjs"; +import { getProcessIdentity } from "../scripts/lib/process.mjs"; import { SESSION_ID_ENV } from "../scripts/lib/tracked-jobs.mjs"; const PROJECT_ROOT = path.resolve( @@ -22,6 +23,7 @@ const SESSION_HOOK = path.join( "hooks", "session-lifecycle-hook.mjs" ); +const HOOKS_MANIFEST = path.join(PROJECT_ROOT, "hooks", "hooks.json"); const STOP_HOOK = path.join( PROJECT_ROOT, "hooks", @@ -465,6 +467,14 @@ describe("hooks", () => { } }); + it("bounds SessionEnd outside the internal cleanup deadline", () => { + const manifest = JSON.parse(fs.readFileSync(HOOKS_MANIFEST, "utf8")); + const handler = manifest.hooks.SessionEnd[0].hooks[0]; + + assert.equal(handler.timeout, 45); + assert.match(handler.command, /session-lifecycle-hook\.mjs.*SessionEnd/u); + }); + it("session lifecycle hook refuses to kill a stored PID without a matching identity", () => { const testEnv = createHookEnvironment(); @@ -499,6 +509,177 @@ describe("hooks", () => { } }); + it("session lifecycle hook preserves recovery handles after its cleanup budget", () => { + const testEnv = createHookEnvironment(); + const createdAt = new Date().toISOString(); + + try { + for (const jobId of ["budget-job-one", "budget-job-two"]) { + writeStateJob(testEnv, jobId, { + id: jobId, + status: "running", + sessionId: "hook-session", + workspaceRoot: testEnv.workspaceDir, + createdAt, + startedAt: createdAt, + pid: process.pid, + pidIdentity: `${jobId}-identity`, + }); + } + writeStateJob(testEnv, "budget-job-without-pid", { + id: "budget-job-without-pid", + status: "queued", + sessionId: "hook-session", + workspaceRoot: testEnv.workspaceDir, + createdAt, + }); + + const clockPreload = path.join(testEnv.rootDir, "cleanup-clock.mjs"); + fs.writeFileSync( + clockPreload, + `const realNow = Date.now.bind(Date); +let cleanupReads = 0; +Date.now = () => { + const caller = new Error().stack?.split("\\n")[2] ?? ""; + if (caller.includes("cleanupSessionJobs")) { + cleanupReads += 1; + return cleanupReads === 1 ? 1_000_000 : 1_020_000; + } + return realNow(); +}; +`, + "utf8" + ); + + runHook( + SESSION_HOOK, + ["SessionEnd"], + { + cwd: testEnv.workspaceDir, + session_id: "hook-session", + }, + { + ...testEnv.env, + NODE_OPTIONS: [ + process.env.NODE_OPTIONS, + `--import=${pathToFileURL(clockPreload).href}`, + ] + .filter(Boolean) + .join(" "), + } + ); + + for (const jobId of ["budget-job-one", "budget-job-two"]) { + const job = readStateJob(testEnv, jobId); + assert.equal(job.status, "cancel_failed"); + assert.equal(job.phase, "cancel_failed"); + assert.equal(job.pid, process.pid); + assert.equal(job.pidIdentity, `${jobId}-identity`); + assert.match(job.errorMessage ?? "", /cleanup budget was exhausted/i); + } + assert.equal( + readStateJob(testEnv, "budget-job-without-pid").status, + "cancelled" + ); + } finally { + cleanupHookEnvironment(testEnv); + } + }); + + it("session lifecycle hook marks an already-exited stored process cancelled", () => { + const testEnv = createHookEnvironment(); + + try { + writeStateJob(testEnv, "exited-running-job", { + id: "exited-running-job", + status: "running", + sessionId: "hook-session", + workspaceRoot: testEnv.workspaceDir, + createdAt: "2026-04-04T01:00:00Z", + startedAt: "2026-04-04T01:00:01Z", + pid: 99_999_999, + pidIdentity: "exited-process-identity", + }); + + runHook( + SESSION_HOOK, + ["SessionEnd"], + { + cwd: testEnv.workspaceDir, + session_id: "hook-session", + }, + testEnv.env + ); + + const job = readStateJob(testEnv, "exited-running-job"); + assert.equal(job.status, "cancelled"); + assert.equal(job.phase, "cancelled"); + assert.equal(job.pid, null); + assert.equal(job.pidIdentity, null); + } finally { + cleanupHookEnvironment(testEnv); + } + }); + + it("session lifecycle hook preserves a live PID when POSIX identity lookup fails", async () => { + if (process.platform !== "darwin") { + return; + } + + const testEnv = createHookEnvironment(); + const child = spawn( + process.execPath, + ["-e", "setInterval(() => {}, 1000)"], + { stdio: "ignore" } + ); + await new Promise((resolve, reject) => { + child.once("spawn", resolve); + child.once("error", reject); + }); + + try { + const identity = getProcessIdentity(child.pid); + const failingBin = path.join(testEnv.rootDir, "failing-ps"); + fs.mkdirSync(failingBin); + const fakePs = path.join(failingBin, "ps"); + fs.writeFileSync(fakePs, "#!/bin/sh\nexit 2\n", "utf8"); + fs.chmodSync(fakePs, 0o755); + writeStateJob(testEnv, "identity-unavailable-job", { + id: "identity-unavailable-job", + status: "running", + sessionId: "hook-session", + workspaceRoot: testEnv.workspaceDir, + createdAt: "2026-04-04T01:00:00Z", + startedAt: "2026-04-04T01:00:01Z", + pid: child.pid, + pidIdentity: identity, + }); + + runHook( + SESSION_HOOK, + ["SessionEnd"], + { + cwd: testEnv.workspaceDir, + session_id: "hook-session", + }, + { + ...testEnv.env, + PATH: `${failingBin}${path.delimiter}${testEnv.env.PATH}`, + } + ); + + const job = readStateJob(testEnv, "identity-unavailable-job"); + assert.equal(job.status, "cancel_failed"); + assert.equal(job.phase, "cancel_failed"); + assert.equal(job.pid, child.pid); + assert.equal(job.pidIdentity, identity); + assert.doesNotThrow(() => process.kill(child.pid, 0)); + } finally { + child.kill(); + cleanupHookEnvironment(testEnv); + } + }); + it("session start preserves the parent marker for nested sessions and exports hook suppression", () => { const testEnv = createHookEnvironment(); diff --git a/tests/mutation-config.test.mjs b/tests/mutation-config.test.mjs index cd4d7f5..3472822 100644 --- a/tests/mutation-config.test.mjs +++ b/tests/mutation-config.test.mjs @@ -13,9 +13,19 @@ import ts from "typescript"; const PROJECT_ROOT = path.resolve(fileURLToPath(new URL("../", import.meta.url))); /** @type {Array<[string, string[]]>} */ const expectations = [ - ["scripts/lib/state.mjs:156-196", ["ensurePluginDataLayout", "resolveWorkspaceHash", "ensureStateDir"]], - ["scripts/lib/state.mjs:319-367", ["writeJobFile", "normalizeStoredJob"]], - ["scripts/lib/state.mjs:695-745", ["casJobStatus", "transitionJob", "writeAtomic"]], + ["scripts/lib/process.mjs:9-54", ["runCommand", "runCommandChecked"]], + ["scripts/lib/process.mjs:75-106", ["isCommandTimeout", "isWindowsIdentityCircuitOpen", "tripWindowsIdentityCircuit"]], + ["scripts/lib/process.mjs:108-179", ["terminateProcessTree"]], + ["scripts/lib/process.mjs:185-367", ["terminateProcessTreeIfIdentityMatches"]], + ["scripts/lib/process.mjs:390-500", ["getProcessIdentity", "getSpawnedProcessIdentity", "validateProcessIdentity", "isProcessAlive", "isProcessGroupAlive"]], + ["scripts/lib/state.mjs:178-218", ["ensurePluginDataLayout", "resolveWorkspaceHash", "ensureStateDir"]], + ["scripts/lib/state.mjs:341-389", ["writeJobFile", "normalizeStoredJob"]], + ["scripts/lib/state.mjs:466-707", ["mostRecentJobTimestamp", "isWithinReapGracePeriod", "reapStaleJobs"]], + ["scripts/lib/state.mjs:756-893", ["unlinkLockIfUnchanged", "recoverStaleLock", "acquireJobLock", "releaseJobLock"]], + ["scripts/lib/state.mjs:959-1009", ["casJobStatus", "transitionJob", "writeAtomic"]], + ["scripts/lib/state.mjs:1015-1061", ["cleanupOldJobs"]], + ["scripts/lib/tracked-jobs.mjs:26-39", ["transitionTrackedJob"]], + ["scripts/lib/tracked-jobs.mjs:356-482", ["runTrackedJob"]], ["scripts/lib/job-control.mjs:144-247", ["matchJobReference", "buildStatusSnapshot", "resolveCancelableJob"]], ["scripts/installer-cli.mjs:96-234", ["readPersonalMarketplace", "prepareLegacyLocalCleanup", "isPluginAlreadyAbsent", "isPluginUninstallRefused"]], ["scripts/installer-cli.mjs:275-371", ["installOrUpdate", "uninstall"]], diff --git a/tests/process.test.mjs b/tests/process.test.mjs index a38be62..4bd5d24 100644 --- a/tests/process.test.mjs +++ b/tests/process.test.mjs @@ -4,16 +4,22 @@ */ import { describe, it } from "node:test"; import assert from "node:assert/strict"; +import childProcess, { spawn } from "node:child_process"; +import { syncBuiltinESMExports } from "node:module"; +import { setTimeout as delay } from "node:timers/promises"; import { runCommand, runCommandChecked, binaryAvailable, terminateProcessTree, + terminateProcessTreeIfIdentityMatches, formatCommandFailure, isProcessAlive, + isProcessGroupAlive, validateProcessIdentity, getProcessIdentity, + getSpawnedProcessIdentity, } from "../scripts/lib/process.mjs"; // node may not be on PATH in this test environment; find it once @@ -25,7 +31,10 @@ const NODE_BIN = process.execPath; describe("runCommand", () => { it("runs a simple command and captures stdout", () => { - const result = runCommand("echo", ["hello"]); + const result = runCommand(NODE_BIN, [ + "-e", + "process.stdout.write('hello\\n')", + ]); assert.equal(result.status, 0); assert.equal(result.stdout.trim(), "hello"); assert.equal(result.signal, null); @@ -46,21 +55,27 @@ describe("runCommand", () => { const result = runCommand("definitely-not-a-real-command-xyz"); assert.ok(result.error); assert.equal(result.error.code, "ENOENT"); + assert.equal(result.status, null); }); it("preserves command and args in result", () => { - const result = runCommand("echo", ["a", "b"]); - assert.equal(result.command, "echo"); - assert.deepEqual(result.args, ["a", "b"]); + const args = ["-e", "process.stdout.write('ok')"]; + const result = runCommand(NODE_BIN, args); + assert.equal(result.command, NODE_BIN); + assert.deepEqual(result.args, args); }); it("accepts input via options.input", () => { - const result = runCommand("cat", [], { input: "stdin data" }); + const result = runCommand( + NODE_BIN, + ["-e", "process.stdin.pipe(process.stdout)"], + { input: "stdin data" } + ); assert.equal(result.stdout, "stdin data"); }); it("does not route commands through a shell", () => { - /** @type {{ shell?: boolean } | null} */ + /** @type {{ shell?: boolean, windowsHide?: boolean } | null} */ let capturedOptions = null; const result = runCommand("echo", ["hello"], { spawnSyncImpl: (_command, _args, options) => { @@ -77,13 +92,15 @@ describe("runCommand", () => { assert.equal(result.status, 0); assert.equal(capturedOptions?.shell, false); + assert.equal(capturedOptions?.windowsHide, true); }); - it("passes maxBuffer through to spawnSync", () => { - /** @type {{ maxBuffer?: number } | null} */ + it("passes resource limits through to spawnSync", () => { + /** @type {{ maxBuffer?: number, timeout?: number } | null} */ let capturedOptions = null; const result = runCommand("echo", ["hello"], { maxBuffer: 1234, + timeout: 2500, spawnSyncImpl: (_command, _args, options) => { capturedOptions = options; return { @@ -98,6 +115,7 @@ describe("runCommand", () => { assert.equal(result.status, 0); assert.equal(capturedOptions?.maxBuffer, 1234); + assert.equal(capturedOptions?.timeout, 2500); }); }); @@ -107,7 +125,10 @@ describe("runCommand", () => { describe("runCommandChecked", () => { it("returns result for successful command", () => { - const result = runCommandChecked("echo", ["ok"]); + const result = runCommandChecked(NODE_BIN, [ + "-e", + "process.stdout.write('ok\\n')", + ]); assert.equal(result.status, 0); assert.equal(result.stdout.trim(), "ok"); }); @@ -115,7 +136,10 @@ describe("runCommandChecked", () => { it("throws on non-zero exit code", () => { assert.throws( () => runCommandChecked(NODE_BIN, ["-e", "process.exit(1)"]), - (err) => err instanceof Error && err.message.includes("exit=1") + (err) => + err instanceof Error && + err.message.includes("exit=1") && + /** @type {Error & { status?: number }} */ (err).status === 1 ); }); @@ -127,6 +151,24 @@ describe("runCommandChecked", () => { /** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT" ); }); + + it("classifies a timeout signalled by spawnSync", () => { + assert.throws( + () => + runCommandChecked("powershell.exe", [], { + timeout: 10, + spawnSyncImpl: () => ({ + status: null, + signal: "SIGTERM", + stdout: "", + stderr: "", + error: null, + }), + }), + (error) => + /** @type {NodeJS.ErrnoException} */ (error).code === "ETIMEDOUT" + ); + }); }); // --------------------------------------------------------------------------- @@ -261,6 +303,22 @@ describe("terminateProcessTree", () => { }); assert.equal(result.attempted, true); assert.equal(result.delivered, false); + assert.equal(result.reason, "process-missing"); + }); + + it("reports a missing direct process after group fallback", () => { + const result = terminateProcessTree(12345, { + platform: "linux", + killImpl: (pid) => { + const code = pid < 0 ? "EPERM" : "ESRCH"; + throw Object.assign(new Error(code), { code }); + }, + }); + + assert.equal(result.attempted, true); + assert.equal(result.delivered, false); + assert.equal(result.method, "process"); + assert.equal(result.reason, "process-missing"); }); }); @@ -315,6 +373,439 @@ describe("terminateProcessTree", () => { }); }); +describe("terminateProcessTreeIfIdentityMatches", () => { + it("checks Windows identity and dispatches taskkill in one PowerShell turn", () => { + let capturedCommand = ""; + let capturedArgs = []; + let capturedOptions = {}; + const result = terminateProcessTreeIfIdentityMatches( + 12345, + "133987654321000000", + { + platform: "win32", + runCommandImpl: (command, args, options) => { + capturedCommand = command; + capturedArgs = args; + capturedOptions = options; + return { error: null, status: 0, stdout: "", stderr: "" }; + }, + } + ); + + assert.equal(capturedCommand, "powershell.exe"); + assert.deepEqual(capturedArgs.slice(0, 4), [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + ]); + assert.match(capturedArgs.at(-1), /ProcessId = 12345/u); + assert.match(capturedArgs.at(-1), /133987654321000000/u); + assert.match(capturedArgs.at(-1), /\$creationTime = \[DateTime\]\$target\.CreationDate/u); + assert.match(capturedArgs.at(-1), /taskkill\.exe \/PID 12345 \/T \/F/u); + assert.match(capturedArgs.at(-1), /\$remaining = Get-CimInstance/u); + assert.match(capturedArgs.at(-1), /-ErrorAction Stop/u); + assert.match(capturedArgs.at(-1), /exit 241/u); + assert.match(capturedArgs.at(-1), /exit 244/u); + assert.match(capturedArgs.at(-1), /exit 245/u); + assert.match(capturedArgs.at(-1), /; /u); + assert.equal(capturedOptions.windowsHide, true); + assert.equal(capturedOptions.timeout, 10_000); + assert.equal(result.attempted, true); + assert.equal(result.delivered, true); + assert.equal(result.method, "identity-checked-taskkill"); + }); + + it("distinguishes missing, recycled, and unavailable Windows identities", () => { + const missing = terminateProcessTreeIfIdentityMatches( + 12345, + "133987654321000000", + { + platform: "win32", + runCommandImpl: () => ({ + error: null, + status: 241, + stdout: "", + stderr: "", + }), + } + ); + const recycled = terminateProcessTreeIfIdentityMatches( + 12345, + "133987654321000000", + { + platform: "win32", + runCommandImpl: () => ({ + error: null, + status: 242, + stdout: "", + stderr: "", + }), + } + ); + const unavailable = terminateProcessTreeIfIdentityMatches( + 12345, + "133987654321000000", + { + platform: "win32", + runCommandImpl: () => ({ + error: null, + status: 244, + stdout: "", + stderr: "", + }), + } + ); + const noisyMissing = terminateProcessTreeIfIdentityMatches( + 12345, + "133987654321000000", + { + platform: "win32", + runCommandImpl: () => ({ + error: null, + status: 241, + stdout: "", + stderr: "RPC server is unavailable", + }), + } + ); + const whitespaceMissing = terminateProcessTreeIfIdentityMatches( + 12345, + "133987654321000000", + { + platform: "win32", + runCommandImpl: () => ({ + error: null, + status: 241, + stdout: "", + stderr: " ", + }), + } + ); + const exitedDuringTermination = terminateProcessTreeIfIdentityMatches( + 12345, + "133987654321000000", + { + platform: "win32", + runCommandImpl: () => ({ + error: null, + status: 245, + stdout: "", + stderr: "ERROR: The process was not found.", + }), + } + ); + + assert.equal(missing.reason, "process-missing"); + assert.equal(recycled.reason, "identity-mismatch"); + assert.equal(unavailable.reason, "identity-unavailable"); + assert.equal(noisyMissing.reason, "identity-unavailable"); + assert.equal(whitespaceMissing.reason, "process-missing"); + assert.equal(exitedDuringTermination.reason, "process-missing"); + assert.equal(exitedDuringTermination.attempted, true); + assert.equal(exitedDuringTermination.delivered, false); + assert.equal(exitedDuringTermination.method, "identity-checked-taskkill"); + assert.equal(missing.attempted, true); + assert.equal(recycled.attempted, true); + assert.equal(missing.method, "identity-checked-taskkill"); + assert.equal(recycled.method, "identity-checked-taskkill"); + assert.equal(missing.delivered, false); + assert.equal(recycled.delivered, false); + }); + + it("fails closed for invalid identities, timeouts, and PowerShell errors", () => { + assert.deepEqual(terminateProcessTreeIfIdentityMatches(12345, null), { + attempted: false, + delivered: false, + method: null, + reason: "identity-unavailable", + }); + assert.deepEqual( + terminateProcessTreeIfIdentityMatches(12345, "not-digits", { + platform: "win32", + }), + { + attempted: false, + delivered: false, + method: null, + reason: "identity-unavailable", + } + ); + const timeout = terminateProcessTreeIfIdentityMatches( + 12345, + "133987654321000000", + { + platform: "win32", + runCommandImpl: () => ({ + error: Object.assign(new Error("timed out"), { code: "ETIMEDOUT" }), + status: 0, + signal: "SIGTERM", + stdout: "", + stderr: "", + }), + } + ); + assert.equal(timeout.attempted, true); + assert.equal(timeout.delivered, false); + assert.equal(timeout.method, "identity-checked-taskkill"); + assert.equal(timeout.reason, "identity-unavailable"); + const commandError = Object.assign(new Error("PowerShell failed"), { + code: "EIO", + }); + assert.throws( + () => + terminateProcessTreeIfIdentityMatches( + 12345, + "133987654321000000", + { + platform: "win32", + runCommandImpl: () => ({ + error: commandError, + status: 0, + signal: null, + stdout: "", + stderr: "", + }), + } + ), + (error) => error === commandError + ); + assert.throws( + () => + terminateProcessTreeIfIdentityMatches( + 12345, + "133987654321000000", + { + platform: "win32", + runCommandImpl: () => ({ + error: null, + status: 243, + stdout: "", + stderr: "taskkill failed", + command: "powershell.exe", + args: [], + signal: null, + }), + } + ), + /taskkill failed/u + ); + }); + + it("distinguishes POSIX identity mismatch, lookup failure, and exit", () => { + let terminated = false; + const mismatched = terminateProcessTreeIfIdentityMatches( + 12345, + "identity", + { + platform: "linux", + getProcessIdentityImpl: () => "different-identity", + terminateProcessTreeImpl: () => { + terminated = true; + }, + } + ); + const matched = terminateProcessTreeIfIdentityMatches( + 12345, + "identity", + { + platform: "linux", + getProcessIdentityImpl: () => "identity", + terminateProcessTreeImpl: () => ({ + attempted: true, + delivered: true, + method: "process-group", + }), + } + ); + const unavailable = terminateProcessTreeIfIdentityMatches( + 12345, + "identity", + { + platform: "linux", + getProcessIdentityImpl: () => { + throw Object.assign(new Error("lookup failed"), { code: "EAGAIN" }); + }, + isProcessAliveImpl: () => true, + } + ); + const missing = terminateProcessTreeIfIdentityMatches( + 12345, + "identity", + { + platform: "linux", + getProcessIdentityImpl: () => { + throw Object.assign(new Error("missing"), { code: "ENOENT" }); + }, + isProcessAliveImpl: () => false, + } + ); + + assert.equal(terminated, false); + assert.equal(mismatched.reason, "identity-mismatch"); + assert.equal(matched.delivered, true); + assert.equal(unavailable.reason, "identity-unavailable"); + assert.equal(unavailable.delivered, false); + assert.equal(missing.reason, "process-missing"); + assert.equal(missing.delivered, false); + }); + + it("retries the Windows identity circuit after cooldown without latching EIO", async () => { + const originalSpawnSync = childProcess.spawnSync; + const originalDateNow = Date.now; + const importKey = originalDateNow(); + let powershellSpawns = 0; + let now = 10_000; + let mode = "unavailable"; + Reflect.set(Date, "now", () => now); + Reflect.set(childProcess, "spawnSync", (command, _args, _options) => { + if (command === "powershell.exe") { + powershellSpawns += 1; + if (mode === "success") { + return { + error: null, + status: 0, + signal: null, + stdout: "133987654321000000\r\n", + stderr: "", + }; + } + if (mode === "eio") { + return { + error: Object.assign(new Error("temporary PowerShell failure"), { + code: "EIO", + }), + status: null, + signal: null, + stdout: "", + stderr: "", + }; + } + if (mode === "unavailable") { + return { + error: null, + status: 244, + signal: null, + stdout: "", + stderr: "", + }; + } + return { + error: Object.assign(new Error("timed out"), { code: "ETIMEDOUT" }), + status: null, + signal: "SIGTERM", + stdout: "", + stderr: "", + }; + } + return originalSpawnSync(command, _args, _options); + }); + syncBuiltinESMExports(); + + try { + const isolated = await import( + `../scripts/lib/process.mjs?identity-circuit=${importKey}` + ); + mode = "success"; + assert.equal( + isolated.getProcessIdentity(12344, { platform: "win32" }), + "133987654321000000" + ); + assert.equal(powershellSpawns, 1); + + mode = "unavailable"; + const first = isolated.terminateProcessTreeIfIdentityMatches( + 12345, + "133987654321000000", + { platform: "win32" } + ); + const second = isolated.terminateProcessTreeIfIdentityMatches( + 12346, + "133987654321000001", + { platform: "win32" } + ); + const third = isolated.terminateProcessTreeIfIdentityMatches( + 12347, + "133987654321000002", + { platform: "win32" } + ); + + assert.equal(first.attempted, true); + assert.equal(first.reason, "identity-unavailable"); + assert.equal(second.attempted, true); + assert.equal(second.reason, "identity-unavailable"); + assert.equal(third.attempted, true); + assert.equal(third.reason, "identity-unavailable"); + assert.throws( + () => isolated.getProcessIdentity(process.pid, { platform: "win32" }), + (error) => + /** @type {NodeJS.ErrnoException} */ (error).code === "ETIMEDOUT" + ); + assert.equal(powershellSpawns, 4); + + mode = "success"; + assert.equal( + isolated.getSpawnedProcessIdentity(12348, { platform: "win32" }), + "133987654321000000" + ); + assert.equal(powershellSpawns, 5); + + mode = "unavailable"; + const retripped = isolated.terminateProcessTreeIfIdentityMatches( + 12348, + "133987654321000003", + { platform: "win32" } + ); + assert.equal(retripped.attempted, true); + assert.equal(retripped.reason, "identity-unavailable"); + assert.equal(powershellSpawns, 6); + + now += 59_999; + assert.throws( + () => isolated.getProcessIdentity(12349, { platform: "win32" }), + (error) => + /** @type {NodeJS.ErrnoException} */ (error).code === "ETIMEDOUT" + ); + assert.equal(powershellSpawns, 6); + + now += 1; + mode = "timeout"; + assert.throws( + () => isolated.getProcessIdentity(12349, { platform: "win32" }), + (error) => + /** @type {NodeJS.ErrnoException} */ (error).code === "ETIMEDOUT" + ); + assert.equal(powershellSpawns, 7); + + mode = "success"; + const recovered = isolated.terminateProcessTreeIfIdentityMatches( + 12350, + "133987654321000005", + { platform: "win32" } + ); + assert.equal(recovered.delivered, true); + assert.equal(powershellSpawns, 8); + + mode = "eio"; + assert.throws( + () => isolated.getProcessIdentity(process.pid, { platform: "win32" }), + (error) => + /** @type {NodeJS.ErrnoException} */ (error).code === "EIO" + ); + mode = "success"; + assert.equal( + isolated.getProcessIdentity(process.pid, { platform: "win32" }), + "133987654321000000" + ); + assert.equal(powershellSpawns, 10); + } finally { + Reflect.set(Date, "now", originalDateNow); + Reflect.set(childProcess, "spawnSync", originalSpawnSync); + syncBuiltinESMExports(); + } + }); +}); + // --------------------------------------------------------------------------- // isProcessAlive // --------------------------------------------------------------------------- @@ -328,6 +819,42 @@ describe("isProcessAlive", () => { // PID 99999999 is extremely unlikely to exist assert.equal(isProcessAlive(99999999), false); }); + + it("treats EPERM as alive and ESRCH as missing", () => { + const error = (code) => () => { + throw Object.assign(new Error(code), { code }); + }; + + assert.equal(isProcessAlive(12345, { killImpl: error("EPERM") }), true); + assert.equal(isProcessAlive(12345, { killImpl: error("ESRCH") }), false); + }); +}); + +describe("isProcessGroupAlive", () => { + it("treats EPERM as alive and ESRCH as missing", () => { + let probe = null; + const error = (code) => () => { + throw Object.assign(new Error(code), { code }); + }; + + assert.equal( + isProcessGroupAlive(12345, { + killImpl: (pid, signal) => { + probe = [pid, signal]; + }, + }), + true + ); + assert.deepEqual(probe, [-12345, 0]); + assert.equal( + isProcessGroupAlive(12345, { killImpl: error("EPERM") }), + true + ); + assert.equal( + isProcessGroupAlive(12345, { killImpl: error("ESRCH") }), + false + ); + }); }); // --------------------------------------------------------------------------- @@ -346,6 +873,132 @@ describe("getProcessIdentity", () => { const id2 = getProcessIdentity(process.pid); assert.equal(id1, id2); }); + + it("uses a stable CIM creation time on Windows", () => { + let capturedCommand = ""; + let capturedArgs = []; + let capturedOptions = {}; + const identity = getProcessIdentity(12345, { + platform: "win32", + runCommandCheckedImpl: (command, args, options) => { + capturedCommand = command; + capturedArgs = args; + capturedOptions = options; + return { stdout: "133987654321000000\r\n" }; + }, + }); + + assert.equal(identity, "133987654321000000"); + assert.equal(capturedCommand, "powershell.exe"); + assert.deepEqual(capturedArgs.slice(0, 3), [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + ]); + assert.equal(capturedArgs[3], "-Command"); + assert.match(capturedArgs.at(-1), /ProcessId = 12345/u); + assert.match(capturedArgs.at(-1), /-ErrorAction Stop/u); + assert.match(capturedArgs.at(-1), /exit 244/u); + assert.match(capturedArgs.at(-1), /\[DateTime\]\$process\.CreationDate/u); + assert.match(capturedArgs.at(-1), /\$creationTime\.ToFileTimeUtc/u); + assert.equal(capturedOptions.timeout, 10_000); + assert.equal(capturedOptions.windowsHide, true); + }); + + it("rejects invalid PIDs and malformed Windows creation times", () => { + assert.throws(() => getProcessIdentity(0), /positive integer/u); + for (const stdout of ["not-a-timestamp\n", "x133987654321000000\n", "133987654321000000x\n"]) { + assert.throws( + () => + getProcessIdentity(12345, { + platform: "win32", + runCommandCheckedImpl: () => ({ stdout }), + }), + /creation time was unavailable/u + ); + } + }); + + it("extracts Linux start time from proc stat after names containing spaces", () => { + const fields = Array.from({ length: 20 }, (_, index) => String(index)); + fields[19] = "stable-start-time"; + let requestedPath = null; + + const identity = getProcessIdentity(12345, { + platform: "linux", + readFileSyncImpl: (filePath, encoding) => { + requestedPath = filePath; + assert.equal(encoding, "utf8"); + return `12345 (node worker process) ${fields.join(" ")}`; + }, + }); + + assert.equal(requestedPath, "/proc/12345/stat"); + assert.equal(identity, "stable-start-time"); + }); + + it("trims Darwin ps identity output", () => { + const identity = getProcessIdentity(12345, { + platform: "darwin", + runCommandCheckedImpl: () => ({ stdout: " stable-darwin-identity \n" }), + }); + + assert.equal(identity, "stable-darwin-identity"); + }); + + it("executes CIM identity and identity-checked tree termination on Windows", async () => { + if (process.platform !== "win32") { + return; + } + + const isolated = await import( + `../scripts/lib/process.mjs?real-cim=${Date.now()}-${Math.random()}` + ); + const firstIdentity = isolated.getProcessIdentity(process.pid); + const secondIdentity = isolated.getProcessIdentity(process.pid); + assert.match(firstIdentity, /^\d+$/u); + assert.equal(secondIdentity, firstIdentity); + assert.throws(() => isolated.getProcessIdentity(99_999_999)); + + const child = spawn( + process.execPath, + ["-e", "setInterval(() => {}, 1000)"], + { + detached: true, + stdio: "ignore", + windowsHide: true, + } + ); + try { + let childIdentity = null; + for (let attempt = 0; attempt < 30 && !childIdentity; attempt++) { + try { + childIdentity = isolated.getProcessIdentity(child.pid); + } catch { + await delay(100); + } + } + assert.match(childIdentity, /^\d+$/u); + + const result = isolated.terminateProcessTreeIfIdentityMatches( + child.pid, + childIdentity + ); + assert.equal(result.delivered, true); + for ( + let attempt = 0; + attempt < 30 && isolated.isProcessAlive(child.pid); + attempt++ + ) { + await delay(100); + } + assert.equal(isolated.isProcessAlive(child.pid), false); + } finally { + if (isolated.isProcessAlive(child.pid)) { + isolated.terminateProcessTree(child.pid); + } + } + }); }); describe("validateProcessIdentity", () => { diff --git a/tests/render.test.mjs b/tests/render.test.mjs index a539eb1..d64af3e 100644 --- a/tests/render.test.mjs +++ b/tests/render.test.mjs @@ -599,6 +599,81 @@ describe("renderJobStatusReport", () => { assert.ok(output.includes("| Model fallback | claude-opus-4-8 -> claude-sonnet-5 (capacity) |")); }); + + it("shows Windows process-tree cleanup for failed cancellation", () => { + const output = renderJobStatusReport( + { + id: "j4", + status: "cancel_failed", + pid: 12345, + pgid: 54321, + pidIdentity: "133820000000000000", + }, + "win32" + ); + + assert.ok(output.includes("Verify process")); + assert.ok(output.includes("133820000000000000")); + assert.ok(output.includes("Manual cleanup (after verification)")); + assert.ok(output.includes("taskkill /PID 54321 /T /F")); + assert.ok(!output.includes("12345")); + assert.ok(!output.includes("kill -9")); + }); + + it("shows Windows cleanup for a failed job that retains a live PID", () => { + const output = renderJobStatusReport( + { + id: "j5", + status: "failed", + pid: 12345, + pidIdentity: "2026-07-27T00:00:00.000Z", + }, + "win32" + ); + + assert.ok(output.includes("Verify process")); + assert.ok(output.includes("Get-CimInstance Win32_Process")); + assert.ok(output.includes("2026-07-27T00:00:00.000Z")); + assert.ok(output.includes("Manual cleanup (after verification)")); + assert.ok(output.includes("taskkill /PID 12345 /T /F")); + }); + + it("does not suggest cleanup without a failed live Windows process", () => { + const reports = [ + renderJobStatusReport( + { id: "j6", status: "failed", pid: 12345 }, + "linux" + ), + renderJobStatusReport({ id: "j7", status: "failed" }, "win32"), + renderJobStatusReport( + { id: "j8", status: "completed", pid: 12345 }, + "win32" + ), + ]; + + for (const output of reports) { + assert.ok(!output.includes("Manual cleanup")); + } + }); + + it("shows POSIX group cleanup for cancel_failed status", () => { + const output = renderJobStatusReport( + { id: "j9", status: "cancel_failed", pgid: 12345 }, + "linux" + ); + assert.ok(output.includes("Manual cleanup")); + assert.ok(output.includes("kill -9 -12345")); + assert.ok(!output.includes("Verify process")); + }); + + it("omits status cleanup when cancel_failed has no recorded process", () => { + const output = renderJobStatusReport( + { id: "j10", status: "cancel_failed" }, + "linux" + ); + assert.ok(!output.includes("Manual cleanup")); + assert.ok(!output.includes("kill -9")); + }); }); // --------------------------------------------------------------------------- @@ -763,8 +838,41 @@ describe("renderCancelReport", () => { }); it("shows manual cleanup warning for cancel_failed", () => { - const output = renderCancelReport({ id: "j1", status: "cancel_failed", pgid: 12345 }); + const output = renderCancelReport( + { id: "j1", status: "cancel_failed", pgid: 12345 }, + "linux" + ); assert.ok(output.includes("Manual cleanup")); assert.ok(output.includes("kill -9 -12345")); + assert.ok(!output.includes("Verify process")); + }); + + it("shows Windows process-tree cleanup for cancel_failed", () => { + const output = renderCancelReport( + { + id: "j1", + status: "cancel_failed", + pid: 12345, + pgid: 54321, + pidIdentity: "133820000000000000", + }, + "win32" + ); + assert.ok(output.includes("Verify process before cleanup")); + assert.ok(output.includes("Get-CimInstance Win32_Process")); + assert.ok(output.includes("133820000000000000")); + assert.ok(output.includes("Manual cleanup (after verification)")); + assert.ok(output.includes("taskkill /PID 54321 /T /F")); + assert.ok(!output.includes("12345")); + assert.ok(!output.includes("kill -9")); + }); + + it("does not render a destructive command when cancel_failed has no PID", () => { + const output = renderCancelReport( + { id: "j1", status: "cancel_failed" }, + "win32" + ); + assert.ok(output.includes("no cleanup PID was recorded")); + assert.ok(!output.includes("taskkill")); }); }); diff --git a/tests/state.test.mjs b/tests/state.test.mjs index 83fb94b..621ee90 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -4,8 +4,9 @@ */ import { describe, it, before, after, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; +import childProcess, { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; import os from "node:os"; import path from "node:path"; import { createHash } from "node:crypto"; @@ -42,6 +43,7 @@ import { resolveJobLogFile, nowIso, } from "../scripts/lib/state.mjs"; +import { getProcessIdentity } from "../scripts/lib/process.mjs"; // 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)), ".."); @@ -59,6 +61,14 @@ function createTempGitRepo() { return dir; } +function isLockBusyError(error) { + const lockError = + /** @type {NodeJS.ErrnoException & { cause?: NodeJS.ErrnoException }} */ ( + error + ); + return lockError.code === "ELOCKBUSY" && lockError.cause?.code === "EEXIST"; +} + // --------------------------------------------------------------------------- // resolveWorkspaceHash // --------------------------------------------------------------------------- @@ -548,6 +558,596 @@ describe("casJobStatus", () => { const lockFile = path.join(resolveJobsDir(PROJECT_CWD), `${jobId}.json.lock`); assert.ok(!fs.existsSync(lockFile), "Lock file should be removed after CAS"); }); + + it("does not remove a lock whose ownership token changed", () => { + writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); + const lockFile = path.join(resolveJobsDir(PROJECT_CWD), `${jobId}.json.lock`); + const originalReadFileSync = fs.readFileSync; + Reflect.set(fs, "readFileSync", (filePath, ...args) => { + if (String(filePath) === lockFile && fs.existsSync(lockFile)) { + return JSON.stringify({ token: "replacement-owner" }); + } + return originalReadFileSync(filePath, ...args); + }); + syncBuiltinESMExports(); + + try { + assert.equal( + casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + true + ); + assert.equal(fs.existsSync(lockFile), true); + } finally { + Reflect.set(fs, "readFileSync", originalReadFileSync); + syncBuiltinESMExports(); + } + }); + + it("does not unlink stale lock contents that another owner replaced", () => { + writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); + const lockFile = path.join(resolveJobsDir(PROJECT_CWD), `${jobId}.json.lock`); + const replacement = JSON.stringify({ + pid: process.pid, + identity: null, + timestamp: Date.now(), + token: "replacement-owner", + }); + fs.writeFileSync(lockFile, "{"); + const hardStaleTime = new Date(Date.now() - 121_000); + fs.utimesSync(lockFile, hardStaleTime, hardStaleTime); + + const originalReadFileSync = fs.readFileSync; + const originalWriteFileSync = fs.writeFileSync; + let lockReads = 0; + Reflect.set(fs, "readFileSync", (filePath, ...args) => { + if (String(filePath) === lockFile && ++lockReads === 2) { + originalWriteFileSync(lockFile, replacement); + return replacement; + } + return originalReadFileSync(filePath, ...args); + }); + syncBuiltinESMExports(); + + try { + assert.throws( + () => casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + isLockBusyError + ); + assert.equal(fs.readFileSync(lockFile, "utf8"), replacement); + } finally { + Reflect.set(fs, "readFileSync", originalReadFileSync); + syncBuiltinESMExports(); + } + }); + + it("retries a transient atomic-publication collision", () => { + writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); + const lockFile = path.join(resolveJobsDir(PROJECT_CWD), `${jobId}.json.lock`); + const originalLinkSync = fs.linkSync; + let lockLinks = 0; + Reflect.set(fs, "linkSync", (existingPath, newPath) => { + if (String(newPath) === lockFile && ++lockLinks === 1) { + throw Object.assign(new Error("synthetic collision"), { + code: "EEXIST", + }); + } + return originalLinkSync(existingPath, newPath); + }); + syncBuiltinESMExports(); + + try { + assert.equal( + casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + true + ); + assert.equal(lockLinks, 2); + } finally { + Reflect.set(fs, "linkSync", originalLinkSync); + syncBuiltinESMExports(); + } + }); + + it("stops after the configured number of lock collisions", () => { + writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); + const lockFile = path.join(resolveJobsDir(PROJECT_CWD), `${jobId}.json.lock`); + fs.writeFileSync( + lockFile, + JSON.stringify({ + pid: process.pid, + identity: null, + timestamp: Date.now(), + }) + ); + const originalLinkSync = fs.linkSync; + let lockLinks = 0; + Reflect.set(fs, "linkSync", (existingPath, newPath) => { + if (String(newPath) === lockFile) { + lockLinks += 1; + } + return originalLinkSync(existingPath, newPath); + }); + syncBuiltinESMExports(); + + try { + assert.throws( + () => casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + isLockBusyError + ); + assert.equal(lockLinks, 3); + } finally { + Reflect.set(fs, "linkSync", originalLinkSync); + syncBuiltinESMExports(); + } + }); + + it("removes the staged lock when its ownership write fails", () => { + writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); + const lockFile = path.join(resolveJobsDir(PROJECT_CWD), `${jobId}.json.lock`); + const originalWriteFileSync = fs.writeFileSync; + Reflect.set(fs, "writeFileSync", (filePath, ...args) => { + if (String(filePath).startsWith(`${lockFile}.publish.`)) { + throw Object.assign(new Error("synthetic ownership write failure"), { + code: "EIO", + }); + } + return originalWriteFileSync(filePath, ...args); + }); + syncBuiltinESMExports(); + + try { + assert.throws( + () => casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + (error) => /** @type {NodeJS.ErrnoException} */ (error).code === "EIO" + ); + assert.equal(fs.existsSync(lockFile), false); + assert.equal( + fs.readdirSync(resolveJobsDir(PROJECT_CWD)).some( + (name) => name.startsWith(`${jobId}.json.lock.publish.`) + ), + false + ); + } finally { + Reflect.set(fs, "writeFileSync", originalWriteFileSync); + syncBuiltinESMExports(); + } + }); + + it("captures owner identity and stages a populated lock before atomic publication", () => { + const result = spawnSync( + process.execPath, + [ + "--input-type=module", + "-e", + ` +import childProcess from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { syncBuiltinESMExports } from "node:module"; + +const originalReadFileSync = fs.readFileSync; +const originalLinkSync = fs.linkSync; +const originalWriteFileSync = fs.writeFileSync; +const originalSpawnSync = childProcess.spawnSync; +let identityResolved = false; +let linkSawIdentity = false; +let linkSawPopulatedSource = false; +let ownershipWriteTarget = null; + +Reflect.set(fs, "readFileSync", (target, ...args) => { + if (String(target) === \`/proc/\${process.pid}/stat\`) { + identityResolved = true; + } + return originalReadFileSync(target, ...args); +}); +Reflect.set(childProcess, "spawnSync", (command, ...args) => { + if (command === "ps" || command === "powershell.exe") { + identityResolved = true; + } + return originalSpawnSync(command, ...args); +}); +Reflect.set(fs, "linkSync", (existingPath, newPath) => { + if (String(newPath).endsWith(".json.lock")) { + linkSawIdentity = identityResolved; + linkSawPopulatedSource = + originalReadFileSync(existingPath, "utf8").includes('"token"'); + } + return originalLinkSync(existingPath, newPath); +}); +Reflect.set(fs, "writeFileSync", (target, data, ...args) => { + if (String(data).includes('"token"')) { + ownershipWriteTarget = typeof target; + } + return originalWriteFileSync(target, data, ...args); +}); +syncBuiltinESMExports(); + +const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), "cc-lock-publication-")); +process.env.CODEX_HOME = stateHome; +try { + const state = await import(${JSON.stringify(STATE_MODULE_URL)} + "?lock-publication=" + Date.now()); + state.writeJobFile(process.env.TEST_PROJECT_CWD, "lock-publication-job", { + id: "lock-publication-job", + status: "running" + }); + const transitioned = state.casJobStatus( + process.env.TEST_PROJECT_CWD, + "lock-publication-job", + "running", + "completed" + ); + process.stdout.write(JSON.stringify({ + transitioned, + linkSawIdentity, + linkSawPopulatedSource, + ownershipWriteTarget + })); +} finally { + fs.rmSync(stateHome, { recursive: true, force: true }); +} +`, + ], + { + cwd: PROJECT_CWD, + env: { + ...process.env, + TEST_PROJECT_CWD: PROJECT_CWD, + }, + encoding: "utf8", + } + ); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.deepEqual(JSON.parse(result.stdout), { + transitioned: true, + linkSawIdentity: true, + linkSawPopulatedSource: true, + ownershipWriteTarget: "string", + }); + }); + + it("keeps a live owner's lock when its identity is unavailable", () => { + writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); + const lockFile = path.join(resolveJobsDir(PROJECT_CWD), `${jobId}.json.lock`); + fs.writeFileSync( + lockFile, + JSON.stringify({ + pid: process.pid, + identity: null, + timestamp: Date.now(), + }) + ); + + assert.throws( + () => casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + isLockBusyError + ); + assert.equal(fs.existsSync(lockFile), true); + assert.equal(readJobFile(PROJECT_CWD, jobId).status, "running"); + }); + + it("recovers a lock whose owner process is dead", () => { + writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); + const lockFile = path.join(resolveJobsDir(PROJECT_CWD), `${jobId}.json.lock`); + fs.writeFileSync( + lockFile, + JSON.stringify({ + pid: 99_999_999, + identity: "dead-owner", + timestamp: Date.now(), + }) + ); + + assert.equal( + casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + true + ); + assert.equal(readJobFile(PROJECT_CWD, jobId).status, "completed"); + }); + + it("recovers a live recycled-PID lock with a mismatched identity", () => { + writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); + const lockFile = path.join(resolveJobsDir(PROJECT_CWD), `${jobId}.json.lock`); + fs.writeFileSync( + lockFile, + JSON.stringify({ + pid: process.pid, + identity: "not-this-process", + timestamp: Date.now() - 31_000, + }) + ); + + assert.equal( + casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + true + ); + assert.equal(readJobFile(PROJECT_CWD, jobId).status, "completed"); + }); + + it("keeps a live lock whose stored identity still matches", () => { + writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); + const lockFile = path.join(resolveJobsDir(PROJECT_CWD), `${jobId}.json.lock`); + fs.writeFileSync( + lockFile, + JSON.stringify({ + pid: process.pid, + identity: getProcessIdentity(process.pid), + timestamp: Date.now(), + }) + ); + + assert.throws( + () => casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + isLockBusyError + ); + assert.equal(fs.existsSync(lockFile), true); + assert.equal(readJobFile(PROJECT_CWD, jobId).status, "running"); + }); + + it("keeps a fresh malformed lock fail-closed", () => { + writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); + const lockFile = path.join(resolveJobsDir(PROJECT_CWD), `${jobId}.json.lock`); + fs.writeFileSync(lockFile, "{"); + + assert.throws( + () => casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + isLockBusyError + ); + assert.equal(fs.existsSync(lockFile), true); + }); + + it("keeps a malformed lock throughout the Windows identity timeout window", () => { + writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); + const lockFile = path.join(resolveJobsDir(PROJECT_CWD), `${jobId}.json.lock`); + fs.writeFileSync(lockFile, ""); + const stillPublishing = new Date(Date.now() - 11_000); + fs.utimesSync(lockFile, stillPublishing, stillPublishing); + + assert.throws( + () => casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + isLockBusyError + ); + assert.equal(fs.existsSync(lockFile), true); + }); + + it("recovers a malformed lock after the stale grace period", () => { + writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); + const lockFile = path.join(resolveJobsDir(PROJECT_CWD), `${jobId}.json.lock`); + fs.writeFileSync(lockFile, "{"); + const staleTime = new Date(Date.now() - 16_000); + fs.utimesSync(lockFile, staleTime, staleTime); + + assert.equal( + casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + true + ); + assert.equal(readJobFile(PROJECT_CWD, jobId).status, "completed"); + }); + + it("recovers a stale lock with an invalid owner PID", () => { + writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); + const lockFile = path.join(resolveJobsDir(PROJECT_CWD), `${jobId}.json.lock`); + fs.writeFileSync( + lockFile, + JSON.stringify({ + pid: 0, + identity: null, + timestamp: Date.now(), + }) + ); + const staleTime = new Date(Date.now() - 16_000); + fs.utimesSync(lockFile, staleTime, staleTime); + + assert.equal( + casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + true + ); + assert.equal(readJobFile(PROJECT_CWD, jobId).status, "completed"); + }); + + it("keeps a fresh lock with an invalid owner PID fail-closed", () => { + writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); + const lockFile = path.join(resolveJobsDir(PROJECT_CWD), `${jobId}.json.lock`); + fs.writeFileSync( + lockFile, + JSON.stringify({ + pid: 0, + identity: null, + timestamp: Date.now(), + }) + ); + + assert.throws( + () => casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + isLockBusyError + ); + assert.equal(fs.existsSync(lockFile), true); + assert.equal(readJobFile(PROJECT_CWD, jobId).status, "running"); + }); + + it("keeps an old live-owner lock without a verifiable identity", () => { + writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); + const lockFile = path.join(resolveJobsDir(PROJECT_CWD), `${jobId}.json.lock`); + fs.writeFileSync( + lockFile, + JSON.stringify({ + pid: process.pid, + identity: null, + timestamp: Date.now() - 31_000, + }) + ); + + assert.throws( + () => casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + isLockBusyError + ); + assert.equal(fs.existsSync(lockFile), true); + assert.equal(readJobFile(PROJECT_CWD, jobId).status, "running"); + }); + + it("keeps an old live-owner lock with a non-string identity", () => { + writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); + const lockFile = path.join(resolveJobsDir(PROJECT_CWD), `${jobId}.json.lock`); + fs.writeFileSync( + lockFile, + JSON.stringify({ + pid: process.pid, + identity: 42, + timestamp: Date.now() - 31_000, + }) + ); + + assert.throws( + () => casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + isLockBusyError + ); + assert.equal(fs.existsSync(lockFile), true); + assert.equal(readJobFile(PROJECT_CWD, jobId).status, "running"); + }); + + it("recovers an unverifiable live-PID lock after the hard age cap", () => { + writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); + const lockFile = path.join(resolveJobsDir(PROJECT_CWD), `${jobId}.json.lock`); + fs.writeFileSync( + lockFile, + JSON.stringify({ + pid: process.pid, + identity: null, + timestamp: Date.now() - 121_000, + }) + ); + const hardStaleTime = new Date(Date.now() - 121_000); + fs.utimesSync(lockFile, hardStaleTime, hardStaleTime); + + assert.equal( + casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + true + ); + assert.equal(readJobFile(PROJECT_CWD, jobId).status, "completed"); + }); + + it("leases a lock when owner identity lookup throws", async () => { + writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); + const owner = spawn( + process.execPath, + ["-e", "setInterval(() => {}, 1000)"], + { stdio: "ignore", windowsHide: true } + ); + await new Promise((resolve, reject) => { + owner.once("spawn", resolve); + owner.once("error", reject); + }); + + const lockFile = path.join(resolveJobsDir(PROJECT_CWD), `${jobId}.json.lock`); + fs.writeFileSync( + lockFile, + JSON.stringify({ + pid: owner.pid, + identity: "unverifiable-owner", + timestamp: Date.now(), + }) + ); + + const originalReadFileSync = fs.readFileSync; + const originalSpawnSync = childProcess.spawnSync; + let identityLookups = 0; + Reflect.set(fs, "readFileSync", (filePath, ...args) => { + if (String(filePath) === `/proc/${owner.pid}/stat`) { + identityLookups += 1; + throw Object.assign(new Error("synthetic identity failure"), { + code: "EIO", + }); + } + return originalReadFileSync(filePath, ...args); + }); + Reflect.set(childProcess, "spawnSync", (command, args, ...rest) => { + const targetsOwner = + (command === "ps" && args?.at(-1) === String(owner.pid)) || + (command === "powershell.exe" && + args?.at(-1)?.includes(`ProcessId = ${owner.pid}`)); + if (targetsOwner) { + identityLookups += 1; + return { + error: Object.assign(new Error("synthetic identity failure"), { + code: "EIO", + }), + status: null, + signal: null, + stdout: "", + stderr: "", + }; + } + return originalSpawnSync(command, args, ...rest); + }); + syncBuiltinESMExports(); + + try { + assert.throws( + () => casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + isLockBusyError + ); + assert.equal(fs.existsSync(lockFile), true); + assert.equal(identityLookups, 0); + fs.writeFileSync( + lockFile, + JSON.stringify({ + pid: owner.pid, + identity: "unverifiable-owner", + timestamp: Date.now() - 31_000, + }) + ); + assert.throws( + () => casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + isLockBusyError + ); + assert.equal(fs.existsSync(lockFile), true); + assert.equal(readJobFile(PROJECT_CWD, jobId).status, "running"); + assert.ok(identityLookups >= 1); + + const hardStaleTime = new Date(Date.now() - 121_000); + fs.utimesSync(lockFile, hardStaleTime, hardStaleTime); + assert.equal( + casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + true + ); + assert.equal(readJobFile(PROJECT_CWD, jobId).status, "completed"); + } finally { + Reflect.set(fs, "readFileSync", originalReadFileSync); + Reflect.set(childProcess, "spawnSync", originalSpawnSync); + syncBuiltinESMExports(); + owner.kill(); + } + }); + + it("falls back to exclusive lock creation when hard links are unsupported", () => { + writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); + const originalLinkSync = fs.linkSync; + let linkAttempts = 0; + Reflect.set(fs, "linkSync", () => { + linkAttempts += 1; + throw Object.assign(new Error("hard links unavailable"), { + code: "EPERM", + }); + }); + syncBuiltinESMExports(); + + try { + assert.equal( + casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + true + ); + writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); + assert.equal( + casJobStatus(PROJECT_CWD, jobId, "running", "completed"), + true + ); + assert.equal(linkAttempts, 1); + assert.equal(readJobFile(PROJECT_CWD, jobId).status, "completed"); + } finally { + Reflect.set(fs, "linkSync", originalLinkSync); + syncBuiltinESMExports(); + } + }); }); // --------------------------------------------------------------------------- @@ -699,6 +1299,21 @@ describe("cleanupOldJobs", () => { logFile: resolveJobLogFile(repoDir, prunedId), }); fs.writeFileSync(resolveJobLogFile(repoDir, prunedId), "old log\n", "utf8"); + const prunedIdentityCheck = path.join( + resolveJobsDir(repoDir), + `${prunedId}.json.identity-check` + ); + const prunedIdentityProbe = path.join( + resolveJobsDir(repoDir), + `${prunedId}.json.identity-probe` + ); + const prunedIdentityUnavailable = path.join( + resolveJobsDir(repoDir), + `${prunedId}.json.identity-unavailable` + ); + fs.writeFileSync(prunedIdentityCheck, "", "utf8"); + fs.writeFileSync(prunedIdentityProbe, "", "utf8"); + fs.writeFileSync(prunedIdentityUnavailable, "", "utf8"); for (let i = 0; i < 100; i++) { const sessionAId = `test-retain-session-a-keep-${i}`; @@ -727,6 +1342,9 @@ describe("cleanupOldJobs", () => { assert.ok(readJobFile(repoDir, runningId), "running job should be preserved"); assert.equal(readJobFile(repoDir, prunedId), null); assert.equal(fs.existsSync(resolveJobLogFile(repoDir, prunedId)), false); + assert.equal(fs.existsSync(prunedIdentityCheck), false); + assert.equal(fs.existsSync(prunedIdentityProbe), false); + assert.equal(fs.existsSync(prunedIdentityUnavailable), false); assert.equal(sessionAJobs.length, 100); assert.equal(sessionBJobs.length, 100); assert.ok(sessionBJobs.some((job) => job.id === "test-retain-session-b-keep-99")); @@ -774,23 +1392,45 @@ describe("cleanupOldJobs", () => { } }); - it("removes stale reserved job marker files", () => { + it("removes stale reservation and staged-lock files", () => { const repoDir = createTempGitRepo(); try { const jobsDir = resolveJobsDir(repoDir); fs.mkdirSync(jobsDir, { recursive: true }); const staleReservation = path.join(jobsDir, "review-stale.reserve"); const freshReservation = path.join(jobsDir, "review-fresh.reserve"); + const staleStagedLock = path.join( + jobsDir, + `review-stale.json.lock.publish.123.${"a".repeat(32)}` + ); + const freshStagedLock = path.join( + jobsDir, + `review-fresh.json.lock.publish.123.${"b".repeat(32)}` + ); + const deceptiveId = "review.json.lock.publish.123"; + const deceptiveJobFile = path.join(jobsDir, `${deceptiveId}.json`); fs.writeFileSync(staleReservation, "{}", "utf8"); fs.writeFileSync(freshReservation, "{}", "utf8"); + fs.writeFileSync(staleStagedLock, "{}", "utf8"); + fs.writeFileSync(freshStagedLock, "{}", "utf8"); + writeJobFile(repoDir, deceptiveId, { + id: deceptiveId, + status: "running", + createdAt: nowIso(), + }); const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000; fs.utimesSync(staleReservation, twoHoursAgo / 1000, twoHoursAgo / 1000); + fs.utimesSync(staleStagedLock, twoHoursAgo / 1000, twoHoursAgo / 1000); + fs.utimesSync(deceptiveJobFile, twoHoursAgo / 1000, twoHoursAgo / 1000); cleanupOldJobs(repoDir); assert.equal(fs.existsSync(staleReservation), false); assert.equal(fs.existsSync(freshReservation), true); + assert.equal(fs.existsSync(staleStagedLock), false); + assert.equal(fs.existsSync(freshStagedLock), true); + assert.ok(readJobFile(repoDir, deceptiveId)); } finally { fs.rmSync(resolveStateDir(repoDir), { recursive: true, force: true }); fs.rmSync(repoDir, { recursive: true, force: true }); @@ -959,6 +1599,480 @@ describe("reapStaleJobs", () => { assert.equal(result[0].status, "running"); }); + it("keeps Windows read paths cheap for a live PID with stored identity", () => { + const id = "test-reap-windows-alive"; + writeJobFile(PROJECT_CWD, id, { + id, + status: "running", + pid: process.pid, + pidIdentity: "stored-identity", + createdAt: nowIso(), + }); + backdateJob(id, staleTimestamp()); + let identityChecks = 0; + + const result = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + { + platform: "win32", + isProcessAliveImpl: () => true, + getProcessIdentityImpl: () => { + identityChecks += 1; + return "different-identity"; + }, + } + ); + + assert.equal(identityChecks, 0); + assert.equal(result[0].status, "running"); + }); + + it("rechecks a silent Windows job after the bounded liveness shortcut", () => { + const id = "test-reap-windows-recycled"; + writeJobFile(PROJECT_CWD, id, { + id, + status: "running", + pid: process.pid, + pidIdentity: "old-process-identity", + createdAt: nowIso(), + }); + backdateJob(id, new Date(Date.now() - 301_000).toISOString()); + let identityChecks = 0; + + const result = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + { + platform: "win32", + isProcessAliveImpl: () => true, + getProcessIdentityImpl: () => { + identityChecks += 1; + return "different-identity"; + }, + } + ); + + assert.equal(identityChecks, 1); + assert.equal(result[0].status, "failed"); + }); + + it("does not look up identity after liveness already failed", () => { + const id = "test-reap-dead-no-identity"; + writeJobFile(PROJECT_CWD, id, { + id, + status: "running", + pid: 99_999_999, + pidIdentity: "stored-identity", + createdAt: nowIso(), + }); + backdateJob(id, new Date(Date.now() - 301_000).toISOString()); + let identityChecks = 0; + + const result = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + { + platform: "win32", + isProcessAliveImpl: () => false, + getProcessIdentityImpl: () => { + identityChecks += 1; + return "stored-identity"; + }, + } + ); + + assert.equal(identityChecks, 0); + assert.equal(result[0].status, "failed"); + }); + + it("reaps a live PID when its directly read identity mismatches", () => { + const id = "test-reap-direct-identity-mismatch"; + writeJobFile(PROJECT_CWD, id, { + id, + status: "running", + pid: process.pid, + pidIdentity: "stored-identity", + createdAt: nowIso(), + }); + backdateJob(id, staleTimestamp()); + + const result = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + { + platform: "linux", + isProcessAliveImpl: () => true, + getProcessIdentityImpl: () => "different-identity", + } + ); + + assert.equal(result[0].status, "failed"); + }); + + it("does not refresh POSIX jobs whose identity matches", () => { + const id = "test-reap-posix-match"; + writeJobFile(PROJECT_CWD, id, { + id, + status: "running", + pid: process.pid, + pidIdentity: "stored-identity", + createdAt: nowIso(), + }); + const timestamp = staleTimestamp(); + backdateJob(id, timestamp); + + const result = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + { + platform: "linux", + isProcessAliveImpl: () => true, + getProcessIdentityImpl: () => "stored-identity", + } + ); + + assert.equal(result[0].status, "running"); + assert.equal(result[0].updatedAt, timestamp); + assert.equal(readJobFile(PROJECT_CWD, id).updatedAt, timestamp); + }); + + it("rate-limits successful Windows identity rechecks across job reads", () => { + const id = "test-reap-windows-cooldown"; + writeJobFile(PROJECT_CWD, id, { + id, + status: "running", + pid: process.pid, + pidIdentity: "stored-identity", + createdAt: nowIso(), + }); + backdateJob(id, new Date(Date.now() - 301_000).toISOString()); + let identityChecks = 0; + const options = { + platform: "win32", + isProcessAliveImpl: () => true, + getProcessIdentityImpl: () => { + identityChecks += 1; + return "stored-identity"; + }, + }; + + const first = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + options + ); + const second = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + options + ); + + assert.equal(first[0].status, "running"); + assert.equal(second[0].status, "running"); + assert.equal(first[0].updatedAt, readJobFile(PROJECT_CWD, id).updatedAt); + assert.equal(first[0].updatedAt, first[0].createdAt); + assert.equal(identityChecks, 1); + assert.equal( + fs.readFileSync( + path.join(resolveJobsDir(PROJECT_CWD), `${id}.json.identity-check`), + "utf8" + ), + "verified\n" + ); + }); + + it("starts the Windows unverifiable ceiling at the first unavailable probe", () => { + const id = "test-reap-windows-first-unavailable"; + writeJobFile(PROJECT_CWD, id, { + id, + status: "running", + pid: process.pid, + pidIdentity: "stored-identity", + createdAt: nowIso(), + }); + backdateJob(id, new Date(Date.now() - 301_000).toISOString()); + let identityUnavailable = false; + const options = { + platform: "win32", + isProcessAliveImpl: () => true, + getProcessIdentityImpl: () => { + if (identityUnavailable) { + throw Object.assign(new Error("timed out"), { code: "ETIMEDOUT" }); + } + return "stored-identity"; + }, + }; + + const verified = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + options + ); + const identityCheckFile = path.join( + resolveJobsDir(PROJECT_CWD), + `${id}.json.identity-check` + ); + const identityProbeFile = path.join( + resolveJobsDir(PROJECT_CWD), + `${id}.json.identity-probe` + ); + const identityUnavailableFile = path.join( + resolveJobsDir(PROJECT_CWD), + `${id}.json.identity-unavailable` + ); + const leaseExpired = new Date(Date.now() - 301_000); + fs.utimesSync(identityCheckFile, leaseExpired, leaseExpired); + fs.utimesSync(identityProbeFile, leaseExpired, leaseExpired); + identityUnavailable = true; + + const firstUnavailable = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + options + ); + const firstUnavailableMtime = + fs.statSync(identityUnavailableFile).mtimeMs; + + const fourteenMinutesAgo = new Date(Date.now() - 14 * 60 * 1000); + fs.utimesSync( + identityUnavailableFile, + fourteenMinutesAgo, + fourteenMinutesAgo + ); + fs.utimesSync(identityProbeFile, leaseExpired, leaseExpired); + const beforeCeiling = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + options + ); + + const sixteenMinutesAgo = new Date(Date.now() - 16 * 60 * 1000); + fs.utimesSync( + identityUnavailableFile, + sixteenMinutesAgo, + sixteenMinutesAgo + ); + fs.utimesSync(identityProbeFile, leaseExpired, leaseExpired); + const afterCeiling = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + options + ); + + assert.equal(verified[0].status, "running"); + assert.equal(firstUnavailable[0].status, "running"); + assert.ok(firstUnavailableMtime > leaseExpired.getTime()); + assert.equal(beforeCeiling[0].status, "running"); + assert.equal(afterCeiling[0].status, "failed"); + assert.equal(afterCeiling[0].reapedUnverifiable, true); + }); + + it("keeps timed-out Windows identity checks alive and rate-limited", () => { + const id = "test-reap-windows-timeout"; + writeJobFile(PROJECT_CWD, id, { + id, + status: "running", + pid: process.pid, + pidIdentity: "stored-identity", + createdAt: nowIso(), + }); + backdateJob(id, new Date(Date.now() - 301_000).toISOString()); + let identityChecks = 0; + const options = { + platform: "win32", + isProcessAliveImpl: () => true, + getProcessIdentityImpl: (_pid, identityOptions) => { + identityChecks += 1; + assert.equal(identityOptions.timeout, 2_000); + throw Object.assign(new Error("timed out"), { code: "ETIMEDOUT" }); + }, + }; + + const first = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + options + ); + const identityUnavailableFile = path.join( + resolveJobsDir(PROJECT_CWD), + `${id}.json.identity-unavailable` + ); + const identityProbeFile = path.join( + resolveJobsDir(PROJECT_CWD), + `${id}.json.identity-probe` + ); + const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000); + fs.utimesSync(identityUnavailableFile, sixMinutesAgo, sixMinutesAgo); + const firstUnavailableMtime = + fs.statSync(identityUnavailableFile).mtimeMs; + const firstProbeMtime = fs.statSync(identityProbeFile).mtimeMs; + const second = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + options + ); + + assert.equal(first[0].status, "running"); + assert.equal(second[0].status, "running"); + assert.equal(identityChecks, 1); + assert.equal( + fs.readFileSync(identityUnavailableFile, "utf8"), + "unavailable\n" + ); + assert.equal(fs.readFileSync(identityProbeFile, "utf8"), "unavailable\n"); + assert.equal( + fs.statSync(identityUnavailableFile).mtimeMs, + firstUnavailableMtime + ); + assert.equal(fs.statSync(identityProbeFile).mtimeMs, firstProbeMtime); + }); + + it("fails open when a Windows identity marker cannot be created", () => { + const id = "test-reap-windows-marker-unwritable"; + const originalWriteFileSync = fs.writeFileSync; + writeJobFile(PROJECT_CWD, id, { + id, + status: "running", + pid: process.pid, + pidIdentity: "stored-identity", + createdAt: nowIso(), + }); + backdateJob(id, new Date(Date.now() - 901_000).toISOString()); + const identityUnavailableFile = path.join( + resolveJobsDir(PROJECT_CWD), + `${id}.json.identity-unavailable` + ); + + try { + Reflect.set(fs, "writeFileSync", (filePath, ...args) => { + if (filePath === identityUnavailableFile) { + throw Object.assign(new Error("marker denied"), { code: "EACCES" }); + } + return originalWriteFileSync(filePath, ...args); + }); + + const result = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + { + platform: "win32", + isProcessAliveImpl: () => true, + getProcessIdentityImpl: () => { + throw Object.assign(new Error("timed out"), { + code: "ETIMEDOUT", + }); + }, + } + ); + + assert.equal(result[0].status, "running"); + assert.equal(fs.existsSync(identityUnavailableFile), false); + } finally { + Reflect.set(fs, "writeFileSync", originalWriteFileSync); + } + }); + + it("fails a Windows job after identity stays unverifiable beyond the hard ceiling", () => { + const id = "test-reap-windows-unverifiable-expired"; + writeJobFile(PROJECT_CWD, id, { + id, + status: "running", + pid: process.pid, + pidIdentity: "stored-identity", + createdAt: nowIso(), + }); + backdateJob(id, new Date(Date.now() - 301_000).toISOString()); + const identityUnavailableFile = path.join( + resolveJobsDir(PROJECT_CWD), + `${id}.json.identity-unavailable` + ); + fs.writeFileSync(identityUnavailableFile, "unavailable\n"); + const expired = new Date(Date.now() - 901_000); + fs.utimesSync(identityUnavailableFile, expired, expired); + + const result = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + { + platform: "win32", + isProcessAliveImpl: () => true, + getProcessIdentityImpl: () => { + throw Object.assign(new Error("timed out"), { code: "ETIMEDOUT" }); + }, + } + ); + + assert.equal(result[0].status, "failed"); + assert.equal(result[0].pid, process.pid); + assert.equal(result[0].pidIdentity, "stored-identity"); + assert.equal(result[0].reapedUnverifiable, true); + assert.match(result[0].errorMessage, /identity remained unverifiable/i); + }); + + it("preserves manual cleanup handles when cancelling identity stays unverifiable", () => { + const id = "test-reap-windows-cancelling-unverifiable"; + writeJobFile(PROJECT_CWD, id, { + id, + status: "cancelling", + pid: process.pid, + pidIdentity: "stored-identity", + createdAt: nowIso(), + }); + backdateJob(id, new Date(Date.now() - 301_000).toISOString()); + const identityUnavailableFile = path.join( + resolveJobsDir(PROJECT_CWD), + `${id}.json.identity-unavailable` + ); + fs.writeFileSync(identityUnavailableFile, "unavailable\n"); + const expired = new Date(Date.now() - 901_000); + fs.utimesSync(identityUnavailableFile, expired, expired); + + const result = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + { + platform: "win32", + isProcessAliveImpl: () => true, + getProcessIdentityImpl: () => { + throw new Error("CIM unavailable"); + }, + } + ); + + assert.equal(result[0].status, "cancel_failed"); + assert.equal(result[0].pid, process.pid); + assert.equal(result[0].pidIdentity, "stored-identity"); + assert.equal(result[0].pgid, process.pid); + }); + + it("keeps jobs alive when identity lookup races cannot be verified", () => { + const id = "test-reap-identity-race"; + writeJobFile(PROJECT_CWD, id, { + id, + status: "running", + pid: process.pid, + pidIdentity: "stored-identity", + createdAt: nowIso(), + }); + backdateJob(id, staleTimestamp()); + + const result = reapStaleJobs( + PROJECT_CWD, + [readJobFile(PROJECT_CWD, id)], + { + platform: "linux", + isProcessAliveImpl: () => true, + getProcessIdentityImpl: () => { + throw new Error("process exited between checks"); + }, + } + ); + + assert.equal(result[0].status, "running"); + assert.equal(result[0].pid, process.pid); + }); + it("keeps recently updated running job alive during the reap grace window", () => { const id = "test-reap-recent"; writeJobFile(PROJECT_CWD, id, { diff --git a/tests/tracked-jobs.test.mjs b/tests/tracked-jobs.test.mjs index 683cc67..02cf18f 100644 --- a/tests/tracked-jobs.test.mjs +++ b/tests/tracked-jobs.test.mjs @@ -9,6 +9,7 @@ import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { syncBuiltinESMExports } from "node:module"; import { SESSION_ID_ENV, @@ -36,6 +37,10 @@ function createTempGitRepo() { return repoDir; } +function isLockBusyError(error) { + return /** @type {NodeJS.ErrnoException} */ (error).code === "ELOCKBUSY"; +} + // --------------------------------------------------------------------------- // SESSION_ID_ENV // --------------------------------------------------------------------------- @@ -375,6 +380,35 @@ describe("createJobProgressUpdater", () => { // --------------------------------------------------------------------------- describe("runTrackedJob", () => { + it("does not revive a queued job after cancellation wins before startup", async () => { + const repoDir = createTempGitRepo(); + const job = { + id: "tracked-startup-cancel-race", + workspaceRoot: repoDir, + status: "queued", + title: "startup race", + createdAt: nowIso(), + updatedAt: nowIso(), + }; + writeJobFile(repoDir, job.id, { + ...job, + status: "cancelling", + }); + let runnerCalled = false; + + await assert.rejects( + runTrackedJob(job, async () => { + runnerCalled = true; + return { exitStatus: 0 }; + }), + /left the queue before execution started \(cancelling\)/ + ); + + assert.equal(runnerCalled, false); + assert.equal(readJobFile(repoDir, job.id).status, "cancelling"); + fs.rmSync(repoDir, { recursive: true, force: true }); + }); + it("does not overwrite a concurrent cancelling transition when onSpawn races with cancel", async () => { const repoDir = createTempGitRepo(); const job = { @@ -411,7 +445,234 @@ describe("runTrackedJob", () => { const finalJob = readJobFile(repoDir, job.id); assert.equal(finalJob.status, "cancelling"); - assert.equal(finalJob.pid, null); + assert.equal(finalJob.pid ?? null, null); fs.rmSync(repoDir, { recursive: true, force: true }); }); + + it("persists a late result after the identity reaper marked the job failed", async () => { + const repoDir = createTempGitRepo(); + const job = { + id: "tracked-reaper-result-job", + workspaceRoot: repoDir, + status: "queued", + title: "late result", + createdAt: nowIso(), + updatedAt: nowIso(), + pidIdentity: "queued-worker-identity", + }; + writeJobFile(repoDir, job.id, job); + + try { + await runTrackedJob(job, async () => { + const running = readJobFile(repoDir, job.id); + assert.equal(running.phase, "starting"); + assert.equal(running.pidIdentity, "queued-worker-identity"); + writeJobFile(repoDir, job.id, { + ...running, + status: "failed", + errorMessage: "identity remained unverifiable", + reapedUnverifiable: true, + pid: 12345, + pidIdentity: "stored-identity", + updatedAt: nowIso(), + }); + return { + exitStatus: 0, + threadId: "thread-late", + turnId: "turn-late", + payload: { answer: 42 }, + rendered: "finished", + summary: "finished", + }; + }); + + const finalJob = readJobFile(repoDir, job.id); + assert.equal(finalJob.status, "completed"); + assert.equal(finalJob.phase, "done"); + assert.equal(finalJob.threadId, "thread-late"); + assert.equal(finalJob.turnId, "turn-late"); + assert.equal(finalJob.summary, "finished"); + assert.equal(finalJob.rendered, "finished"); + assert.deepEqual(finalJob.result, { answer: 42 }); + assert.equal(finalJob.errorMessage, null); + assert.equal(finalJob.reapedUnverifiable, false); + assert.equal(finalJob.pid, null); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it("persists an ordinary runner failure without treating it as lock contention", async () => { + const repoDir = createTempGitRepo(); + const job = { + id: "tracked-runner-failure-job", + workspaceRoot: repoDir, + status: "queued", + title: "runner failure", + createdAt: nowIso(), + updatedAt: nowIso(), + }; + writeJobFile(repoDir, job.id, job); + + try { + await assert.rejects( + runTrackedJob(job, async () => { + throw new Error("runner exploded"); + }), + /runner exploded/ + ); + + const finalJob = readJobFile(repoDir, job.id); + assert.equal(finalJob.status, "failed"); + assert.equal(finalJob.phase, "failed"); + assert.equal(finalJob.errorMessage, "runner exploded"); + assert.equal(finalJob.pid, null); + assert.equal(finalJob.pidIdentity, null); + assert.ok(finalJob.completedAt); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it("does not overwrite an unrelated failed state with a late result", async () => { + const repoDir = createTempGitRepo(); + const job = { + id: "tracked-unrelated-failure-job", + workspaceRoot: repoDir, + status: "queued", + title: "unrelated failure", + createdAt: nowIso(), + updatedAt: nowIso(), + }; + writeJobFile(repoDir, job.id, job); + + try { + await runTrackedJob(job, async () => { + const running = readJobFile(repoDir, job.id); + writeJobFile(repoDir, job.id, { + ...running, + status: "failed", + errorMessage: "independent failure", + updatedAt: nowIso(), + }); + return { + exitStatus: 0, + payload: { answer: 42 }, + rendered: "finished", + summary: "finished", + }; + }); + + const finalJob = readJobFile(repoDir, job.id); + assert.equal(finalJob.status, "failed"); + assert.equal(finalJob.errorMessage, "independent failure"); + assert.equal(finalJob.result, undefined); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it("retries tagged lock contention when persisting a spawned job", async () => { + const repoDir = createTempGitRepo(); + const job = { + id: "tracked-lock-retry-job", + workspaceRoot: repoDir, + status: "queued", + title: "lock retry", + createdAt: nowIso(), + updatedAt: nowIso(), + }; + const originalLinkSync = fs.linkSync; + let collisions = 0; + let injectCollisions = false; + writeJobFile(repoDir, job.id, job); + + Reflect.set(fs, "linkSync", (existingPath, newPath) => { + if ( + injectCollisions && + String(newPath).endsWith(`${job.id}.json.lock`) && + collisions < 3 + ) { + collisions += 1; + throw Object.assign(new Error("synthetic collision"), { + code: "EEXIST", + }); + } + return originalLinkSync(existingPath, newPath); + }); + syncBuiltinESMExports(); + + try { + await runTrackedJob(job, async (onSpawn) => { + injectCollisions = true; + onSpawn({ pid: 12345, pidIdentity: "spawned-identity" }); + const spawnedJob = readJobFile(repoDir, job.id); + assert.equal(spawnedJob.pid, 12345); + assert.equal(spawnedJob.pidIdentity, "spawned-identity"); + return { + exitStatus: 0, + payload: {}, + rendered: "finished", + summary: "finished", + }; + }); + + assert.equal(collisions, 3); + assert.equal(readJobFile(repoDir, job.id).status, "completed"); + } finally { + Reflect.set(fs, "linkSync", originalLinkSync); + syncBuiltinESMExports(); + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it("fails closed after bounded tagged lock retries without corrupting job state", async () => { + const repoDir = createTempGitRepo(); + const job = { + id: "tracked-lock-exhaustion-job", + workspaceRoot: repoDir, + status: "queued", + title: "lock exhaustion", + createdAt: nowIso(), + updatedAt: nowIso(), + }; + const originalLinkSync = fs.linkSync; + let collisions = 0; + let injectCollisions = false; + writeJobFile(repoDir, job.id, job); + + Reflect.set(fs, "linkSync", (existingPath, newPath) => { + if ( + injectCollisions && + String(newPath).endsWith(`${job.id}.json.lock`) + ) { + collisions += 1; + throw Object.assign(new Error("persistent synthetic collision"), { + code: "EEXIST", + }); + } + return originalLinkSync(existingPath, newPath); + }); + syncBuiltinESMExports(); + + try { + await assert.rejects( + runTrackedJob(job, async (onSpawn) => { + injectCollisions = true; + onSpawn({ pid: 99999999, pidIdentity: "spawned-identity" }); + throw new Error("onSpawn should reject first"); + }), + isLockBusyError + ); + + const finalJob = readJobFile(repoDir, job.id); + assert.equal(collisions, 9); + assert.equal(finalJob.status, "running"); + assert.equal(finalJob.errorMessage, undefined); + } finally { + Reflect.set(fs, "linkSync", originalLinkSync); + syncBuiltinESMExports(); + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); }); From 0ebc4130c23ad1a824a91fa9d81b9a0ece5ea7a9 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Tue, 28 Jul 2026 06:38:30 +0300 Subject: [PATCH 4/4] test: use file URLs for ESM preloads --- tests/cancel-command.test.mjs | 11 ++++------- tests/installer-cli.test.mjs | 4 ++-- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/tests/cancel-command.test.mjs b/tests/cancel-command.test.mjs index 51bc090..1ff02f5 100644 --- a/tests/cancel-command.test.mjs +++ b/tests/cancel-command.test.mjs @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import process from "node:process"; import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { afterEach, test } from "node:test"; import { getProcessIdentity } from "../scripts/lib/process.mjs"; @@ -16,12 +16,9 @@ import { const ROOT = path.resolve(fileURLToPath(new URL("..", import.meta.url))); const COMPANION = path.join(ROOT, "scripts", "claude-companion.mjs"); -const SWAP_PRELOAD = path.join( - ROOT, - "tests", - "fixtures", - "swap-job-after-read.mjs" -); +const SWAP_PRELOAD = pathToFileURL( + path.join(ROOT, "tests", "fixtures", "swap-job-after-read.mjs") +).href; const cleanup = []; afterEach(() => { diff --git a/tests/installer-cli.test.mjs b/tests/installer-cli.test.mjs index 149140b..467450a 100644 --- a/tests/installer-cli.test.mjs +++ b/tests/installer-cli.test.mjs @@ -9,7 +9,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const PROJECT_ROOT = path.resolve(fileURLToPath(new URL("../", import.meta.url))); @@ -1804,7 +1804,7 @@ fs.renameSync = (source, destination) => { ); const result = spawnProjectInstaller("install", homeDir, { - NODE_OPTIONS: `--import=${preload}`, + NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`, CC_PLUGIN_ATOMIC_RENAME_FAIL_PATH: configFile, });