diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b448b83..bfb4d5cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: push: + branches: [main] pull_request: permissions: @@ -15,7 +16,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - node: [18.18.0, lts/*] + node: [22.13.0, lts/*] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -24,7 +25,7 @@ jobs: cache: npm - run: npm ci - run: npm run check - - name: Clean packed production install and Node 18.18 native binding smoke + - name: Clean packed production install and Node 22.13 native binding smoke run: node --test tests/integration/package-install.test.mjs - name: Production lockfile native binding smoke shell: bash diff --git a/.github/workflows/publish-marketplace.yml b/.github/workflows/publish-marketplace.yml index 6b8c2d35..aa2eb4af 100644 --- a/.github/workflows/publish-marketplace.yml +++ b/.github/workflows/publish-marketplace.yml @@ -33,7 +33,7 @@ jobs: persist-credentials: false - uses: actions/setup-node@v4 with: - node-version: 18.18.0 + node-version: 22.13.0 cache: npm - name: Resolve and validate source provenance id: source @@ -65,7 +65,7 @@ jobs: run: npm ci - name: Run complete quality gate run: npm run check - - name: Re-run Node 18 packed native qualification + - name: Re-run Node 22.13 packed native qualification run: node --test tests/integration/package-install.test.mjs - name: Build exact production marketplace snapshot shell: bash diff --git a/README.md b/README.md index d48d8728..d032a299 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ZCode for Codex is a native Codex marketplace plugin that delegates independent - Codex with native plugins and hooks enabled. - ZCode CLI `>=0.16.1`, installed and authenticated for at least one model. -- Node.js `>=18.18.0` (the plugin packages its production native lock dependency). +- Node.js `>=22.13.0` (the plugin packages its production native lock dependency). Install from the production snapshot published on this repository's `marketplace` branch; the source-code root on `main` is not itself a marketplace catalog: diff --git a/README.zh-CN.md b/README.zh-CN.md index 4769f07e..d8c7ed0e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,7 +8,7 @@ ZCode for Codex 是原生 Codex marketplace 插件:由 Codex 保持用户交 - 支持原生插件与 hooks 的 Codex。 - 已安装 ZCode CLI `>=0.16.1`,并至少为一个模型完成认证。 -- Node.js `>=18.18.0`;插件包内自带生产环境原生锁依赖。 +- Node.js `>=22.13.0`;插件包内自带生产环境原生锁依赖。 从本仓库 `marketplace` 分支发布的生产快照安装;`main` 上的源码根目录本身不是 marketplace catalog: diff --git a/docs/adr/0009-cross-process-locking.md b/docs/adr/0009-cross-process-locking.md index 9cb2d5c1..afeba00d 100644 --- a/docs/adr/0009-cross-process-locking.md +++ b/docs/adr/0009-cross-process-locking.md @@ -4,7 +4,7 @@ status: accepted # Use native advisory locks for cross-process state -Node.js 18 has no built-in cross-platform advisory file-lock API or filesystem compare-and-delete primitive. The plugin must nevertheless serialize job transitions and authorization consumption across processes on local macOS, Linux, and Windows workspaces. It therefore keeps one persistent lockfile for each lock scope, opens that file for the lifetime of a critical section, and uses the exactly pinned `fs-native-extensions@1.5.0` fd lock. The operating system grants ownership atomically and releases it when the fd closes or the process exits; the plugin never renames or unlinks the active lockfile. +Node.js 22.13 still has no built-in cross-platform advisory file-lock API or filesystem compare-and-delete primitive. The plugin must nevertheless serialize job transitions and authorization consumption across processes on local macOS, Linux, and Windows workspaces. It therefore keeps one persistent lockfile for each lock scope, opens that file for the lifetime of a critical section, and uses the exactly pinned `fs-native-extensions@1.5.0` fd lock. The operating system grants ownership atomically and releases it when the fd closes or the process exits; the plugin never renames or unlinks the active lockfile. ## Rejected alternatives @@ -12,4 +12,4 @@ Directory leases with heartbeat timestamps were rejected because stale takeover ## Consequences -Every state and authorization participant must open and lock the same persistent lockfile; advisory locking cannot protect against a participant that ignores the protocol. The runtime carries native prebuilds for supported macOS, Linux, and Windows architectures. Because a consuming install does not apply a dependency package's root-level npm override, the published tarball includes `npm-shrinkwrap.json` and bundles the `fs-native-extensions` dependency tree; that tree pins `bare-addon-resolve` to exactly `1.9.4` so binding resolution remains compatible with Node 18.18. Clean production installation, packed-plugin installation, bundled resolver inspection, binding loading, and lock acquisition/release must be tested in CI on all three operating systems. Release review must also audit the dependency's Apache-2.0 license, shipped prebuilds, package integrity, and security advisories. +Every state and authorization participant must open and lock the same persistent lockfile; advisory locking cannot protect against a participant that ignores the protocol. The runtime carries native prebuilds for supported macOS, Linux, and Windows architectures. The published tarball includes `npm-shrinkwrap.json` and bundles the `fs-native-extensions` dependency tree; Node 22.13 supports the current `bare-addon-resolve` release, so no legacy resolver compatibility override is needed, while the shrinkwrap records the resolved `1.10.1` resolver. Clean production installation, packed-plugin installation, bundled resolver inspection, binding loading, and lock acquisition/release must be tested in CI on all three operating systems. Release review must also audit the dependency's Apache-2.0 license, shipped prebuilds, package integrity, and security advisories. diff --git a/docs/superpowers/plans/2026-08-03-zcode-plugin-codex-implementation.md b/docs/superpowers/plans/2026-08-03-zcode-plugin-codex-implementation.md index 65fffdcc..fdf1c5ef 100644 --- a/docs/superpowers/plans/2026-08-03-zcode-plugin-codex-implementation.md +++ b/docs/superpowers/plans/2026-08-03-zcode-plugin-codex-implementation.md @@ -6,7 +6,7 @@ **Architecture:** Thin Codex skills and lifecycle hooks call one Node.js companion runtime. The companion owns argument contracts, caller/job authorization, workspace-scoped state, prompts, rendering, and two isolated adapters: a bounded Codex app-server client and a long-lived ZCode protocol broker. -**Tech Stack:** Node.js 18.18+ ESM, Node built-in test runner, JSDoc checked by TypeScript, ESLint, `fs-native-extensions` advisory locks, Codex plugin manifest/hooks/skills, JSONL app-server protocols. +**Tech Stack:** Node.js 22.13+ ESM, Node built-in test runner, JSDoc checked by TypeScript, ESLint, `fs-native-extensions` advisory locks, Codex plugin manifest/hooks/skills, JSONL app-server protocols. --- @@ -70,19 +70,17 @@ test("plugin manifest identifies the vitry ZCode plugin", () => { assert.equal("hooks" in manifest, false); }); -test("package requires Node 18.18 and only the pinned native lock dependency", () => { +test("package requires Node 22.13 and the native lock dependency", () => { const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); assert.equal(pkg.type, "module"); - assert.equal(pkg.engines.node, ">=18.18.0"); + assert.equal(pkg.engines.node, ">=22.13.0"); assert.deepEqual(pkg.dependencies ?? {}, { "fs-native-extensions": "1.5.0", }); - assert.deepEqual(pkg.overrides ?? {}, { - "bare-addon-resolve": "1.9.4", - }); + assert.deepEqual(pkg.overrides ?? {}, {}); assert.deepEqual(pkg.bundleDependencies ?? [], ["fs-native-extensions"]); const shrinkwrap = JSON.parse(fs.readFileSync(path.join(root, "npm-shrinkwrap.json"), "utf8")); - assert.equal(shrinkwrap.packages["node_modules/require-addon/node_modules/bare-addon-resolve"].version, "1.9.4"); + assert.equal(shrinkwrap.packages["node_modules/bare-addon-resolve"].version, "1.10.1"); assert.match(pkg.version, semverPattern); assert.equal(manifest.version, pkg.version); }); @@ -96,7 +94,7 @@ Expected: FAIL because `package.json` and `.codex-plugin/plugin.json` do not exi - [ ] **Step 3: Add the minimal package and plugin files** -Use version `0.1.0`, repository `https://github.com/vitry/zcode-plugin-codex`, Node `>=18.18.0`, and Apache-2.0. The only runtime dependency is the exact pin `fs-native-extensions@1.5.0`, which provides process-owned advisory file locks on macOS, Linux, and Windows. Pin the `bare-addon-resolve` override to `1.9.4` because later releases use JavaScript unavailable in Node 18.18. Publish `npm-shrinkwrap.json` and bundle the `fs-native-extensions` tree, because a consuming install does not apply this package's root override. Contract tests must compare the complete dependency, override, and bundle objects and verify the shrinkwrapped resolver version so additional runtime packages cannot be added implicitly. +Use version `0.1.0`, repository `https://github.com/vitry/zcode-plugin-codex`, Node `>=22.13.0`, and Apache-2.0. The only runtime dependency is the exact pin `fs-native-extensions@1.5.0`, which provides process-owned advisory file locks on macOS, Linux, and Windows. Node 22.13 supports the current `bare-addon-resolve` release, so no legacy resolver compatibility override is required; the published `npm-shrinkwrap.json` still records the resolved dependency tree. Publish `npm-shrinkwrap.json` and bundle the `fs-native-extensions` tree so consuming installs receive the native lock implementation. Contract tests must compare the complete dependency and bundle objects and verify the shrinkwrapped resolver version so additional runtime packages cannot be added implicitly. Use these scripts: @@ -111,12 +109,12 @@ Use these scripts: } ``` -Use Node-18-compatible development dependencies: +Use Node-22-compatible development dependencies: ```json { "@eslint/js": "^9.39.1", - "@types/node": "^18.19.0", + "@types/node": "^22.13.0", "eslint": "^9.39.1", "globals": "^16.5.0", "typescript": "^5.9.3" @@ -462,9 +460,9 @@ Exercise every skill against fake Codex/ZCode peers, including the two-session i Document installation through a Codex marketplace, ZCode `>=0.16.1`, macOS bundled discovery, model aliases, all eight commands, permission limits, job storage, review gate, troubleshooting, Linux/Windows qualification status, and Apache-2.0 provenance. -Add a package-install integration test that packs the plugin, installs that tarball into an empty temporary consumer with production dependencies only, asserts the bundled plugin-local `bare-addon-resolve` is exactly `1.9.4`, loads the installed `fs-native-extensions` binding on Node 18.18, and acquires/releases a lock through the installed companion runtime. The test must prove that the native dependency is installed beside the plugin rather than relying on the repository's development `node_modules`. +Add a package-install integration test that packs the plugin, installs that tarball into an empty temporary consumer with production dependencies only, asserts the bundled plugin-local `bare-addon-resolve` is exactly `1.10.1`, loads the installed `fs-native-extensions` binding on Node 22.13, and acquires/releases a lock through the installed companion runtime. The test must prove that the native dependency is installed beside the plugin rather than relying on the repository's development `node_modules`. -CI runs the fake-protocol suite on current macOS, Ubuntu, and Windows. Each platform job must start with `npm ci`, run `npm run check`, perform the clean packed-plugin production install, and execute a binding-load plus lock smoke test. Include Node 18.18 coverage for the pinned override in addition to the current Node LTS matrix. A platform job is not successful if it skips the native binding smoke. +CI runs the fake-protocol suite on current macOS, Ubuntu, and Windows. Each platform job must start with `npm ci`, run `npm run check`, perform the clean packed-plugin production install, and execute a binding-load plus lock smoke test. Include Node 22.13 coverage for the production dependency tree in addition to the current Node LTS matrix. A platform job is not successful if it skips the native binding smoke. - [ ] **Step 5: Add opt-in real ZCode E2E** diff --git a/docs/superpowers/plans/2026-08-06-runtime-correctness-remediation.md b/docs/superpowers/plans/2026-08-06-runtime-correctness-remediation.md index 6e483521..59c55b72 100644 --- a/docs/superpowers/plans/2026-08-06-runtime-correctness-remediation.md +++ b/docs/superpowers/plans/2026-08-06-runtime-correctness-remediation.md @@ -6,7 +6,7 @@ **Architecture:** Installed Skills call a constant direct-companion command over ordinary stdio. Native hooks persist exact thread/turn/workspace input, while the companion resolves it from runtime-observed `CODEX_THREAD_ID`; private capabilities remain inside production Node. Durable job turn boundaries drive locked broker reconciliation, and workspace-private configuration supplies model defaults and aliases. -**Tech Stack:** Node.js 18 ESM, native Codex hooks and Skills, Codex app-server JSONL, ZCode Protocol JSONL, `node:test`, `fs-native-extensions` advisory locks. +**Tech Stack:** Node.js 22.13+ ESM, native Codex hooks and Skills, Codex app-server JSONL, ZCode Protocol JSONL, `node:test`, `fs-native-extensions` advisory locks. --- diff --git a/docs/superpowers/plans/2026-08-07-deduplicate-ci-triggers.md b/docs/superpowers/plans/2026-08-07-deduplicate-ci-triggers.md new file mode 100644 index 00000000..952e6f2a --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-deduplicate-ci-triggers.md @@ -0,0 +1,110 @@ +# Deduplicate CI Triggers 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:** Ensure an open pull-request branch creates only one six-job CI matrix while merged commits continue to run CI on `main`. + +**Architecture:** Preserve the existing matrix and steps. Narrow only the workflow event boundary, and enforce it through the existing release contract suite. + +**Tech Stack:** GitHub Actions YAML, Node.js built-in test runner + +--- + +### Task 1: Restrict branch push CI to main + +**Files:** +- Modify: `tests/release-contracts.test.mjs` +- Modify: `.github/workflows/ci.yml` + +- [ ] **Step 1: Write the failing trigger contract** + +Add these assertions to the existing CI release-contract test: + +```js +assert.match(workflow, /^ push:\n branches: \[main\]$/m); +assert.match(workflow, /^ pull_request:\s*$/m); +``` + +- [ ] **Step 2: Verify the contract fails for the broad push trigger** + +Run: `node --test --test-name-pattern='CI runs full' tests/release-contracts.test.mjs` + +Expected: FAIL because `.github/workflows/ci.yml` has an unrestricted `push` trigger. + +- [ ] **Step 3: Apply the minimal workflow change** + +Change the event block to: + +```yaml +on: + push: + branches: [main] + pull_request: +``` + +- [ ] **Step 4: Verify the focused and complete suites** + +Run: `node --test --test-name-pattern='CI runs full' tests/release-contracts.test.mjs` + +Expected: PASS. + +Run: `npm run check` + +Expected: zero failures; only the two credential-gated real E2E tests may be skipped. + +- [ ] **Step 5: Commit the implementation** + +```bash +git add .github/workflows/ci.yml tests/release-contracts.test.mjs +git commit -m "ci: avoid duplicate pull request matrices" +``` + +### Task 2: Stabilize intended timeout seams on Node 22.13 + +**Files:** +- Modify: `tests/zcode-client.test.mjs` +- Modify: `tests/fixtures/stop-gate-with-timeout.mjs` +- Modify: `tests/hooks.test.mjs` + +- [ ] **Step 1: Reproduce the request-budget failures under contention** + +Run the stderr-tail test repeatedly under Node 22.13 with parallel workers and confirm at least one `ZCODE_REQUEST_TIMEOUT` occurs before `session/list`. + +Run the Stop-gate timeout group repeatedly under Node 22.13 with parallel workers and confirm at least one result has no `decision` because only `session/create` was recorded. + +- [ ] **Step 2: Give only the stderr-tail scenario a two-second request budget** + +Pass this options object as the third `withClient` argument in the stderr-tail test: + +```js +{ requestTimeoutMs: 2_000 } +``` + +Keep the helper's 500 ms default unchanged. + +- [ ] **Step 3: Make the deliberate Stop completion timeout platform-neutral** + +In `tests/fixtures/stop-gate-with-timeout.mjs`, use: + +```js +const timeoutMs = 2_000; +``` + +This keeps the intended suppressed completion as the timeout source on every platform without changing production defaults. + +- [ ] **Step 4: Prove the Stop timeout reaches the intended seam** + +For the timeout case in `tests/hooks.test.mjs`, assert the recorded calls include `session/send` before accepting the conservative block result. Retain the existing `session/stop` assertion. + +- [ ] **Step 5: Verify focused stress and full suites** + +Run the Node 22.13 targeted stress commands from the diagnosis, then run `npm run check` and `git diff --check`. + +Expected: no targeted timeouts before the intended seam, zero suite failures, and only the two credential-gated real E2E skips. + +- [ ] **Step 6: Commit** + +```bash +git add tests/zcode-client.test.mjs tests/fixtures/stop-gate-with-timeout.mjs tests/hooks.test.mjs +git commit -m "test: stabilize Node 22 timeout scenarios" +``` diff --git a/docs/superpowers/specs/2026-08-07-deduplicate-ci-triggers-design.md b/docs/superpowers/specs/2026-08-07-deduplicate-ci-triggers-design.md new file mode 100644 index 00000000..304a7bd8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-deduplicate-ci-triggers-design.md @@ -0,0 +1,17 @@ +# Deduplicate CI Triggers + +## Problem + +The CI workflow currently runs on every `push` and every `pull_request`. A push to an open pull-request branch therefore starts two identical six-job matrices for the same commit. The duplicated check runs compete for hosted runners, clutter the pull-request check rollup, and leave cancelled or failed duplicates when GitHub Actions cannot provision every job. + +## Design + +Run the full matrix for pull requests, and run it for pushes to `main` only. A feature-branch commit then produces one pull-request matrix; the merged commit still receives post-merge validation on `main`. Keep the existing operating-system and Node.js matrix, `fail-fast: false`, and every test and native-binding smoke step unchanged. + +## Verification + +Extend the release contract test to require an unfiltered `pull_request` trigger and a `push` trigger restricted to `main`. Verify the test fails against the current broad push trigger, passes after the workflow change, and then run the complete project check. + +## Node 22.13 test stability + +The first non-duplicated matrix exposed two test-only timing budgets that expire before their intended assertions under Node 22.13 contention. The stderr-tail test must give session startup a two-second request budget. The Stop-gate timeout fixture must use the same two-second allowance on every platform and prove that `session/send` occurred before accepting the expected conservative block. Production request/completion defaults and fail-open behavior remain unchanged. diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index cc00ac33..6f8ed678 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -17,13 +17,13 @@ "devDependencies": { "@eslint/js": "^9.39.1", "@openai/codex": "0.146.0", - "@types/node": "^18.19.0", + "@types/node": "^22.13.0", "eslint": "^9.39.1", "globals": "^16.5.0", "typescript": "^5.9.3" }, "engines": { - "node": ">=18.18.0" + "node": ">=22.13.0" } }, "node_modules/@eslint-community/eslint-utils": { @@ -393,13 +393,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.21.0" } }, "node_modules/acorn": { @@ -472,6 +472,25 @@ "dev": true, "license": "MIT" }, + "node_modules/bare-addon-resolve": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.10.1.tgz", + "integrity": "sha512-F/SD2du8keuYSb4xipnGz5j2E6yhNdHA8ZVxtHae6h2uOrpBIjjbhXvjzKZbr5XUOzqBzh/i8GVFycj2DlFQIA==", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "bare-module-resolve": "^1.10.0", + "bare-semver": "^1.0.0" + }, + "peerDependencies": { + "bare-url": "*" + }, + "peerDependenciesMeta": { + "bare-url": { + "optional": true + } + } + }, "node_modules/bare-module-resolve": { "version": "1.12.4", "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.4.tgz", @@ -1189,25 +1208,6 @@ "bare": ">=1.10.0" } }, - "node_modules/require-addon/node_modules/bare-addon-resolve": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.9.4.tgz", - "integrity": "sha512-unn6Vy/Yke6F99vg/7tcrvM2KUvIhTNniaSqDbam4AWkd4NhvDVSrQiRYVlNzUV2P7SPobkCK7JFVxrJk9btCg==", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "bare-module-resolve": "^1.10.0", - "bare-semver": "^1.0.0" - }, - "peerDependencies": { - "bare-url": "*" - }, - "peerDependenciesMeta": { - "bare-url": { - "optional": true - } - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -1295,9 +1295,9 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index bddd54c2..db658d48 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "author": "vitry ", "license": "Apache-2.0", "engines": { - "node": ">=18.18.0" + "node": ">=22.13.0" }, "files": [ ".codex-plugin/", @@ -42,7 +42,7 @@ "devDependencies": { "@eslint/js": "^9.39.1", "@openai/codex": "0.146.0", - "@types/node": "^18.19.0", + "@types/node": "^22.13.0", "eslint": "^9.39.1", "globals": "^16.5.0", "typescript": "^5.9.3" @@ -52,8 +52,5 @@ }, "bundleDependencies": [ "fs-native-extensions" - ], - "overrides": { - "bare-addon-resolve": "1.9.4" - } + ] } diff --git a/scripts/lib/review.mjs b/scripts/lib/review.mjs index a77e2f96..a5359b75 100644 --- a/scripts/lib/review.mjs +++ b/scripts/lib/review.mjs @@ -96,7 +96,17 @@ export async function readResultArtifact({ dataRoot, workspace, artifact }) { const pathInfo = await lstat(path); if (pathInfo.isSymbolicLink() || !pathInfo.isFile()) throw artifactError(); if (await realpath(dirname(path)) !== root) throw artifactError(); const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); - try { const before = await handle.stat(); const contents = await handle.readFile('utf8'); const after = await lstat(path); if (after.isSymbolicLink() || before.dev !== after.dev || before.ino !== after.ino) throw artifactError(); return contents; } + try { + const before = await handle.stat(); const contents = await handle.readFile('utf8'); const handleAfter = await handle.stat(); + const after = await lstat(path); if (after.isSymbolicLink() || !after.isFile() || await realpath(dirname(path)) !== root) throw artifactError(); + const pathHandle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + const pathAfter = await pathHandle.stat(); + if (!sameFileIdentity(before, handleAfter) || !sameFileIdentity(before, pathAfter)) throw artifactError(); + } + finally { await pathHandle.close(); } + return contents; + } finally { await handle.close(); } }); } catch (error) { throw new PluginError('RESULT_READ_FAILED', 'Could not safely read the result artifact.', { category: 'storage', remedy: 'Inspect the private workspace result store.', cause: error }); } @@ -125,10 +135,18 @@ async function writeArtifact({ dataRoot, workspace, directory, jobId, contents } const targetDirectory = await secureArtifactRoot(storage.directory, directory, true); const path = join(targetDirectory, `${jobId}.md`); try { if ((await lstat(path)).isSymbolicLink()) throw artifactError(); } catch (error) { if (errorCode(error) !== 'ENOENT') throw error; } temporary = join(targetDirectory, `.${basename(path)}.${randomBytes(8).toString('hex')}.tmp`); - handle = await open(temporary, 'wx', 0o600); await handle.writeFile(contents, 'utf8'); await handle.sync(); const sourceInfo = await handle.stat(); await handle.close(); handle = undefined; + handle = await open(temporary, 'wx', 0o600); await handle.writeFile(contents, 'utf8'); await handle.sync(); + // Compare the temporary and final files through FileHandle.stat on both + // sides. Node 22.13 Windows uses different libuv stat paths for lstat + // and fstat, so a path-stat comparison rejects a valid rename. Keeping + // both identities handle-bound preserves the replacement check. + const sourceInfo = await handle.stat(); await handle.close(); handle = undefined; if (await realpath(targetDirectory) !== targetDirectory) throw artifactError(); await rename(temporary, path); temporary = undefined; const finalInfo = await lstat(path); - if (finalInfo.isSymbolicLink() || !finalInfo.isFile() || finalInfo.dev !== sourceInfo.dev || finalInfo.ino !== sourceInfo.ino || await realpath(dirname(path)) !== targetDirectory) throw artifactError(); + if (finalInfo.isSymbolicLink() || !finalInfo.isFile() || await realpath(dirname(path)) !== targetDirectory) throw artifactError(); + const finalHandle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { if (!sameFileIdentity(sourceInfo, await finalHandle.stat())) throw artifactError(); } + finally { await finalHandle.close(); } await chmod(path, 0o600); await syncDirectory(targetDirectory); return relative; }); } catch (error) { await closeFileHandle(handle); if (temporary) await unlink(temporary).catch(() => {}); throw new PluginError('ARTIFACT_WRITE_FAILED', 'Could not durably write the private artifact.', { category: 'storage', remedy: 'Check plugin data storage and retry.', cause: error }); } @@ -137,6 +155,9 @@ async function writeArtifact({ dataRoot, workspace, directory, jobId, contents } /** @param {import('node:fs/promises').FileHandle|undefined} handle */ async function closeFileHandle(handle) { await handle?.close().catch(() => {}); } +/** @param {any} left @param {any} right */ +function sameFileIdentity(left, right) { return left.dev === right.dev && left.ino === right.ino; } + /** @param {string} storageDirectory @param {string} directory @param {boolean} create */ async function secureArtifactRoot(storageDirectory, directory, create) { const storageRoot = await realpath(resolve(storageDirectory)); const lexicalRoot = join(storageDirectory, directory); let info; diff --git a/tests/fixtures/stop-gate-with-timeout.mjs b/tests/fixtures/stop-gate-with-timeout.mjs index 58b441a8..592af87b 100644 --- a/tests/fixtures/stop-gate-with-timeout.mjs +++ b/tests/fixtures/stop-gate-with-timeout.mjs @@ -8,8 +8,5 @@ const dataRoot = process.env.PLUGIN_DATA; if (!dataRoot) throw new Error('PLUGIN_DATA required'); const discoveryCode = process.env.FAKE_GATE_DISCOVERY_ERROR; const discoverZCode = discoveryCode ? async () => { throw Object.assign(new Error('fixture discovery failure'), { code: discoveryCode }); } : undefined; -// Windows child startup can consume most of a 100 ms budget under CI. Keep -// the deliberately suppressed completion as the timeout trigger while leaving -// enough time for the fake protocol process to create its session. -const timeoutMs = process.platform === 'win32' ? 2_000 : 100; +const timeoutMs = 2_000; process.stdout.write(JSON.stringify(await runStopReviewGate(input, { dataRoot, env: process.env, timeoutMs, ...(discoverZCode ? { discoverZCode } : {}) }))); diff --git a/tests/hooks.test.mjs b/tests/hooks.test.mjs index 3dbe6744..c6d56080 100644 --- a/tests/hooks.test.mjs +++ b/tests/hooks.test.mjs @@ -351,10 +351,11 @@ test('Stop gate suppresses continuation/nested runs, fails open when setup is no const stop = stopFields(base); const script = name === 'timeout' ? join(root, 'tests/fixtures/stop-gate-with-timeout.mjs') : 'stop-review-gate-hook.mjs'; const result = await runHook(script, { ...stop, hook_event_name: 'Stop', stop_hook_active: false, last_assistant_message: 'done' }, { ...env, ZCODE_PATH: fakeZCode, FAKE_ZCODE_RECORD: record, FAKE_ZCODE_GATE_RESULT: fake, ...extra }, { absolute: name === 'timeout' }); + const calls = ['failure', 'read-failure', 'timeout'].includes(name) ? (await readFile(record, 'utf8')).trim().split('\n').filter(Boolean).map((line) => JSON.parse(line)) : []; + if (name === 'timeout') assert.ok(calls.some((call) => call.method === 'session/send'), 'timeout must reach the intended completion wait'); assert.equal(result.code, 0); if (expected.decision) assert.equal(result.json.decision, expected.decision); else assert.deepEqual(result.json, expected); if (expected.reason) assert.equal(result.json.reason, expected.reason); if (result.json.reason) assert.ok(result.json.reason.length <= 1000); if (['failure', 'read-failure', 'timeout'].includes(name)) { - const calls = (await readFile(record, 'utf8')).trim().split('\n').filter(Boolean).map((line) => JSON.parse(line)); assert.ok(calls.some((call) => call.method === 'session/stop'), `${name} must stop its created review session`); } }); diff --git a/tests/integration/package-install.test.mjs b/tests/integration/package-install.test.mjs index 3a79411b..6ca9659b 100644 --- a/tests/integration/package-install.test.mjs +++ b/tests/integration/package-install.test.mjs @@ -16,17 +16,17 @@ async function run(launch, cwd) { } /** @param {string[]} args */ -function node18Launch(args) { - const configured = process.env.NODE18_BINARY; +function node22Launch(args) { + const configured = process.env.NODE22_BINARY; if (configured) return { command: configured, args, options: { shell: false } }; - return npxLaunch(['--yes', 'node@18.18.0', ...args]); + return npxLaunch(['--yes', 'node@22.13.0', ...args]); } -test('packed production install loads and locks on Node 18 with pinned resolver', async (t) => { - const probe = await run(node18Launch(['--version']), rootPath); +test('packed production install loads and locks on Node 22.13', async (t) => { + const probe = await run(node22Launch(['--version']), rootPath); if (probe.code !== 0) { - if (process.env.CI) assert.fail(`Node 18.18 is mandatory in CI: ${probe.stderr || probe.stdout}`); - t.skip('Node 18.18 is unavailable; CI must set NODE18_BINARY or allow npx download'); + if (process.env.CI) assert.fail(`Node 22.13 is mandatory in CI: ${probe.stderr || probe.stdout}`); + t.skip('Node 22.13 is unavailable; CI must set NODE22_BINARY or allow npx download'); return; } @@ -63,13 +63,13 @@ test('packed production install loads and locks on Node 18 with pinned resolver' }); if (!resolverMain.startsWith(bundledRoot)) throw new Error('external resolver=' + resolverMain); const resolver = require(path.join(path.dirname(resolverMain), 'package.json')); - if (resolver.version !== '1.9.4') throw new Error('resolver=' + resolver.version); + if (resolver.version !== '1.10.1') throw new Error('resolver=' + resolver.version); import(path.join(pluginRoot, 'scripts/lib/fs.mjs')).then(async ({ withFileLock }) => { const lockPath = path.join(process.cwd(), 'smoke.lock'); const result = await withFileLock(lockPath, async () => 'locked'); if (result !== 'locked') throw new Error('lock failed'); }); `; - const result = await run(node18Launch(['--eval', smoke]), consumerDirectory); + const result = await run(node22Launch(['--eval', smoke]), consumerDirectory); assert.equal(result.code, 0, result.stderr || result.stdout); }); diff --git a/tests/plugin-contracts.test.mjs b/tests/plugin-contracts.test.mjs index a7af79cb..4e2beb68 100644 --- a/tests/plugin-contracts.test.mjs +++ b/tests/plugin-contracts.test.mjs @@ -192,7 +192,7 @@ test('plugin manifest contract rejects invalid skills and starter prompts', () = } }); -test('package metadata exposes Node 18 and only the pinned native lock dependency', () => { +test('package metadata exposes Node 22.13 and the native lock dependency', () => { const packagePath = new URL('package.json', root); assert.equal(existsSync(packagePath), true, 'package.json must exist'); @@ -200,13 +200,11 @@ test('package metadata exposes Node 18 and only the pinned native lock dependenc const packageJson = readJson('package.json'); assert.equal(packageJson.type, 'module'); - assert.equal(packageJson.engines?.node, '>=18.18.0'); + assert.equal(packageJson.engines?.node, '>=22.13.0'); assert.deepEqual(packageJson.dependencies ?? {}, { 'fs-native-extensions': '1.5.0', }, 'no runtime dependency other than the exact native lock pin is allowed'); - assert.deepEqual(packageJson.overrides ?? {}, { - 'bare-addon-resolve': '1.9.4', - }, 'only the Node 18 compatibility override is allowed'); + assert.deepEqual(packageJson.overrides ?? {}, {}, 'no legacy resolver override is allowed'); assert.deepEqual(packageJson.bundleDependencies ?? [], [ 'fs-native-extensions', ], 'the native lock tree must be bundled for production consumers'); @@ -217,15 +215,15 @@ test('package metadata exposes Node 18 and only the pinned native lock dependenc assert.equal(existsSync(new URL('package-lock.json', root)), false); const shrinkwrap = readJson('npm-shrinkwrap.json'); assert.equal( - shrinkwrap.packages?.['node_modules/require-addon/node_modules/bare-addon-resolve']?.version, - '1.9.4', - 'the published dependency tree must retain the Node 18-compatible resolver', + shrinkwrap.packages?.['node_modules/bare-addon-resolve']?.version, + '1.10.1', + 'the published dependency tree must use the Node 22-compatible resolver', ); assert.match(packageJson.version, semverPattern); const [major, minor] = packageJson.version.split('.').map(Number); assert.equal(major, 0); assert.ok(minor >= 1); - assert.equal(packageJson.devDependencies?.['@types/node'], '^18.19.0'); + assert.equal(packageJson.devDependencies?.['@types/node'], '^22.13.0'); }); test('package test scripts do not depend on shell glob expansion', () => { diff --git a/tests/release-contracts.test.mjs b/tests/release-contracts.test.mjs index 98723020..25549b6f 100644 --- a/tests/release-contracts.test.mjs +++ b/tests/release-contracts.test.mjs @@ -11,6 +11,7 @@ test('English and Chinese release docs cover installation, operation, and qualif for (const path of ['README.md', 'README.zh-CN.md']) { const source = read(path); assert.match(source, /marketplace/i); + assert.match(source, /Node\.js `>=22\.13\.0`/); assert.match(source, /vitry\/zcode-plugin-codex/); assert.match(source, /--ref marketplace/); assert.match(source, /zcode@vitry/); @@ -91,10 +92,22 @@ test('security, changelog, and provenance are release-ready', () => { assert.doesNotMatch(notice, /scaffold stage/); }); -test('CI runs full and packed native suites on three platforms and Node 18.18', () => { +test('CI runs full and packed native suites on three platforms and Node 22.13', () => { const workflow = read('.github/workflows/ci.yml'); + const eventBlock = /^on:\n[ ]{2}push:\n[ ]{4}branches: \[main\]\n[ ]{2}pull_request:\n\npermissions:$/m; + const assertEventBlock = (source) => { + const normalizedSource = source.replaceAll('\r\n', '\n'); + assert.match(normalizedSource, eventBlock); + const filteredPullRequestWorkflow = normalizedSource.replace( + /^([ ]{2}pull_request:)\n/m, + '$1\n branches: [main]\n', + ); + assert.doesNotMatch(filteredPullRequestWorkflow, eventBlock); + }; + assertEventBlock(workflow); + assertEventBlock(workflow.replaceAll(/\r?\n/g, '\r\n')); for (const os of ['ubuntu-latest', 'macos-latest', 'windows-latest']) assert.match(workflow, new RegExp(os)); - assert.match(workflow, /18\.18\.0/); + assert.match(workflow, /22\.13\.0/); assert.match(workflow, /lts\/\*/); assert.match(workflow, /npm ci/); assert.match(workflow, /npm run check/); @@ -112,6 +125,20 @@ test('CI runs full and packed native suites on three platforms and Node 18.18', assert.match(source, /await run\(/); assert.doesNotMatch(source, /\.cmd/); } + assert.match(packageTest, /NODE22_BINARY/); + assert.doesNotMatch(packageTest, /NODE18_BINARY|node@18|Node 18/); +}); + +test('runtime baseline is Node 22.13 across implementation plans and locking ADR', () => { + for (const path of [ + 'docs/superpowers/plans/2026-08-03-zcode-plugin-codex-implementation.md', + 'docs/superpowers/plans/2026-08-06-runtime-correctness-remediation.md', + 'docs/adr/0009-cross-process-locking.md', + ]) { + const source = read(path); + assert.match(source, /Node(?:\.js)? 22\.13/); + assert.doesNotMatch(source, /Node(?:\.js)? 18(?:\.18)?/); + } }); test('release qualification covers the installed direct bridge and explicit real model', () => { diff --git a/tests/windows-compat.test.mjs b/tests/windows-compat.test.mjs index 7be8dc91..168097c3 100644 --- a/tests/windows-compat.test.mjs +++ b/tests/windows-compat.test.mjs @@ -59,3 +59,109 @@ test('artifact writes tolerate an unsupported Windows directory fsync', async () const result = await runNode(syncFailureProbe(reviewModule, 'writeResultArtifact')); assert.equal(result.code, 0, result.stderr || result.stdout); }); + +test('artifact identity checks do not mix handle and path stat implementations', async () => { + const source = ` + import { mkdtemp, open, readFile, rm } from 'node:fs/promises'; + import { tmpdir } from 'node:os'; + import { join } from 'node:path'; + import { readResultArtifact, writeResultArtifact } from ${JSON.stringify(reviewModule)}; + const directory = await mkdtemp(join(tmpdir(), 'zcode-stat-')); + const probe = await open(join(directory, 'probe'), 'a+'); + const prototype = Object.getPrototypeOf(probe); + await probe.close(); + const originalStat = prototype.stat; + prototype.stat = async function patchedStat(...args) { + const stats = await originalStat.call(this, ...args); + return new Proxy(stats, { get(target, property) { + if (property === 'dev') return target.dev + 1; + if (property === 'ino') return target.ino + 1; + return Reflect.get(target, property); + } }); + }; + try { + const artifact = await writeResultArtifact({ dataRoot: directory, workspace: directory, jobId: 'b'.repeat(64), contents: 'done' }); + if (artifact !== 'results/' + 'b'.repeat(64) + '.md') throw new Error('artifact path did not persist'); + const contents = await readResultArtifact({ dataRoot: directory, workspace: directory, artifact }); + if (contents !== 'done') throw new Error('artifact contents did not read'); + } finally { + prototype.stat = originalStat; + await rm(directory, { recursive: true, force: true }); + } + `; + const result = await runNode(source); + assert.equal(result.code, 0, result.stderr || result.stdout); +}); + +test('artifact writes retain handle-bound identity checks', async () => { + const source = ` + import { mkdtemp, open, rm } from 'node:fs/promises'; + import { tmpdir } from 'node:os'; + import { join } from 'node:path'; + import { writeResultArtifact } from ${JSON.stringify(reviewModule)}; + const directory = await mkdtemp(join(tmpdir(), 'zcode-write-identity-')); + const probe = await open(join(directory, 'probe'), 'a+'); + const prototype = Object.getPrototypeOf(probe); + await probe.close(); + const originalStat = prototype.stat; + let statCalls = 0; + prototype.stat = async function patchedStat(...args) { + const stats = await originalStat.call(this, ...args); + statCalls += 1; + if (statCalls !== 2) return stats; + return new Proxy(stats, { get(target, property) { + if (property === 'dev') return target.dev + 1; + if (property === 'ino') return target.ino + 1; + return Reflect.get(target, property); + } }); + }; + try { + await writeResultArtifact({ dataRoot: directory, workspace: directory, jobId: 'c'.repeat(64), contents: 'done' }); + throw new Error('artifact write unexpectedly accepted a destination identity mismatch'); + } catch (error) { + if (error?.code !== 'ARTIFACT_WRITE_FAILED') throw error; + } finally { + prototype.stat = originalStat; + await rm(directory, { recursive: true, force: true }); + } + `; + const result = await runNode(source); + assert.equal(result.code, 0, result.stderr || result.stdout); +}); + +test('artifact reads retain handle-bound identity checks', async () => { + const source = ` + import { mkdtemp, open, rm } from 'node:fs/promises'; + import { tmpdir } from 'node:os'; + import { join } from 'node:path'; + import { readResultArtifact, writeResultArtifact } from ${JSON.stringify(reviewModule)}; + const directory = await mkdtemp(join(tmpdir(), 'zcode-read-identity-')); + const probe = await open(join(directory, 'probe'), 'a+'); + const prototype = Object.getPrototypeOf(probe); + await probe.close(); + const artifact = await writeResultArtifact({ dataRoot: directory, workspace: directory, jobId: 'd'.repeat(64), contents: 'done' }); + const originalStat = prototype.stat; + let statCalls = 0; + prototype.stat = async function patchedStat(...args) { + const stats = await originalStat.call(this, ...args); + statCalls += 1; + if (statCalls !== 2) return stats; + return new Proxy(stats, { get(target, property) { + if (property === 'dev') return target.dev + 1; + if (property === 'ino') return target.ino + 1; + return Reflect.get(target, property); + } }); + }; + try { + await readResultArtifact({ dataRoot: directory, workspace: directory, artifact }); + throw new Error('artifact read unexpectedly accepted a path identity mismatch'); + } catch (error) { + if (error?.code !== 'RESULT_READ_FAILED') throw error; + } finally { + prototype.stat = originalStat; + await rm(directory, { recursive: true, force: true }); + } + `; + const result = await runNode(source); + assert.equal(result.code, 0, result.stderr || result.stdout); +}); diff --git a/tests/zcode-client.test.mjs b/tests/zcode-client.test.mjs index e173cd67..43c6d374 100644 --- a/tests/zcode-client.test.mjs +++ b/tests/zcode-client.test.mjs @@ -300,7 +300,7 @@ test('session/create rejects unsafe or amplified session identifiers at the ZCod }); test('disconnect diagnostics retain only a bounded redacted stderr tail', async () => { - await withClient(async (client) => { await client.createSession({ workspace: '/repo' }); await assert.rejects(client.listSessions(), (error) => error.code === 'ZCODE_DISCONNECTED' && error.details.stderrTail.length <= 8192 && !error.details.stderrTail.includes('super-secret') && error.details.stderrTail.includes('[REDACTED]')); }, { FAKE_ZCODE_STDERR_BYTES: '20000', FAKE_ZCODE_STDERR_TEXT: ' token=super-secret ', FAKE_ZCODE_DISCONNECT: 'session/list' }); + await withClient(async (client) => { await client.createSession({ workspace: '/repo' }); await assert.rejects(client.listSessions(), (error) => error.code === 'ZCODE_DISCONNECTED' && error.details.stderrTail.length <= 8192 && !error.details.stderrTail.includes('super-secret') && error.details.stderrTail.includes('[REDACTED]')); }, { FAKE_ZCODE_STDERR_BYTES: '20000', FAKE_ZCODE_STDERR_TEXT: ' token=super-secret ', FAKE_ZCODE_DISCONNECT: 'session/list' }, { requestTimeoutMs: 2_000 }); }); test('managed broker clients require an explicit stable owner credential', async () => {