From 354a9a0099dd282e46017498ca95f7246bb8dcda Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:58:15 +0800 Subject: [PATCH 1/5] docs(validation): freeze Node P2-0 contract and regression matrix --- .../node-project-validation-p2-contract.md | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 docs/implementation/node-project-validation-p2-contract.md diff --git a/docs/implementation/node-project-validation-p2-contract.md b/docs/implementation/node-project-validation-p2-contract.md new file mode 100644 index 000000000..0f58b7416 --- /dev/null +++ b/docs/implementation/node-project-validation-p2-contract.md @@ -0,0 +1,81 @@ +# Node project validation: #962 P2-0 contract and regression freeze + +**Status:** P2-0 v1 scope and N01-N24 regression boundary frozen for pre-implementation review (2026-10-09). Independent agent review has not completed; this design freeze is not approval to implement until its go/no-go gates are met. + +**Baseline:** `yyjeqhc/webcodex@6733418db7e0462771fbf8d220b32d03e5c6092e` (includes #982 and #988). **Scope:** next independently reviewable `project_validate` Node `check` slice (P2-A). + +## Goal and invariant + +Add exactly one bounded, truthful project-script check behind existing `project_validate` without adding a model-facing `node_*` tool, accepting model argv/shell/env, weakening test-count evidence, or creating a second planning authority. The Runner owns project/root resolution, script selection, interpreter probe, exact canonical step, provenance, queue-time replan and Job execution. The Server matches only semantic identity/evidence; the public output schema must accept every legitimately emitted form. + +**Truth claim:** A selected Project-local script named `check`, `typecheck` or `lint` ran under the admitted Node execution and its observed process status is known. Success does **not** prove any particular linter/typechecker executed, all checks ran, no project files changed, or no network access occurred. The project-authored script body and its transitive commands remain untrusted executable code. This is an `Execute` operation, not a filesystem sandbox. Do not advertise this as a guaranteed read-only static analyzer. + +## P2-A selected execution contract + +1. **Runner/runtime:** First production version uses Node's built-in `node --run ` on a previously installed, Runner-selected Node >=22.3.0; probe the exact selected program, use the same executable and prepared environment for spawn, and fail closed on unavailable/unsupported versions. No installation, Corepack invocation, manager activation or alternative-runtime fallback. Clear `NODE_OPTIONS` for both probe and actual spawn; document other project/profile environment and PATH limitations. Probe is bounded, managed and cancellable. +2. **Why not npm first:** An isolated fixture with Node v26.10.0/npm v11.19.1 demonstrated `npm run --silent check` executing precheck/check/postcheck, `npm --ignore-scripts run --silent check` executing only check, and local `.npmrc` `script-shell` affecting npm execution. The same fixture with `node --run check` ran only check and did not take the npm shell override. Node's documented `--run` semantics explicitly omit pre/post scripts and package-manager-specific environment variables. Those are narrower first-slice semantics, **not** a claim of universal npm/pnpm/Yarn/Bun compatibility. +3. **Selection:** Resolve one exact, project-contained nearest `package.json` using existing Runner workspace resolver; execute from its resolved recipe root. Select the first present **string-valued** script in this fixed order: `check`, `typecheck`, `lint`. Missing, malformed or empty script means `validation_check_unavailable` / `validation_manifest_invalid` as appropriate. Only one script, no user-supplied script name/flags. Avoid a second parser/selector: extract/reuse the current Node script-name selection fact but keep the general package-manager recipe unchanged. +4. **Canonical step:** `name=check, program=node, args=[--run, ], env=[]`; constrain to the above allowlist; disallow additional args, stdin and shell text. This step is created only after the Runner's manifest/root decision. `node --run` may still execute arbitrary project-controlled script contents, so privilege/policy semantics must stay honest. +5. **Manifest authority:** Parse and hash the **same bytes** of the resolved Project-local `package.json` (bounded to at most 1 MiB). Reject containment escapes, oversized files and non-regular files; bind root/path plus manifest and exact invocation digests. Do not read or hash unbounded lockfiles for this path: Node native `--run` does not select its manager using `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, or `bun.lock`. Lockfiles remain relevant to existing general recipes and later manager-specific P2-B, not executable authority for P2-A. Source-fence freshness is separate and weaker than a source snapshot. +6. **Identity:** Use a separate `node:script:check` adapter identity and evidence profile. The Runner-derived `validation_target_id` must include the selected canonical script name and execution-engine discriminator **as well as** resolved recipe cwd/action; `check` and `lint` are not the same validation target. Exclude manifest content from target identity per existing source-vs-target contract; compare exact provenance/manifest digest at pre-start fence. The Server must use the Runner-provided plan identity, not independently reselect the script. +7. **Capabilities:** Add a standalone `project_validation_node_script_check_v1` capability (omitted/false for old Runner). Validate at planning, validate the actual retained node adapter/step again at Job admission (including `adapter=auto` and Runner replacement), and reject before process start if unavailable. Existing `project_validation_v1` alone is not authority for the Node extension. +8. **Unsupported surfaces:** `test`, `format_check`, package scope, dependency policy, test selectors/count policy and package-manager-specific scripts remain unavailable in P2-A. P2-B may consider formats/managers; P3 must first establish trustworthy machine-readable executed-test counts. Do not use an arbitrary script's exit code as a proven test count. +9. **Effects and evidence:** Expose `selected_script` (if necessary) only as a bounded enum value **derived from the retained canonical step**; never serialize script bodies, npm/yarn configuration, raw paths, stdin or arbitrary log output as trusted structured diagnostics. Emit no Rust/Go/Python diagnostic counts or test counts; an absent parser is not evidence of zero errors. Preserve process exit, timeout/cancel/lost/unknown/stale outcomes, same-Job continuation and no redispatch. +10. **Public contracts:** Enumerate every planning and prestart error reachable via P2-A; validate sparse planning and rich fenced responses against `project_validate` output schema. Reuse existing canonical codes where correct. Do not leak `package_manager_ambiguous` into the production Node native path, since no package manager is selected there; if execution design changes, extend schema and tests in the same PR. Keep specialist Cargo/Go schemas unchanged. + +### Explicit limitations + +`node --run` searches ancestor `package.json` locations and prepends ancestor `node_modules/.bin` to PATH. The Runner's exact recipe cwd must therefore be verified; do not claim that child executable/module resolution is wholly Project-confined. `NODE_OPTIONS` can preload arbitrary modules, as confirmed by an isolated fixture; remove it for preflight and spawn. Dependencies and scripts may still perform I/O, network operations or nested manager calls. No special network/offline guarantee, mutation-free guarantee, or project script correctness guarantee exists. + +This scope deliberately does not change `project_build`, introduce `project_format`, accept an arbitrary CLI, alter generic Node recipes, or try to unify every package manager. + +## Regression contract: freeze before implementation + +All checks below must be specified before editing production modules. Focused Rust tests can run on the server; full multi-platform validation belongs to GitHub Actions. Use isolated temporary Node projects and never install dependencies to run contract tests. + +| ID | Fixture / perturbation | Required outcome | +| --- | --- | --- | +| N01 | Exact registered project, explicit Node adapter, only `check` | One canonical Node step; Runner-selected root and valid identity | +| N02 | All three scripts defined | Deterministically select `check`, once | +| N03 | `check` missing, `typecheck` present; then introduce `check` | Selected script AND target identity change; old queued plan is stale | +| N04 | Only `lint` defined | Choose `lint`; no claim that all other checks ran | +| N05 | Missing script, wrong script type, malformed JSON, >1 MiB JSON | Stable unavailable/manifest failure before Job | +| N06 | Symlink manifest outside Project, nonfile marker, cwd traversal | Fail closed; no external read or process start | +| N07 | Nested Node package, mixed language markers, auto vs explicit hint | Exact nearest root; ambiguity fails without fallback | +| N08 | `format_check`, `test`, test options, package scope or dependency policy | Explicit unsupported result; no execution | +| N09 | Crafted extra `node --run` argv, substituted script name, nonempty env | `ShellJobValidationStep` and metadata reject | +| N10 | Modify `package.json` while Job waits in queue | Fresh Runner replan rejects stale plan before command starts | +| N11 | Change Project identity, root, cwd or symlink between planning/admission | Fail closed, no misbound command | +| N12 | Capability absent, explicitly false, old Server/Runner, auto-detected Node | No command start on unsupported Runner | +| N13 | New Runner plan followed by old/replaced Runner at Job admission | Recheck actual step/adapter and reject | +| N14 | Selected node executable missing, invalid or older than 22.3 | Bounded unavailable; no install/fallback | +| N15 | `NODE_OPTIONS` preloads fixture module; probe and spawn both sanitize | No preload; same resolved interpreter used | +| N16 | Script with `precheck`, `check`, `postcheck` | Native Node runner executes only `check` | +| N17 | Project `.npmrc` with `script-shell`; packageManager/lockfile disagreement | Native Node command identity unaffected; no manager invoked | +| N18 | Exit 0, exit nonzero, deliberately missing binary, verbose/truncated output | Distinguish process success/failure and unknown diagnostics | +| N19 | Fake cargo, Go, pytest and test-count-looking stdout | Never adopt foreign parser, counts or test assertions | +| N20 | Pending, cancellation, timeout, restart recovery and lost Job | Preserve one execution; never re-dispatch or infer success | +| N21 | Source changes after completed validation | Existing source fence reports stale/unknown, not current proof | +| N22 | Every reachable planner, capability and queue-time failure | Actual public output validates against exact `project_validate` schema | +| N23 | Invalid/contradictory lifecycle fields in output | Output schema rejects contradiction | +| N24 | Linux Node 22/24/26 and Windows/macOS supported runners | Canonical argv, environment and lifecycle work equivalently or fail explicitly | + +**Go/no-go:** No production module or test implementation begins until the frozen P2-0 contract and N01-N24 matrix receive independent review. Real Node v26.10.0 script-selection/environment fixtures have been executed and cleaned; Node 22/24 and multi-platform behavior still require validation. The eventual production/test diff must stay within this scope and pass a complete matching-HEAD CI. Later manager-specific and test adapters require independent proof and separate slices. The attempted Codex read-only review did not return a verdict during P2-0 and cannot be treated as an approval. + +## Source-truth map + +- `crates/webcodex-core/src/project_validation.rs`: public adapter/request/provenance. +- `crates/webcodex-validation/src/recipe.rs`, `crates/webcodex-workspace/src/project_recipe.rs`: Runner-owned resolution and manifest inputs. +- `crates/webcodex-validation/src/adapters/mod.rs`, `crates/webcodex-core/src/validation_identity.rs`: validation identity/evidence, not a second command planner. +- `crates/webcodex-core/src/runner_protocol/job.rs`: exact canonical step and durable metadata. +- `crates/webcodex-runner/src/webcodex_runner/validation/project.rs`: planning and queue-time fence. +- `crates/webcodex-runner/src/webcodex_runner/shell/commands.rs`, `job_manager/local_shell.rs`: selected executable, probe, environment and actual Job. +- `crates/webcodex-runner-registry/src/requests.rs`, `job_updates.rs`: additive capability and prestart admission. +- `src/tool_runtime/validation.rs`, `jobs.rs`: same-Job orchestration and bounded evidence projection. +- `crates/webcodex-tool-contracts/src/registry/output_schemas/testing.rs`, `src/tests/output_schemas.rs`: output contract; see maintainer's follow-up in #988. + +Official references: https://nodejs.org/download/release/v24.20.0/docs/api/cli.html#--run ; https://docs.npmjs.com/cli/v11/using-npm/config/ ; https://pnpm.io/cli/run ; https://yarnpkg.com/cli/run . + +### Follow-up phases + +P2-A: bounded built-in Node project-script check only, after test boundary freeze. P2-B: separately prove `format_check` and package-manager-specific behavior, if necessary. P3: prove test-run counts for each Node test runner before enabling `action=test`. Offline/dependency/mutating-format/build-artifact extensions remain independent #962 follow-ups. From 04c17d6314390bc847f94ee1f5dbe20124c3b62a Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:31:21 +0800 Subject: [PATCH 2/5] docs(validation): resolve Node P2-0 contract review findings --- .../node-project-validation-p2-contract.md | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/implementation/node-project-validation-p2-contract.md b/docs/implementation/node-project-validation-p2-contract.md index 0f58b7416..995a572a4 100644 --- a/docs/implementation/node-project-validation-p2-contract.md +++ b/docs/implementation/node-project-validation-p2-contract.md @@ -1,6 +1,6 @@ # Node project validation: #962 P2-0 contract and regression freeze -**Status:** P2-0 v1 scope and N01-N24 regression boundary frozen for pre-implementation review (2026-10-09). Independent agent review has not completed; this design freeze is not approval to implement until its go/no-go gates are met. +**Status:** P2-0 v2 candidate following independent Codex CHANGES REQUIRED review (2026-10-09). The following contract and N01-N24 regression boundaries must pass focused re-review before P2-A implementation. No functional source or test implementation is authorized by this document alone. **Baseline:** `yyjeqhc/webcodex@6733418db7e0462771fbf8d220b32d03e5c6092e` (includes #982 and #988). **Scope:** next independently reviewable `project_validate` Node `check` slice (P2-A). @@ -14,13 +14,13 @@ Add exactly one bounded, truthful project-script check behind existing `project_ 1. **Runner/runtime:** First production version uses Node's built-in `node --run ` on a previously installed, Runner-selected Node >=22.3.0; probe the exact selected program, use the same executable and prepared environment for spawn, and fail closed on unavailable/unsupported versions. No installation, Corepack invocation, manager activation or alternative-runtime fallback. Clear `NODE_OPTIONS` for both probe and actual spawn; document other project/profile environment and PATH limitations. Probe is bounded, managed and cancellable. 2. **Why not npm first:** An isolated fixture with Node v26.10.0/npm v11.19.1 demonstrated `npm run --silent check` executing precheck/check/postcheck, `npm --ignore-scripts run --silent check` executing only check, and local `.npmrc` `script-shell` affecting npm execution. The same fixture with `node --run check` ran only check and did not take the npm shell override. Node's documented `--run` semantics explicitly omit pre/post scripts and package-manager-specific environment variables. Those are narrower first-slice semantics, **not** a claim of universal npm/pnpm/Yarn/Bun compatibility. -3. **Selection:** Resolve one exact, project-contained nearest `package.json` using existing Runner workspace resolver; execute from its resolved recipe root. Select the first present **string-valued** script in this fixed order: `check`, `typecheck`, `lint`. Missing, malformed or empty script means `validation_check_unavailable` / `validation_manifest_invalid` as appropriate. Only one script, no user-supplied script name/flags. Avoid a second parser/selector: extract/reuse the current Node script-name selection fact but keep the general package-manager recipe unchanged. +3. **Selection:** Resolve one exact, project-contained nearest `package.json` using the existing Runner workspace resolver; execute from its resolved recipe root. Inspect keys in fixed order `check`, `typecheck`, `lint`; select the **first present key**, then require its value to be a nonempty, non-whitespace-only string. If `check` exists but is null/numeric/empty, reject `validation_manifest_invalid` even when `lint` is valid; do not silently fall through. No matching key yields `validation_check_unavailable`. Only one script, no user-supplied script name/flags. Reuse the existing generic recipe's first-present-key selection fact; do not change its backward-compatible handling of empty strings for other ordinary recipe paths. 4. **Canonical step:** `name=check, program=node, args=[--run, ], env=[]`; constrain to the above allowlist; disallow additional args, stdin and shell text. This step is created only after the Runner's manifest/root decision. `node --run` may still execute arbitrary project-controlled script contents, so privilege/policy semantics must stay honest. -5. **Manifest authority:** Parse and hash the **same bytes** of the resolved Project-local `package.json` (bounded to at most 1 MiB). Reject containment escapes, oversized files and non-regular files; bind root/path plus manifest and exact invocation digests. Do not read or hash unbounded lockfiles for this path: Node native `--run` does not select its manager using `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, or `bun.lock`. Lockfiles remain relevant to existing general recipes and later manager-specific P2-B, not executable authority for P2-A. Source-fence freshness is separate and weaker than a source snapshot. -6. **Identity:** Use a separate `node:script:check` adapter identity and evidence profile. The Runner-derived `validation_target_id` must include the selected canonical script name and execution-engine discriminator **as well as** resolved recipe cwd/action; `check` and `lint` are not the same validation target. Exclude manifest content from target identity per existing source-vs-target contract; compare exact provenance/manifest digest at pre-start fence. The Server must use the Runner-provided plan identity, not independently reselect the script. +5. **Manifest authority:** Parse and hash the **same bytes** of the resolved Project-local `package.json` (bounded to at most 1 MiB). Reject containment escapes, oversized files and non-regular files; bind root/path plus manifest and exact invocation digests. Recheck exact marker/root/manifest and canonical step in the existing queue-time fence **immediately before process command construction**, and fail closed when marker is removed/replaced or nearest root changes (including potential ancestor fallback). Node itself reopens/searches `package.json` on execution: the gap between the final Runner fence and Node's own filesystem read is not atomic and is **not** claimed to prevent a concurrent malicious writer; trust requires an authorized project and the ordinary source-fence semantics, not a source snapshot or immutable script execution proof. Do not read or hash unbounded lockfiles for this path: Node native `--run` does not select its manager using `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, or `bun.lock`. Lockfiles remain relevant to existing general recipes and later manager-specific P2-B, not executable authority for P2-A. +6. **Identity:** Use a separate `node:script:check` adapter identity and evidence profile. The Runner-derived `validation_target_id` must include canonical script **name**, fixed execution-engine discriminator (`node-native-run-v1`), recipe cwd and check action. `check` and `lint` and distinct engines are different targets. Changing only the selected script **body** keeps target identity stable but changes manifest digest and rejects queued stale plans. Keep source bytes outside target identity per existing source-vs-target contract. The Server uses the Runner-returned identity without reselecting script. 7. **Capabilities:** Add a standalone `project_validation_node_script_check_v1` capability (omitted/false for old Runner). Validate at planning, validate the actual retained node adapter/step again at Job admission (including `adapter=auto` and Runner replacement), and reject before process start if unavailable. Existing `project_validation_v1` alone is not authority for the Node extension. 8. **Unsupported surfaces:** `test`, `format_check`, package scope, dependency policy, test selectors/count policy and package-manager-specific scripts remain unavailable in P2-A. P2-B may consider formats/managers; P3 must first establish trustworthy machine-readable executed-test counts. Do not use an arbitrary script's exit code as a proven test count. -9. **Effects and evidence:** Expose `selected_script` (if necessary) only as a bounded enum value **derived from the retained canonical step**; never serialize script bodies, npm/yarn configuration, raw paths, stdin or arbitrary log output as trusted structured diagnostics. Emit no Rust/Go/Python diagnostic counts or test counts; an absent parser is not evidence of zero errors. Preserve process exit, timeout/cancel/lost/unknown/stale outcomes, same-Job continuation and no redispatch. +9. **Effects, authorization and evidence:** Preserve the existing `project_validate` owner authorization, `Execute` effect and `JobRun` approval; do not reinterpret script-based check as read-only permission, or let the Node capability bit substitute for per-project authorization. Project-authored scripts may write files, spawn children and access network resources; validate process-tree cleanup on cancel/timeout. Expose `selected_script` (if necessary) only as a bounded enum value **derived from the retained canonical step**; never serialize script bodies, npm/yarn configuration, raw paths, stdin or arbitrary log output as trusted structured diagnostics. Emit no Rust/Go/Python diagnostic counts or test counts; an absent parser is not evidence of zero errors. Preserve process exit, timeout/cancel/lost/unknown/stale outcomes, same-Job continuation and no redispatch. 10. **Public contracts:** Enumerate every planning and prestart error reachable via P2-A; validate sparse planning and rich fenced responses against `project_validate` output schema. Reuse existing canonical codes where correct. Do not leak `package_manager_ambiguous` into the production Node native path, since no package manager is selected there; if execution design changes, extend schema and tests in the same PR. Keep specialist Cargo/Go schemas unchanged. ### Explicit limitations @@ -37,15 +37,15 @@ All checks below must be specified before editing production modules. Focused Ru | --- | --- | --- | | N01 | Exact registered project, explicit Node adapter, only `check` | One canonical Node step; Runner-selected root and valid identity | | N02 | All three scripts defined | Deterministically select `check`, once | -| N03 | `check` missing, `typecheck` present; then introduce `check` | Selected script AND target identity change; old queued plan is stale | +| N03 | `check` missing, `typecheck` present; then introduce `check` | Selected script AND target identity change; old queued plan is stale. Distinct execution engine, same script/cwd => distinct target ID | | N04 | Only `lint` defined | Choose `lint`; no claim that all other checks ran | -| N05 | Missing script, wrong script type, malformed JSON, >1 MiB JSON | Stable unavailable/manifest failure before Job | +| N05 | Missing script; `check:null` plus valid `lint`; number/array; empty/whitespace-only script; malformed JSON; >1 MiB JSON | Existing highest-priority key malformed => `validation_manifest_invalid`, no fallback; missing all keys => `validation_check_unavailable`, no Job | | N06 | Symlink manifest outside Project, nonfile marker, cwd traversal | Fail closed; no external read or process start | | N07 | Nested Node package, mixed language markers, auto vs explicit hint | Exact nearest root; ambiguity fails without fallback | | N08 | `format_check`, `test`, test options, package scope or dependency policy | Explicit unsupported result; no execution | | N09 | Crafted extra `node --run` argv, substituted script name, nonempty env | `ShellJobValidationStep` and metadata reject | -| N10 | Modify `package.json` while Job waits in queue | Fresh Runner replan rejects stale plan before command starts | -| N11 | Change Project identity, root, cwd or symlink between planning/admission | Fail closed, no misbound command | +| N10 | Change selected script body while queued (same name); remove/replace manifest while ancestor defines `check` | Body change retains target ID but changes digest; old queued plan stale; removed/replaced marker fails closed before command construction | +| N11 | Change Project identity, root, cwd or symlink between planning/admission; nested manifest replaced by ancestor one | Fail closed on prestart fence; document residual post-fence Node reopen race without claiming atomic source snapshot | | N12 | Capability absent, explicitly false, old Server/Runner, auto-detected Node | No command start on unsupported Runner | | N13 | New Runner plan followed by old/replaced Runner at Job admission | Recheck actual step/adapter and reject | | N14 | Selected node executable missing, invalid or older than 22.3 | Bounded unavailable; no install/fallback | @@ -54,13 +54,13 @@ All checks below must be specified before editing production modules. Focused Ru | N17 | Project `.npmrc` with `script-shell`; packageManager/lockfile disagreement | Native Node command identity unaffected; no manager invoked | | N18 | Exit 0, exit nonzero, deliberately missing binary, verbose/truncated output | Distinguish process success/failure and unknown diagnostics | | N19 | Fake cargo, Go, pytest and test-count-looking stdout | Never adopt foreign parser, counts or test assertions | -| N20 | Pending, cancellation, timeout, restart recovery and lost Job | Preserve one execution; never re-dispatch or infer success | +| N20 | Pending, cancellation, timeout, restart recovery and lost Job; script intentionally writes an isolated fixture and forks a child | Verify `Execute` authorization first; preserve one execution, clean managed child tree on cancel/timeout; never re-dispatch or infer no writes | | N21 | Source changes after completed validation | Existing source fence reports stale/unknown, not current proof | | N22 | Every reachable planner, capability and queue-time failure | Actual public output validates against exact `project_validate` schema | -| N23 | Invalid/contradictory lifecycle fields in output | Output schema rejects contradiction | +| N23 | Invalid/contradictory lifecycle fields in output; unauthorized project owner | Output schema rejects contradiction; owner/JobRun denial prevents any script spawn | | N24 | Linux Node 22/24/26 and Windows/macOS supported runners | Canonical argv, environment and lifecycle work equivalently or fail explicitly | -**Go/no-go:** No production module or test implementation begins until the frozen P2-0 contract and N01-N24 matrix receive independent review. Real Node v26.10.0 script-selection/environment fixtures have been executed and cleaned; Node 22/24 and multi-platform behavior still require validation. The eventual production/test diff must stay within this scope and pass a complete matching-HEAD CI. Later manager-specific and test adapters require independent proof and separate slices. The attempted Codex read-only review did not return a verdict during P2-0 and cannot be treated as an approval. +**Go/no-go:** The v1 contract failed an independent Codex review on script selection, identity, post-fence manifest race and effects/permissions; v2 addresses all four explicitly. Re-review v2 and N01-N24 before coding. Real Node v26.10.0 script-selection/environment fixtures have been executed and cleaned; Node 22/24 and multi-platform behavior still require validation. The eventual production/test diff must stay within this scope and pass complete matching-HEAD CI. Manager-specific/test adapters require separate proof. ## Source-truth map From 579a4f3b858e8e3136e626c8f03cf486476ea977 Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:57:31 +0800 Subject: [PATCH 3/5] feat(validation): add bounded Node native project checks --- .../webcodex-core/src/project_validation.rs | 3 +- .../src/project_validation_tests.rs | 93 +++++++++++ crates/webcodex-core/src/runner_protocol.rs | 8 + .../webcodex-core/src/runner_protocol/job.rs | 24 +++ .../webcodex-core/src/validation_identity.rs | 9 ++ crates/webcodex-runner-config/src/lib.rs | 1 + .../src/job_updates.rs | 18 +++ .../webcodex-runner-registry/src/requests.rs | 8 + .../src/tests/project_dependency_policy.rs | 72 +++++++++ .../job_manager/local_shell.rs | 28 ++++ .../src/webcodex_runner/shell.rs | 7 +- .../src/webcodex_runner/shell/commands.rs | 41 +++++ .../src/webcodex_runner/shell/scripts.rs | 8 + .../src/webcodex_runner/shell_tests.rs | 55 +++++++ .../webcodex_runner/transport/registration.rs | 1 + .../src/webcodex_runner/validation/project.rs | 69 +++++--- .../validation/project_tests.rs | 151 ++++++++++++++++++ .../src/registry/output_schemas/testing.rs | 22 ++- .../src/tests/output_schemas.rs | 91 +++++++++++ .../src/tool_definition/testing.rs | 2 +- .../webcodex-validation/src/adapters/mod.rs | 31 +++- .../webcodex-validation/src/adapters/node.rs | 56 +++++++ crates/webcodex-validation/src/lib.rs | 7 +- crates/webcodex-validation/src/recipe.rs | 129 +++++++++++++-- .../webcodex-validation/src/recipe_tests.rs | 146 +++++++++++++++++ .../webcodex-workspace/src/project_recipe.rs | 42 +++++ src/runner_quic.rs | 1 + src/runner_ws.rs | 1 + src/tool_runtime/tests/jobs.rs | 22 +++ src/tool_runtime/tests/metadata.rs | 1 + src/tool_runtime/validation.rs | 2 +- src/tool_runtime/validation_profile/mod.rs | 5 + 32 files changed, 1094 insertions(+), 60 deletions(-) create mode 100644 crates/webcodex-validation/src/adapters/node.rs diff --git a/crates/webcodex-core/src/project_validation.rs b/crates/webcodex-core/src/project_validation.rs index 27d6affd1..a0cde8ecf 100644 --- a/crates/webcodex-core/src/project_validation.rs +++ b/crates/webcodex-core/src/project_validation.rs @@ -33,6 +33,7 @@ pub enum ProjectValidationAdapter { Rust, Go, Python, + Node, } /// Test-only selection and evidence policy. Filtering changes the execution @@ -167,7 +168,7 @@ impl ProjectValidationProvenance { pub fn is_valid(&self) -> bool { self.request.validate().is_ok() && validate_relative(&self.recipe_root).is_ok() - && matches!(self.backend.as_str(), "rust" | "go" | "python") + && matches!(self.backend.as_str(), "rust" | "go" | "python" | "node") && [ &self.root_digest, &self.manifest_digest, diff --git a/crates/webcodex-core/src/project_validation_tests.rs b/crates/webcodex-core/src/project_validation_tests.rs index 8168e0f37..63aac694d 100644 --- a/crates/webcodex-core/src/project_validation_tests.rs +++ b/crates/webcodex-core/src/project_validation_tests.rs @@ -1,6 +1,99 @@ use crate::project_validation::*; use crate::runner_operation::{RunnerInvocationMetadata, RunnerOperation}; use crate::runner_protocol::RunnerRequest; + +#[test] +fn project_node_check_step_identity_capability_and_metadata_are_closed() { + use crate::runner_protocol::{ + RunnerCapabilities, ShellJobValidationMetadata, ShellJobValidationStep, + }; + use crate::validation_identity::{ + structured_validation_target_identity, ToolValidationIdentityKind, + }; + let old: RunnerCapabilities = + serde_json::from_value(serde_json::json!({"project_validation_v1":true})).unwrap(); + assert!(!old.project_validation_node_script_check_v1); + let mut ids = std::collections::HashSet::new(); + for script in ["check", "typecheck", "lint"] { + let step = ShellJobValidationStep { + name: "check".into(), + program: "node".into(), + args: vec!["--run".into(), script.into()], + env: vec![], + }; + assert!(step.is_canonical() && step.is_structured_node_check()); + let id = structured_validation_target_identity( + ToolValidationIdentityKind::NodeScriptCheck, + &serde_json::json!({"cwd":".","script":script}), + ) + .unwrap(); + assert!(ids.insert(id.clone())); + let mut metadata = ShellJobValidationMetadata { + tool: "project_validate".into(), + kind: "check".into(), + steps: vec![step], + effective_timeout_secs: 60, + sync_wait_secs: 1, + adapter: "node:script:check".into(), + validation_target_id: Some(id), + source_fence: None, + minimum_tests: None, + require_tests: None, + no_run: None, + project_validation: Some(ProjectValidationProvenance { + request: ProjectValidationRequest { + project_id: "demo".into(), + cwd: None, + action: ProjectValidationAction::Check, + adapter: ProjectValidationAdapter::Node, + scope: None, + dependency_policy: None, + test: None, + }, + backend: "node".into(), + recipe_root: ".".into(), + root_digest: "a".repeat(64), + manifest_digest: "b".repeat(64), + invocation_digest: "c".repeat(64), + }), + }; + assert!(metadata.is_valid()); + let wire = serde_json::to_string(&metadata).unwrap(); + assert!(serde_json::from_str::(&wire) + .unwrap() + .is_valid()); + metadata.steps[0].args.push("arbitrary".into()); + assert!(!metadata.is_valid()); + metadata.steps[0].args.pop(); + metadata.steps[0] + .env + .push(("CARGO_TARGET_DIR".into(), "/tmp".into())); + assert!(!metadata.is_valid()); + metadata.steps[0].env.clear(); + metadata.require_tests = Some(true); + assert!(!metadata.is_valid()); + metadata.require_tests = None; + metadata.project_validation.as_mut().unwrap().request.action = + ProjectValidationAction::Test; + assert!(!metadata.is_valid()); + } + assert!(structured_validation_target_identity( + ToolValidationIdentityKind::NodeScriptCheck, + &serde_json::json!({"cwd":".","script":"--eval"}), + ) + .is_none()); + let node = structured_validation_target_identity( + ToolValidationIdentityKind::NodeScriptCheck, + &serde_json::json!({"cwd":".","script":"check"}), + ) + .unwrap(); + let ruff = structured_validation_target_identity( + ToolValidationIdentityKind::PythonRuffCheck, + &serde_json::json!({"cwd":"."}), + ) + .unwrap(); + assert_ne!(node, ruff); +} #[test] fn project_validation_protocol_is_closed_declarative_and_roundtrips() { let input = ProjectValidationRequest { diff --git a/crates/webcodex-core/src/runner_protocol.rs b/crates/webcodex-core/src/runner_protocol.rs index eaa41b1c7..579e01143 100644 --- a/crates/webcodex-core/src/runner_protocol.rs +++ b/crates/webcodex-core/src/runner_protocol.rs @@ -616,6 +616,13 @@ runner_capabilities! { #[serde(default, skip_serializing_if = "is_false")] pub project_validation_python_ruff_v1: bool = false; } + /// Bounded Node native project-script check, with Runner-owned manifest, + /// exact Node probe, selected-script identity and durable Job admission. + ProjectValidationNodeScriptCheck => RUNNER_CAPABILITY_PROJECT_VALIDATION_NODE_SCRIPT_CHECK("project_validation_node_script_check_v1"), + v2_baseline = false { + #[serde(default, skip_serializing_if = "is_false")] + pub project_validation_node_script_check_v1: bool = false; + } /// The Runner understands the first-class model-facing `go_test` tool identity /// and its durable `ShellJobValidationMetadata` contract. This is deliberately /// separate from Go JSON parsing support: older Runners may advertise @@ -2840,6 +2847,7 @@ mod envelope_tests { project_validation_test_options_v1: false, project_validation_python_pytest_v1: false, project_validation_python_ruff_v1: false, + project_validation_node_script_check_v1: false, structured_go_test_tool: true, structured_go_test_packages: true, structured_process_argv: true, diff --git a/crates/webcodex-core/src/runner_protocol/job.rs b/crates/webcodex-core/src/runner_protocol/job.rs index ff59da6d6..af4898dd5 100644 --- a/crates/webcodex-core/src/runner_protocol/job.rs +++ b/crates/webcodex-core/src/runner_protocol/job.rs @@ -221,6 +221,17 @@ impl ShellJobValidationStep { self.is_canonical_with_project_workspace(true) } + /// Canonical bounded Node project script. The project manifest and Runner + /// capability remain separate required authorization facts. + pub fn is_structured_node_check(&self) -> bool { + self.name == "check" + && self.program == "node" + && self.env.is_empty() + && self.args.len() == 2 + && self.args[0] == "--run" + && matches!(self.args[1].as_str(), "check" | "typecheck" | "lint") + } + fn is_canonical_with_project_workspace(&self, allow_project_workspace: bool) -> bool { if self .args @@ -245,6 +256,7 @@ impl ShellJobValidationStep { ("test", "cargo") => is_canonical_cargo_test_args(&args, allow_project_workspace), ("check", "go") => is_canonical_go_vet_args(&args), ("test", "go") => args == ["test", "./..."] || self.is_structured_go_test_json(), + ("check", "node") => self.is_structured_node_check(), ("format", "python") => { args == ["-m", "ruff", "format", "--check"] || args == ["-m", "black", "--check"] @@ -929,7 +941,14 @@ impl ShellJobValidationMetadata { "python", "python:pytest:test" | "python:ruff:check" | "python:ruff:format" ) + | ("node", "node:script:check") ) + || (provenance.backend == "node" + && (provenance.request.action + != crate::project_validation::ProjectValidationAction::Check + || provenance.request.scope.is_some() + || provenance.request.dependency_policy.is_some() + || provenance.request.test.is_some())) || (self.require_tests, self.minimum_tests) != provenance.request.test_requirements() || self.no_run.is_some() @@ -961,6 +980,11 @@ impl ShellJobValidationMetadata { && step.is_structured_ruff() } "go_vet" => self.kind == "check" && step.name == "check" && step.program == "go", + "node:script:check" => { + self.tool == "project_validate" + && self.kind == "check" + && step.is_structured_node_check() + } _ => false, } } diff --git a/crates/webcodex-core/src/validation_identity.rs b/crates/webcodex-core/src/validation_identity.rs index 7e451cf0c..ad905805a 100644 --- a/crates/webcodex-core/src/validation_identity.rs +++ b/crates/webcodex-core/src/validation_identity.rs @@ -25,6 +25,7 @@ pub enum ToolValidationIdentityKind { PythonPytest, PythonRuffCheck, PythonRuffFormat, + NodeScriptCheck, Project, } @@ -40,6 +41,7 @@ impl ToolValidationIdentityKind { Self::PythonPytest => Some("python:pytest:test"), Self::PythonRuffCheck => Some("python:ruff:check"), Self::PythonRuffFormat => Some("python:ruff:format"), + Self::NodeScriptCheck => Some("node:script:check"), } } } @@ -214,6 +216,13 @@ pub fn structured_validation_target_identity( | ToolValidationIdentityKind::PythonRuffFormat => { serde_json::json!({"tool":tool_name,"kind": if kind == ToolValidationIdentityKind::PythonRuffCheck { "check" } else { "format" },"cwd":cwd}) } + ToolValidationIdentityKind::NodeScriptCheck => { + let script = obj.get("script")?.as_str()?; + if !matches!(script, "check" | "typecheck" | "lint") { + return None; + } + serde_json::json!({"tool": tool_name, "kind": "check", "cwd": cwd, "script": script, "engine": "node-native-run-v1"}) + } ToolValidationIdentityKind::PythonPytest => { let filter = match obj.get("filter") { None | Some(Value::Null) => None, diff --git a/crates/webcodex-runner-config/src/lib.rs b/crates/webcodex-runner-config/src/lib.rs index 3c08cb72d..bc68fdd46 100644 --- a/crates/webcodex-runner-config/src/lib.rs +++ b/crates/webcodex-runner-config/src/lib.rs @@ -298,6 +298,7 @@ pub fn generated_runner_config_toml(opts: &RunnerInitOptions) -> Result, + step: &ShellJobValidationStep, + cwd: &Path, + stop_requested: Option<&AtomicBool>, +) -> Result { + if !step.is_structured_node_check() { + return Err("invalid Node project validation step".into()); + } + let unavailable = + || webcodex_core::runner_protocol::VALIDATION_TOOL_UNAVAILABLE_CODE.to_string(); + let program = super::scripts::configured_validation_node_interpreter(shell, profile) + .map_err(|_| unavailable())?; + let mut probe = Command::new(&program); + probe.arg("--version").current_dir(cwd).stdin(Stdio::null()); + super::scripts::apply_script_environment(&mut probe, shell, profile) + .map_err(|_| unavailable())?; + probe.env_remove("NODE_OPTIONS"); + // Bounded managed process-tree probe: no fallbacks, installs, or package + // manager invocations. Version 22.3 added the native root/PATH behavior. + let (status, stdout, _) = run_prepare_command(probe, Duration::from_secs(5), stop_requested) + .map_err(|_| unavailable())?; + let version = super::scripts::parse_node_version(&stdout).ok_or_else(unavailable)?; + if !status.success() || version.major < 22 || (version.major == 22 && version.minor < 3) { + return Err(unavailable()); + } + let mut command = Command::new(&program); + command + .args(&step.args) + .current_dir(cwd) + .stdin(Stdio::null()); + super::scripts::apply_script_environment(&mut command, shell, profile) + .map_err(|_| unavailable())?; + command.env_remove("NODE_OPTIONS"); + Ok(command) +} + pub(crate) fn configured_pytest_job_command( shell: &ShellConfig, profile: Option<&PreparedShellProfile>, diff --git a/crates/webcodex-runner/src/webcodex_runner/shell/scripts.rs b/crates/webcodex-runner/src/webcodex_runner/shell/scripts.rs index faaa216a1..ab93a6ea3 100644 --- a/crates/webcodex-runner/src/webcodex_runner/shell/scripts.rs +++ b/crates/webcodex-runner/src/webcodex_runner/shell/scripts.rs @@ -10,6 +10,14 @@ pub(super) fn configured_script_interpreter( select_script_interpreter(shell, profile, language, false) } +/// Pin a configured Node interpreter for the production project-check path. +pub(super) fn configured_validation_node_interpreter( + shell: &ShellConfig, + profile: Option<&PreparedShellProfile>, +) -> Result { + select_script_interpreter(shell, profile, ShellScriptLanguage::Javascript, true) +} + pub(super) fn configured_validation_python_interpreter( shell: &ShellConfig, profile: Option<&PreparedShellProfile>, diff --git a/crates/webcodex-runner/src/webcodex_runner/shell_tests.rs b/crates/webcodex-runner/src/webcodex_runner/shell_tests.rs index a785a2375..97b95585d 100644 --- a/crates/webcodex-runner/src/webcodex_runner/shell_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/shell_tests.rs @@ -1734,6 +1734,61 @@ fn powershell_plan_uses_ps1_file_and_never_command_text_mode() { })); } +#[cfg(unix)] +#[test] +fn native_node_runner_probe_pins_version_and_strips_ambient_options() { + use std::os::unix::fs::PermissionsExt; + let temp = crate::tests::executable_tempdir(); + let node = temp.path().join("node"); + let install_stub = |version: &str| { + std::fs::write( + &node, + format!( + "#!/bin/sh\n[ \"$1\" = '--version' ] || exit 9\nprintf '{}\\n'\n", + version + ), + ) + .unwrap(); + std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o700)).unwrap(); + }; + let mut shell = ShellConfig::default(); + shell.program = node.to_string_lossy().into_owned(); + shell + .env + .insert("PATH".into(), temp.path().to_string_lossy().into_owned()); + shell + .env + .insert("NODE_OPTIONS".into(), "--require=./unsafe.cjs".into()); + let step = webcodex_core::runner_protocol::ShellJobValidationStep { + name: "check".into(), + program: "node".into(), + args: vec!["--run".into(), "check".into()], + env: vec![], + }; + install_stub("v22.2.0"); + assert!( + configured_node_project_check_job_command(&shell, None, &step, temp.path(), None).is_err() + ); + + install_stub("v22.3.0"); + let command = + configured_node_project_check_job_command(&shell, None, &step, temp.path(), None).unwrap(); + assert_eq!(PathBuf::from(command.get_program()), node); + assert_eq!( + command.get_args().collect::>(), + [std::ffi::OsStr::new("--run"), std::ffi::OsStr::new("check")] + ); + assert!(command + .get_envs() + .any(|(key, value)| { key == std::ffi::OsStr::new("NODE_OPTIONS") && value.is_none() })); + let mut forged = step.clone(); + forged.args.push("--eval".into()); + assert!( + configured_node_project_check_job_command(&shell, None, &forged, temp.path(), None) + .is_err() + ); +} + #[test] fn javascript_temp_file_uses_mjs_and_exact_script_bytes() { let payload = ShellScriptPayload { diff --git a/crates/webcodex-runner/src/webcodex_runner/transport/registration.rs b/crates/webcodex-runner/src/webcodex_runner/transport/registration.rs index cc7f856b4..bb70146e1 100644 --- a/crates/webcodex-runner/src/webcodex_runner/transport/registration.rs +++ b/crates/webcodex-runner/src/webcodex_runner/transport/registration.rs @@ -257,6 +257,7 @@ pub(crate) fn runner_register_capabilities(cfg: &RunnerConfig) -> RunnerCapabili capabilities.set(RunnerCapabilityId::ProjectValidationTestOptions, true); capabilities.set(RunnerCapabilityId::ProjectValidationPythonPytest, true); capabilities.set(RunnerCapabilityId::ProjectValidationPythonRuff, true); + capabilities.set(RunnerCapabilityId::ProjectValidationNodeScriptCheck, true); // This binary also understands the first-class go_test durable metadata // identity. Keep this independent from JSON parsing so an old Runner that // supported Connector Go evidence cannot be mistaken for a first-class diff --git a/crates/webcodex-runner/src/webcodex_runner/validation/project.rs b/crates/webcodex-runner/src/webcodex_runner/validation/project.rs index 913e1e28e..b5da32547 100644 --- a/crates/webcodex-runner/src/webcodex_runner/validation/project.rs +++ b/crates/webcodex-runner/src/webcodex_runner/validation/project.rs @@ -3,11 +3,12 @@ use super::*; use sha2::{Digest, Sha256}; use webcodex_core::project_validation::*; use webcodex_core::validation_identity::{ - contextualize_structured_validation_target_identity, StructuredValidationExecutionContext, + contextualize_structured_validation_target_identity, structured_validation_target_identity, + StructuredValidationExecutionContext, ToolValidationIdentityKind, }; use webcodex_validation::{ - detect_validation_recipe, project_validation_operation, resolve_project_validation_recipe, - RecipeId, SemanticCheck, + detect_validation_recipe, project_validation_operation, resolve_node_native_project_check, + resolve_project_validation_recipe, validate_node_project_markers, RecipeId, SemanticCheck, }; pub(crate) fn plan( @@ -32,15 +33,17 @@ pub(crate) fn plan( ProjectValidationAdapter::Rust => Some(RecipeId::Rust), ProjectValidationAdapter::Go => Some(RecipeId::Go), ProjectValidationAdapter::Python => Some(RecipeId::Python), + ProjectValidationAdapter::Node => Some(RecipeId::Node), }; + if matches!( + request.adapter, + ProjectValidationAdapter::Auto | ProjectValidationAdapter::Node + ) { + validate_node_project_markers(&root, request.cwd.as_deref()) + .map_err(|e| unavailable(e.code, Some("node")))?; + } let backend = detect_validation_recipe(&root, request.cwd.as_deref(), hint) .map_err(|e| unavailable(e.code, None))?; - if matches!(backend, RecipeId::Node) { - return Err(unavailable( - "validation_adapter_unavailable", - Some(backend.as_str()), - )); - } let action = match request.action { ProjectValidationAction::FormatCheck => SemanticCheck::Format, ProjectValidationAction::Check => SemanticCheck::Check, @@ -66,23 +69,39 @@ pub(crate) fn plan( .and_then(|operation| operation.with_test_filter(filter)) .map_err(|code| unavailable(code, Some(backend.as_str())))?; let profile = operation.compatibility_profile(); - let resolved = resolve_project_validation_recipe( - &root, - request.cwd.as_deref(), - hint, - &[action], - filter, - request - .scope - .as_ref() - .and_then(ProjectValidationScope::explicit_packages), - all_packages, - request.dependency_policy, - ) + let resolved = if backend == RecipeId::Node { + resolve_node_native_project_check(&root, request.cwd.as_deref()) + } else { + resolve_project_validation_recipe( + &root, + request.cwd.as_deref(), + hint, + &[action], + filter, + request + .scope + .as_ref() + .and_then(ProjectValidationScope::explicit_packages), + all_packages, + request.dependency_policy, + ) + } .map_err(|e| unavailable(e.code, Some(backend.as_str())))?; - let identity = operation - .validation_target_id(Some(&resolved.recipe_root_relative)) - .ok_or_else(|| unavailable("validation_scope_invalid", Some(backend.as_str())))?; + let identity = if backend == RecipeId::Node { + let script = resolved + .steps + .first() + .filter(|step| step.is_structured_node_check()) + .and_then(|step| step.args.get(1)) + .ok_or_else(|| unavailable("validation_manifest_invalid", Some(backend.as_str())))?; + structured_validation_target_identity( + ToolValidationIdentityKind::NodeScriptCheck, + &serde_json::json!({"cwd": resolved.recipe_root_relative, "script": script}), + ) + } else { + operation.validation_target_id(Some(&resolved.recipe_root_relative)) + } + .ok_or_else(|| unavailable("validation_scope_invalid", Some(backend.as_str())))?; let identity = if backend == RecipeId::Go { contextualize_structured_validation_target_identity( &identity, diff --git a/crates/webcodex-runner/src/webcodex_runner/validation/project_tests.rs b/crates/webcodex-runner/src/webcodex_runner/validation/project_tests.rs index 75df778dc..7778ec6c7 100644 --- a/crates/webcodex-runner/src/webcodex_runner/validation/project_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/validation/project_tests.rs @@ -35,6 +35,157 @@ fn request(action: ProjectValidationAction) -> ProjectValidationRequest { } } +#[test] +fn project_node_auto_and_explicit_reject_nonregular_nested_marker() { + let (_tmp, root, registry, policy) = fixture("package.json"); + fs::write( + root.join("package.json"), + r#"{"scripts":{"check":"echo ancestor"}}"#, + ) + .unwrap(); + fs::create_dir_all(root.join("frontend/src")).unwrap(); + fs::create_dir(root.join("frontend/package.json")).unwrap(); + for adapter in [ + ProjectValidationAdapter::Auto, + ProjectValidationAdapter::Node, + ] { + let mut req = request(ProjectValidationAction::Check); + req.adapter = adapter; + req.cwd = Some("frontend/src".into()); + assert!(matches!( + project::plan(&policy, ®istry, &req), + Err(ProjectValidationPlanningResult::Unavailable { code, .. }) + if code == "validation_manifest_invalid" + )); + } +} + +#[test] +fn project_node_script_check_plan_identity_and_queue_fence() { + use webcodex_core::runner_operation::{RunnerJobOperation, RunnerJobValidationOperation}; + use webcodex_core::runner_protocol::{ShellJobContext, ShellJobValidationMetadata}; + + let (_tmp, root, registry, policy) = fixture("package.json"); + fs::write( + root.join("package.json"), + r#"{"packageManager":"yarn@4","scripts":{"check":"node one.js","lint":"node lint.js"}}"#, + ) + .unwrap(); + // No lockfile or installed package manager needed for Node's built-in + // runner; the native script selection is Runner-owned. + let auto = request(ProjectValidationAction::Check); + let (baseline, cwd) = project::plan(&policy, ®istry, &auto).unwrap(); + assert_eq!(baseline.adapter, "node:script:check"); + assert_eq!(baseline.step.args, ["--run", "check"]); + assert!(baseline.step.is_structured_node_check()); + let mut explicit = auto.clone(); + explicit.adapter = ProjectValidationAdapter::Node; + let (selected, _) = project::plan(&policy, ®istry, &explicit).unwrap(); + assert_eq!(baseline.step, selected.step); + assert_eq!(baseline.validation_target_id, selected.validation_target_id); + let metadata = ShellJobValidationMetadata { + project_validation: Some(baseline.provenance.clone()), + tool: "project_validate".into(), + kind: "check".into(), + adapter: baseline.adapter.clone(), + steps: vec![baseline.step.clone()], + effective_timeout_secs: 60, + sync_wait_secs: 1, + validation_target_id: Some(baseline.validation_target_id.clone()), + source_fence: None, + minimum_tests: None, + require_tests: None, + no_run: None, + }; + assert!(metadata.is_valid()); + let operation = RunnerJobOperation::StartValidation(RunnerJobValidationOperation { + job_id: "node-job".into(), + cwd: Some(cwd.to_string_lossy().into_owned()), + steps: metadata.steps.clone(), + timeout_secs: 60, + context: ShellJobContext { + runtime_project_id: Some("agent:runner:demo".into()), + validation: Some(metadata), + workflow_session_id: None, + ssh_resource: None, + project_cwd: Some(".".into()), + cwd: Some(cwd.to_string_lossy().into_owned()), + purpose: Some("validation".into()), + shell: None, + command_preview: "node --run check".into(), + validation_steps: vec!["check".into()], + structured_execution: None, + }, + }); + project::fence(&policy, ®istry, &operation).unwrap(); + + // Changed body: same target, different source digest and failed old fence. + fs::write( + root.join("package.json"), + r#"{"packageManager":"yarn@4","scripts":{"check":"node two.js","lint":"node lint.js"}}"#, + ) + .unwrap(); + let (changed, _) = project::plan(&policy, ®istry, &auto).unwrap(); + assert_eq!(changed.validation_target_id, baseline.validation_target_id); + assert_ne!( + changed.provenance.manifest_digest, + baseline.provenance.manifest_digest + ); + assert!(project::fence(&policy, ®istry, &operation) + .unwrap_err() + .contains("validation_plan_stale")); + + fs::write( + root.join("package.json"), + r#"{"scripts":{"lint":"node lint.js"}}"#, + ) + .unwrap(); + let (lint, _) = project::plan(&policy, ®istry, &auto).unwrap(); + assert_eq!(lint.step.args, ["--run", "lint"]); + assert_ne!(lint.validation_target_id, baseline.validation_target_id); + fs::remove_file(root.join("package.json")).unwrap(); + assert!(project::fence(&policy, ®istry, &operation).is_err()); +} + +#[test] +fn project_node_validation_rejects_other_actions_and_options() { + let (_tmp, root, registry, policy) = fixture("package.json"); + fs::write( + root.join("package.json"), + r#"{"scripts":{"check":"node check.js"}}"#, + ) + .unwrap(); + for action in [ + ProjectValidationAction::Test, + ProjectValidationAction::FormatCheck, + ] { + let request = request(action); + assert!(matches!( + project::plan(&policy, ®istry, &request), + Err(ProjectValidationPlanningResult::Unavailable { code, .. }) + if code == "validation_action_unsupported" + )); + } + let mut request = request(ProjectValidationAction::Check); + request.scope = Some(ProjectValidationScope { + packages: vec!["app".into()], + all_packages: false, + }); + assert!(matches!( + project::plan(&policy, ®istry, &request), + Err(ProjectValidationPlanningResult::Unavailable { code, .. }) + if code == "validation_scope_unsupported" + )); + request.scope = None; + request.dependency_policy = Some(ProjectDependencyPolicy { + mode: ProjectDependencyMode::Locked, + }); + assert!(matches!( + project::plan(&policy, ®istry, &request), + Err(ProjectValidationPlanningResult::Unavailable { code, .. }) + if code == "dependency_policy_unsupported" + )); +} #[test] fn project_validation_runner_plans_locked_dependency_policy_for_rust_and_go() { for (marker, adapter, expected) in [ diff --git a/crates/webcodex-tool-contracts/src/registry/output_schemas/testing.rs b/crates/webcodex-tool-contracts/src/registry/output_schemas/testing.rs index f78ee2ec1..1cdb36f63 100644 --- a/crates/webcodex-tool-contracts/src/registry/output_schemas/testing.rs +++ b/crates/webcodex-tool-contracts/src/registry/output_schemas/testing.rs @@ -189,9 +189,10 @@ fn cargo_output_schema(tool_name: &str) -> Value { } if tool_name == "project_validate" { fields.extend([ - ("backend", json!({"type":"string", "enum":["rust","go","python"]})), + ("execution_source", json!({"const":"project_validate"})), + ("backend", json!({"type":"string", "enum":["rust","go","python","node"]})), ("action", json!({"type":"string", "enum":["format_check","check","test"]})), - ("adapter", json!({"type":"string", "enum":["cargo_fmt","cargo_check","cargo_test","go_vet","go_test","python:pytest:test","python:ruff:check","python:ruff:format"]})), + ("adapter", json!({"type":"string", "enum":["cargo_fmt","cargo_check","cargo_test","go_vet","go_test","python:pytest:test","python:ruff:check","python:ruff:format","node:script:check"]})), ("validation_target_id", schema_type("string", "Canonical resolved validation target, independent of source freshness.")), ("detected_backend", json!({"type":["string","null"], "enum":["rust","go","node","python",null]})), ]); @@ -687,7 +688,13 @@ fn validation_success_evidence_schema(tool: &str, rich_required: &[Value]) -> Va if tool == "project_validate" { rich["allOf"] = json!([rich_assertion_schema(), {"oneOf":[ {"properties":{"action":{"const":"format_check"}}}, - {"properties":{"action":{"const":"check"}},"required":["warnings_count","errors_count","diagnostics"]}, + {"properties":{"action":{"const":"check"},"adapter":{"not":{"const":"node:script:check"}}},"required":["warnings_count","errors_count","diagnostics"]}, + {"properties":{"action":{"const":"check"},"adapter":{"const":"node:script:check"}, + "warnings_count":{"type":"null"},"errors_count":{"type":"null"}, + "tests_detected":{"enum":[]},"tests_run_count":{"enum":[]}, + "tests_passed":{"enum":[]},"tests_failed":{"enum":[]}, + "zero_tests_run":{"enum":[]},"test_count_assertion":{"enum":[]}, + "require_tests":{"enum":[]},"no_run":{"enum":[]}},"required":["adapter"]}, {"properties":{"action":{"const":"test"}},"required":["tests_detected","tests_run_count","tests_passed","tests_failed","zero_tests_run","diagnostics"]} ]}]); } @@ -727,7 +734,14 @@ fn validation_success_evidence_schema(tool: &str, rich_required: &[Value]) -> Va {"properties":{"adapter":{"const":"cargo_check"},"tests_run_count":{"enum":[]},"test_count_assertion":{"enum":[]},"no_run":{"enum":[]},"require_tests":{"enum":[]}}}, {"properties":{"adapter":{"const":"go_vet"},"tests_run_count":{"enum":[]},"test_count_assertion":{"enum":[]},"no_run":{"enum":[]},"require_tests":{"enum":[]}}}, {"allOf":[test["oneOf"][0].clone()],"properties":{"adapter":{"const":"cargo_test"},"test_count_assertion":{"enum":[]}}}, - {"allOf":[test["oneOf"][0].clone()],"properties":{"adapter":{"const":"go_test"},"test_count_assertion":{"enum":[]}}} + {"allOf":[test["oneOf"][0].clone()],"properties":{"adapter":{"const":"go_test"},"test_count_assertion":{"enum":[]}}}, + {"properties":{"adapter":{"const":"node:script:check"}, + "tests_run_count":{"enum":[]},"tests_detected":{"enum":[]}, + "tests_passed":{"enum":[]},"tests_failed":{"enum":[]}, + "zero_tests_run":{"enum":[]},"test_count_assertion":{"enum":[]}, + "no_run":{"enum":[]},"require_tests":{"enum":[]}, + "warnings_count":{"enum":[]},"errors_count":{"enum":[]}, + "diagnostics":{"enum":[]}}} ]); } _ => { diff --git a/crates/webcodex-tool-contracts/src/tests/output_schemas.rs b/crates/webcodex-tool-contracts/src/tests/output_schemas.rs index bccc8ac06..1e32b6491 100644 --- a/crates/webcodex-tool-contracts/src/tests/output_schemas.rs +++ b/crates/webcodex-tool-contracts/src/tests/output_schemas.rs @@ -3374,6 +3374,97 @@ fn run_skill_resource_success_requires_provenance_and_keeps_lifecycle_constraint ); } +#[test] +fn node_project_check_success_schema_preserves_unproven_counts_and_native_identity() { + let specs = registered_tool_specs(); + let schema = &spec_named(&specs, "project_validate").output_schema; + let rich = json!({ + "success": true, "error": null, + "output": { + "source_state":{"freshness":"unproven","observed_mutation_fence":"unknown"}, + "backend":"node", "action":"check", "adapter":"node:script:check", + "validation_target_id":"target:0123456789abcdef01234567", + "warnings_count":null, "errors_count":null, + "diagnostics":{ + "available":false, + "parser":"structured_validation_parser", + "reason":"project script has no trusted structured diagnostics", + "diagnostic_count":null, + "diagnostics":[], + "returned_diagnostic_count":0, + "diagnostics_truncated":false, + "invalid_diagnostics_omitted":0, + "failed_test_details":[], + "failed_test_details_truncated":false, + "truncated":false + } + } + }); + test_support::validate_schema_instance(&rich, schema).unwrap(); + let mut fake_count = rich.clone(); + fake_count["output"]["warnings_count"] = json!(0); + assert!(test_support::validate_schema_instance(&fake_count, schema).is_err()); + let mut fake_tests = rich.clone(); + fake_tests["output"]["tests_run_count"] = json!(12); + assert!(test_support::validate_schema_instance(&fake_tests, schema).is_err()); + let mut forged_parser = rich.clone(); + forged_parser["output"]["diagnostics"]["parser"] = json!("node_script_exit_status_v1"); + assert!(test_support::validate_schema_instance(&forged_parser, schema).is_err()); + + let compact = json!({ + "success":true, "error":null, + "output":{ + "source_state":{"freshness":"unproven","observed_mutation_fence":"uncrossed"}, + "adapter":"node:script:check", + "validation_target_id":"target:0123456789abcdef01234567" + } + }); + test_support::validate_schema_instance(&compact, schema).unwrap(); + let mut invalid = compact; + invalid["output"]["tests_run_count"] = json!(3); + assert!(test_support::validate_schema_instance(&invalid, schema).is_err()); +} + +#[test] +fn node_project_check_unavailable_responses_keep_project_validate_schema_closed() { + let specs = registered_tool_specs(); + let schema = &spec_named(&specs, "project_validate").output_schema; + for failure_kind in [ + "validation_manifest_invalid", + "validation_check_unavailable", + "validation_scope_unsupported", + "validation_action_unsupported", + "dependency_policy_unsupported", + ] { + let output = json!({ + "success": false, + "output": { + "execution_source": "project_validate", + "execution_state": "not_started", + "command_started": false, + "command_completed": false, + "failure_kind": failure_kind, + "detected_backend": "node" + }, + "error": "Project validation unavailable" + }); + test_support::validate_schema_instance(&output, schema) + .unwrap_or_else(|e| panic!("{failure_kind}: {e}")); + } + let invalid = json!({ + "success": false, + "output": { + "execution_state": "not_started", + "command_started": true, + "command_completed": false, + "failure_kind": "validation_manifest_invalid", + "detected_backend": "node" + }, + "error": "invalid lifecycle" + }); + assert!(test_support::validate_schema_instance(&invalid, schema).is_err()); +} + #[test] fn project_validate_schema_admits_canonical_planning_and_replan_failures() { let specs = registered_tool_specs(); diff --git a/crates/webcodex-tool-contracts/src/tool_definition/testing.rs b/crates/webcodex-tool-contracts/src/tool_definition/testing.rs index 5262f027c..28587e2ac 100644 --- a/crates/webcodex-tool-contracts/src/tool_definition/testing.rs +++ b/crates/webcodex-tool-contracts/src/tool_definition/testing.rs @@ -131,7 +131,7 @@ pub(super) const DEFINITIONS: &[ToolDefinition] = &[ ) .with_composition_policy(super::ToolCompositionPolicy::Sequential) .with_host_orchestration_hint(super::ToolHostOrchestrationHint::sequential()), - "Portable project validation; Runner owns recipe and argv. Rust/Go: format_check/check/test as supported; Python: pytest test, Ruff check/format_check; Node unavailable. scope selects packages (1..8) or all_packages=true for Rust/Go check/test; scoped formatting fails closed. Rust all-packages requires a Runner-proven Project-root Cargo workspace; Go keeps ./... single-module scope. Python rejects scope. test.filter is a bounded Rust substring, Go -run regexp or pytest -k expression; require_tests/min_tests govern proven counts. Python uses existing configured/profile/PATH Python 3 and pytest/Ruff; never installs or falls back. Ruff needs local pyproject.toml [tool.ruff], explicit target-version, no extend; read-only with no fixes, cache, bytecode or output files. Missing environment is unavailable. dependency_policy.mode=locked maps to Cargo --locked/Go -mod=readonly, not offline; Python and formatting reject it. Additive semantics require Runner support. Go forces GO111MODULE=on/GOWORK=off. No arbitrary executable, argv or shell. Project tests may have effects. Long work keeps the same Job; observe pending, never retry. Advanced options remain on cargo_* / go_test.", ) + "Portable project validation; Runner owns recipe and argv. Rust/Go: format_check/check/test as supported; Python: pytest test, Ruff check/format_check; Node: native Node >=22.3 script check only (selected check > typecheck > lint), no pre/post; other Node actions unavailable. Node project script contents may modify files or access network; exit status proves only the selected script process outcome, not which lint tool ran or a read-only filesystem. scope selects packages (1..8) or all_packages=true for Rust/Go check/test; scoped formatting fails closed. Rust all-packages requires a Runner-proven Project-root Cargo workspace; Go keeps ./... single-module scope. Python/Node reject scope. test.filter is a bounded Rust substring, Go -run regexp or pytest -k expression; require_tests/min_tests govern proven counts. Python uses existing configured/profile/PATH Python 3 and pytest/Ruff; never installs or falls back. Ruff needs local pyproject.toml [tool.ruff], explicit target-version, no extend; read-only with no fixes, cache, bytecode or output files. Missing environment is unavailable. dependency_policy.mode=locked maps to Cargo --locked/Go -mod=readonly, not offline; Python and formatting reject it. Additive semantics require Runner support. Go forces GO111MODULE=on/GOWORK=off. No arbitrary executable, argv or shell. Project tests may have effects. Long work keeps the same Job; observe pending, never retry. Advanced options remain on cargo_* / go_test.", ) .with_execution(super::ToolExecutionContract::new( super::ToolExecutionForm::StructuredValidation, super::ToolExecutionLifetime::Runner, diff --git a/crates/webcodex-validation/src/adapters/mod.rs b/crates/webcodex-validation/src/adapters/mod.rs index 25bd05206..a660af05e 100644 --- a/crates/webcodex-validation/src/adapters/mod.rs +++ b/crates/webcodex-validation/src/adapters/mod.rs @@ -1,6 +1,7 @@ -//! Structured Cargo, Go and project pytest validation adapters. +//! Structured project validation for Cargo, Go, Python and Node. mod go; +mod node; mod python; mod rust; @@ -108,6 +109,7 @@ pub enum ReadOnlyValidationOperation { Python(PythonTestOptions), PythonRuffCheck, PythonRuffFormat, + NodeScriptCheck, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -152,6 +154,7 @@ impl ReadOnlyValidationOperation { Self::Python(_) | Self::PythonRuffCheck | Self::PythonRuffFormat + | Self::NodeScriptCheck | Self::Cargo(CargoReadOnlyValidationOperation::FormatCheck) => { return Err("dependency_policy_unsupported") } @@ -176,6 +179,7 @@ impl ReadOnlyValidationOperation { Self::Python(_) | Self::PythonRuffCheck | Self::PythonRuffFormat + | Self::NodeScriptCheck | Self::Cargo(CargoReadOnlyValidationOperation::FormatCheck) => None, Self::Cargo(CargoReadOnlyValidationOperation::Check(options)) => { options.dependency_mode @@ -195,12 +199,17 @@ impl ReadOnlyValidationOperation { Self::Python(_) | Self::PythonRuffCheck | Self::PythonRuffFormat + | Self::NodeScriptCheck | Self::Cargo(CargoReadOnlyValidationOperation::FormatCheck) => false, } } pub fn compatibility_profile(&self) -> ValidationCompatibilityProfile { match self { + Self::NodeScriptCheck => ValidationCompatibilityProfile { + tool_identity: "node:script:check", + validation_identity: ToolValidationIdentityKind::NodeScriptCheck, + }, Self::PythonRuffCheck => ValidationCompatibilityProfile { tool_identity: "python:ruff:check", validation_identity: ToolValidationIdentityKind::PythonRuffCheck, @@ -249,10 +258,13 @@ impl ReadOnlyValidationOperation { pub fn build_readonly_plan(&self) -> Result { let adapter = validation_adapter_for_tool(self.compatibility_profile().tool_identity) - .ok_or("Ruff requires Runner-owned project manifest resolution")?; + .ok_or_else(|| match self { + Self::NodeScriptCheck => "Node requires Runner-owned package manifest resolution", + _ => "Ruff requires Runner-owned project manifest resolution", + })?; match self { - Self::PythonRuffCheck | Self::PythonRuffFormat => { - unreachable!("Ruff has no static command adapter") + Self::PythonRuffCheck | Self::PythonRuffFormat | Self::NodeScriptCheck => { + unreachable!("project-local adapter has no static command") } Self::Python(options) => adapter.build_readonly_plan(ValidationCommandOptions { filter: options.filter.clone(), @@ -283,7 +295,9 @@ impl ReadOnlyValidationOperation { pub fn validation_target_id(&self, cwd: Option<&str>) -> Option { let profile = self.compatibility_profile(); let arguments = match self { - Self::PythonRuffCheck | Self::PythonRuffFormat => serde_json::json!({"cwd":cwd}), + Self::PythonRuffCheck | Self::PythonRuffFormat | Self::NodeScriptCheck => { + serde_json::json!({"cwd":cwd}) + } Self::Python(options) => serde_json::json!({"cwd":cwd,"filter":options.filter}), Self::Cargo(CargoReadOnlyValidationOperation::FormatCheck) => serde_json::json!({ "cwd": cwd, @@ -396,6 +410,9 @@ pub fn project_validation_operation( })), ("python", Check) => Ok(ReadOnlyValidationOperation::PythonRuffCheck), ("python", Format) => Ok(ReadOnlyValidationOperation::PythonRuffFormat), + ("node", _) if packages.is_some() || all_packages => Err("validation_scope_unsupported"), + ("node", Check) => Ok(ReadOnlyValidationOperation::NodeScriptCheck), + ("node", _) => Err("validation_action_unsupported"), _ => Err("validation_adapter_unavailable"), } } @@ -600,6 +617,7 @@ pub fn validation_evidence_profile_for_tool( validation_adapter_for_tool(tool_identity) .map(|adapter| adapter as &dyn ValidationEvidenceProfile) .or_else(|| python::ruff_evidence_profile(tool_identity)) + .or_else(|| node::evidence_profile(tool_identity)) } /// Evidence-only view of the canonical backend/action mapping. @@ -608,6 +626,9 @@ pub fn validation_evidence_profile_for_recipe( action: crate::SemanticCheck, ) -> Option<&'static dyn ValidationEvidenceProfile> { match (backend, action) { + ("node", crate::SemanticCheck::Check) => { + validation_evidence_profile_for_tool("node:script:check") + } ("python", crate::SemanticCheck::Check) => { validation_evidence_profile_for_tool("python:ruff:check") } diff --git a/crates/webcodex-validation/src/adapters/node.rs b/crates/webcodex-validation/src/adapters/node.rs new file mode 100644 index 000000000..10045cfc5 --- /dev/null +++ b/crates/webcodex-validation/src/adapters/node.rs @@ -0,0 +1,56 @@ +//! Evidence-only Node native project-script check. Project scripts are untrusted +//! executable code, not structured lint output or guaranteed read-only operations. +use super::{ValidationEvidenceProfile, ValidationFailureEvidence}; +use webcodex_core::validation_evidence::{ValidationDiagnostics, PARSER_KIND}; + +struct NodeScriptCheck; +static NODE_SCRIPT_CHECK: NodeScriptCheck = NodeScriptCheck; + +pub(super) fn evidence_profile(identity: &str) -> Option<&'static dyn ValidationEvidenceProfile> { + (identity == "node:script:check") + .then_some(&NODE_SCRIPT_CHECK as &dyn ValidationEvidenceProfile) +} + +impl ValidationEvidenceProfile for NodeScriptCheck { + fn tool_identity(&self) -> &'static str { + "node:script:check" + } + + fn validation_kind(&self) -> &'static str { + "check" + } + + fn parse(&self, _stdout: &str, _stderr: &str, truncated: bool) -> ValidationDiagnostics { + // Arbitrary project script text must not be parsed as another tool's + // diagnostics or test-count evidence. + ValidationDiagnostics { + available: false, + parser: PARSER_KIND, + reason: Some("project script has no trusted structured diagnostics"), + diagnostic_count: None, + diagnostics: Vec::new(), + returned_diagnostic_count: 0, + diagnostics_truncated: truncated, + invalid_diagnostics_omitted: 0, + test_summary: None, + failed_test_details: Vec::new(), + failed_test_details_truncated: false, + truncated: Some(truncated), + } + } + + fn map_failure_kind(&self, evidence: ValidationFailureEvidence<'_>) -> &'static str { + if evidence.success { + "unknown" + } else if matches!( + evidence.reported_failure_kind, + Some("timeout" | "timed_out" | "command_timeout") + ) { + "timeout" + } else if evidence.exit_code == Some(1) { + "validation_failed" + } else { + "process_exit" + } + } +} diff --git a/crates/webcodex-validation/src/lib.rs b/crates/webcodex-validation/src/lib.rs index 2a67d66b6..148fba767 100644 --- a/crates/webcodex-validation/src/lib.rs +++ b/crates/webcodex-validation/src/lib.rs @@ -37,9 +37,10 @@ pub use evidence::{ validation_summary_from_events, CurrentValidationEvidenceProjection, ValidationEvent, }; pub use recipe::{ - detect_validation_recipe, resolve_project_validation_recipe, resolve_validation_recipe, - resolve_validation_recipe_with_packages, resolve_validation_recipe_with_project_policy, - RecipeError, RecipeId, ResolvedValidationRecipe, SemanticCheck, + detect_validation_recipe, resolve_node_native_project_check, resolve_project_validation_recipe, + resolve_validation_recipe, resolve_validation_recipe_with_packages, + resolve_validation_recipe_with_project_policy, validate_node_project_markers, RecipeError, + RecipeId, ResolvedValidationRecipe, SemanticCheck, }; pub use webcodex_core::cargo_test_count::{ parse_cargo_test_run_metadata, CargoTestRunMetadata, CargoTestRunMetadataAccumulator, diff --git a/crates/webcodex-validation/src/recipe.rs b/crates/webcodex-validation/src/recipe.rs index 6c007464f..63d97c306 100644 --- a/crates/webcodex-validation/src/recipe.rs +++ b/crates/webcodex-validation/src/recipe.rs @@ -10,7 +10,7 @@ use webcodex_core::runner_protocol::{normalize_rust_test_filter, ShellJobValidat use webcodex_workspace::project_recipe::{ digest_project_cargo_all_packages_provenance, digest_project_recipe_files, project_recipe_provenance_files, read_project_recipe_file, resolve_project_recipe_root, - ProjectRecipeResolutionError, + validate_node_project_marker_chain, ProjectRecipeResolutionError, }; pub use webcodex_workspace::project_recipe::ProjectRecipeId as RecipeId; @@ -567,6 +567,116 @@ fn canonical_adapter_steps( Ok(steps) } +/// First production Node project validation: native built-in script runner. +/// The ordinary package-manager recipe below keeps its established semantics. +/// Shared strict Node marker guard for auto and explicit project planning. +pub fn validate_node_project_markers( + execution_root: &Path, + cwd: Option<&str>, +) -> Result<(), RecipeError> { + validate_node_project_marker_chain(execution_root, cwd).map_err(map_project_recipe_error) +} + +pub fn resolve_node_native_project_check( + execution_root: &Path, + cwd: Option<&str>, +) -> Result { + use std::io::Read; + const MAX_NODE_MANIFEST_BYTES: u64 = 1024 * 1024; + + validate_node_project_markers(execution_root, cwd)?; + let resolved = resolve_project_recipe_root(execution_root, cwd, Some(RecipeId::Node)) + .map_err(map_project_recipe_error)?; + let marker = resolved.marker_path(); + // Reject link-like and special manifest files; the project must actually own + // the selected package.json at its canonical recipe root. + if !fs::symlink_metadata(&marker).is_ok_and(|metadata| metadata.file_type().is_file()) { + return Err(manifest_invalid()); + } + let canonical = marker.canonicalize().map_err(|_| manifest_invalid())?; + if !canonical.starts_with(&resolved.execution_root) { + return Err(manifest_invalid()); + } + let file = fs::File::open(&canonical).map_err(|_| manifest_invalid())?; + let mut manifest = Vec::new(); + file.take(MAX_NODE_MANIFEST_BYTES + 1) + .read_to_end(&mut manifest) + .map_err(|_| manifest_invalid())?; + if manifest.len() as u64 > MAX_NODE_MANIFEST_BYTES { + return Err(manifest_invalid()); + } + let value: Value = serde_json::from_slice(&manifest).map_err(|_| manifest_invalid())?; + let scripts = value + .as_object() + .ok_or_else(manifest_invalid)? + .get("scripts") + .map(|value| value.as_object().ok_or_else(manifest_invalid)) + .transpose()?; + let selected = select_node_script(scripts, SemanticCheck::Check)?; + if scripts + .and_then(|scripts| scripts.get(selected)) + .and_then(Value::as_str) + .is_none_or(|body| body.trim().is_empty()) + { + return Err(manifest_invalid()); + } + let step = ShellJobValidationStep { + name: "check".into(), + program: "node".into(), + args: vec!["--run".into(), selected.into()], + env: Vec::new(), + }; + debug_assert!(step.is_structured_node_check()); + let mut hasher = Sha256::new(); + let relative = canonical + .strip_prefix(&resolved.execution_root) + .map_err(|_| manifest_invalid())?; + hasher.update(relative.as_os_str().as_encoded_bytes()); + hasher.update((manifest.len() as u64).to_be_bytes()); + hasher.update(&manifest); + let manifest_digest = format!("{:x}", hasher.finalize()); + let invocation_digest = format!( + "{:x}", + Sha256::digest( + serde_json::to_vec(std::slice::from_ref(&step)).map_err(|_| manifest_invalid())? + ) + ); + Ok(ResolvedValidationRecipe { + recipe_id: "node", + recipe_root_relative: resolved.relative_root, + steps: vec![step], + invocation_digest, + manifest_digest, + test_filter: None, + }) +} + +/// Shared first-present script selector. Validation of body contents is separate: +/// ordinary recipes preserve their old string-only rule; native check rejects +/// empty/whitespace-only bodies without changing the ordinary workflow. +fn select_node_script( + scripts: Option<&serde_json::Map>, + check: SemanticCheck, +) -> Result<&'static str, RecipeError> { + let names: &[&str] = match check { + SemanticCheck::Format => &["format:check", "format-check", "check:format"], + SemanticCheck::Check => &["check", "typecheck", "lint"], + SemanticCheck::Test => &["test"], + }; + let name = names + .iter() + .copied() + .find(|name| scripts.is_some_and(|scripts| scripts.contains_key(*name))) + .ok_or_else(check_unavailable)?; + if !scripts + .and_then(|scripts| scripts.get(name)) + .is_some_and(Value::is_string) + { + return Err(manifest_invalid()); + } + Ok(name) +} + fn node_steps( root: &Path, manifest: &[u8], @@ -612,22 +722,7 @@ fn node_steps( let manager = managers.into_iter().next().unwrap(); let mut steps = Vec::with_capacity(checks.len()); for check in checks { - let names: &[&str] = match check { - SemanticCheck::Format => &["format:check", "format-check", "check:format"], - SemanticCheck::Check => &["check", "typecheck", "lint"], - SemanticCheck::Test => &["test"], - }; - let script = names - .iter() - .copied() - .find(|name| scripts.is_some_and(|scripts| scripts.get(*name).is_some())) - .ok_or_else(check_unavailable)?; - if !scripts - .and_then(|scripts| scripts.get(script)) - .is_some_and(Value::is_string) - { - return Err(manifest_invalid()); - } + let script = select_node_script(scripts, *check)?; let args = vec![ "run".to_string(), "--silent".to_string(), diff --git a/crates/webcodex-validation/src/recipe_tests.rs b/crates/webcodex-validation/src/recipe_tests.rs index ce8e50e6d..5b7a87514 100644 --- a/crates/webcodex-validation/src/recipe_tests.rs +++ b/crates/webcodex-validation/src/recipe_tests.rs @@ -23,6 +23,152 @@ fn resolve( resolve_validation_recipe(root, cwd, recipe, checks, test_filter) } +#[test] +fn native_node_rejects_nonregular_nested_marker_before_ancestor_fallback() { + let temp = tempfile::tempdir().unwrap(); + write( + temp.path(), + "package.json", + r#"{"scripts":{"check":"echo ancestor"}}"#, + ); + write(temp.path(), "frontend/src/.keep", ""); + fs::create_dir(temp.path().join("frontend/package.json")).unwrap(); + let error = + super::resolve_node_native_project_check(temp.path(), Some("frontend/src")).unwrap_err(); + assert_eq!(error.code, "validation_manifest_invalid"); + fs::remove_dir(temp.path().join("frontend/package.json")).unwrap(); + #[cfg(unix)] + { + std::os::unix::fs::symlink( + temp.path().join("missing-package.json"), + temp.path().join("frontend/package.json"), + ) + .unwrap(); + assert_eq!( + super::resolve_node_native_project_check(temp.path(), Some("frontend/src")) + .unwrap_err() + .code, + "validation_manifest_invalid" + ); + } +} + +#[test] +fn native_node_project_script_selection_is_closed_and_digest_fenced() { + let temp = tempfile::tempdir().unwrap(); + // Native Node deliberately ignores package-manager selection/lockfiles. + write(temp.path(), "yarn.lock", "lockfileVersion: 9\n"); + write( + temp.path(), + "package.json", + r#"{"packageManager":"pnpm@9.0.0","scripts":{"lint":"eslint .","typecheck":"tsc --noEmit","check":"node check.js","precheck":"node pre.js"}}"#, + ); + let baseline = super::resolve_node_native_project_check(temp.path(), None).unwrap(); + assert_eq!(baseline.recipe_root_relative, "."); + assert_eq!(baseline.steps.len(), 1); + assert!(baseline.steps[0].is_structured_node_check()); + assert_eq!(baseline.steps[0].args, ["--run", "check"]); + let serialized = serde_json::to_string(&baseline.steps).unwrap(); + assert!(!serialized.contains("node check.js")); + assert!(!serialized.contains("node pre.js")); + + write( + temp.path(), + "package.json", + r#"{"packageManager":"pnpm@9.0.0","scripts":{"lint":"eslint .","typecheck":"tsc --noEmit","check":"node changed.js"}}"#, + ); + let changed_body = super::resolve_node_native_project_check(temp.path(), None).unwrap(); + assert_eq!(baseline.steps, changed_body.steps); + assert_eq!(baseline.invocation_digest, changed_body.invocation_digest); + assert_ne!(baseline.manifest_digest, changed_body.manifest_digest); + + write( + temp.path(), + "package.json", + r#"{"scripts":{"lint":"eslint .","typecheck":"tsc --noEmit"}}"#, + ); + let typecheck = super::resolve_node_native_project_check(temp.path(), None).unwrap(); + assert_eq!(typecheck.steps[0].args, ["--run", "typecheck"]); + assert_ne!(baseline.invocation_digest, typecheck.invocation_digest); + write( + temp.path(), + "package.json", + r#"{"scripts":{"lint":"eslint ."}}"#, + ); + let lint = super::resolve_node_native_project_check(temp.path(), None).unwrap(); + assert_eq!(lint.steps[0].args, ["--run", "lint"]); + + write( + temp.path(), + "frontend/package.json", + r#"{"scripts":{"check":"echo frontend"}}"#, + ); + write(temp.path(), "frontend/src/.keep", ""); + let nested = + super::resolve_node_native_project_check(temp.path(), Some("frontend/src")).unwrap(); + assert_eq!(nested.recipe_root_relative, "frontend"); + assert_eq!(nested.steps[0].args, ["--run", "check"]); +} + +#[test] +fn native_node_project_check_rejects_malformed_and_escaped_manifests() { + let temp = tempfile::tempdir().unwrap(); + for (manifest, expected) in [ + (r#"{"scripts":{"lint":"yes"}}"#, None), + ( + r#"{"scripts":{"check":null,"lint":"yes"}}"#, + Some("validation_manifest_invalid"), + ), + ( + r#"{"scripts":{"check":42,"lint":"yes"}}"#, + Some("validation_manifest_invalid"), + ), + ( + r#"{"scripts":{"check":"","lint":"yes"}}"#, + Some("validation_manifest_invalid"), + ), + ( + r#"{"scripts":{"check":" ","lint":"yes"}}"#, + Some("validation_manifest_invalid"), + ), + (r#"{"scripts":{}}"#, Some("validation_check_unavailable")), + ( + r#"{"scripts":{"check":"ok"}"#, + Some("validation_manifest_invalid"), + ), + ] { + write(temp.path(), "package.json", manifest); + let outcome = super::resolve_node_native_project_check(temp.path(), None); + match expected { + None => assert_eq!(outcome.unwrap().steps[0].args, ["--run", "lint"]), + Some(expected_code) => assert_eq!(outcome.unwrap_err().code, expected_code), + } + } + let oversized = format!(r#"{{"scripts":{{"check":"{}"}}}}"#, "x".repeat(1024 * 1024)); + write(temp.path(), "package.json", &oversized); + assert_eq!( + super::resolve_node_native_project_check(temp.path(), None) + .unwrap_err() + .code, + "validation_manifest_invalid" + ); + #[cfg(unix)] + { + let outside = tempfile::tempdir().unwrap(); + write( + outside.path(), + "package.json", + r#"{"scripts":{"check":"echo outside"}}"#, + ); + fs::remove_file(temp.path().join("package.json")).unwrap(); + std::os::unix::fs::symlink( + outside.path().join("package.json"), + temp.path().join("package.json"), + ) + .unwrap(); + assert!(super::resolve_node_native_project_check(temp.path(), None).is_err()); + } +} #[test] fn recipe_resolution_is_nearest_deterministic_and_project_bounded() { struct Case { diff --git a/crates/webcodex-workspace/src/project_recipe.rs b/crates/webcodex-workspace/src/project_recipe.rs index b0b98b3a9..8d7409aa9 100644 --- a/crates/webcodex-workspace/src/project_recipe.rs +++ b/crates/webcodex-workspace/src/project_recipe.rs @@ -1329,6 +1329,48 @@ fn nearest_recipe_root( } } +/// Reject an invalid Node marker before ordinary nearest-root detection can +/// silently fall back to an ancestor project. This validates filesystem facts; +/// it does not independently select or synthesize a validation operation. +pub fn validate_node_project_marker_chain( + execution_root: &Path, + cwd: Option<&str>, +) -> Result<(), ProjectRecipeResolutionError> { + let root = execution_root + .canonicalize() + .map_err(|_| ProjectRecipeResolutionError::ExecutionRootUnavailable)?; + let mut directory = resolve_cwd(&root, cwd)?; + loop { + match fs::symlink_metadata(directory.join(ProjectRecipeId::Node.marker())) { + Ok(metadata) if !metadata.file_type().is_file() => { + return Err(ProjectRecipeResolutionError::SourceFileInvalid); + } + Ok(_) => return Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(ProjectRecipeResolutionError::SourceFileInvalid), + } + // The existing resolver stops at the nearest project marker. Ignore + // unrelated ancestor packages after such a valid boundary. + if [ + ProjectRecipeId::Rust, + ProjectRecipeId::Go, + ProjectRecipeId::Python, + ] + .into_iter() + .any(|recipe| directory.join(recipe.marker()).is_file()) + { + return Ok(()); + } + if directory == root { + return Ok(()); + } + directory = directory + .parent() + .ok_or(ProjectRecipeResolutionError::SourceFileInvalid)? + .to_path_buf(); + } +} + pub fn read_project_recipe_file( execution_root: &Path, path: &Path, diff --git a/src/runner_quic.rs b/src/runner_quic.rs index 654ab1943..c797cfb81 100644 --- a/src/runner_quic.rs +++ b/src/runner_quic.rs @@ -619,6 +619,7 @@ mod tests { project_validation_test_options_v1: false, project_validation_python_pytest_v1: false, project_validation_python_ruff_v1: false, + project_validation_node_script_check_v1: false, structured_go_test_tool: true, structured_go_test_packages: true, structured_process_argv: true, diff --git a/src/runner_ws.rs b/src/runner_ws.rs index 45ef472c3..2be667cfa 100644 --- a/src/runner_ws.rs +++ b/src/runner_ws.rs @@ -430,6 +430,7 @@ mod tests { project_validation_test_options_v1: false, project_validation_python_pytest_v1: false, project_validation_python_ruff_v1: false, + project_validation_node_script_check_v1: false, structured_go_test_tool: true, structured_go_test_packages: true, structured_process_argv: true, diff --git a/src/tool_runtime/tests/jobs.rs b/src/tool_runtime/tests/jobs.rs index f533b350a..984d24868 100644 --- a/src/tool_runtime/tests/jobs.rs +++ b/src/tool_runtime/tests/jobs.rs @@ -11,6 +11,28 @@ use crate::runner_protocol::{ }; use serde_json::json; +#[test] +fn node_script_check_does_not_infer_lint_or_test_counts_from_arbitrary_output() { + use crate::tool_runtime::jobs::structured_validation_evidence; + let fake = "test result: ok. 500 passed; 0 failed;\n\ + warning: suspicious\nerror[E0308]: fake rustc\n\ + {\"code\":\"E001\",\"message\":\"fake lint\"}"; + for truncated in [false, true] { + let evidence = + structured_validation_evidence("node:script:check", "check", fake, fake, truncated); + let diagnostics = evidence.diagnostics.unwrap(); + assert!(!diagnostics.available); + assert!(diagnostics.diagnostic_count.is_none()); + assert!(diagnostics.diagnostics.is_empty()); + assert_eq!(diagnostics.truncated, Some(truncated)); + assert_eq!(evidence.tests_run_count, None); + assert_eq!(evidence.tests_detected, None); + assert_eq!(evidence.tests_failed, None); + assert_eq!(evidence.warnings_count, None); + assert_eq!(evidence.errors_count, None); + } +} + #[test] fn validation_evidence_unknown_profiles_preserve_only_process_success() { assert_unproven_validation_profiles( diff --git a/src/tool_runtime/tests/metadata.rs b/src/tool_runtime/tests/metadata.rs index 5212f6306..bc92fd60a 100644 --- a/src/tool_runtime/tests/metadata.rs +++ b/src/tool_runtime/tests/metadata.rs @@ -520,6 +520,7 @@ async fn register_agent_projects_for_auth( project_validation_test_options_v1: false, project_validation_python_pytest_v1: false, project_validation_python_ruff_v1: false, + project_validation_node_script_check_v1: false, structured_go_test_tool: true, structured_go_test_packages: true, structured_process_argv: true, diff --git a/src/tool_runtime/validation.rs b/src/tool_runtime/validation.rs index af28eab75..1821d5ee4 100644 --- a/src/tool_runtime/validation.rs +++ b/src/tool_runtime/validation.rs @@ -517,7 +517,7 @@ impl ToolRuntime { let plan = match serde_json::from_str::(response.stdout.as_deref().unwrap_or("")) { Ok(ProjectValidationPlanningResult::Ready { plan }) => plan, Ok(ProjectValidationPlanningResult::Unavailable { code, detected_backend }) => return ToolResult::err_with_output( - "Project validation unavailable; use a supported Rust/Go action or Python pytest/Ruff with existing tooling and supported project configuration.", + "Project validation unavailable; use a supported Rust/Go action, configured Python pytest/Ruff, or a Node >=22.3 native check script on a supported Runner.", json!({"execution_source":"project_validate", "execution_state":"not_started", "command_started":false, "command_completed":false, "failure_kind":code, "detected_backend":detected_backend})), Err(_) => return ToolResult::err("invalid Runner validation plan; upgrade Server and Runner together"), }; diff --git a/src/tool_runtime/validation_profile/mod.rs b/src/tool_runtime/validation_profile/mod.rs index 4ec872560..d27f83a3e 100644 --- a/src/tool_runtime/validation_profile/mod.rs +++ b/src/tool_runtime/validation_profile/mod.rs @@ -18,6 +18,11 @@ pub(crate) struct ValidationRuntimeProfile { pub(crate) fn runtime_profile(operation: &ReadOnlyValidationOperation) -> ValidationRuntimeProfile { match operation { + ReadOnlyValidationOperation::NodeScriptCheck => ValidationRuntimeProfile { + default_timeout_secs: DEFAULT_CARGO_CHECK_TIMEOUT_SECS, + force_agent_handoff: false, + invalid_argument_guidance: "Node >=22.3 must be installed; require one project-local check/typecheck/lint script. Script effects are project-controlled.", + }, ReadOnlyValidationOperation::PythonRuffCheck | ReadOnlyValidationOperation::PythonRuffFormat => ValidationRuntimeProfile { default_timeout_secs: DEFAULT_CARGO_CHECK_TIMEOUT_SECS, force_agent_handoff: false, From 764bb16227ed5ff397700ac5f098ff532ae3ac1f Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:13:27 +0800 Subject: [PATCH 4/5] test(validation): align Node checks and bounded tool projection --- .../validation/project_tests.rs | 26 ++++++++++++++----- .../src/tool_definition/testing.rs | 2 +- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/crates/webcodex-runner/src/webcodex_runner/validation/project_tests.rs b/crates/webcodex-runner/src/webcodex_runner/validation/project_tests.rs index 7778ec6c7..cdfa6d8c5 100644 --- a/crates/webcodex-runner/src/webcodex_runner/validation/project_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/validation/project_tests.rs @@ -576,17 +576,31 @@ fn project_validation_nearest_root_hint_and_ambiguity() { ); } #[test] -fn project_validation_deferred_backends_do_not_resolve_scripts() { - for (marker, backend) in [("package.json", "node")] { - let (_tmp, _root, registry, policy) = fixture(marker); +fn project_validation_node_unsupported_actions_do_not_resolve_scripts() { + let (_tmp, root, registry, policy) = fixture("package.json"); + fs::write(root.join("package.json"), "{}").unwrap(); + // Node now has a bounded native check adapter, but never silently runs a + // project-authored test or formatting script as validation evidence. + for action in [ + ProjectValidationAction::Test, + ProjectValidationAction::FormatCheck, + ] { assert_eq!( - project::plan(&policy, ®istry, &request(ProjectValidationAction::Test)).unwrap_err(), + project::plan(&policy, ®istry, &request(action)).unwrap_err(), ProjectValidationPlanningResult::Unavailable { - code: "validation_adapter_unavailable".into(), - detected_backend: Some(backend.into()) + code: "validation_action_unsupported".into(), + detected_backend: Some("node".into()) } ); } + // No check/typecheck/lint script remains definitely unavailable. + assert_eq!( + project::plan(&policy, ®istry, &request(ProjectValidationAction::Check)).unwrap_err(), + ProjectValidationPlanningResult::Unavailable { + code: "validation_check_unavailable".into(), + detected_backend: Some("node".into()) + } + ); let (_tmp, _root, registry, policy) = fixture("go.mod"); assert!( matches!(project::plan(&policy, ®istry, &request(ProjectValidationAction::FormatCheck)), Err(ProjectValidationPlanningResult::Unavailable { code, .. }) if code == "validation_action_unsupported") diff --git a/crates/webcodex-tool-contracts/src/tool_definition/testing.rs b/crates/webcodex-tool-contracts/src/tool_definition/testing.rs index 28587e2ac..26666133a 100644 --- a/crates/webcodex-tool-contracts/src/tool_definition/testing.rs +++ b/crates/webcodex-tool-contracts/src/tool_definition/testing.rs @@ -131,7 +131,7 @@ pub(super) const DEFINITIONS: &[ToolDefinition] = &[ ) .with_composition_policy(super::ToolCompositionPolicy::Sequential) .with_host_orchestration_hint(super::ToolHostOrchestrationHint::sequential()), - "Portable project validation; Runner owns recipe and argv. Rust/Go: format_check/check/test as supported; Python: pytest test, Ruff check/format_check; Node: native Node >=22.3 script check only (selected check > typecheck > lint), no pre/post; other Node actions unavailable. Node project script contents may modify files or access network; exit status proves only the selected script process outcome, not which lint tool ran or a read-only filesystem. scope selects packages (1..8) or all_packages=true for Rust/Go check/test; scoped formatting fails closed. Rust all-packages requires a Runner-proven Project-root Cargo workspace; Go keeps ./... single-module scope. Python/Node reject scope. test.filter is a bounded Rust substring, Go -run regexp or pytest -k expression; require_tests/min_tests govern proven counts. Python uses existing configured/profile/PATH Python 3 and pytest/Ruff; never installs or falls back. Ruff needs local pyproject.toml [tool.ruff], explicit target-version, no extend; read-only with no fixes, cache, bytecode or output files. Missing environment is unavailable. dependency_policy.mode=locked maps to Cargo --locked/Go -mod=readonly, not offline; Python and formatting reject it. Additive semantics require Runner support. Go forces GO111MODULE=on/GOWORK=off. No arbitrary executable, argv or shell. Project tests may have effects. Long work keeps the same Job; observe pending, never retry. Advanced options remain on cargo_* / go_test.", ) + "Portable project validation; Runner owns recipe and argv. Rust/Go: format_check/check/test as supported; Python: pytest test, Ruff check/format_check; Node >=22.3: native check script only (check > typecheck > lint); no pre/post or other actions. Project scripts may modify files or use network; exit status proves only script outcome, not lint identity or read-only effects. scope selects packages (1..8) or all_packages=true for Rust/Go check/test; scoped formatting fails closed. Rust all-packages requires a Runner-proven Project-root Cargo workspace; Go keeps ./... single-module scope. Python/Node reject scope. test.filter is a bounded Rust substring, Go -run regexp or pytest -k expression; require_tests/min_tests govern proven counts. Python uses existing configured/profile/PATH Python 3 and pytest/Ruff; never installs or falls back. Ruff needs local pyproject.toml [tool.ruff], explicit target-version, no extend; read-only with no fixes, cache, bytecode or output files. Missing environment is unavailable. dependency_policy.mode=locked maps to Cargo --locked/Go -mod=readonly, not offline; Python and formatting reject it. Additive semantics require Runner support. Go forces GO111MODULE=on/GOWORK=off. No arbitrary executable, argv or shell. Project tests may have effects. Long work keeps the same Job; observe pending, never retry. Advanced options remain on cargo_* / go_test.", ) .with_execution(super::ToolExecutionContract::new( super::ToolExecutionForm::StructuredValidation, super::ToolExecutionLifetime::Runner, From 170396d17a8c2d4faa009c19b5b5045f6b807e91 Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:44:24 +0800 Subject: [PATCH 5/5] fix(tooling): bound project validation callable purpose --- crates/webcodex-tool-contracts/src/tool_definition/testing.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/webcodex-tool-contracts/src/tool_definition/testing.rs b/crates/webcodex-tool-contracts/src/tool_definition/testing.rs index 26666133a..24527aae0 100644 --- a/crates/webcodex-tool-contracts/src/tool_definition/testing.rs +++ b/crates/webcodex-tool-contracts/src/tool_definition/testing.rs @@ -131,7 +131,7 @@ pub(super) const DEFINITIONS: &[ToolDefinition] = &[ ) .with_composition_policy(super::ToolCompositionPolicy::Sequential) .with_host_orchestration_hint(super::ToolHostOrchestrationHint::sequential()), - "Portable project validation; Runner owns recipe and argv. Rust/Go: format_check/check/test as supported; Python: pytest test, Ruff check/format_check; Node >=22.3: native check script only (check > typecheck > lint); no pre/post or other actions. Project scripts may modify files or use network; exit status proves only script outcome, not lint identity or read-only effects. scope selects packages (1..8) or all_packages=true for Rust/Go check/test; scoped formatting fails closed. Rust all-packages requires a Runner-proven Project-root Cargo workspace; Go keeps ./... single-module scope. Python/Node reject scope. test.filter is a bounded Rust substring, Go -run regexp or pytest -k expression; require_tests/min_tests govern proven counts. Python uses existing configured/profile/PATH Python 3 and pytest/Ruff; never installs or falls back. Ruff needs local pyproject.toml [tool.ruff], explicit target-version, no extend; read-only with no fixes, cache, bytecode or output files. Missing environment is unavailable. dependency_policy.mode=locked maps to Cargo --locked/Go -mod=readonly, not offline; Python and formatting reject it. Additive semantics require Runner support. Go forces GO111MODULE=on/GOWORK=off. No arbitrary executable, argv or shell. Project tests may have effects. Long work keeps the same Job; observe pending, never retry. Advanced options remain on cargo_* / go_test.", ) + "Runner owns project recipes and argv. Rust/Go: format_check/check/test as supported; Python: pytest test, Ruff check/format_check; Node >=22.3: native check script only (check > typecheck > lint); no pre/post or other actions. Project scripts may modify files or use network; exit status proves only script outcome, not lint identity or read-only effects. scope selects packages (1..8) or all_packages=true for Rust/Go check/test; scoped formatting fails closed. Rust all-packages requires a Runner-proven Project-root Cargo workspace; Go keeps ./... single-module scope. Python/Node reject scope. test.filter is a bounded Rust substring, Go -run regexp or pytest -k expression; require_tests/min_tests govern proven counts. Python uses existing configured/profile/PATH Python 3 and pytest/Ruff; never installs or falls back. Ruff needs local pyproject.toml [tool.ruff], explicit target-version, no extend; read-only with no fixes, cache, bytecode or output files. Missing environment is unavailable. dependency_policy.mode=locked maps to Cargo --locked/Go -mod=readonly, not offline; Python and formatting reject it. Additive semantics require Runner support. Go forces GO111MODULE=on/GOWORK=off. No arbitrary executable, argv or shell. Project tests may have effects. Long work keeps the same Job; observe pending, never retry. Advanced options remain on cargo_* / go_test.", ) .with_execution(super::ToolExecutionContract::new( super::ToolExecutionForm::StructuredValidation, super::ToolExecutionLifetime::Runner,