diff --git a/.c8rc.json b/.c8rc.json new file mode 100644 index 000000000..197e8b627 --- /dev/null +++ b/.c8rc.json @@ -0,0 +1,12 @@ +{ + "all": true, + "include": ["plugins/codex/scripts/**/*.mjs", "scripts/**/*.mjs"], + "exclude": ["plugins/codex/.generated/**", "scripts/run-tests.mjs"], + "reporter": ["text", "json-summary", "lcov"], + "reports-dir": "reports/coverage", + "check-coverage": true, + "lines": 88, + "statements": 88, + "branches": 78, + "functions": 95 +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4c6fa1314..e5ff7df79 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,13 +6,13 @@ }, "metadata": { "description": "CBEPX fork of the OpenAI Codex plugin for Claude Code: max/ultra effort, per-thread config overrides, gpt-5.6 aliases, rescue agent fixes.", - "version": "1.3.0" + "version": "1.4.0" }, "plugins": [ { "name": "codex", "description": "Use Codex from Claude Code to review code or delegate tasks.", - "version": "1.3.0", + "version": "1.4.0", "author": { "name": "OpenAI" }, diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..6313b56c5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 000000000..34145364e --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +echo "[pre-commit] lint" +npm run lint + +echo "[pre-commit] typecheck" +npm run typecheck diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..c93104004 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 000000000..cca2abb53 --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,39 @@ +name: Mutation + +permissions: + contents: read + +on: + workflow_dispatch: + schedule: + - cron: "0 3 * * 0" + +jobs: + critical: + name: Critical mutation (ubuntu, node 24) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run critical mutation shard + run: npm run test:mutation:critical:force + + - name: Upload mutation report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: mutation-critical + path: reports/mutation/ + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index c99b6c20f..38a6b12ce 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -3,7 +3,12 @@ name: Pull Request CI on: pull_request: push: - branches: [main, "release/**", "ci/**"] + branches: [main] + workflow_dispatch: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true permissions: contents: read @@ -12,10 +17,8 @@ jobs: ci: name: CI (${{ matrix.os }}, node ${{ matrix.node }}) runs-on: ${{ matrix.os }} - timeout-minutes: 20 - # Spike: Windows is exploratory until the test harness runs there - # (fake codex is a shebang script; see docs/superpowers/plans step 0). - continue-on-error: ${{ startsWith(matrix.os, 'windows') }} + # Windows runners have been seen 2-3x slower for hours at a time. + timeout-minutes: 40 strategy: fail-fast: false matrix: @@ -45,7 +48,7 @@ jobs: npm test 2>&1 | tee test-output.log status=${PIPESTATUS[0]} echo "## Test summary (${{ matrix.os }}, node ${{ matrix.node }})" >> "$GITHUB_STEP_SUMMARY" - { rg -e 'ℹ (tests|pass|fail)' -e '^not ok' test-output.log || grep -E 'ℹ (tests|pass|fail)|^not ok' test-output.log; } >> "$GITHUB_STEP_SUMMARY" || true + { rg -e 'ℹ (tests|pass|fail)' -e '^# (tests|pass|fail)' -e '^not ok' -e '^✖' test-output.log || grep -E 'ℹ (tests|pass|fail)|^# (tests|pass|fail)|^not ok|^✖' test-output.log; } >> "$GITHUB_STEP_SUMMARY" || true exit "$status" - name: Upload test log @@ -56,11 +59,64 @@ jobs: path: test-output.log retention-days: 7 + # Same check as the local gate. Windows is reported, not enforced, until + # the Windows kill path lands (v1.4.1); it runs even after a red suite so + # the leak list is always available. - name: No leaked test processes - if: runner.os != 'Windows' + if: always() + shell: bash run: | sleep 10 - if pgrep -f codex-plugin-test- ; then echo "leaked test processes" >&2; exit 1; fi + if [ "$RUNNER_OS" = "Windows" ]; then + powershell -NoProfile -Command "\$p = @(Get-CimInstance Win32_Process | Where-Object { \$_.CommandLine -match 'codex-plugin-test-' }); Write-Output ('Leaked test processes after 10 s: ' + \$p.Count); \$p | Select-Object ProcessId,ParentProcessId,Name,@{n='Cmd';e={\$_.CommandLine.Substring(0,[Math]::Min(160,\$_.CommandLine.Length))}} | Format-Table -AutoSize | Out-String -Width 220" | tee -a "$GITHUB_STEP_SUMMARY" + elif pgrep -af codex-plugin-test- ; then echo "leaked test processes" >&2; exit 1; fi - name: Run build run: npm run build + + quality: + name: Quality (ubuntu, node 24) + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Codex CLI + run: npm install -g @openai/codex + + - name: Version metadata matches the package + run: npm run check-version + + - name: Changelog has a section for the version + run: npm run check:changelog + + - name: Lint + run: npm run lint + + - name: Run build + run: npm run build + + - name: Typecheck tests and scripts + run: npm run typecheck:tests + + - name: Test coverage + run: npm run test:coverage + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: coverage + path: reports/coverage/ + retention-days: 14 diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index 90f06a82a..6d46fe54f 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -19,10 +19,16 @@ permissions: contents: read jobs: - verify: - name: Verify - runs-on: ubuntu-latest - timeout-minutes: 15 + ci: + name: CI (${{ matrix.os }}, node ${{ matrix.node }}) + runs-on: ${{ matrix.os }} + # Windows runners have been seen 2-3x slower for hours at a time. + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node: [18, 22, 24] steps: - name: Check out release ref @@ -33,7 +39,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: 22 + node-version: ${{ matrix.node }} cache: npm - name: Install dependencies @@ -42,25 +48,82 @@ jobs: - name: Install Codex CLI run: npm install -g @openai/codex - - name: Codex version - run: codex --version - - - name: Version metadata matches the tag - run: npm run check-version - - name: Run test suite - run: npm test + shell: bash + run: | + set +e + npm test 2>&1 | tee test-output.log + status=${PIPESTATUS[0]} + echo "## Test summary (${{ matrix.os }}, node ${{ matrix.node }})" >> "$GITHUB_STEP_SUMMARY" + { rg -e 'ℹ (tests|pass|fail)' -e '^# (tests|pass|fail)' -e '^not ok' -e '^✖' test-output.log || grep -E 'ℹ (tests|pass|fail)|^# (tests|pass|fail)|^not ok|^✖' test-output.log; } >> "$GITHUB_STEP_SUMMARY" || true + exit "$status" + + - name: Upload test log + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: test-log-${{ matrix.os }}-node${{ matrix.node }} + path: test-output.log + retention-days: 7 + # Same check as the local gate. Windows is reported, not enforced, until + # the Windows kill path lands (v1.4.1); it runs even after a red suite so + # the leak list is always available. - name: No leaked test processes + if: always() + shell: bash run: | sleep 10 - if pgrep -f codex-plugin-test- ; then echo "leaked test processes" >&2; exit 1; fi + if [ "$RUNNER_OS" = "Windows" ]; then + powershell -NoProfile -Command "\$p = @(Get-CimInstance Win32_Process | Where-Object { \$_.CommandLine -match 'codex-plugin-test-' }); Write-Output ('Leaked test processes after 10 s: ' + \$p.Count); \$p | Select-Object ProcessId,ParentProcessId,Name,@{n='Cmd';e={\$_.CommandLine.Substring(0,[Math]::Min(160,\$_.CommandLine.Length))}} | Format-Table -AutoSize | Out-String -Width 220" | tee -a "$GITHUB_STEP_SUMMARY" + elif pgrep -af codex-plugin-test- ; then echo "leaked test processes" >&2; exit 1; fi + + + - name: Run build + run: npm run build + + quality: + name: Quality (ubuntu, node 24) + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Check out release ref + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.release.tag_name || inputs.ref || github.ref_name }} + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Codex CLI + run: npm install -g @openai/codex + + - name: Version metadata matches the tag + run: npm run check-version + + - name: Changelog has a section for the version + run: npm run check:changelog + + - name: Lint + run: npm run lint - name: Run build run: npm run build + - name: Typecheck tests and scripts + run: npm run typecheck:tests + - name: Runtime dependency audit run: npm audit --omit=dev - name: Pack (dry run) - run: npm pack --dry-run + run: | + npm pack --dry-run + rm -f ./*.tgz diff --git a/.gitignore b/.gitignore index d5cdb3a7f..e70e3444f 100644 --- a/.gitignore +++ b/.gitignore @@ -151,3 +151,5 @@ plugins/codex/.generated/ # git worktrees (project-local) .worktrees/ +reports/ +.stryker-tmp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 195552b42..0ed3ec774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## 1.4.0 — 2026-09-28 + +### Fixed +- Windows: commands are no longer spawned through `$SHELL` (usually Git Bash under Claude Code, which mangled `taskkill /PID /T /F` and other arguments); executables are resolved with `where.exe`, `.exe`/`.com` files run directly, and `.cmd`/`.bat` shims (including a global `npm install -g @openai/codex`) run through `cmd.exe` with every argument escaped for both `cmd /c` and the shim's own `%*` re-parse (`%VAR:a=b%` substitution is the one documented ceiling; an argument containing CR/LF is refused) (#525, #647, #656, #669, #708, #287, #409, #735). +- Windows: the broker and app-server child processes no longer flash a visible console window on spawn (`windowsHide: true`) (#440, #451). +- The `Stop`, `SessionStart` and `SessionEnd` hooks read stdin with a bounded deadline (2 s / 5 s / 1 s respectively) and a 1 MiB limit instead of a blocking `readFileSync(0)`; a disabled Stop review gate no longer hangs until the 900 s hook timeout when stdin never arrives, and the companion no longer crashes with `EAGAIN` reading a non-blocking stdin under concurrent sessions (#530, #544, #120, #247, #123, #150, #165). +- `--args-stdin` (and `$ARGUMENTS`) no longer eats a backslash that does not escape a quote, another backslash or whitespace, so Windows paths such as `C:\Users\me\project\file.mjs` survive; `\\server\share` is a documented limitation, and `--prompt-stdin` remains available for byte-exact text. +- A turn without subagents no longer has its completion inferred 250 ms after the final answer: on a slow host a delayed `turn/completed` with status `failed` was being recorded as `completed` (seen twice on hosted CI). Inference now applies only once a subagent thread has joined the turn, the case it was built for. +- A Codex app-server that exits after the final answer but before `turn/completed` now ends the turn as `failed` with the captured output (previously the completion never settled: a foreground run could exit silently and a background job stayed `running` until reaped). The shared broker shuts down when its app-server dies, so every connected client sees the same outcome instead of waiting on a runtime that no longer exists. +- Windows: `state.json` reads and writes and lock-ticket reads retry briefly (bounded, roughly 300 ms worst case) on `EPERM`/`EBUSY`/`EACCES` instead of failing outright when another process holds the file open. + +### Added +- `SECURITY.md` (supported versions, GitHub Security Advisories reporting) (#326). +- Node 24 development tooling: ESLint, a `typecheck:tests` script over `tests/**` and `scripts/**` (`checkJs` is off for now — turning it on surfaced 54 pre-existing findings, deferred), `c8` coverage (`npm run test:coverage`, thresholds in `.c8rc.json`), Stryker mutation testing over the critical `args.mjs`/`model-catalog.mjs` pair (`npm run test:mutation:critical`), a `check:changelog` gate that keeps `CHANGELOG.md` and `plugins/codex/CHANGELOG.md` byte-identical, Dependabot, and `npm run setup:git-hooks` (pre-commit lint + typecheck). +- CI: one workflow run per SHA (push limited to `main`, release branches checked via manual `workflow_dispatch`, with a `concurrency` group cancelling superseded runs) and a new `quality` job on Node 24 alongside the existing OS × Node matrix. + +### Changed +- The per-user fallback state root used when `CLAUDE_PLUGIN_DATA` is unset is now `%LOCALAPPDATA%\codex-companion` on Windows. A pre-1.4.0 root under `%TEMP%\codex-companion-user` keeps being used, with a one-line stderr notice, until it is removed by hand — nothing is migrated automatically. This transitional notice only applies to an unversioned/dev checkout: a marketplace install's state-root hash is derived from `CLAUDE_PLUGIN_ROOT`, which already includes the plugin version in the marketplace cache, so a normal upgrade never sees the legacy root. +- `BROKER_BUSY_RETRY_MS` widened from 1000 to 3000 ms; the "teardown only after the broker confirmed idle" invariant is unchanged. +- Deferred v1.3.0 review minors folded in: `CODEX_REVIEW_GATE_MAX_ROUNDS` rejects a non-integer, negative or otherwise malformed value (falls back to 3 with a warning) instead of misreading it; `setup --review-gate-model ""` / `--review-gate-effort ""` now fails with `-- needs a value; use inherit to clear it.` instead of writing anything; a job id used to build the `workerCommandLine` match is now regex-escaped; the `kill-failed` broker-teardown reason is documented in the README's reason table. Also added (test coverage only, no behavior change): a catalogue-only model alias resolving correctly on a priority tie, and the fallback-root refusal covering a non-standard directory mode. + +### Known limitations +- Kills issued from stored process records (`/codex:cancel`, `SessionEnd` cleanup, stale-broker replacement, broker teardown) are still refused on Windows (`identity-unavailable`); process identity verification is now targeted for v1.4.1, not v1.4.0 as previously stated. `/codex:cancel` still sends the turn interrupt on a best-effort basis; `SessionEnd` only refuses. A worker or broker left behind exits when its turn ends and, for the broker, once every client has disconnected and its idle timeout elapses — an unbounded turn is not reaped on Windows until v1.4.1. + +Ported with reference to upstream PRs by mohammad-malik, mittalpk, stantheman0128, tmchow, D2758695161, ikbear, e345ee, tanakauo. + ## 1.3.0 — 2026-09-27 ### Fixed @@ -26,7 +52,7 @@ - `runCommand` reports `status: null`, not `0`, for a subprocess that timed out. ### Known limitations -- On Windows, kills issued from stored process records (cancel worker, `SessionEnd` cleanup, stale-broker replacement, broker teardown) are refused until process identity lands in v1.4.0; leaks are bounded by the broker idle timeout, and the turn interrupt is still sent regardless. +- On Windows, kills issued from stored process records (cancel worker, `SessionEnd` cleanup, stale-broker replacement, broker teardown) are refused until process identity lands in v1.4.0; a leaked broker exits on its idle timeout once every client has disconnected, and `cancel` still sends the turn interrupt on a best-effort basis (`SessionEnd` does not). - Foreground job records written by v1.2.x (no `pidIdentity`) are not killed at `SessionEnd` after upgrading to v1.3.0 (one-off). - When `CLAUDE_PLUGIN_DATA` is unset (inside Claude Code the SessionStart hook normally sets it), the fallback state root under `os.tmpdir()` hashes `CLAUDE_PLUGIN_ROOT`, whose path includes the plugin version: job and broker state is orphaned on every plugin update, not only when upgrading from v1.2.x. - Darwin identity checks use `ps lstart`, which has 1 s resolution. diff --git a/README.md b/README.md index 67d2fccea..ebe20c820 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,6 @@ Use Codex from inside Claude Code for code reviews or to delegate tasks to Codex This plugin is for Claude Code users who want an easy way to start using Codex from the workflow they already have. - - ## What You Get - `/codex:review` for a normal read-only Codex review @@ -165,12 +163,22 @@ Ask Codex to redesign the database connection to be more resilient. - model aliases resolve against the local Codex model catalogue (`$CODEX_HOME/models_cache.json`, else `codex debug models --bundled`): an alias picks the listed model whose slug ends in `-`, lowest priority number first, newest family on ties; today `sol` -> `gpt-6-sol`, `astra` -> `gpt-6-astra`, `luna` -> `gpt-6-luna`, `terra` -> `gpt-5.6-terra`, `spark` -> `gpt-5.3-codex-spark`, `mini` -> `gpt-5.4-mini`; run `codex debug models` to see yours. An exact model slug passes through unchanged, and when the model is in the catalogue `--effort` is checked against the reasoning levels it lists - `--config key=value` (repeatable, also on `/codex:review` and `/codex:adversarial-review`) forwards a `config.toml` override to the Codex thread, e.g. `--config model_provider=ollama`. On `--resume-last` the plugin opens a fresh app-server session (cold resume) so `--config` overrides, sandbox and approval policy take effect; model and effort for the resumed turn are sent on the turn, never on the resume request. In a `--background`/`--await` job record the config **keys** are recorded and the **values** are never stored (they read back as `[redacted]` in `status`/`result`): the real values live only in the job's private 0600 `jobs/.request.json`, which the worker consumes and deletes. - follow-up rescue requests can continue the latest Codex task in the repo -- under the hood, `/codex:rescue` and the `codex-rescue` agent are each a single `scripts/codex-companion.mjs task --await --prompt-stdin ` call: `--await [--await-timeout-ms ]` launches the same tracked background job as `--background`, then waits for it (default 540000 ms), and `--prompt-stdin` reads the prompt as stdin verbatim (so it cannot be combined with `--args-stdin`, `--prompt-file`, or prompt text on the command line). Exit code is 0 when the job completed, 1 when it failed or was cancelled, and 3 when the wait times out while the job is still queued or running — exit 3 prints a `Re-run: node "" result --wait --timeout-ms 540000` hint, which is the only follow-up call the rescue flow makes. +- under the hood, `/codex:rescue` and the `codex-rescue` agent are each a single `scripts/codex-companion.mjs task --await --prompt-stdin ` call: `--await [--await-timeout-ms ]` launches the same tracked background job as `--background`, then waits for it (default 540000 ms), and `--prompt-stdin` reads the prompt as stdin verbatim (so it cannot be combined with `--args-stdin`, `--prompt-file`, or prompt text on the command line). Exit code is 0 when the job completed, 1 when it failed or was cancelled, and 3 when the wait times out while the job is still queued or running — exit 3 prints a `Re-run: node "" result --wait --timeout-ms 540000` hint, which is the only follow-up call the rescue flow makes. With `--args-stdin` (and a single-string `$ARGUMENTS`), a backslash escapes only a following quote, backslash or whitespace and stays literal before anything else, so `C:\Users\me` survives but `\\server\share` becomes `\server\share`; use `--prompt-stdin` for byte-exact text. - `result [--wait [--timeout-ms ]]` answers a different question, so it has its own contract: `result` exits 0 for any terminal record (completed, failed or cancelled) and 3 while the job is still active. Its exit code means "a result was retrieved", not "the job succeeded" — unlike `task --await` it never returns 1 for a failed job, so read the rendered record for the outcome. A plain `result ` on a still-running job prints the same `--wait` hint and exits 3 instead of failing (fixes upstream #498/#524, which reported "No job found" for a running job). `--json` on either returns `{ job, storedJob }` (or, on a timeout, the `status --json` snapshot plus a `resumeCommand` field). - The detached worker outlives the companion only when the companion returns on its own (exit 3); a host process-tree kill — e.g. Claude Code's Bash timeout — also kills the worker, so keep `--await-timeout-ms` below the host limit (default 540000 < 600000). - `--turn-timeout-ms ` (or `CODEX_TURN_TIMEOUT_MS`, also on `/codex:review` and `/codex:adversarial-review`) bounds a single Codex turn: on expiry it interrupts the turn and returns a structured failed result ("turn timed out after `` ms") instead of hanging. Default is `0` (unbounded). The budget travels with a `--background`/`--await` job, so a detached worker enforces it too. The interrupt is not trusted on its own: the run waits up to 10 s for the turn's terminal notification, and if none arrives the failure says so ("interrupt not acknowledged — the turn may still be running in the shared runtime, check status or cancel"), because a shared broker runtime can keep executing a turn nobody is listening to any more. A run that owns its own app-server (a cold `--resume-last`) closes it in that case, which does stop the turn (stdin EOF, then `SIGTERM`, then `SIGKILL`, so the close is bounded too). Partial output on a timed-out turn is best-effort: only whole items Codex had already completed are kept, so a turn interrupted mid-message reports less text than Codex had produced. - the `SessionEnd` hook works to one absolute budget (`SESSION_END_BUDGET_MS`, 12 s; `CODEX_COMPANION_SESSION_END_BUDGET_MS` can only *shorten* it — a larger value is ignored with a note, since the hook timeout is fixed), and every bounded step inside it — the workspace state lock, each broker handshake, the busy retries, the teardown probe — is clamped to what is left of that budget. `hooks/hooks.json` gives `SessionEnd` a 15 s timeout, which must stay **above** the budget: below it Claude Code would kill the hook mid-decision instead of letting it report one. A test asserts the pair, so the two numbers cannot drift apart. - if a background job's session ends while `CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS=0`, the shared broker that keeps running for that job never self-terminates on its own — its normal idle exit is disabled in that configuration, so the broker only goes away once the job finishes (or is reaped as dead) and a later `SessionEnd` runs. +- the `SessionEnd` broker teardown line (`[codex] Broker teardown: ... reason=`) names one of: + + | reason | meaning | + | --- | --- | + | `no-pid` | no broker pid was recorded; nothing to signal | + | `identity-match` | the pid was proven to be this broker by its recorded identity, so the signal was attempted (`signalled` says whether it landed) | + | `command-line-match` | a record without an identity was proven by its command line, so the signal was attempted | + | `identity-mismatch` | the pid is no longer provably ours (another process, or a command line that did not match or could not be read); left alone | + | `identity-unavailable` | the identity could not be read (e.g. on Windows); left alone | + | `kill-failed` | the ownership probe or the kill threw; the broker may still be running | ### `/codex:transfer` @@ -185,7 +193,7 @@ Examples: /codex:transfer --source ~/.claude/projects/-Users-me-repo/.jsonl ``` -The plugin's existing `SessionStart` hook supplies the current transcript path automatically; `--source` is available as a manual override. The transfer uses Codex's external-agent session importer, so it follows the same conversion rules as importing Claude history in the Codex App and creates visible turns that can be continued in the App or TUI. The source must be under `~/.claude/projects`, and older Codex versions that do not expose session import must be upgraded before using this command. The transcript root honours `CLAUDE_CONFIG_DIR` when set, resolving to `/projects` instead of `~/.claude/projects`. +The plugin's existing `SessionStart` hook supplies the current transcript path automatically; `--source` is available as a manual override. The transfer uses Codex's external-agent session importer, so it follows the same conversion rules as importing Claude history in the Codex App and creates visible turns that can be continued in the App or TUI. The source must be under `~/.claude/projects` (`$CLAUDE_CONFIG_DIR/projects` when `CLAUDE_CONFIG_DIR` is set), and older Codex versions that do not expose session import must be upgraded before using this command. ### `/codex:status` @@ -247,7 +255,7 @@ You can also use `/codex:setup` to manage the optional review gate. /codex:setup --disable-review-gate ``` -When the review gate is enabled, the plugin uses a `Stop` hook to run a targeted Codex review based on Claude's response. If that review finds issues, the stop is blocked so Claude can address them first. When the review itself fails (timeout, killed by a signal, invalid output), the block reason says why and ends with `Disable with /codex:setup --disable-review-gate.` +When the review gate is enabled, the plugin uses a `Stop` hook to run a targeted Codex review based on Claude's response. If that review finds issues, the stop is blocked so Claude can address them first. When the review itself fails (timeout, killed by a signal, invalid output), the block reason says why and ends with `Disable with /codex:setup --disable-review-gate.` The hooks read their input from stdin against a deadline (1 s for `SessionEnd`, before its budget starts; 5 s for `SessionStart`; 2 s for `Stop`), so a disabled gate never waits on a stdin Claude Code leaves open, while an enabled gate blocks when the input never arrives. With the gate on and a host that never closes stdin or never sends the input, every stop is blocked; run `/codex:setup --disable-review-gate` to get out. To pin the model and reasoning effort the gate's review uses, independently of your Codex config: @@ -378,4 +386,34 @@ If you need to point the built-in OpenAI provider at a different endpoint, set ` ### Windows -As of v1.3.0, kills issued from stored process records (`/codex:cancel`, `SessionEnd` cleanup, stale-broker replacement, broker teardown) are refused on Windows until process identity lands in v1.4.0. This bounds any leak by the broker idle timeout, and a turn interrupt is still sent regardless — it just cannot be followed by a forced kill on that platform yet. +As of v1.4.0, the plugin no longer spawns commands through `$SHELL` on Windows (usually Git Bash, which mangled `taskkill` and other arguments): `codex`, `npm` and `git` are resolved with `where.exe`, `.exe` files run directly and `.cmd` shims run through `cmd.exe`. This is the spawn path behind the commands, `/codex:review`, `/codex:adversarial-review` and background `task`/`--await` jobs. Separately, the `Stop`, `SessionStart` and `SessionEnd` hooks now read stdin with a bounded deadline instead of a blocking read, so a disabled review gate no longer hangs until the hook timeout on Windows. `/codex:transfer`'s own Windows-specific issues (verbatim `\\?\` paths, ledger lookups) are unrelated to this change and are not fixed in v1.4.0. + +Still limited until v1.4.1: kills issued from stored process records — `/codex:cancel`, `SessionEnd` cleanup of a still-running job, stale-broker replacement, and broker teardown — refuse to signal a stored pid (reason `identity-unavailable`) because process identity verification has not landed yet; that is now targeted for v1.4.1, not v1.4.0. `/codex:cancel` still sends the turn interrupt on a best-effort basis; `SessionEnd` only refuses. A worker or broker left behind exits when its turn ends and, for the broker, once every client has disconnected and its idle timeout elapses — an unbounded turn is not reaped on Windows until v1.4.1. + +Requirements: `where.exe` and `cmd.exe` ship with Windows, so nothing extra needs installing for them, and PowerShell is not required in v1.4.0. `codex` and `npm` must be on the Windows `PATH` as `.cmd`/`.exe` (a global `npm install -g @openai/codex` already does that for `codex`); a `codex` or `npm` that only exists inside Git Bash (an alias, a shell function or a bash-only `PATH` entry) is no longer found. + +When `CLAUDE_PLUGIN_DATA` is not set, job state falls back to a per-user directory: `%LOCALAPPDATA%\codex-companion` on Windows as of v1.4.0 (a private `codex-companion-` directory under the system temp directory elsewhere). If a pre-1.4.0 state root under `%TEMP%\codex-companion-user` already exists, it keeps being used (with a one-line notice) until you remove it; nothing is migrated. On Windows, `state.json` reads and writes and lock-ticket reads also retry briefly (up to 20 attempts, roughly 300 ms worst case) on `EPERM`/`EBUSY`/`EACCES` when another process holds `state.json` or a lock ticket open. + +## Development + +The plugin runtime supports Node.js 18.18 or later; the development tooling below +(eslint, c8, Stryker) needs Node.js 24. `npm run build` also needs the `codex` CLI +on `PATH`, because it generates the app-server protocol types first. + +- `npm run check` — the full local gate: version metadata, changelog, lint, + typecheck (`npm run build`), typecheck of tests and scripts, and the test suite. +- `npm run setup:git-hooks` — points git at `.githooks/` (pre-commit runs lint and + typecheck). The setting lives in the shared `.git/config`, so it applies to the + main checkout and every worktree and replaces any `.git/hooks/*`; typecheck runs + `prebuild`, so committing needs the `codex` CLI on `PATH`. +- `npm run test:coverage` — runs the suite under c8 and writes + `reports/coverage/`; thresholds live in `.c8rc.json` (long-term target: + 85% lines, 75% branches, 90% functions). +- `npm run test:mutation:critical` — Stryker over `args.mjs` and + `model-catalog.mjs`, reports in `reports/mutation/` (also runs weekly in CI). + +Coverage includes the companion, broker and hook subprocesses that tests spawn, +because c8 passes `NODE_V8_COVERAGE` to child processes. It has limits: a child +killed with SIGKILL or `taskkill /F` leaves no coverage dump, a detached broker or +worker may exit after the report is written, and Windows-only branches are not +measured on the ubuntu CI job. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..30089a772 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security Policy + +## Supported Versions + +Security fixes are applied to the latest release on `main`. + +| Version | Supported | +| --- | --- | +| Latest release | Yes | +| Older releases | No | + +## Reporting a Vulnerability + +Please do not report security vulnerabilities in public GitHub issues. + +Open a private vulnerability report through GitHub Security Advisories for +[`CBEPX/codex-plugin-cc`](https://github.com/CBEPX/codex-plugin-cc/security/advisories/new) +(private vulnerability reporting). Include: + +- a description of the issue +- affected files or flows +- reproduction steps or a proof of concept +- any suggested mitigation + +Confirmed issues are fixed on the latest release and disclosed through the advisory. diff --git a/docs/superpowers/plans/2026-09-28-codex-plugin-cc-v1.4.0.md b/docs/superpowers/plans/2026-09-28-codex-plugin-cc-v1.4.0.md new file mode 100644 index 000000000..710b41ccd --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-codex-plugin-cc-v1.4.0.md @@ -0,0 +1,187 @@ +# codex-plugin-cc v1.4.0 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Сделать Windows-джоб CI обязательным, дать репозиторию тулинг уровня cc-plugin-codex (lint/typecheck/coverage/mutation/dependabot/SECURITY/changelog-gate), закрыть мелкие Windows-баги без изменения kill-семантики и перенести отложенные minor из v1.3.0. + +**Architecture:** Никаких новых runtime-зависимостей. Конфиги копируются из `/Users/g.mehrenin/project/personal/cc-plugin-codex` с заменой путей `hooks/**`,`scripts/**` → `plugins/codex/scripts/**`. Общее чтение stdin для хуков выносится в один модуль `plugins/codex/scripts/lib/hook-input.mjs`. На win32 spawn через `$SHELL` заменяется: `taskkill.exe`/`powershell.exe`/`where.exe` — `shell:false`; `codex`/`npm`/`node` — резолв через `where.exe`, `.cmd`/`.bat` → `cmd.exe /d /s /c`, `.exe` → напрямую. Process identity на Windows **не** трогается (v1.4.1). + +**Tech Stack:** Node ≥18.18, ESM `.mjs`, `node --test`, eslint 10 flat config, tsc `checkJs`, c8, Stryker 9 (command runner), GitHub Actions. + +**Spec:** `/Users/g.mehrenin/.claude/plans/glistening-chasing-backus.md`, раздел «v1.4.0 (пересмотр после v1.3.0)»; `docs/superpowers/triage/2026-09-27-ci-matrix-findings.md`; леджер `docs/superpowers/reports/v1.3.0/sdd-ledger.md` (строки `minor (deferred`/`parked`). + +## Global Constraints + +- Worktree `/Users/g.mehrenin/project/personal/codex-plugin-cc/.worktrees/release-v1.4.0`, ветка `release/v1.4.0` от `main` (f6f3db5). `main` = установленный `codex@cbepx` 1.3.0 — не трогать. +- Гейт на задачу: `npm test > /tmp/npm-test.log 2>&1; st=$?; rg -e 'ℹ (tests|pass|fail)' -e '^not ok' /tmp/npm-test.log; test "$st" -eq 0` → `fail 0` (314 на базе); `sleep 10; pgrep -f codex-plugin-test- | wc -l` → 0; `npm run build`. С Task 3 добавляются `npm run lint`, `npm run typecheck`, `npm run typecheck:tests`, и `npm run check` становится единым гейтом. +- Только `rg`, никаких `git add -A`, не пушить без команды. Трейлер `Co-Authored-By: Claude Fable 5.1 `; портированные upstream-PR — `Co-authored-by: `. +- Windows нельзя проверить локально: задачи 1–2 доказываются CI-матрицей (push ветки `release/v1.4.0` — с разрешения пользователя); реальные Windows-проверки Task 5 и Task 6 — тоже только CI. +- Kill-семантика (`terminateRecordedProcess`, `ownsBrokerProcess` на win32, отказы без identity, `cancellationPending` при живом непроверяемом worker) в этом релизе не меняется; тесты на Windows принимают текущие отказы, а не ослабляют их. +- Tooling (eslint 10, Stryker 9, c8 12) требует Node ≥20 → lint/typecheck/coverage/mutation гоняются только на Node 24; runtime-тесты — на 18/22/24. `engines.node >=18.18.0` не меняется. +- Codex-ревью плана (thread `01a0e4e4-9afc-7f30-8355-af30403a3cc4`, 2026-09-28) учтено во всех задачах ниже; при исполнении Task 5 и Task 6 обязательный второй проход `/codex:rescue --effort xhigh` (read-only) по диффу до Claude-ревью. + +## Review Focus + +1. Windows-путь в тексте задачи через `--args-stdin` (`C:\Users\x\proj`) доходит до Codex без потери `\` (Task 4). +2. `taskkill.exe` на Windows вызывается без `$SHELL`, поэтому `/PID` не превращается в путь (Task 5; проверяется unit-тестом на аргументы и CI). +3. Stop-hook с выключенным gate завершается за ≤2 s, даже если stdin никогда не закрывается (Task 6). +4. `npm test` на Windows под node 18 находит тесты (каталог вместо glob) (Task 1). +5. Один SHA гоняется одним прогоном матрицы, а не двумя (Task 2). + +--- + +### Task 1: Windows-обвязка тестов (класс A) — тесты, а не продукт + +**Files:** +- Create: `.gitattributes` (`* text=auto eol=lf`), `scripts/run-tests.mjs` +- Modify: `package.json` (`test`, `prebuild`), `tests/test-env.mjs`, `tests/helpers.mjs`, `tests/runtime.test.mjs`, `tests/state.test.mjs`, `tests/broker-endpoint.test.mjs`, `tests/broker-idle-timeout.test.mjs`, `tests/broker-stale-pid.test.mjs`, `tests/app-server.test.mjs`, `tests/commands.test.mjs` + +**Interfaces:** `tests/helpers.mjs`: `export const IS_WIN = process.platform === "win32";`, `export function homeEnv(home) { return { HOME: home, USERPROFILE: home }; }`; `run()` — `shell:false` для `node`/`git` (в тестах `run("node", …)` → `run(process.execPath, …)`), `shell` только там, где цель — `.cmd`. Тесты POSIX-семантики (mode-биты, unix socket, отрицательные pid, `pgrep`, graceful-signal сценарии, «неубиваемый ребёнок» в `app-server.test.mjs:145–154`) получают `{ skip: IS_WIN }` с комментарием, какой инвариант на Windows не моделируется; **замена ожиданий на другие exit-коды запрещена**. + +- [ ] **Step 0 (инвентарь)**: spike-список относится к v1.2.1; после Task 2 (по команде пользователя — push ветки, `workflow_dispatch`) снять актуальный список падений Windows на текущем SHA и записать в отчёт задачи; класс A ниже — ожидаемое, не исчерпывающее. +- [ ] **Step 1**: `.gitattributes`; единого вызова `node --test` для Node 18/22/24 и Windows нет (проверено локально на Node 24: `node --test tests/` → `ERR_UNSUPPORTED_DIR_IMPORT`; на Node 18 каталог сработал бы, но подхватил бы и `tests/test-env.mjs` по шаблону `test-*`; cmd.exe не раскрывает glob). Поэтому `scripts/run-tests.mjs` (≈10 строк): `readdirSync("tests")` → `*.test.mjs` → `spawnSync(process.execPath, ["--import", "./tests/test-env.mjs", "--test", ...files, ...process.argv.slice(2)], { stdio: "inherit" })` → `process.exit(status ?? 1)`; `package.json`: `"test": "node scripts/run-tests.mjs"`; остальные test-скрипты — явные списки файлов. `prebuild`: `mkdir -p` → `node -e "require('fs').mkdirSync('plugins/codex/.generated/app-server-types',{recursive:true})"`. +- [ ] **Step 2**: `tests/test-env.mjs` — `fileURLToPath(new URL("./fixtures/models-catalog.json", import.meta.url))`. +- [ ] **Step 3**: `tests/commands.test.mjs` — один хелпер `read()` нормализует `\r\n` → `\n`. +- [ ] **Step 4**: `tests/runtime.test.mjs`: transfer-тесты (~252, 297, 327, 353) → `...homeEnv(home)`; тест «setup is ready without npm…» (~76–94) — на win32 `{ skip: IS_WIN }` (изолировать `node.exe` без `npm` переносимо нельзя; добавление каталога Node в PATH вернуло бы npm и обессмыслило тест); mode-assert'ы (~3314) и `tests/state.test.mjs` (~141, 696, 864) под `if (!IS_WIN)`. +- [ ] **Step 5**: `tests/broker-endpoint.test.mjs:6` — ожидание строить через `path.join` хоста (`"unix:" + path.join(dir, "broker.sock")`), не `path.posix`; сигнальные тесты: `app-server.test.mjs` close()×2 — `{ skip: IS_WIN }` (сценарий «SIGTERM-immune child» на Windows не моделируется), `broker-idle-timeout.test.mjs` (~365/386, ~395–446: `pgrep`, graceful SIGTERM) и `broker-stale-pid.test.mjs` (~443/~483: отрицательные pid) — классифицировать каждый: posix-only → skip; платформенно-нейтральный → оставить. Тестовые `process.kill` на мёртвый pid — `try/catch`. +- [ ] **Step 6**: `tests/state.test.mjs` «concurrent writers…» (~160–184, >100 KiB JSON в `-e`) → временный `.mjs` файл + проверка `spawn` error и exit status writer'а. +- [ ] **Step 7**: Windows-ожидания для `cancel`: тест «cancelling an awaited job…» (~3668) на win32 принимает `cancellationPending` + exit 1 (документированный отказ v1.3.0), код не ослабляется. +- [ ] **Step 8**: гейт; commit `test: make the suite runnable on Windows (LF, explicit runner, env/mode/signal expectations)`. + +--- + +### Task 2: CI hardening; Windows обязательный — в конце релиза + +**Files:** `.github/workflows/pull-request-ci.yml`, `.github/workflows/release-verify.yml`, `plugins/codex/scripts/session-lifecycle-hook.mjs` (`BROKER_BUSY_RETRY_MS`), `plugins/codex/scripts/app-server-broker.mjs` (одна строка лога при закрытии клиентского сокета, если её нет), `tests/broker-stale-pid.test.mjs`, `tests/commands.test.mjs`. + +- [ ] **Step 1**: `pull-request-ci.yml`: `on: { pull_request, push: { branches: [main] }, workflow_dispatch }` — release-ветки до PR проверяются через `workflow_dispatch`, так один SHA гоняется одним событием; `concurrency: { group: ci-${{ github.workflow }}-${{ github.ref }}, cancel-in-progress: true }`. Матрица runtime-тестов `{ubuntu, macos, windows} × {18, 22, 24}`; отдельный job `quality` на ubuntu/node 24 (lint, build/typecheck, typecheck:tests, check:changelog, coverage-артефакт — наполняется в Task 3). `continue-on-error` для Windows **остаётся** до Task 9. +- [ ] **Step 2**: `BROKER_BUSY_RETRY_MS` 1000 → 3000 — увеличение окна ожидания ответа `busy:false`; инвариант «teardown только после подтверждённого idle» не меняется; бюджет: handshake bound 5 s (`session-lifecycle-hook.mjs:40`, зажат `stepBudget`) + retry 3 s + teardown ≤2 s ≤ 12 s. Тест «session end reaps a SIGKILLed background worker…» (~443): к существующему `waitFor(!isAlive)` добавить `waitFor(() => brokerLog.includes("client disconnected"))` по `broker.log` (если broker такой строки не пишет — добавить одну в `app-server-broker.mjs` на `close` сокета). Никаких фиксированных `sleep`. +- [ ] **Step 3**: `release-verify.yml` — та же матрица + quality job + `npm audit --omit=dev`, `npm pack --dry-run`. +- [ ] **Step 4**: commit `ci: one run per SHA, quality job on node 24, wider broker busy-retry`. Снятие `continue-on-error` — Task 9, после полного зелёного прогона с Task 4–6. + +--- + +### Task 3: Тулинг (только Node 24) + +**Files:** Create `eslint.config.mjs`, `tsconfig.tests.json`, `.githooks/pre-commit`, `scripts/setup-git-hooks.mjs`, `scripts/check-changelog.mjs`, `scripts/lib/changelog.mjs`, `.c8rc.json`, `.github/dependabot.yml`, `SECURITY.md`, `stryker.config.mjs`, `.github/workflows/mutation.yml`; Modify `package.json`, `.gitignore` (`reports/`, `.stryker-tmp/`), новый `tests/changelog.test.mjs`. + +**Interfaces:** `lint`; `typecheck` = существующий `npm run build` (`tsconfig.app-server.json` уже `checkJs` против generated types — **не** переводить на NodeNext: extensionless JSDoc-импорты в `app-server.mjs:3–8`, `codex.mjs:2–9`); `typecheck:tests` (новый `tsconfig.tests.json`, `extends` app-server config, `include: ["tests/**/*.mjs", "scripts/**/*.mjs"]`); `check:changelog`; `test:coverage`; `test:mutation:critical`(+`:unit`); `setup:git-hooks`; `check` = `check-version && check:changelog && lint && build && typecheck:tests && test`. `prebuild` (генерация protocol types) остаётся и всегда предшествует typecheck. + +- [ ] **Step 1**: `eslint.config.mjs` из референса + ignores `plugins/codex/.generated/**`, `.worktrees/**`, `docs/**`, `reports/**`; `tsconfig.tests.json`. devDependencies: `eslint ^10.2.0`, `@eslint/js ^10.0.1`, `globals ^17.5.0`, `c8 12.0.0`, `@stryker-mutator/core ^9.6.1`; `npm install`; lint/typecheck:tests зелёные с минимальными правками (только реальные находки; `// @ts-expect-error` с причиной, не `@ts-ignore`). +- [ ] **Step 2**: `scripts/check-changelog.mjs` + `scripts/lib/changelog.mjs` (регекс `^##\s+v?(\s|$)` под формат `## 1.3.0 — 2026-09-27`) + побайтное равенство `CHANGELOG.md` и `plugins/codex/CHANGELOG.md`; `tests/changelog.test.mjs`: нет секции → fail; секция без bullet → fail; копии расходятся → fail с подсказкой `cp`. +- [ ] **Step 3**: coverage: globs в `.c8rc.json` (`include: ["plugins/codex/scripts/**/*.mjs", "scripts/**/*.mjs"]`, `exclude: ["plugins/codex/.generated/**"]`, `all: true`, reporters text/json-summary/lcov, `reports-dir: reports/coverage`), `"test:coverage": "c8 --check-coverage node scripts/run-tests.mjs"`. c8 передаёт `NODE_V8_COVERAGE` дочерним процессам, поэтому companion-подпроцессы покрытие дают; ограничения (в README/отчёт): SIGKILL/`taskkill /F` не оставляют dump, detached broker/worker может завершиться после отчёта, Windows-ветки на ubuntu не измеряются. Сначала подтвердить ненулевое покрытие `codex-companion.mjs` в отчёте, затем пороги = факт − 2 п.п. в `.c8rc.json`; ориентир 85/75/90 — в README. +- [ ] **Step 4**: Stryker critical: `mutate: ["plugins/codex/scripts/lib/args.mjs", "plugins/codex/scripts/lib/model-catalog.mjs"]`, `commandRunner: node --import ./tests/test-env.mjs --test tests/args.test.mjs tests/model-catalog.test.mjs`, thresholds 80/55/55; `mutation.yml` — `workflow_dispatch` + `schedule` (вс 03:00 UTC), node 24, без pull_request. Один локальный прогон → score в отчёт. +- [ ] **Step 5**: `.githooks/pre-commit`, `scripts/setup-git-hooks.mjs`, `.github/dependabot.yml`, `SECURITY.md` (Supported: latest release; Reporting: GitHub Security Advisories `CBEPX/codex-plugin-cc`, без email). README «Development». +- [ ] **Step 6**: наполнить quality job из Task 2. Гейт `npm run check`; commit `chore: lint, typecheck for tests, coverage, mutation (critical), dependabot, SECURITY.md, changelog gate`. + +--- + +### Task 4: `--args-stdin` не съедает обратные слэши Windows-путей + +**Files:** `plugins/codex/scripts/lib/args.mjs` (`splitRawArgumentString`), `tests/args.test.mjs`, README. + +**Контракт (минимальное изменение старой модели):** модель кавычек не меняется (`\` обрабатывается до проверки кавычек, `\'` внутри `'…'` по-прежнему даёт `'`); меняется одно: `\` экранирует **только** следующий символ из whitelist `"`, `'`, `\`, whitespace (`/\s/`, включая перевод строки — как сегодня); перед любым другим символом `\` — литерал. Документируемые ограничения: `\\server\share` → `\server\share`; `C:\dir\` перед закрывающей `"` экранирует кавычку. Для byte-exact текста есть `--prompt-stdin` (rescue уже его использует); `normalizeArgv` (`codex-companion.mjs:191–224`) получает то же поведение намеренно. + +- [ ] **Step 1: failing tests** (`tests/args.test.mjs`): +```js +test("splitRawArgumentString keeps a backslash that escapes nothing (Windows paths)", () => { + assert.deepEqual(splitRawArgumentString("investigate C:\\Users\\me\\proj\\file.mjs"), ["investigate", "C:\\Users\\me\\proj\\file.mjs"]); + assert.deepEqual(splitRawArgumentString("'C:\\dir\\x' \"D:\\y\""), ["C:\\dir\\x", "D:\\y"]); +}); +test("splitRawArgumentString keeps the old escape semantics for quotes, backslash and whitespace", () => { + assert.deepEqual(splitRawArgumentString("say \\\"q\\\" a\\ b back\\\\slash it\\'s"), ["say", "\"q\"", "a b", "back\\slash", "it's"]); + assert.deepEqual(splitRawArgumentString("'it\\'s'"), ["it's"]); // old behaviour, kept + assert.deepEqual(splitRawArgumentString("\\\\server\\share"), ["\\server\\share"]); // documented limitation +}); +``` +- [ ] **Step 2: implement** — в ветке `character === "\\"`: `const next = raw[index + 1]; if (next === "\"" || next === "'" || next === "\\" || /\s/.test(next ?? "")) { escaping = true; continue; } current += "\\"; continue;` (цикл по индексу). Больше ничего. +- [ ] **Step 3**: README: правило в одном предложении + `--prompt-stdin` для точного текста. Гейт; commit `fix(args): keep backslashes that escape nothing in --args-stdin text`. + +--- + +### Task 5: Windows spawn без `$SHELL` + +**Files:** `plugins/codex/scripts/lib/process.mjs`, `plugins/codex/scripts/lib/app-server.mjs` (spawn `codex`), `plugins/codex/scripts/lib/broker-lifecycle.mjs` (`windowsHide: true` в `spawnBrokerProcess`), `tests/process.test.mjs`, `tests/fake-codex-fixture.mjs`, `tests/helpers.mjs`, README (совместимость). + +**Дизайн (с учётом ревью Codex):** +- `resolveExecutable(command, { env, cwd })` — только win32; **сырой** `spawnSync("where.exe", [command], { shell: false, timeout: 5000, env, cwd })` (не через `runCommand` — иначе рекурсия); из строк результата берётся первая с расширением из `PATHEXT` (`.exe`, `.cmd`, `.bat`, `.com`); extensionless shim (bash-скрипт) пропускается; не найдено → `null` → `ENOENT`, как сегодня. Без кэша. +- `buildLaunch(resolvedPath, args)`: `.exe`/`.com` → `{ file: resolvedPath, args, windowsVerbatimArguments: false }`; `.cmd`/`.bat` → `{ file: env.ComSpec || "cmd.exe", args: ["/d", "/s", "/c", '"' + [resolvedPath, ...args].map(quoteForCmd).join(" ") + '"'], windowsVerbatimArguments: true }`; `quoteForCmd` — правила Node `child_process` для `shell:true` (обернуть в `"` при пробелах/кавычках/пустой строке, `"` → `\"`, завершающие `\` удваивать перед закрывающей `"`; `& | < > ^ %` внутри кавычек не трогать — `/s` снимает внешнюю пару). ≈15 строк, таблица случаев в тесте. +- `runCommand`: на win32 `shell:false` всегда; `command` без пути → `resolveExecutable` → `buildLaunch`. `taskkill.exe`/`powershell.exe`/`where.exe` — прямые `.exe`. Критерии `terminateProcessTree` (`/T /F`, `looksLikeMissingProcessMessage`, ENOENT-fallback) не меняются. +- `app-server.mjs:245`: `spawn(launch.file, launch.args, { shell: false, windowsVerbatimArguments: launch.windowsVerbatimArguments, windowsHide: true, … })` для `resolveExecutable("codex")` + `["app-server"]`; комментарий ~283 обновить (`terminateProcessTree(this.proc.pid)` на живом handle остаётся допустимым исключением). +- **Совместимость (README + CHANGELOG «Changed»)**: codex/npm, доступные только внутри Git Bash (alias, функция, bash-only PATH), перестают находиться — нужен `codex.cmd`/`codex.exe` в Windows PATH. + +- [ ] **Step 1: unit tests** (`tests/process.test.mjs`, инъекция `spawnSyncImpl`): `resolveExecutable` выбирает `codex.cmd` из вывода `codex\r\ncodex.cmd\r\n`; таблица `buildLaunch`/`quoteForCmd`: путь с пробелом, аргумент с `"`, пустой аргумент, `%PATH%`, `a&b`, завершающий `\`; `terminateProcessTree(win32)` вызывает `taskkill.exe` c `shell:false` (шпион на options). +- [ ] **Step 2: implement**; `spawnBrokerProcess`: `windowsHide: true`. +- [ ] **Step 3: fixtures** — `installFakeCodex` на win32 пишет `codex.cjs` (тело фикстуры использует `require`) + `codex.cmd` = `@echo off\r\nnode "%~dp0codex.cjs" %*`; на posix как сейчас. `tests/helpers.mjs` `run()`: `shell:false` для `process.execPath`/`git`. +- [ ] **Step 4: Windows round-trip test** (`{ skip: !IS_WIN }`, выполняется только на CI): `.cmd`-шим, печатающий `JSON.stringify(process.argv.slice(2))`, в каталоге **с пробелом**; `runCommand` с `["plain", "with space", "q\"uote", "", "%PATH%", "a&b", "trail\\"]` → argv совпадает. +- [ ] **Step 5**: второй проход `/codex:rescue --effort xhigh` по диффу (read-only) до Claude-ревью. Гейт локально (posix без изменения поведения); Windows — CI. Commit `fix(windows): spawn without $SHELL — where.exe resolution, quoted cmd.exe launch for .cmd shims, direct taskkill/powershell` с `Co-authored-by: mohammad-malik ` (#735), `Co-authored-by: mittalpk ` (#669). + +--- + +### Task 6: Чтение stdin в хуках — дедлайн, EAGAIN, лимит (без потери payload) + +**Files:** Create `plugins/codex/scripts/lib/hook-input.mjs`; Modify `plugins/codex/scripts/session-lifecycle-hook.mjs`, `plugins/codex/scripts/stop-review-gate-hook.mjs` (`main` → async; prompt в companion через `--prompt-stdin`, не argv), `plugins/codex/scripts/lib/fs.mjs` (`readStdinIfPiped`), `tests/hook-input.test.mjs`, `tests/runtime.test.mjs`, `tests/commands.test.mjs`. + +**Interfaces:** `export async function readHookInput({ timeoutMs = 2000, maxBytes = 1024 * 1024, stdin = process.stdin } = {})` → `{ input: object|null, error: null | { code: "timeout"|"overflow"|"invalid-json", message } }`. EOF → parse полного ввода; дедлайн → если накопленный буфер **уже** валидный JSON — принять (EOF задержался), иначе `error.code = "timeout"`; байты > `maxBytes` → прекратить чтение, `"overflow"`, усечённый буфер не парсится; невалидный JSON → `"invalid-json"`. `StringDecoder("utf8")` на границах chunk'ов, лимит в байтах; по завершении `clearTimeout`, снять listeners, `stdin.pause()`/`destroy()`. Env `CODEX_HOOK_STDIN_TIMEOUT_MS` — для тестов. +- Stop-hook: вызов внутри существующего fail-closed блока: любой `error` → `{"decision":"block"}` (как сегодня для malformed), **кроме** `timeout` с пустым буфером при `stopReviewGate === false` в workspace по `CLAUDE_PROJECT_DIR`/`process.cwd()` → allow (это #530); при включённом gate timeout → block с причиной «hook input did not arrive». Prompt в companion — через `--prompt-stdin` (снимает лимит argv на Windows). +- SessionEnd-hook: `error` → stderr-строка и `return` без cleanup (не подставлять `{}`); бюджет 12 s стартует после чтения, поэтому `timeoutMs` = 1000; тест «SessionEnd hook timeout stays above…» дополнить `15 > 12 + 1`. +- `readStdinIfPiped` (companion): `readSync` в цикле, накопленные байты сохраняются между повторами `EAGAIN` (до 50 × 20 ms); исчерпание → throw, не частичный prompt. + +- [ ] **Step 1: tests** — `tests/hook-input.test.mjs` через `PassThrough`: (a) JSON частями + EOF; (b) полный JSON без EOF → принят на дедлайне; (c) частичный на дедлайне → `timeout`; (d) UTF-8 символ через границу chunk'ов; (e) > `maxBytes` → `overflow` без parse; (f) `{not-json` → `invalid-json`. `runtime.test.mjs`: stop-hook с выключенным gate и открытым stdin без EOF → exit 0 за <3 s (`CODEX_HOOK_STDIN_TIMEOUT_MS=200`); с включённым gate → `block` с причиной про input; `last_assistant_message` 300 KB → prompt доходит до fake codex целиком. +- [ ] **Step 2: implement**; оба хука; `fs.mjs`. +- [ ] **Step 3**: второй проход `/codex:rescue --effort xhigh` (read-only). Гейт; commit `fix(hooks): bounded stdin read that never drops a complete payload; review prompt via stdin` с `Co-authored-by: stantheman0128 ` (#544), `Co-authored-by: tmchow ` (#123). + +--- + +### Task 7: Windows state: `%LOCALAPPDATA%` fallback root и `writeFileAtomic` под открытым читателем + +**Дополнение по результату CI Task 1 (run 36356506497):** на Windows `renameSync(tmp → state.json)` в `writeFileAtomic` (`state.mjs:~190`) падает с `EPERM`, когда другой процесс держит `state.json` открытым на чтение (тест «concurrent writers never leave a torn state.json» → writer exit 1; на node 24 — падение прогона). Фикс (продукт): на win32 повторять `renameSync` до 20 раз с паузой 10–50 ms при `EPERM`/`EBUSY`/`EACCES` (`// ponytail: Windows refuses rename over an open reader; bounded retry, no lock`), затем бросать как сегодня. Читатели (`loadState`) на win32 аналогично повторяют `readFileSync` при `EBUSY`. Тест из Task 1 (`{ skip: IS_WIN }`) в этой задаче снова включается на всех платформах. Kill-семантика не затрагивается. + + +**Files:** `plugins/codex/scripts/lib/state.mjs` (`resolveFallbackStateRoot`), `tests/state.test.mjs`, CHANGELOG. + +- [ ] **Step 1: test** — `resolveFallbackStateRoot({ env: { LOCALAPPDATA: "C:\\Users\\me\\AppData\\Local" }, platform: "win32", tmpdir: "C:\\Temp", pluginRoot })` → начинается с `C:\Users\me\AppData\Local\codex-companion\`; без `LOCALAPPDATA` → `tmpdir`. (Добавить параметр `platform` в опции; `mkdirSync` в тесте на posix создаст каталог — использовать `makeTempDir()` как `LOCALAPPDATA`.) +- [ ] **Step 2: implement** — `const base = platform === "win32" && env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, "codex-companion") : path.join(tmpdir, \`codex-companion-${uid ?? "user"}\`)`; **переходная политика**: если новый корень ещё не существует, а старый `/codex-companion-user/` существует — использовать старый (без миграции файлов) и написать одну stderr-строку; тест на оба случая; CHANGELOG «Changed». Гейт; commit `fix(state): per-user fallback state root under %LOCALAPPDATA% on Windows`. + +--- + +### Task 8: Перенос отложенных minor из леджера v1.3.0 + +**Files:** `tests/model-catalog.test.mjs` + `tests/fixtures/models-catalog.json`; `tests/state.test.mjs`; `tests/runtime.test.mjs`; `plugins/codex/scripts/codex-companion.mjs` (`handleSetup`, gate-флаги); `plugins/codex/scripts/stop-review-gate-hook.mjs` (`getMaxRounds`); `plugins/codex/scripts/lib/process.mjs` (`workerCommandLine`, ps-ветка); `plugins/codex/scripts/lib/broker-lifecycle.mjs` (enum причины); `tests/broker-stale-pid.test.mjs` (комментарии, `t.after`); README (transfer-предложение). + +Один PR, пункты независимы: +- [ ] catalogue-only запись в fixture (`gpt-7-nova`, `priority: 0`) + tie по `priority` между двумя семействами → тест, что алиас `nova` резолвится **только** через каталог (в `FALLBACK_ALIASES` его нет), tie → новейшее семейство. +- [ ] refusal-тест: `fs.chmodSync(shared, 0o755)` явно; foreign-uid ветка: `uid: process.getuid() + 1` → refusal. +- [ ] `CODEX_REVIEW_GATE_MAX_ROUNDS=0` → без предела (4 блокировки подряд), `=5` → пятая блокирует, шестая allow; `Number.isInteger(parsed) && parsed >= 0`, иначе default 3 + stderr-предупреждение. +- [ ] `--review-gate-model ""`/`--review-gate-effort ""` → ошибка «use inherit to clear», ничего не пишется. +- [ ] `kill-failed` добавить в документированный enum причин (`process.mjs` JSDoc + README-таблица). +- [ ] `workerCommandLine(jobId)` — `escapeRegExp(jobId)` (строго сужает matching; id генерируются). **Не в v1.4.0**: guard `timeoutMs > 0` в ps-ветке `processCommandLine` — меняет matching kill-путей (вызовы без timeout стали бы возвращать `null`) → v1.4.1. +- [ ] `tests/runtime.test.mjs` G1-тест (SIGTERM-immune worker) — `t.after(() => { try { process.kill(-workerPid, "SIGKILL"); } catch {} })`; заголовки fresh-broker тестов — «killed as a process group». +- [ ] README transfer: одно предложение про `~/.claude/projects` / `$CLAUDE_CONFIG_DIR/projects`; комментарий в `codex.mjs` `registerThread` о single-tenant допущении broker. +- [ ] Гейт; commit `chore: fold in deferred v1.3.0 review minors`. + +--- + +### Task 9: Документация + +**Files:** `README.md`, `CHANGELOG.md` + `plugins/codex/CHANGELOG.md`. + +- [ ] README: убрать `