From 0a01d4784120c3891459e41e07b546d7843e076f Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:18:47 +0800 Subject: [PATCH 01/12] docs(project): freeze bounded formatting lifecycle contract --- .../project-format-lifecycle.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/implementation/project-format-lifecycle.md diff --git a/docs/implementation/project-format-lifecycle.md b/docs/implementation/project-format-lifecycle.md new file mode 100644 index 000000000..fb2cffc54 --- /dev/null +++ b/docs/implementation/project-format-lifecycle.md @@ -0,0 +1,49 @@ +# Project formatting lifecycle — #962 contract freeze + +Status: **pre-implementation v1 contract and regression boundary**. This document belongs to the eventual full feature PR; it is **not** a standalone planning PR. Base: upstream/main `8c7a1c3fd1cf8baeac40652ca8b252cc89e2c685` (includes #1004). + +## Product goal and authority + +Expose **one** ordinary `project_format` lifecycle that actually applies supported formatters and reports observed mutation truth, rather than requiring callers to choose `cargo_fmt` or an unconstrained process. Keep `project_validate(action=format_check)` separate and unchanged. + +Initial backend target: Rust/Cargo and Python/Ruff, **only if both satisfy this contract**. If a backend cannot prove bounded selection and truthful effects, fail closed or defer that backend rather than weakening the contract for parity. No Node/Go, arbitrary script, user-provided executable/argv, formatter installation, network fallback, workspace-wide exclusions, or new model-visible aliases. + +An intentional writer MUST be independently authorized as a source mutation (`project:write`) and as Runner execution (`job:run`) under canonical permission/Runner capability checks. Public effect = **Mutate**; idempotency must not imply safe replay. This is **not** read-only validation, and no status code or truncated stdout alone proves what files changed. Do not rewrite legacy `cargo_fmt` behavior without a separate demonstrated compatibility need. + +## Single execution contract + +1. Model inputs are the exact registered Project, optional project-relative cwd, closed adapter choice (`auto|rust|python`) and bounded timing only. No options implying arbitrary command execution. Auto must fail closed on ambiguous or mismatched recipes. +2. Runner resolves the owning recipe root, selected formatter/configuration and fixed argv, validates containment and bounded manifest inputs, computes a deterministic plan identity/provenance. Unsupported versions or missing installed tooling are **not started**; never install or silently switch executables. +3. The admission path and the worker immediately before execution independently re-resolve the plan against the authoritative Project root and manifest/lock/config/target facts. Invalidated plans never write. +4. A single Runner-owned execution identity governs the precheck, optional mutation, and post-execution evidence. Precheck passes -> known no-op only if a bounded, complete file witness establishes no mutation; stable formatter diff -> mutate once; other precheck results -> no mutation. Never run a second independent operation after a lost first result. Preserve same Job for asynchronous continuation, cancellation, timeouts and disconnects. +5. Before *potential* mutation, collect a **bounded** manifest/source witness of the exact permitted candidate files (canonical path, regular-file type, size/content digest). Reject links, escaped paths, unsupported source topologies or oversized scans instead of following them. After the operation compare against a complete bounded witness. Only complete trustworthy before/after comparisons may establish `state_changed=true|false`. Missing/partial/changed file set, concurrent interference, truncated observation, uncertain dispatch and incomplete postflight -> unknown; never infer `state_changed=true` from exit 0. Explicitly do not claim OS-level confinement of a formatter binary or all external filesystem effects. +6. Formatter execution is project-authored toolchain code: a successful exit attests to the selected native formatter run, not independent assurance of source safety. Ruff retains pinned local pyproject.toml, explicit target-version, rejected extend, isolated interpreter/module and cache/bytecode/output-file safeguards; `cargo fmt` retains its native mode/edition and project-root constraints. No public test-run counts or validation diagnostics are invented. +7. The existing `edit_project_files` canonical mutation receipt remains a separate, authoritative producer. The new operation must not synthesize that receipt or weaken the Code Mode dependent-validation gate. Canonical mutation truth is captured internally **before** model-result projection, and result fields stay minimal. +8. An unknown outcome requires observing the original Job and inspecting workspace state before any new write. No automatic retry, re-dispatch, detached duplicate, ambient SSH resource, or shell fallback. Maintain version/capability and replacement-runner fail-closed behavior. + +## Regression boundary (must be in the same feature PR) + +| ID | Requirement | +| --- | --- | +| PF01 | ToolSpec, ToolCall, schema and metadata recognize one `project_format` Mutate operation, with both write+execute permissions. | +| PF02 | Missing/legacy Runner capability and unauthorized scopes reject without dispatch, including auto backend. | +| PF03 | Project/cwd exact authority, explicit vs auto adapter, nested/ambiguous recipes and mismatched markers reject correctly. | +| PF04 | Rust/Ruff supported version, fixed executable/argv and no user-provided script, environment or dependency installation. | +| PF05 | Ruff config nonregular/extend/missing target-version and missing interpreter/module fail closed. | +| PF06 | Bounded files, path traversal, symlink, external workspace/project members and oversized content reject before mutation. | +| PF07 | Clean precheck performs no mutation and reports a proven no-op, not a fabricated diff count. | +| PF08 | Stable native diff leads to exactly one formatting pass and a demonstrably changed content witness. | +| PF09 | Precheck tool failure, timeout or ambiguous exit never starts mutation. | +| PF10 | Manifest, selected source file, cwd, root, registered Project or configuration changes after planning, while queued, or before worker start reject stale plan. | +| PF11 | Missing/partial after-witness, a changed candidate set, concurrent source changes or formatter partial failure do not invent known change truth. | +| PF12 | Complete known *zero* change and complete known *positive* change remain distinguishable from unknown outcomes. | +| PF13 | Worker timeout/cancel/Runner interruption uses original Job identity and no effect redispatch or blanket retry. | +| PF14 | Model-facing projection, full/sparse Job result, Session/Window correlation and canonical internal `state_changed` do not disagree. | +| PF15 | Code Mode mutation accounting respects authoritative internal facts and does not masquerade as `edit_project_files`. | +| PF16 | Other project validation/build paths and existing `cargo_fmt` remain unchanged and cross-platform builds/tests pass. | + +## Implementation and review sequence + +Within the **same** multi-commit feature branch and one future PR: first review the frozen contract against code; then typed contract/schemas/auth/capability; Runner planning/admission/source witnesses; durable execution/result truth and Host integration; focused tests + actual formatter probes + docs; self-review + independent Codex/agy review; fork full CI for exact final SHA. **Do not open an upstream PR without user approval** and complete review/CI evidence. + +Open preimplementation gates: identify the exact existing durable Job and result-truth extension points, prove the source-witness candidate set can cover native Rust/Cargo and Ruff without changing unrelated files, and verify how Runner-side mutation facts survive Job observation. Failure of any gate requires a narrower honest capability, not invented guarantees. From a826d86cf5fe2956b37d5256c91d661f411c2a89 Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:23:19 +0800 Subject: [PATCH 02/12] docs(project): bind format v1 to explicit guarded source files --- .../project-format-lifecycle.md | 73 +++++++++---------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/docs/implementation/project-format-lifecycle.md b/docs/implementation/project-format-lifecycle.md index fb2cffc54..dcbedf1bd 100644 --- a/docs/implementation/project-format-lifecycle.md +++ b/docs/implementation/project-format-lifecycle.md @@ -1,49 +1,46 @@ -# Project formatting lifecycle — #962 contract freeze +# Bounded project formatting lifecycle (#962) -Status: **pre-implementation v1 contract and regression boundary**. This document belongs to the eventual full feature PR; it is **not** a standalone planning PR. Base: upstream/main `8c7a1c3fd1cf8baeac40652ca8b252cc89e2c685` (includes #1004). +**Contract freeze v1 (2026-10-10)**. Base: `8c7a1c3fd1cf8baeac40652ca8b252cc89e2c685`; this design and implementation live in **one complete PR**, not separate design/API/runtime PRs. Preliminary independent source audit found full-project Cargo/Ruff output-set discovery cannot be proven by simply scanning source roots; v1 therefore selects explicit files. -## Product goal and authority +## Product and scope -Expose **one** ordinary `project_format` lifecycle that actually applies supported formatters and reports observed mutation truth, rather than requiring callers to choose `cargo_fmt` or an unconstrained process. Keep `project_validate(action=format_check)` separate and unchanged. +Expose a model-facing `project_format` with exact Runner Project, optional project-relative cwd, adapter `auto|rust|python`, and **required** bounded ordered file paths (1..8) under the selected recipe root. File selection is explicit; no implicit workspace/project traversal, package-manager script selection or arbitrary CLI flags. Rust `.rs` and Python `.py/.pyi` are supported only when their installed formatter and local configuration satisfy the closed profile. More expansive project/workspace formatting is unavailable, not silently approximated. -Initial backend target: Rust/Cargo and Python/Ruff, **only if both satisfy this contract**. If a backend cannot prove bounded selection and truthful effects, fail closed or defer that backend rather than weakening the contract for parity. No Node/Go, arbitrary script, user-provided executable/argv, formatter installation, network fallback, workspace-wide exclusions, or new model-visible aliases. +The tool is an intentional source **Mutate** operation with non-idempotent dispatch and both `project:write` and `job:run` authorization. It is distinct from `project_validate(action=format_check)`, existing `cargo_fmt`, and the canonical `edit_project_files` receipt; do not change their behavior or fake their authority. A formatting subprocess is not an OS sandbox or an assertion that no other filesystem effects are possible. -An intentional writer MUST be independently authorized as a source mutation (`project:write`) and as Runner execution (`job:run`) under canonical permission/Runner capability checks. Public effect = **Mutate**; idempotency must not imply safe replay. This is **not** read-only validation, and no status code or truncated stdout alone proves what files changed. Do not rewrite legacy `cargo_fmt` behavior without a separate demonstrated compatibility need. +## Trusted execution -## Single execution contract +- Runner resolves exact registered Project and nearest unambiguous recipe root, refuses cross-root, symlink and nonregular inputs; filenames have length/count bounds, files have per-file and total-byte caps, and inputs stay private. +- Runner computes a closed plan from project/cwd/adapter/selected files, bounded manifest/config, exact fixed command choices and content witnesses. The plan and files are revalidated at admission and immediately before worker execution. Any stale plan is rejected before writes. +- Rust path: selected files only; run trusted installed `rustfmt` on **stdin**, with `--emit=stdout`, fixed edition from bounded local Cargo manifest and `skip_children=true`; project custom rustfmt configs and unsupported source/manifest topology are unavailable unless an exact bounded configuration rule is independently proven. This is **not** workspace `cargo fmt`. +- Python path: selected files only; use existing Runner-authorized isolated Python interpreter and installed Ruff module. Run `python -I -B -m ruff format --no-cache --config pyproject.toml --stdin-filename -` with bounded UTF-8 stdin/stdout; inherit the existing Ruff manifest requirements (local `[tool.ruff]`, explicit target-version, no `extend`) and environment sanitization, with no installs/fallback. Validate actual tool version/behavior before adding backend support. Treat stderr failures/oversized output as unknown and never write. +- The native formatters generate **candidate bytes**, never edit project files directly. Compare exact original bytes: identical candidate -> known no-op. If changed, Runner performs guarded compare-and-write to each exact selected file, verifying file type, canonical path and source content immediately before commit. Multi-file write is not falsely described as atomic; on a partial failure, interrupted write, changed path/contents, timeout or lost receipt, mutation truth becomes **unknown**, not false/success. Only complete verified outcomes return `state_changed=true|false`. +- All formatting phases belong to **one durable Runner Job**, created before execution. At most one write attempt per file per Job. Sync completion may return a terminal result; pending returns the **same** Job for observe/recovery. Runner persists a bounded three-state mutation report in the canonical Job/receipt before projecting it to the model. Host kernel may capture the authoritative `state_changed` before projection, never infer it from user-visible status or exit 0. Crash windows remain unknowable; **at-most-once admission, not cross-crash exactly-once completion**. +- Preserve source mutation observation, session identity, runner capability gates, replacement Runner fail-closed behavior and existing Code Mode dependent-validation gate (which still requires `edit_project_files`). Never auto retry or redispatch a mutating Job. No network permission is inferred from a successful formatter. -1. Model inputs are the exact registered Project, optional project-relative cwd, closed adapter choice (`auto|rust|python`) and bounded timing only. No options implying arbitrary command execution. Auto must fail closed on ambiguous or mismatched recipes. -2. Runner resolves the owning recipe root, selected formatter/configuration and fixed argv, validates containment and bounded manifest inputs, computes a deterministic plan identity/provenance. Unsupported versions or missing installed tooling are **not started**; never install or silently switch executables. -3. The admission path and the worker immediately before execution independently re-resolve the plan against the authoritative Project root and manifest/lock/config/target facts. Invalidated plans never write. -4. A single Runner-owned execution identity governs the precheck, optional mutation, and post-execution evidence. Precheck passes -> known no-op only if a bounded, complete file witness establishes no mutation; stable formatter diff -> mutate once; other precheck results -> no mutation. Never run a second independent operation after a lost first result. Preserve same Job for asynchronous continuation, cancellation, timeouts and disconnects. -5. Before *potential* mutation, collect a **bounded** manifest/source witness of the exact permitted candidate files (canonical path, regular-file type, size/content digest). Reject links, escaped paths, unsupported source topologies or oversized scans instead of following them. After the operation compare against a complete bounded witness. Only complete trustworthy before/after comparisons may establish `state_changed=true|false`. Missing/partial/changed file set, concurrent interference, truncated observation, uncertain dispatch and incomplete postflight -> unknown; never infer `state_changed=true` from exit 0. Explicitly do not claim OS-level confinement of a formatter binary or all external filesystem effects. -6. Formatter execution is project-authored toolchain code: a successful exit attests to the selected native formatter run, not independent assurance of source safety. Ruff retains pinned local pyproject.toml, explicit target-version, rejected extend, isolated interpreter/module and cache/bytecode/output-file safeguards; `cargo fmt` retains its native mode/edition and project-root constraints. No public test-run counts or validation diagnostics are invented. -7. The existing `edit_project_files` canonical mutation receipt remains a separate, authoritative producer. The new operation must not synthesize that receipt or weaken the Code Mode dependent-validation gate. Canonical mutation truth is captured internally **before** model-result projection, and result fields stay minimal. -8. An unknown outcome requires observing the original Job and inspecting workspace state before any new write. No automatic retry, re-dispatch, detached duplicate, ambient SSH resource, or shell fallback. Maintain version/capability and replacement-runner fail-closed behavior. +## Regression boundary — included in the same feature PR -## Regression boundary (must be in the same feature PR) +| ID | Acceptance | +|---|---| +| PF01 | ToolSpec/ToolCall/input-output schema has one closed `project_format` and Mutate + ProjectWrite + JobRun authority, non-idempotent. | +| PF02 | Missing capability, old Runner, wrong auth, changed Runner identity and unauthorized project reject without dispatch. | +| PF03 | Recipe/cwd auto vs explicit, ambiguous root, cross-root path, traversal, Windows separators, control/NUL and extension mismatch fail closed. | +| PF04 | Too many, duplicate, oversized, unreadable, symlink or nonregular file targets reject without writing. | +| PF05 | Valid Rust edition selection and fixed stdin/stdout rustfmt behavior; external/config-extended/custom rustfmt topology unavailable. | +| PF06 | Ruff missing/unsupported interpreter/module, pyproject missing target, invalid/extend config, injected output/cache env fail closed. | +| PF07 | Output identical to input proves no-op without write, not a guessed zero. | +| PF08 | Formatter candidate differs and guarded write commits only selected files, proving positive change. | +| PF09 | Manifest/file/source change between plan and enqueue/worker rejects; between candidate and guarded write detects conflict. | +| PF10 | Formatter failure/truncation, unexpected stdout, concurrent modification, postwrite observation failure and multi-file partial failure never claim known full completion. | +| PF11 | Cancellation, timeout, dropped Server response, reconnection and replacement use original Job; unknown cannot authorize replay. | +| PF12 | Durable terminal report and synchronous result retain the same mutation truth; missing old receipt defaults unknown. | +| PF13 | Kernel pre-projection canonical truth, model sparse result, Code Mode receipt and source invalidation preserve authority boundaries. | +| PF14 | Exact failure kind, no test counts or validation-summary reuse; process logs and model output bounded. | +| PF15 | Existing `project_validate`, `project_build`, `cargo_fmt`, editors and unrelated languages stay unchanged. | +| PF16 | Focused regression, native formatter fixtures, Windows/Linux/macOS CI, independent reviews and fork full CI before upstream PR. | -| ID | Requirement | -| --- | --- | -| PF01 | ToolSpec, ToolCall, schema and metadata recognize one `project_format` Mutate operation, with both write+execute permissions. | -| PF02 | Missing/legacy Runner capability and unauthorized scopes reject without dispatch, including auto backend. | -| PF03 | Project/cwd exact authority, explicit vs auto adapter, nested/ambiguous recipes and mismatched markers reject correctly. | -| PF04 | Rust/Ruff supported version, fixed executable/argv and no user-provided script, environment or dependency installation. | -| PF05 | Ruff config nonregular/extend/missing target-version and missing interpreter/module fail closed. | -| PF06 | Bounded files, path traversal, symlink, external workspace/project members and oversized content reject before mutation. | -| PF07 | Clean precheck performs no mutation and reports a proven no-op, not a fabricated diff count. | -| PF08 | Stable native diff leads to exactly one formatting pass and a demonstrably changed content witness. | -| PF09 | Precheck tool failure, timeout or ambiguous exit never starts mutation. | -| PF10 | Manifest, selected source file, cwd, root, registered Project or configuration changes after planning, while queued, or before worker start reject stale plan. | -| PF11 | Missing/partial after-witness, a changed candidate set, concurrent source changes or formatter partial failure do not invent known change truth. | -| PF12 | Complete known *zero* change and complete known *positive* change remain distinguishable from unknown outcomes. | -| PF13 | Worker timeout/cancel/Runner interruption uses original Job identity and no effect redispatch or blanket retry. | -| PF14 | Model-facing projection, full/sparse Job result, Session/Window correlation and canonical internal `state_changed` do not disagree. | -| PF15 | Code Mode mutation accounting respects authoritative internal facts and does not masquerade as `edit_project_files`. | -| PF16 | Other project validation/build paths and existing `cargo_fmt` remain unchanged and cross-platform builds/tests pass. | +## Execution sequence -## Implementation and review sequence +Implement **vertically within one branch/one PR**: Core typed plan and durable mutation report → Runner plan/probe/admission/guarded writer → Runner Job protocol/persistence/recovery → Registry capability/permission → ToolCall/ToolSpec/Host projection → tests and real formatter cases → review/Codex/agy/fork complete CI. No new abstraction duplicating `project_build` planning, no universal task tool, no implicit full-workspace scanning. If a critical source-truth gate cannot be closed, keep feature branch unsubmitted and report exact blocker; do not ship a misleading API. -Within the **same** multi-commit feature branch and one future PR: first review the frozen contract against code; then typed contract/schemas/auth/capability; Runner planning/admission/source witnesses; durable execution/result truth and Host integration; focused tests + actual formatter probes + docs; self-review + independent Codex/agy review; fork full CI for exact final SHA. **Do not open an upstream PR without user approval** and complete review/CI evidence. - -Open preimplementation gates: identify the exact existing durable Job and result-truth extension points, prove the source-witness candidate set can cover native Rust/Cargo and Ruff without changing unrelated files, and verify how Runner-side mutation facts survive Job observation. Failure of any gate requires a narrower honest capability, not invented guarantees. +No automatic push, fork PR, or upstream PR until staged evidence is ready and upstream submission is explicitly approved. From 23103527d33bf9476044b6a0f75f6cee90ab54a2 Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:05:12 +0800 Subject: [PATCH 03/12] feat(project): add bounded format planning and candidate generation --- crates/webcodex-core/src/lib.rs | 3 + crates/webcodex-core/src/project_format.rs | 210 +++++++ .../webcodex-core/src/project_format_tests.rs | 122 ++++ crates/webcodex-core/src/runner_operation.rs | 27 + crates/webcodex-core/src/runner_protocol.rs | 8 + crates/webcodex-runner-config/src/lib.rs | 1 + .../src/webcodex_runner/dispatch.rs | 5 + .../src/webcodex_runner/file_access.rs | 529 ++++++++++++++++++ .../src/webcodex_runner/mod.rs | 4 + .../src/webcodex_runner/project_format.rs | 348 ++++++++++++ .../webcodex_runner/project_format_tests.rs | 322 +++++++++++ .../runner_instruction_tests.rs | 21 +- .../webcodex_runner/runner_instructions.rs | 466 +-------------- .../webcodex_runner/transport/registration.rs | 3 + src/runner_quic.rs | 1 + src/runner_ws.rs | 1 + src/tool_runtime/tests/metadata.rs | 1 + 17 files changed, 1600 insertions(+), 472 deletions(-) create mode 100644 crates/webcodex-core/src/project_format.rs create mode 100644 crates/webcodex-core/src/project_format_tests.rs create mode 100644 crates/webcodex-runner/src/webcodex_runner/file_access.rs create mode 100644 crates/webcodex-runner/src/webcodex_runner/project_format.rs create mode 100644 crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs diff --git a/crates/webcodex-core/src/lib.rs b/crates/webcodex-core/src/lib.rs index 11e861a60..a21c5a382 100644 --- a/crates/webcodex-core/src/lib.rs +++ b/crates/webcodex-core/src/lib.rs @@ -19,6 +19,9 @@ pub mod memory_contract; pub mod model_reference; pub mod plugin; pub mod project_build; +pub mod project_format; +#[cfg(test)] +mod project_format_tests; pub mod project_context_contract; pub mod project_instructions; pub mod project_listing; diff --git a/crates/webcodex-core/src/project_format.rs b/crates/webcodex-core/src/project_format.rs new file mode 100644 index 000000000..f11d4972a --- /dev/null +++ b/crates/webcodex-core/src/project_format.rs @@ -0,0 +1,210 @@ +//! Closed, explicit-file project formatting plans. Source and candidate bytes +//! remain private to the Runner; plans carry bounded content witnesses only. + +use crate::runner_protocol::ShellProcessArgv; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +pub const PROJECT_FORMAT_MAX_FILES: usize = 8; +pub const PROJECT_FORMAT_FILE_MAX_BYTES: usize = 64 * 1024; +pub const PROJECT_FORMAT_TOTAL_MAX_BYTES: usize = 256 * 1024; +pub const PROJECT_FORMAT_MANIFEST_MAX_BYTES: usize = 256 * 1024; +pub const PROJECT_FORMAT_PLAN_MAX_BYTES: usize = 32 * 1024; +pub const PROJECT_FORMAT_TIMEOUT_MAX_SECS: u64 = 120; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ProjectFormatAdapter { + #[default] + Auto, + Rust, + Python, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectFormatRequest { + pub project_id: String, + pub cwd: Option, + #[serde(default)] + pub adapter: ProjectFormatAdapter, + /// Ordered paths relative to the registered Project, not the recipe cwd. + pub files: Vec, +} + +pub fn valid_format_relative_path(path: &str, directory: bool) -> bool { + (directory && path == ".") + || (!path.is_empty() + && path.len() <= 1024 + && !path.contains(['\\', ':']) + && !path.chars().any(char::is_control) + && path.split('/').all(|part| !matches!(part, "" | "." | ".."))) +} + +impl ProjectFormatRequest { + pub fn validate(&self) -> Result<(), &'static str> { + if self.project_id.is_empty() + || self.project_id.len() > 200 + || self.project_id.chars().any(char::is_control) + || !valid_format_relative_path(self.cwd.as_deref().unwrap_or("."), true) + || !(1..=PROJECT_FORMAT_MAX_FILES).contains(&self.files.len()) + { + return Err("invalid project format request"); + } + let mut backend = None; + for (index, file) in self.files.iter().enumerate() { + if !valid_format_relative_path(file, false) || self.files[..index].contains(file) { + return Err("invalid project format files"); + } + let selected = if file.ends_with(".rs") { + ProjectFormatAdapter::Rust + } else if file.ends_with(".py") || file.ends_with(".pyi") { + ProjectFormatAdapter::Python + } else { + return Err("unsupported project format extension"); + }; + if backend.is_some_and(|backend| backend != selected) + || (self.adapter != ProjectFormatAdapter::Auto && self.adapter != selected) + { + return Err("project format scope mismatch"); + } + backend = Some(selected); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "backend", rename_all = "snake_case", deny_unknown_fields)] +pub enum ProjectFormatProfile { + Rust { edition: String }, + Python { target_version: String }, +} + +impl ProjectFormatProfile { + pub fn adapter(&self) -> ProjectFormatAdapter { + match self { + Self::Rust { .. } => ProjectFormatAdapter::Rust, + Self::Python { .. } => ProjectFormatAdapter::Python, + } + } + + pub fn is_valid(&self) -> bool { + match self { + Self::Rust { edition } => matches!(edition.as_str(), "2015" | "2018" | "2021" | "2024"), + Self::Python { target_version } => matches!( + target_version.as_str(), + "py37" | "py38" | "py39" | "py310" | "py311" | "py312" | "py313" | "py314" + ), + } + } + + /// The Runner must resolve/probe the trusted executable separately. This + /// command shape never accepts executable, flags or scripts from the model. + pub fn process(&self, recipe_relative_file: &str) -> Result { + if !self.is_valid() || !valid_format_relative_path(recipe_relative_file, false) { + return Err("invalid project format profile"); + } + let (executable, args) = match self { + Self::Rust { edition } if recipe_relative_file.ends_with(".rs") => ( + "rustfmt", + vec!["--emit=stdout".into(), format!("--edition={edition}"), "--config".into(), "skip_children=true".into()], + ), + Self::Python { .. } if recipe_relative_file.ends_with(".py") || recipe_relative_file.ends_with(".pyi") => ( + "python", + vec!["-I", "-B", "-m", "ruff", "format", "--no-cache", "--config", "pyproject.toml", "--stdin-filename", recipe_relative_file, "-"] + .into_iter().map(str::to_string).collect(), + ), + _ => return Err("project format scope mismatch"), + }; + Ok(ShellProcessArgv { executable: executable.into(), args }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectFormatFileWitness { + pub path: String, + pub bytes: usize, + pub sha256: String, + pub identity_digest: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectFormatPlan { + pub request: ProjectFormatRequest, + pub recipe_root: String, + pub root_digest: String, + pub manifest_digest: String, + pub profile: ProjectFormatProfile, + pub files: Vec, +} + +impl ProjectFormatPlan { + pub fn is_valid(&self) -> bool { + let digest = |s: &str| s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)); + self.request.validate().is_ok() + && valid_format_relative_path(&self.recipe_root, true) + && digest(&self.root_digest) + && digest(&self.manifest_digest) + && self.profile.is_valid() + && (self.request.adapter == ProjectFormatAdapter::Auto || self.request.adapter == self.profile.adapter()) + && self.files.len() == self.request.files.len() + && self.files.iter().zip(&self.request.files).all(|(witness, path)| { + witness.path == *path + && witness.bytes <= PROJECT_FORMAT_FILE_MAX_BYTES + && digest(&witness.sha256) + && digest(&witness.identity_digest) + && self.recipe_relative_file(path).is_some_and(|relative| self.profile.process(relative).is_ok()) + }) + && self.files.iter().try_fold(0usize, |total, file| total.checked_add(file.bytes)).is_some_and(|total| total <= PROJECT_FORMAT_TOTAL_MAX_BYTES) + } + + pub fn recipe_relative_file<'a>(&self, path: &'a str) -> Option<&'a str> { + if self.recipe_root == "." { + Some(path) + } else { + path.strip_prefix(&self.recipe_root)?.strip_prefix('/') + } + } + + pub fn digest(&self) -> Result { + if !self.is_valid() { + return Err("invalid project format plan"); + } + let bytes = serde_json::to_vec(self).map_err(|_| "invalid project format plan")?; + if bytes.len() > PROJECT_FORMAT_PLAN_MAX_BYTES { + return Err("project format plan exceeds payload bound"); + } + Ok(format!("{:x}", Sha256::digest(bytes))) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)] +pub enum ProjectFormatPlanningResult { + Ready { plan: ProjectFormatPlan }, + Unavailable { code: String }, +} + +/// Executor-owned evidence, never derived from process exit status. Missing +/// historical evidence is unknown, including after a crash or lost delivery. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProjectFormatMutationReport { + #[default] + Unknown, + Unchanged, + Changed, +} + +impl ProjectFormatMutationReport { + pub fn state_changed(self) -> Option { + match self { + Self::Unknown => None, + Self::Unchanged => Some(false), + Self::Changed => Some(true), + } + } +} diff --git a/crates/webcodex-core/src/project_format_tests.rs b/crates/webcodex-core/src/project_format_tests.rs new file mode 100644 index 000000000..64a22432d --- /dev/null +++ b/crates/webcodex-core/src/project_format_tests.rs @@ -0,0 +1,122 @@ +use crate::project_format::*; +use crate::runner_operation::{RunnerInvocationMetadata, RunnerOperation}; +use crate::runner_protocol::{RunnerCapabilities, RunnerCapabilityId, RunnerRequest}; + +fn request() -> ProjectFormatRequest { + ProjectFormatRequest { + project_id: "demo".into(), cwd: None, adapter: ProjectFormatAdapter::Auto, + files: vec!["src/main.rs".into()], + } +} + +fn plan() -> ProjectFormatPlan { + ProjectFormatPlan { + request: request(), recipe_root: ".".into(), root_digest: "a".repeat(64), + manifest_digest: "b".repeat(64), profile: ProjectFormatProfile::Rust { edition: "2021".into() }, + files: vec![ProjectFormatFileWitness { path: "src/main.rs".into(), bytes: 12, sha256: "c".repeat(64), identity_digest: "d".repeat(64) }], + } +} + +#[test] +fn project_format_request_is_closed_bounded_and_single_language() { + assert!(request().validate().is_ok()); + for files in [ + vec![], vec!["x.rs"; 9], vec!["x.rs", "x.rs"], vec!["x.rs", "x.py"], + vec!["../x.rs"], vec!["/x.rs"], vec!["C:/x.rs"], vec!["a\\x.rs"], + vec!["./x.rs"], vec!["a//x.rs"], vec!["x\n.rs"], vec!["x\0.rs"], + vec!["x.js"], vec!["x.RS"], + ] { + let mut invalid = request(); + invalid.files = files.into_iter().map(str::to_string).collect(); + assert!(invalid.validate().is_err()); + } + let mut invalid = request(); + invalid.files = vec![format!("{}.rs", "x".repeat(1024))]; + assert!(invalid.validate().is_err()); + invalid = request(); + invalid.adapter = ProjectFormatAdapter::Python; + assert!(invalid.validate().is_err()); + for field in ["argv", "exec", "command", "script", "scope", "all_files"] { + let mut json = serde_json::to_value(request()).unwrap(); + json[field] = serde_json::json!("injected"); + assert!(serde_json::from_value::(json).is_err()); + } +} + +#[test] +fn project_format_plan_fences_order_bytes_profile_and_recipe_root() { + let valid = plan(); + let digest = valid.digest().unwrap(); + for change in 0..4 { + let mut updated = valid.clone(); + match change { + 0 => updated.files[0].sha256 = "d".repeat(64), + 1 => updated.files[0].bytes += 1, + 2 => updated.manifest_digest = "d".repeat(64), + _ => updated.profile = ProjectFormatProfile::Rust { edition: "2024".into() }, + } + assert_ne!(updated.digest().unwrap(), digest); + } + for change in 0..6 { + let mut invalid = valid.clone(); + match change { + 0 => invalid.files[0].bytes = usize::MAX, + 1 => invalid.recipe_root = "nested".into(), + 2 => invalid.files[0].sha256 = "invalid".into(), + 3 => invalid.files[0].path = "other.rs".into(), + 4 => invalid.files.clear(), + _ => invalid.profile = ProjectFormatProfile::Python { target_version: "py311".into() }, + } + assert!(invalid.digest().is_err()); + } + let encoded = serde_json::to_string(&valid).unwrap(); + assert!(encoded.len() < PROJECT_FORMAT_PLAN_MAX_BYTES); + assert!(!encoded.contains("content")); +} + +#[test] +fn project_format_commands_have_only_fixed_stdin_profiles() { + let rust = plan().profile.process("src/main.rs").unwrap(); + assert_eq!(rust.executable, "rustfmt"); + assert_eq!(rust.args, ["--emit=stdout", "--edition=2021", "--config", "skip_children=true"]); + let python = ProjectFormatProfile::Python { target_version: "py311".into() }; + let python = python.process("package/code.pyi").unwrap(); + assert_eq!(python.args, ["-I", "-B", "-m", "ruff", "format", "--no-cache", "--config", "pyproject.toml", "--stdin-filename", "package/code.pyi", "-"]); +} + +#[test] +fn project_format_wire_roundtrip_rejects_execution_and_oversized_payloads() { + let wire = RunnerRequest::from_operation( + RunnerInvocationMetadata { request_id: "r".into(), client_id: "runner".into(), requested_by: "test".into(), created_at: 0 }, + RunnerOperation::PlanProjectFormat(request()), + ).unwrap(); + assert_eq!(wire.kind, "plan_project_format"); + assert!(matches!(wire.decode_operation().unwrap(), RunnerOperation::PlanProjectFormat(decoded) if decoded == request())); + for field in ["command", "stdin", "cwd", "path", "job_id", "content"] { + let mut invalid = wire.clone(); + match field { + "command" => invalid.command = "echo x".into(), + "stdin" => invalid.stdin = Some("input".into()), + "cwd" => invalid.cwd = Some("/tmp".into()), + "path" => invalid.path = Some("x.rs".into()), + "job_id" => invalid.job_id = Some("job".into()), + _ => invalid.content = Some(" ".repeat(PROJECT_FORMAT_PLAN_MAX_BYTES + 1)), + } + assert!(invalid.decode_operation().is_err(), "{field}"); + } +} + +#[test] +fn project_format_capability_and_mutation_truth_default_closed() { + let old: RunnerCapabilities = serde_json::from_str("{}").unwrap(); + assert!(!old.supports(RunnerCapabilityId::ProjectFormat)); + assert_eq!(ProjectFormatMutationReport::default().state_changed(), None); + for (report, changed) in [ + (ProjectFormatMutationReport::Unknown, None), + (ProjectFormatMutationReport::Unchanged, Some(false)), + (ProjectFormatMutationReport::Changed, Some(true)), + ] { + let retained: ProjectFormatMutationReport = serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap(); + assert_eq!(retained.state_changed(), changed); + } +} diff --git a/crates/webcodex-core/src/runner_operation.rs b/crates/webcodex-core/src/runner_operation.rs index ae5488594..5057485a5 100644 --- a/crates/webcodex-core/src/runner_operation.rs +++ b/crates/webcodex-core/src/runner_operation.rs @@ -725,6 +725,7 @@ pub enum RunnerOperation { Browser(RunnerBrowserOperation), PlanProjectValidation(crate::project_validation::ProjectValidationRequest), PlanProjectBuild(crate::project_build::ProjectBuildRequest), + PlanProjectFormat(crate::project_format::ProjectFormatRequest), Validation { payload: ValidationBridgeRequest, timeout_secs: u64, @@ -769,6 +770,7 @@ impl RunnerOperation { Self::Browser(operation) => operation.kind.wire_kind(), Self::PlanProjectValidation(_) => "plan_project_validation", Self::PlanProjectBuild(_) => "plan_project_build", + Self::PlanProjectFormat(_) => "plan_project_format", Self::Validation { .. } => crate::validation_bridge::AGENT_VALIDATION_REQUEST_KIND, Self::Lsp { .. } => crate::lsp_bridge::AGENT_LSP_REQUEST_KIND, Self::PersistentShell(_) => "persistent_shell", @@ -996,6 +998,11 @@ fn encode_operation( wire.content = Some(serde_json::to_string(&payload).map_err(|e| e.to_string())?); wire.timeout_secs = 30; } + RunnerOperation::PlanProjectFormat(payload) => { + payload.validate()?; + wire.content = Some(serde_json::to_string(&payload).map_err(|e| e.to_string())?); + wire.timeout_secs = 30; + } RunnerOperation::Validation { payload, timeout_secs, @@ -1508,6 +1515,26 @@ fn decode_operation(wire: &RunnerRequest) -> Result { payload.validate()?; Ok(RunnerOperation::PlanProjectBuild(payload)) } + "plan_project_format" => { + ensure_special_payloads_absent(wire)?; + ensure_no_file_fields_except_content(wire)?; + if !wire.command.is_empty() + || wire.cwd.is_some() + || wire.stdin.is_some() + || wire.job_context.is_some() + || wire.job_id.is_some() + || wire.shell.is_some() + || wire.login + { + return Err("project format planning contains execution fields".into()); + } + let payload: crate::project_format::ProjectFormatRequest = serde_json::from_str( + bounded_content(wire, crate::project_format::PROJECT_FORMAT_PLAN_MAX_BYTES, "project format request")?, + ) + .map_err(|e| e.to_string())?; + payload.validate()?; + Ok(RunnerOperation::PlanProjectFormat(payload)) + } crate::validation_bridge::AGENT_VALIDATION_REQUEST_KIND => { ensure_only_validation_payload(wire)?; ensure_empty_generic_execution_fields(wire, false)?; diff --git a/crates/webcodex-core/src/runner_protocol.rs b/crates/webcodex-core/src/runner_protocol.rs index 9dbbce725..b791e1603 100644 --- a/crates/webcodex-core/src/runner_protocol.rs +++ b/crates/webcodex-core/src/runner_protocol.rs @@ -559,6 +559,13 @@ runner_capabilities! { #[serde(default, skip_serializing_if = "is_false")] pub project_build_v1: bool = false; } + /// Complete bounded project formatting lifecycle. Planning support alone + /// must never advertise this capability; older Runners default to false. + ProjectFormat => RUNNER_CAPABILITY_PROJECT_FORMAT("project_format_v1"), + v2_baseline = false { + #[serde(default, skip_serializing_if = "is_false")] + pub project_format_v1: bool = false; + } /// Additive portable dependency policy for project build/validation gateways. /// Missing on older Runners is false; policy-bearing plans must fail closed. ProjectDependencyPolicy => RUNNER_CAPABILITY_PROJECT_DEPENDENCY_POLICY("project_dependency_policy_v1"), @@ -2847,6 +2854,7 @@ mod envelope_tests { structured_go_test_json: true, project_validation_v1: false, project_build_v1: false, + project_format_v1: false, project_dependency_policy_v1: false, project_go_single_module_v1: false, project_validation_package_scope_v1: false, diff --git a/crates/webcodex-runner-config/src/lib.rs b/crates/webcodex-runner-config/src/lib.rs index e7a43338e..c9cf717da 100644 --- a/crates/webcodex-runner-config/src/lib.rs +++ b/crates/webcodex-runner-config/src/lib.rs @@ -289,6 +289,7 @@ pub fn generated_runner_config_toml(opts: &RunnerInitOptions) -> Result { + let result = super::project_format::handle(policy, project_registry_dir, &payload); + sink.submit_result_with_metadata(request_id, result, config, runtime) + .map(|_| true) + } RunnerOperation::Validation { payload, .. } => { let result = handle_validation_request( policy, diff --git a/crates/webcodex-runner/src/webcodex_runner/file_access.rs b/crates/webcodex-runner/src/webcodex_runner/file_access.rs new file mode 100644 index 000000000..5e58f89b8 --- /dev/null +++ b/crates/webcodex-runner/src/webcodex_runner/file_access.rs @@ -0,0 +1,529 @@ +//! Pinned non-symlink file reads shared by configured instructions and format planning. + +use super::configured_skills::metadata_is_link_like; +use std::fs::File; +use std::io; +use std::path::Path; + +/// Stable native object identity, retained only as a bounded digest in plans. +pub(crate) fn file_identity(file: &File) -> io::Result> { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let metadata = file.metadata()?; + Ok([metadata.dev().to_be_bytes(), metadata.ino().to_be_bytes()].concat()) + } + #[cfg(windows)] + { + let (volume, id) = windows_file_identity(file)?; + Ok([volume.to_be_bytes().as_slice(), id.as_slice()].concat()) + } + #[cfg(not(any(unix, windows)))] + { + let _ = file; + Err(io::Error::new(io::ErrorKind::Unsupported, "file identity unavailable")) + } +} + +/// Return the number of hard links to the file referenced by the handle. +pub(crate) fn file_link_count(file: &File) -> io::Result { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let metadata = file.metadata()?; + Ok(metadata.nlink()) + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, + }; + let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(file.as_raw_handle() as _, &mut info) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(info.nNumberOfLinks as u64) + } + #[cfg(not(any(unix, windows)))] + { + let _ = file; + Err(io::Error::new(io::ErrorKind::Unsupported, "file link count unavailable")) + } +} + +pub(crate) fn directory_identity(path: &Path) -> io::Result> { + #[cfg(unix)] + { + file_identity(&File::from(open_unix_directory(path)?)) + } + #[cfg(windows)] + { + file_identity(&File::from(open_windows_directory(path)?)) + } + #[cfg(not(any(unix, windows)))] + { + let _ = path; + Err(io::Error::new(io::ErrorKind::Unsupported, "directory identity unavailable")) + } +} + +#[cfg(windows)] +const WINDOWS_FILE_DIRECTORY_FILE: u32 = 0x0000_0001; +#[cfg(windows)] +const WINDOWS_FILE_NON_DIRECTORY_FILE: u32 = 0x0000_0040; +#[cfg(windows)] +const WINDOWS_OBJ_CASE_INSENSITIVE: u32 = 0x0000_0040; +#[cfg(windows)] +const WINDOWS_OBJ_DONT_REPARSE: u32 = 0x0000_1000; + +// File authority names ordinary files, not redirectable filesystem +// trees. Resolve the path through pinned directory handles so an ancestor +// cannot be swapped to a symlink/reparse point between validation and open. +pub(crate) fn open_regular_file(path: &Path) -> io::Result { + #[cfg(unix)] + { + open_regular_file_unix(path, || {}) + } + #[cfg(windows)] + { + open_regular_file_windows(path, || {}) + } + #[cfg(not(any(unix, windows)))] + { + unsupported_regular_file_open(path) + } +} + +#[cfg(any(test, not(any(unix, windows))))] +pub(crate) fn unsupported_regular_file_open(_path: &Path) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "secure configured instruction reads are unsupported on this platform", + )) +} + +#[cfg(unix)] +pub(crate) fn open_regular_file_unix(path: &Path, before_leaf: impl FnOnce()) -> io::Result { + use std::ffi::CString; + use std::os::fd::{AsRawFd, FromRawFd}; + + let parent_path = path + .parent() + .ok_or_else(|| io::Error::other("instruction path must name an absolute file"))?; + let parent = open_unix_directory(parent_path).map_err(|error| { + if error.kind() == io::ErrorKind::NotFound { + io::Error::other("instruction parent is unavailable") + } else { + error + } + })?; + let leaf = path + .file_name() + .ok_or_else(|| io::Error::other("instruction path must name an absolute file"))?; + let leaf = CString::new(std::os::unix::ffi::OsStrExt::as_bytes(leaf)) + .map_err(|_| io::Error::other("instruction path contains NUL"))?; + + before_leaf(); + + let fd = unsafe { + libc::openat( + parent.as_raw_fd(), + leaf.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::NotFound + && !unix_parent_still_current(parent_path, parent.as_raw_fd()) + { + return Err(io::Error::other( + "instruction parent changed during observation", + )); + } + return Err(error); + } + let file = unsafe { File::from_raw_fd(fd) }; + let metadata = file.metadata()?; + if !metadata.is_file() || metadata_is_link_like(&metadata) { + return Err(io::Error::other( + "instruction handle is not an ordinary file", + )); + } + if !unix_parent_still_current(parent_path, parent.as_raw_fd()) { + return Err(io::Error::other( + "instruction parent changed during observation", + )); + } + Ok(file) +} + +#[cfg(unix)] +fn open_unix_directory(path: &Path) -> io::Result { + use std::ffi::CString; + use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; + use std::os::unix::ffi::OsStrExt; + use std::path::Component; + + if !path.is_absolute() { + return Err(io::Error::other("instruction parent must be absolute")); + } + let root_fd = unsafe { libc::open(b"/\0".as_ptr().cast(), unix_directory_open_flags()) }; + if root_fd < 0 { + return Err(io::Error::last_os_error()); + } + let mut directory = unsafe { OwnedFd::from_raw_fd(root_fd) }; + for component in path.components() { + let name = match component { + Component::RootDir | Component::CurDir => continue, + Component::Normal(name) => name, + Component::ParentDir | Component::Prefix(_) => { + return Err(io::Error::other( + "instruction path must be absolute without parent traversal", + )); + } + }; + let name = CString::new(name.as_bytes()) + .map_err(|_| io::Error::other("instruction path contains NUL"))?; + let fd = unsafe { + libc::openat( + directory.as_raw_fd(), + name.as_ptr(), + unix_directory_open_flags(), + ) + }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + directory = unsafe { OwnedFd::from_raw_fd(fd) }; + } + Ok(directory) +} + +#[cfg(unix)] +fn unix_parent_still_current(path: &Path, expected_fd: std::os::fd::RawFd) -> bool { + use std::os::fd::AsRawFd; + + let Ok(current) = open_unix_directory(path) else { + return false; + }; + unix_fd_identity(current.as_raw_fd()) + .zip(unix_fd_identity(expected_fd)) + .is_some_and(|(current, expected)| current == expected) +} + +#[cfg(unix)] +fn unix_fd_identity(fd: std::os::fd::RawFd) -> Option<(u64, u64)> { + let mut stat = std::mem::MaybeUninit::::uninit(); + if unsafe { libc::fstat(fd, stat.as_mut_ptr()) } != 0 { + return None; + } + let stat = unsafe { stat.assume_init() }; + Some((stat.st_dev as u64, stat.st_ino as u64)) +} + +#[cfg(unix)] +fn unix_directory_open_flags() -> libc::c_int { + #[cfg(any(target_os = "linux", target_os = "android"))] + let access = libc::O_PATH; + #[cfg(any( + target_vendor = "apple", + target_os = "freebsd", + target_os = "netbsd", + target_os = "solaris", + target_os = "illumos", + target_os = "aix", + target_os = "fuchsia" + ))] + let access = libc::O_SEARCH; + #[cfg(not(any( + target_os = "linux", + target_os = "android", + target_vendor = "apple", + target_os = "freebsd", + target_os = "netbsd", + target_os = "solaris", + target_os = "illumos", + target_os = "aix", + target_os = "fuchsia" + )))] + let access = libc::O_RDONLY; + + access | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC +} + +#[cfg(windows)] +pub(crate) fn open_regular_file_windows(path: &Path, before_leaf: impl FnOnce()) -> io::Result { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::Storage::FileSystem::{FILE_GENERIC_READ, FILE_SHARE_READ}; + + let parent_path = path + .parent() + .ok_or_else(|| io::Error::other("instruction path must name an absolute file"))?; + let parent = match open_windows_directory(parent_path) { + Ok(handle) => File::from(handle), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Err(io::Error::other("instruction parent is unavailable")); + } + Err(error) => return Err(error), + }; + let parent_identity = windows_file_identity(&parent)?; + let leaf = path + .file_name() + .ok_or_else(|| io::Error::other("instruction path must name an absolute file"))?; + + before_leaf(); + + let file = match windows_nt_open_relative( + parent.as_raw_handle() as HANDLE, + leaf, + FILE_GENERIC_READ, + FILE_SHARE_READ, + WINDOWS_FILE_NON_DIRECTORY_FILE, + ) { + Ok(handle) => File::from(handle), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + if !windows_parent_still_current(parent_path, parent_identity) { + return Err(io::Error::other( + "instruction parent changed during observation", + )); + } + return Err(error); + } + Err(error) => return Err(error), + }; + let metadata = file.metadata()?; + if !metadata.is_file() || metadata_is_link_like(&metadata) { + return Err(io::Error::other( + "instruction handle is not an ordinary file", + )); + } + if !windows_parent_still_current(parent_path, parent_identity) { + return Err(io::Error::other( + "instruction parent changed during observation", + )); + } + Ok(file) +} + +#[cfg(windows)] +fn windows_parent_still_current(path: &Path, expected: (u64, [u8; 16])) -> bool { + open_windows_directory(path) + .map(File::from) + .and_then(|file| windows_file_identity(&file)) + .is_ok_and(|current| current == expected) +} + +#[cfg(windows)] +fn windows_file_identity(file: &File) -> io::Result<(u64, [u8; 16])> { + use std::mem::size_of; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + FileIdInfo, GetFileInformationByHandleEx, FILE_ID_INFO, + }; + + let mut info = std::mem::MaybeUninit::::zeroed(); + if unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle().cast(), + FileIdInfo, + info.as_mut_ptr().cast(), + size_of::() as u32, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + let info = unsafe { info.assume_init() }; + Ok((info.VolumeSerialNumber, info.FileId.Identifier)) +} + +#[cfg(windows)] +fn open_windows_directory(path: &Path) -> io::Result { + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle}; + use std::path::Component; + use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, + FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, + }; + + let mut components = path.components(); + let prefix = match components.next() { + Some(Component::Prefix(prefix)) => prefix, + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "instruction path must be an absolute Windows path", + )) + } + }; + if !matches!(components.next(), Some(Component::RootDir)) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "instruction path must include a Windows root", + )); + } + + let mut root = std::path::PathBuf::from(prefix.as_os_str()); + root.push(r"\"); + let wide = std::os::windows::ffi::OsStrExt::encode_wide(root.as_os_str()) + .chain(std::iter::once(0)) + .collect::>(); + // Resolve only the Windows drive/share root through Win32 namespace rules. + // Every real filesystem descendant is then opened relative to the pinned + // parent handle with OBJ_DONT_REPARSE, so junction/reparse ancestors remain + // fail-closed without rejecting the ordinary DOS drive mapping itself. + let root_handle: HANDLE = unsafe { + CreateFileW( + wide.as_ptr(), + FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + std::ptr::null_mut(), + ) + }; + if root_handle.is_null() || root_handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + let mut directory = unsafe { OwnedHandle::from_raw_handle(root_handle as RawHandle) }; + + for component in components { + let name = match component { + Component::Normal(name) => name, + Component::CurDir => continue, + Component::ParentDir | Component::Prefix(_) | Component::RootDir => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "instruction path must not contain parent traversal", + )) + } + }; + directory = windows_nt_open_relative( + directory.as_raw_handle() as HANDLE, + name, + FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + WINDOWS_FILE_DIRECTORY_FILE, + )?; + } + + Ok(directory) +} + +#[cfg(windows)] +fn windows_nt_open_relative( + root: windows_sys::Win32::Foundation::HANDLE, + name: &std::ffi::OsStr, + desired_access: u32, + share_access: u32, + create_options: u32, +) -> io::Result { + use std::os::windows::ffi::OsStrExt; + + let mut name = name.encode_wide().collect::>(); + windows_nt_open( + root, + &mut name, + desired_access, + share_access, + create_options, + ) +} + +#[cfg(windows)] +fn windows_nt_open( + root: windows_sys::Win32::Foundation::HANDLE, + name: &mut [u16], + desired_access: u32, + share_access: u32, + create_options: u32, +) -> io::Result { + use std::ffi::c_void; + use std::mem::size_of; + use std::os::windows::io::{FromRawHandle, OwnedHandle, RawHandle}; + use std::ptr; + use windows_sys::Win32::Foundation::{ + RtlNtStatusToDosError, HANDLE, INVALID_HANDLE_VALUE, NTSTATUS, UNICODE_STRING, + }; + use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL; + use windows_sys::Win32::System::IO::IO_STATUS_BLOCK; + + const FILE_OPEN: u32 = 1; + const FILE_SYNCHRONOUS_IO_NONALERT: u32 = 0x0000_0020; + const SYNCHRONIZE_ACCESS: u32 = 0x0010_0000; + + #[repr(C)] + struct ObjectAttributes { + length: u32, + root_directory: HANDLE, + object_name: *const UNICODE_STRING, + attributes: u32, + security_descriptor: *const c_void, + security_quality_of_service: *const c_void, + } + + #[link(name = "ntdll")] + unsafe extern "system" { + fn NtCreateFile( + file_handle: *mut HANDLE, + desired_access: u32, + object_attributes: *const ObjectAttributes, + io_status_block: *mut IO_STATUS_BLOCK, + allocation_size: *const i64, + file_attributes: u32, + share_access: u32, + create_disposition: u32, + create_options: u32, + ea_buffer: *const c_void, + ea_length: u32, + ) -> NTSTATUS; + } + + let name_length = u16::try_from(name.len().saturating_mul(size_of::())) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "instruction path is too long"))?; + let object_name = UNICODE_STRING { + Length: name_length, + MaximumLength: name_length, + Buffer: name.as_mut_ptr(), + }; + let object_attributes = ObjectAttributes { + length: size_of::() as u32, + root_directory: root, + object_name: &object_name, + attributes: WINDOWS_OBJ_CASE_INSENSITIVE | WINDOWS_OBJ_DONT_REPARSE, + security_descriptor: ptr::null(), + security_quality_of_service: ptr::null(), + }; + let mut io_status = std::mem::MaybeUninit::::zeroed(); + let mut handle: HANDLE = std::ptr::null_mut(); + let status = unsafe { + NtCreateFile( + &mut handle, + desired_access | SYNCHRONIZE_ACCESS, + &object_attributes, + io_status.as_mut_ptr(), + ptr::null(), + FILE_ATTRIBUTE_NORMAL, + share_access, + FILE_OPEN, + create_options | FILE_SYNCHRONOUS_IO_NONALERT, + ptr::null(), + 0, + ) + }; + if status < 0 { + let code = unsafe { RtlNtStatusToDosError(status) }; + return Err(io::Error::from_raw_os_error(code as i32)); + } + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + return Err(io::Error::other( + "NtCreateFile returned an invalid instruction handle", + )); + } + Ok(unsafe { OwnedHandle::from_raw_handle(handle as RawHandle) }) +} diff --git a/crates/webcodex-runner/src/webcodex_runner/mod.rs b/crates/webcodex-runner/src/webcodex_runner/mod.rs index 920068bc4..f0cb9c44a 100644 --- a/crates/webcodex-runner/src/webcodex_runner/mod.rs +++ b/crates/webcodex-runner/src/webcodex_runner/mod.rs @@ -14,6 +14,7 @@ pub(crate) mod execution_io; pub(crate) mod exit_diagnostics; pub(crate) mod external_tools; pub(crate) mod file_dispatch; +pub(crate) mod file_access; pub(crate) mod files; pub(crate) mod job_manager; pub(crate) mod lsp; @@ -25,6 +26,9 @@ pub(crate) mod patches; pub(crate) mod persistent_shell; pub(crate) mod plugin; pub(crate) mod project_build; +pub(crate) mod project_format; +#[cfg(test)] +mod project_format_tests; #[cfg(test)] mod project_build_tests; pub(crate) mod projects; diff --git a/crates/webcodex-runner/src/webcodex_runner/project_format.rs b/crates/webcodex-runner/src/webcodex_runner/project_format.rs new file mode 100644 index 000000000..ae3dc9459 --- /dev/null +++ b/crates/webcodex-runner/src/webcodex_runner/project_format.rs @@ -0,0 +1,348 @@ +//! Runner-owned explicit-file format planning. This module deliberately does +//! not confer write authority or advertise an executable formatting capability. + +use super::config::RunnerPolicy; +use super::projects::find_project_shell_context_by_id; +use super::shell::cwd_allowed; +use sha2::{Digest, Sha256}; +use std::fs; +use super::file_access::{directory_identity, file_identity, file_link_count, open_regular_file}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use webcodex_core::apply_edits_shared::is_sensitive_edit_path; +use webcodex_core::project_format::*; +use webcodex_workspace::project_recipe::{ + resolve_project_recipe_root, ProjectRecipeId, ProjectRecipeResolutionError, +}; + +fn unavailable(code: &str) -> ProjectFormatPlanningResult { + ProjectFormatPlanningResult::Unavailable { code: code.into() } +} + +fn recipe_error(error: ProjectRecipeResolutionError) -> ProjectFormatPlanningResult { + unavailable(match error { + ProjectRecipeResolutionError::Ambiguous { .. } => "format_recipe_ambiguous", + ProjectRecipeResolutionError::NotFound + | ProjectRecipeResolutionError::ExecutionRootUnavailable => "format_recipe_not_found", + ProjectRecipeResolutionError::SourceFileInvalid => "format_manifest_invalid", + _ => "format_recipe_mismatch", + }) +} + +fn digest(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn root_digest(root: &Path) -> Result { + let identity = directory_identity(root).map_err(|_| "format_path_invalid")?; + let mut digest = Sha256::new(); + digest.update(root.as_os_str().as_encoded_bytes()); + digest.update(identity); + Ok(format!("{:x}", digest.finalize())) +} + +fn validate_marker_chain(root: &Path, relative: &str) -> Result<(), &'static str> { + let directory = checked_path(root, relative, true)?; + for parent in directory.ancestors() { + if !parent.starts_with(root) { + break; + } + let mut found = false; + for marker in ["Cargo.toml", "pyproject.toml", "package.json", "go.mod"] { + match fs::symlink_metadata(parent.join(marker)) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => found = true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + _ => return Err("format_manifest_invalid"), + } + } + if found { + return Ok(()); + } + } + Ok(()) +} + +/// Check every component, including directory aliases within the Project. +/// Canonical containment alone would accept a symlink to another selected file. +fn checked_path(root: &Path, relative: &str, directory: bool) -> Result { + if !valid_format_relative_path(relative, directory) || is_sensitive_edit_path(relative) { + return Err("format_path_invalid"); + } + let mut path = root.to_path_buf(); + if relative != "." { + for component in relative.split('/') { + path.push(component); + let metadata = fs::symlink_metadata(&path).map_err(|_| "format_file_unavailable")?; + if metadata.file_type().is_symlink() { + return Err("format_path_invalid"); + } + } + } + let canonical = path.canonicalize().map_err(|_| "format_file_unavailable")?; + if !canonical.starts_with(root) || canonical != path { + return Err("format_path_invalid"); + } + let metadata = fs::symlink_metadata(&path).map_err(|_| "format_file_unavailable")?; + if if directory { !metadata.is_dir() } else { !metadata.is_file() } { + return Err("format_path_invalid"); + } + Ok(path) +} + +fn read_source(root: &Path, relative: &str, max: usize) -> Result<(String, String), &'static str> { + let path = checked_path(root, relative, false)?; + let file = open_regular_file(&path).map_err(|_| "format_file_unavailable")?; + if file_link_count(&file).map_err(|_| "format_file_unavailable")? != 1 { + return Err("format_alias_unavailable"); + } + let identity = file_identity(&file).map_err(|_| "format_file_unavailable")?; + let mut bytes = Vec::new(); + file.take(max as u64 + 1).read_to_end(&mut bytes).map_err(|_| "format_file_unavailable")?; + if bytes.len() > max { + return Err("format_input_too_large"); + } + if checked_path(root, relative, false)? != path + || file_identity(&open_regular_file(&path).map_err(|_| "format_file_unavailable")?).map_err(|_| "format_file_unavailable")? != identity + { + return Err("format_plan_stale"); + } + Ok((String::from_utf8(bytes).map_err(|_| "format_invalid_utf8")?, digest(&identity))) +} + +fn rust_profile(manifest: &toml::Value) -> Result { + let package = manifest.get("package").and_then(toml::Value::as_table) + .ok_or("format_manifest_unsupported")?; + // Inherited edition and explicit external workspace topology require an + // independent profile. Do not silently choose a default edition for them. + if package.contains_key("workspace") { + return Err("format_manifest_unsupported"); + } + let edition = match package.get("edition") { + None => "2015", + Some(value) => value.as_str().ok_or("format_manifest_unsupported")?, + }; + let profile = ProjectFormatProfile::Rust { edition: edition.into() }; + if !profile.is_valid() { + return Err("format_manifest_invalid"); + } + Ok(profile) +} + +fn python_profile(manifest: &toml::Value) -> Result { + let ruff = manifest.get("tool").and_then(|tool| tool.get("ruff")) + .and_then(toml::Value::as_table).ok_or("format_manifest_unsupported")?; + if ruff.contains_key("extend") { + return Err("format_manifest_invalid"); + } + let target_version = ruff.get("target-version").and_then(toml::Value::as_str) + .ok_or("format_manifest_invalid")?; + let profile = ProjectFormatProfile::Python { target_version: target_version.into() }; + if !profile.is_valid() { + return Err("format_manifest_invalid"); + } + Ok(profile) +} + +fn reject_custom_rustfmt_config(root: &Path) -> Result<(), &'static str> { + // rustfmt searches parents for configuration. Only metadata is inspected + // outside the Project; no external configuration is read or authorized. + for (depth, parent) in root.ancestors().enumerate() { + if depth >= 64 { + return Err("format_scope_unavailable"); + } + for name in ["rustfmt.toml", ".rustfmt.toml"] { + match fs::symlink_metadata(parent.join(name)) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + _ => return Err("format_config_unsupported"), + } + } + } + Ok(()) +} + +/// Private snapshots are retained only by the planning/execution owner. Do not +/// derive Debug/Serialize: these bytes are never a transport or log payload. +pub(crate) struct PlannedFormat { + pub(crate) plan: ProjectFormatPlan, + pub(crate) root: PathBuf, + pub(crate) cwd: PathBuf, + pub(crate) sources: Vec, +} + +pub(crate) fn plan( + policy: &RunnerPolicy, + registry: &Path, + request: &ProjectFormatRequest, +) -> Result { + request.validate().map_err(|_| unavailable("invalid_arguments"))?; + let project = find_project_shell_context_by_id(registry, &request.project_id) + .ok_or_else(|| unavailable("unknown_project"))?; + if !project.allow_patch || !policy.allow_raw_shell { + return Err(unavailable("permission_denied")); + } + let root = PathBuf::from(&project.path); + cwd_allowed(policy, &root).map_err(|_| unavailable("invalid_project_path"))?; + let root = root.canonicalize().map_err(|_| unavailable("invalid_project_path"))?; + let initial_root_digest = root_digest(&root).map_err(unavailable)?; + checked_path(&root, request.cwd.as_deref().unwrap_or("."), true).map_err(unavailable)?; + // No manifestless Python fallback and no explicit-adapter bypass of an + // ambiguous root. Formatting needs a concrete local manifest in all cases. + validate_marker_chain(&root, request.cwd.as_deref().unwrap_or(".")).map_err(unavailable)?; + let resolved = resolve_project_recipe_root(&root, request.cwd.as_deref(), None).map_err(recipe_error)?; + let adapter = match resolved.recipe { + ProjectRecipeId::Rust => ProjectFormatAdapter::Rust, + ProjectRecipeId::Python => ProjectFormatAdapter::Python, + _ => return Err(unavailable("format_adapter_unavailable")), + }; + if request.adapter != ProjectFormatAdapter::Auto && request.adapter != adapter { + return Err(unavailable("format_recipe_mismatch")); + } + let marker = resolved.marker_path().strip_prefix(&root) + .map_err(|_| unavailable("format_path_invalid"))?.to_string_lossy().replace('\\', "/"); + let (manifest, manifest_identity) = read_source(&root, &marker, PROJECT_FORMAT_MANIFEST_MAX_BYTES).map_err(unavailable)?; + let value: toml::Value = toml::from_str(&manifest).map_err(|_| unavailable("format_manifest_invalid"))?; + let profile = match adapter { + ProjectFormatAdapter::Rust => { + reject_custom_rustfmt_config(&resolved.absolute_root).map_err(unavailable)?; + rust_profile(&value).map_err(unavailable)? + } + ProjectFormatAdapter::Python => python_profile(&value).map_err(unavailable)?, + ProjectFormatAdapter::Auto => unreachable!(), + }; + let mut sources = Vec::new(); + let mut files = Vec::new(); + let mut identities = Vec::new(); + let mut total = 0usize; + for relative in &request.files { + let path = checked_path(&root, relative, false).map_err(unavailable)?; + let parent = path.parent().ok_or_else(|| unavailable("format_path_invalid"))?; + let parent = parent.strip_prefix(&root).map_err(|_| unavailable("format_path_invalid"))?; + let parent = if parent.as_os_str().is_empty() { ".".into() } else { parent.to_string_lossy().replace('\\', "/") }; + validate_marker_chain(&root, &parent).map_err(unavailable)?; + let owner = resolve_project_recipe_root(&root, Some(&parent), None).map_err(recipe_error)?; + if owner != resolved { + return Err(unavailable("format_scope_mismatch")); + } + let (source, identity) = read_source(&root, relative, PROJECT_FORMAT_FILE_MAX_BYTES).map_err(unavailable)?; + if identities.contains(&identity) { + return Err(unavailable("format_duplicate_file")); + } + identities.push(identity.clone()); + total += source.len(); + if total > PROJECT_FORMAT_TOTAL_MAX_BYTES { + return Err(unavailable("format_input_too_large")); + } + files.push(ProjectFormatFileWitness { path: relative.clone(), bytes: source.len(), sha256: digest(source.as_bytes()), identity_digest: identity }); + sources.push(source); + } + if root_digest(&root).map_err(unavailable)? != initial_root_digest { + return Err(unavailable("format_plan_stale")); + } + let plan = ProjectFormatPlan { + request: request.clone(), recipe_root: resolved.relative_root, + root_digest: initial_root_digest, + manifest_digest: digest(&[manifest_identity.as_bytes(), manifest.as_bytes()].concat()), profile, files, + }; + plan.digest().map_err(|_| unavailable("format_scope_mismatch"))?; + Ok(PlannedFormat { plan, root, cwd: resolved.absolute_root, sources }) +} + +/// Admission and worker must independently obtain fresh snapshots through this +/// same planner. A digest is a stale-plan fence, never permission to replay. +pub(crate) fn replan( + policy: &RunnerPolicy, + registry: &Path, + expected: &ProjectFormatPlan, +) -> Result { + expected.digest().map_err(|_| "format_plan_stale")?; + let current = plan(policy, registry, &expected.request).map_err(|_| "format_plan_stale")?; + if current.plan != *expected { + return Err("format_plan_stale"); + } + Ok(current) +} + +pub(crate) fn handle( + policy: &RunnerPolicy, + registry: &Path, + request: &ProjectFormatRequest, +) -> super::output::CommandResult { + // Planning is implemented, but a Ready wire response would imply a worker + // can consume it. Keep native execution unavailable until exact output, + // guarded commit and durable mutation-receipt gates are implemented. + let result = match plan(policy, registry, request) { + Ok(_) => unavailable("format_execution_unavailable"), + Err(error) => error, + }; + super::output::CommandResult { + exit_code: Some(0), + stdout: Some(serde_json::to_string(&result).expect("typed format planning response")), + stderr: None, + duration_ms: Some(0), + error: None, + } +} + +/// Dry-run formatter candidate generation. +/// Runs the trusted installed formatter (e.g. rustfmt) on stdin and captures stdout. +/// Never mutates project files, never runs arbitrary scripts, and does not leak sources into logs. +pub(crate) fn format_candidates( + policy: &RunnerPolicy, + shell: &super::config::ShellConfig, + registry: &Path, + planned: &PlannedFormat, + cache: &super::shell::PreparedShellProfileCache, +) -> Result, &'static str> { + if !planned.plan.is_valid() { + return Err("format_plan_invalid"); + } + if planned.sources.len() != planned.plan.files.len() { + return Err("format_plan_invalid"); + } + + let mut candidates = Vec::with_capacity(planned.sources.len()); + for (index, file_witness) in planned.plan.files.iter().enumerate() { + let source = &planned.sources[index]; + let recipe_relative = planned + .plan + .recipe_relative_file(&file_witness.path) + .ok_or("format_path_invalid")?; + let process_cmd = planned + .plan + .profile + .process(recipe_relative) + .map_err(|_| "format_profile_invalid")?; + + let cwd_str = planned.cwd.to_str().ok_or("format_path_invalid")?; + let result = super::shell::run_process_with_profiles_and_execution_state( + 1, + policy, + shell, + registry, + cache, + Some(cwd_str), + &process_cmd.executable, + &process_cmd.args, + Some(source), + PROJECT_FORMAT_TIMEOUT_MAX_SECS, + None, + ); + + if result.stdout_truncated || result.stderr_truncated { + return Err("format_output_truncated"); + } + + if result.result.exit_code != Some(0) { + return Err("format_process_failed"); + } + + let stdout = result.result.stdout.ok_or("format_process_failed")?; + if stdout.len() > PROJECT_FORMAT_FILE_MAX_BYTES { + return Err("format_candidate_too_large"); + } + + candidates.push(stdout); + } + + Ok(candidates) +} diff --git a/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs b/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs new file mode 100644 index 000000000..6aa6f71c3 --- /dev/null +++ b/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs @@ -0,0 +1,322 @@ +use super::config::{RunnerPolicy, ShellConfig}; +use super::project_format::{format_candidates, handle, plan, replan, PlannedFormat}; +use super::shell::PreparedShellProfileCache; +use std::fs; +#[cfg(unix)] +use std::path::Path; +use std::path::PathBuf; +use webcodex_core::project_format::*; + +const MANIFEST: &str = "[package]\nname='demo'\nversion='0.1.0'\nedition='2021'\n"; + +struct Fixture { + _temp: tempfile::TempDir, + root: PathBuf, + registry: PathBuf, + policy: RunnerPolicy, +} + +impl Fixture { + fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + let registry = temp.path().join("registry"); + fs::create_dir_all(root.join("src")).unwrap(); + fs::create_dir(®istry).unwrap(); + fs::write(root.join("Cargo.toml"), MANIFEST).unwrap(); + fs::write(root.join("src/main.rs"), "fn main( ){ }\n").unwrap(); + let fixture = Self { _temp: temp, root: root.clone(), registry, policy: RunnerPolicy { allowed_roots: vec![root], ..Default::default() } }; + fixture.register(true); + fixture + } + + fn register(&self, allow_patch: bool) { + fs::write(self.registry.join("demo.toml"), format!( + "id='demo'\npath={}\nallow_patch={allow_patch}\n", + serde_json::to_string(self.root.to_str().unwrap()).unwrap(), + )).unwrap(); + } + + fn request(&self) -> ProjectFormatRequest { + ProjectFormatRequest { project_id: "demo".into(), cwd: None, adapter: ProjectFormatAdapter::Auto, files: vec!["src/main.rs".into()] } + } + + fn plan(&self) -> PlannedFormat { + plan(&self.policy, &self.registry, &self.request()).unwrap() + } +} + +fn rejection(result: Result) -> String { + match result { + Err(ProjectFormatPlanningResult::Unavailable { code }) => code, + _ => panic!("expected unavailable format plan"), + } +} + +#[test] +fn project_format_plan_keeps_source_private_and_replans_exact_bytes() { + let fixture = Fixture::new(); + let original = fixture.plan(); + assert_eq!(original.sources, ["fn main( ){ }\n"]); + assert_eq!(original.root, fixture.root.canonicalize().unwrap()); + assert_eq!(original.cwd, original.root); + assert!(original.plan.is_valid()); + assert!(!serde_json::to_string(&original.plan).unwrap().contains("fn main")); + assert!(replan(&fixture.policy, &fixture.registry, &original.plan).is_ok()); + fs::write(fixture.root.join("src/main.rs"), "fn main() {}\n").unwrap(); + assert!(matches!(replan(&fixture.policy, &fixture.registry, &original.plan), Err("format_plan_stale"))); + let updated = fixture.plan(); + fs::write(fixture.root.join("Cargo.toml"), MANIFEST.replace("2021", "2024")).unwrap(); + assert!(replan(&fixture.policy, &fixture.registry, &updated.plan).is_err()); +} + +#[test] +fn project_format_replan_rejects_same_path_replacement_with_identical_bytes() { + let fixture = Fixture::new(); + let original = fixture.plan(); + let retired = fixture.root.with_extension("retired"); + fs::rename(&fixture.root, &retired).unwrap(); + fs::create_dir_all(fixture.root.join("src")).unwrap(); + fs::write(fixture.root.join("Cargo.toml"), MANIFEST).unwrap(); + fs::write(fixture.root.join("src/main.rs"), &original.sources[0]).unwrap(); + let replacement = fixture.plan(); + assert_ne!(replacement.plan.root_digest, original.plan.root_digest); + assert!(replan(&fixture.policy, &fixture.registry, &original.plan).is_err()); +} + +#[test] +fn project_format_requires_exact_enabled_writable_project_and_process_policy() { + let mut fixture = Fixture::new(); + let mut unknown = fixture.request(); + unknown.project_id = "other".into(); + assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &unknown)), "unknown_project"); + fixture.register(false); + assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), "permission_denied"); + fixture.register(true); + fixture.policy.allow_raw_shell = false; + assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), "permission_denied"); +} + +#[test] +fn project_format_rejects_cross_recipe_and_ambiguous_roots_without_adapter_fallback() { + let fixture = Fixture::new(); + fs::create_dir(fixture.root.join("nested")).unwrap(); + fs::write(fixture.root.join("nested/Cargo.toml"), MANIFEST).unwrap(); + fs::write(fixture.root.join("nested/main.rs"), "fn main() {}\n").unwrap(); + let mut request = fixture.request(); + request.files.push("nested/main.rs".into()); + assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &request)), "format_scope_mismatch"); + request.files.remove(0); + request.cwd = Some("nested".into()); + assert_eq!(plan(&fixture.policy, &fixture.registry, &request).unwrap().plan.recipe_root, "nested"); + fs::write(fixture.root.join("nested/pyproject.toml"), "[tool.ruff]\ntarget-version='py311'\n").unwrap(); + request.adapter = ProjectFormatAdapter::Rust; + assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &request)), "format_recipe_ambiguous"); +} + +#[test] +fn project_format_invalid_nested_markers_never_fall_back_to_parent() { + let fixture = Fixture::new(); + fs::create_dir(fixture.root.join("src/Cargo.toml")).unwrap(); + assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), "format_manifest_invalid"); +} + +#[test] +fn project_format_rejects_custom_config_inherited_edition_and_large_inputs() { + let fixture = Fixture::new(); + fs::write(fixture.root.join("rustfmt.toml"), "max_width=80\n").unwrap(); + assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), "format_config_unsupported"); + fs::remove_file(fixture.root.join("rustfmt.toml")).unwrap(); + fs::write(fixture.root.join("Cargo.toml"), "[package]\nname='demo'\nedition.workspace=true\n").unwrap(); + assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), "format_manifest_unsupported"); + fs::write(fixture.root.join("Cargo.toml"), MANIFEST).unwrap(); + fs::write(fixture.root.join("src/main.rs"), vec![b' '; PROJECT_FORMAT_FILE_MAX_BYTES + 1]).unwrap(); + assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), "format_input_too_large"); +} + +#[test] +fn project_format_bounds_total_bytes_and_rejects_invalid_utf8() { + let fixture = Fixture::new(); + let mut request = fixture.request(); + request.files.clear(); + for index in 0..5 { + let path = format!("src/file{index}.rs"); + fs::write(fixture.root.join(&path), vec![b' '; PROJECT_FORMAT_FILE_MAX_BYTES]).unwrap(); + request.files.push(path); + } + assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &request)), "format_input_too_large"); + fs::write(fixture.root.join("src/main.rs"), [0xff]).unwrap(); + assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), "format_invalid_utf8"); +} + +#[cfg(feature = "runner-real-process-tests")] +#[test] +#[ignore = "requires installed rustfmt; exercises the owned native process boundary"] +fn runner_real_process_project_format_rustfmt_stdin_leaves_sources_untouched() { + use super::config::ShellConfig; + use super::shell::{run_process_with_profiles_and_execution_state, PreparedShellProfileCache}; + let fixture = Fixture::new(); + fs::write(fixture.root.join("src/main.rs"), "mod child;\nfn main( ){ }\n").unwrap(); + fs::write(fixture.root.join("child.rs"), "fn child( ){ }\n").unwrap(); + let planned = fixture.plan(); + let command = planned.plan.profile.process("src/main.rs").unwrap(); + let result = run_process_with_profiles_and_execution_state( + 1, &fixture.policy, &ShellConfig::default(), &fixture.registry, + &PreparedShellProfileCache::default(), planned.cwd.to_str(), + &command.executable, &command.args, Some(&planned.sources[0]), 10, None, + ); + assert_eq!(result.result.exit_code, Some(0), "{:?}", result.result.error); + assert_eq!(result.result.stdout.as_deref(), Some("mod child;\nfn main() {}\n")); + assert!(!result.stdout_truncated && !result.stderr_truncated); + assert!(result.result.stderr.as_deref().unwrap_or_default().is_empty()); + assert_eq!(fs::read_to_string(fixture.root.join("src/main.rs")).unwrap(), planned.sources[0]); + assert_eq!(fs::read_to_string(fixture.root.join("child.rs")).unwrap(), "fn child( ){ }\n"); +} + +#[test] +fn project_format_python_pins_local_target_and_rejects_extend() { + let fixture = Fixture::new(); + fs::remove_file(fixture.root.join("Cargo.toml")).unwrap(); + fs::write(fixture.root.join("src/main.py"), "x=1\n").unwrap(); + let mut request = fixture.request(); + request.adapter = ProjectFormatAdapter::Python; + request.files = vec!["src/main.py".into()]; + for manifest in ["[tool.ruff]\n", "[tool.ruff]\ntarget-version='ambient'\n", "[tool.ruff]\ntarget-version='py311'\nextend='other.toml'\n"] { + fs::write(fixture.root.join("pyproject.toml"), manifest).unwrap(); + assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &request)), "format_manifest_invalid"); + } + fs::write(fixture.root.join("pyproject.toml"), "[tool.ruff]\ntarget-version='py311'\n").unwrap(); + let planned = plan(&fixture.policy, &fixture.registry, &request).unwrap(); + assert_eq!(planned.plan.profile, ProjectFormatProfile::Python { target_version: "py311".into() }); + // A valid static profile is not evidence of installed formatter support. + let result = handle(&fixture.policy, &fixture.registry, &request); + assert_eq!(serde_json::from_str::(result.stdout.as_deref().unwrap()).unwrap()["code"], "format_execution_unavailable"); +} + +#[cfg(unix)] +#[test] +fn project_format_rejects_symlink_hardlink_and_fifo_inputs() { + use std::os::unix::fs::symlink; + let fixture = Fixture::new(); + let mut request = fixture.request(); + symlink("main.rs", fixture.root.join("src/link.rs")).unwrap(); + request.files = vec!["src/link.rs".into()]; + assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &request)), "format_path_invalid"); + fs::hard_link(fixture.root.join("src/main.rs"), fixture.root.join("src/alias.rs")).unwrap(); + request.files = vec!["src/main.rs".into()]; + // Even a single selected path must not mutate an inode with other aliases. + assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &request)), "format_alias_unavailable"); + let fifo = fixture.root.join("src/pipe.rs"); + make_fifo(&fifo); + request.files = vec!["src/pipe.rs".into()]; + assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &request)), "format_path_invalid"); + // Deterministically replace a checked regular leaf at the protected open + // boundary. Opening the FIFO must return an error without waiting for stdin. + let target = fixture.root.canonicalize().unwrap().join("src/main.rs"); + let opened = super::file_access::open_regular_file_unix(&target, || { + fs::remove_file(&target).unwrap(); + make_fifo(&target); + }); + assert!(opened.is_err()); +} + +#[cfg(unix)] +fn make_fifo(path: &Path) { + use std::os::unix::ffi::OsStrExt; + let path = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0); +} + +#[test] +fn project_format_candidates_returns_formatted_output_and_unchanged_detection() { + let fixture = Fixture::new(); + let planned = fixture.plan(); + let shell = ShellConfig::default(); + let cache = PreparedShellProfileCache::default(); + let candidates = format_candidates( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &cache, + ) + .expect("dry-run rustfmt candidate formatting succeeded"); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0], "fn main() {}\n"); + assert_ne!(candidates[0], planned.sources[0]); + + // Already formatted -> candidate equals original source + fs::write(fixture.root.join("src/main.rs"), "fn main() {}\n").unwrap(); + let planned_formatted = fixture.plan(); + let candidates_formatted = format_candidates( + &fixture.policy, + &shell, + &fixture.registry, + &planned_formatted, + &cache, + ) + .expect("dry-run formatting formatted source succeeds"); + assert_eq!(candidates_formatted.len(), 1); + assert_eq!(candidates_formatted[0], planned_formatted.sources[0]); + + // Original file content was untouched by candidate dry-run + assert_eq!( + fs::read_to_string(fixture.root.join("src/main.rs")).unwrap(), + "fn main() {}\n" + ); +} + +#[test] +fn project_format_candidates_fails_on_nonzero_exit_syntax_error() { + let fixture = Fixture::new(); + fs::write(fixture.root.join("src/main.rs"), "fn broken syntax {{{ \n").unwrap(); + let planned = fixture.plan(); + let shell = ShellConfig::default(); + let cache = PreparedShellProfileCache::default(); + let result = format_candidates( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &cache, + ); + assert_eq!(result, Err("format_process_failed")); +} + +#[test] +fn project_format_candidates_fails_on_truncated_output() { + let fixture = Fixture::new(); + let planned = fixture.plan(); + let mut policy = fixture.policy.clone(); + // Set max_output_bytes very low so the formatter output triggers truncation + policy.max_output_bytes = 4; + let shell = ShellConfig::default(); + let cache = PreparedShellProfileCache::default(); + let result = format_candidates( + &policy, + &shell, + &fixture.registry, + &planned, + &cache, + ); + assert_eq!(result, Err("format_output_truncated")); +} + +#[test] +fn project_format_candidates_fails_on_missing_rustfmt() { + let fixture = Fixture::new(); + let planned = fixture.plan(); + let mut shell = ShellConfig::default(); + // Restrict PATH to an empty directory so rustfmt cannot be located + shell.env.insert("PATH".into(), "/nonexistent_bin_path_12345".into()); + shell.environment_mode = super::config::ShellEnvironmentMode::Isolated; + let cache = PreparedShellProfileCache::default(); + let result = format_candidates( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &cache, + ); + assert_eq!(result, Err("format_process_failed")); +} diff --git a/crates/webcodex-runner/src/webcodex_runner/runner_instruction_tests.rs b/crates/webcodex-runner/src/webcodex_runner/runner_instruction_tests.rs index 1d2a6ecb3..b63c61627 100644 --- a/crates/webcodex-runner/src/webcodex_runner/runner_instruction_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/runner_instruction_tests.rs @@ -1,4 +1,9 @@ use super::*; +use crate::webcodex_runner::file_access::unsupported_regular_file_open; +#[cfg(unix)] +use crate::webcodex_runner::file_access::open_regular_file_unix; +#[cfg(windows)] +use crate::webcodex_runner::file_access::open_regular_file_windows; use std::path::Path; fn observe(path: &Path) -> RunnerInstructionSnapshotResponse { @@ -52,7 +57,7 @@ fn instruction_reader_stops_at_byte_cap_even_when_the_source_grows() { #[test] fn unsupported_instruction_open_fails_closed() { - let error = unsupported_instruction_file_open(Path::new("ignored")).unwrap_err(); + let error = unsupported_regular_file_open(Path::new("ignored")).unwrap_err(); assert_eq!(error.kind(), io::ErrorKind::Unsupported); } @@ -81,7 +86,7 @@ fn instruction_open_supports_search_only_parent_directories() { let original_permissions = std::fs::metadata(&parent).unwrap().permissions(); std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o111)).unwrap(); - let opened = open_instruction_file(&path); + let opened = open_regular_file(&path); std::fs::set_permissions(&parent, original_permissions).unwrap(); let mut file = opened.unwrap(); @@ -98,7 +103,7 @@ fn instruction_secure_open_reads_ordinary_windows_file() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("AGENTS.md"); std::fs::write(&path, "ordinary guidance").unwrap(); - let mut file = open_instruction_file_windows(&path, || {}) + let mut file = open_regular_file_windows(&path, || {}) .unwrap_or_else(|error| panic!("ordinary Windows instruction open failed: {error:?}")); let mut content = String::new(); file.read_to_string(&mut content).unwrap(); @@ -196,7 +201,7 @@ fn instruction_open_marks_parent_swap_unavailable_instead_of_following_it() { std::fs::write(&path, "configured guidance").unwrap(); std::fs::write(outside.join("AGENTS.md"), "unconfigured secret").unwrap(); - let error = open_instruction_file_unix(&path, || { + let error = open_regular_file_unix(&path, || { std::fs::rename(&configured, &moved).unwrap(); std::os::unix::fs::symlink(&outside, &configured).unwrap(); }) @@ -216,7 +221,7 @@ fn instruction_open_detects_ordinary_parent_replacement_by_identity() { let path = configured.join("AGENTS.md"); std::fs::write(&path, "configured guidance").unwrap(); - let error = open_instruction_file_unix(&path, || { + let error = open_regular_file_unix(&path, || { std::fs::rename(&configured, &moved).unwrap(); std::fs::create_dir(&configured).unwrap(); std::fs::write(configured.join("AGENTS.md"), "replacement guidance").unwrap(); @@ -239,7 +244,7 @@ fn missing_leaf_during_parent_swap_is_not_confirmed_removal() { let path = configured.join("AGENTS.md"); std::fs::write(outside.join("AGENTS.md"), "unconfigured secret").unwrap(); - let error = open_instruction_file_unix(&path, || { + let error = open_regular_file_unix(&path, || { std::fs::rename(&configured, &moved).unwrap(); std::os::unix::fs::symlink(&outside, &configured).unwrap(); }) @@ -258,7 +263,7 @@ fn instruction_open_rejects_parent_replacement_after_parent_open() { let path = configured.join("AGENTS.md"); std::fs::write(&path, "configured guidance").unwrap(); - let error = open_instruction_file_windows(&path, || { + let error = open_regular_file_windows(&path, || { std::fs::rename(&configured, &moved).unwrap(); std::fs::create_dir(&configured).unwrap(); std::fs::write(configured.join("AGENTS.md"), "replacement guidance").unwrap(); @@ -277,7 +282,7 @@ fn missing_leaf_during_parent_swap_is_not_confirmed_removal_on_windows() { std::fs::create_dir(&configured).unwrap(); let path = configured.join("AGENTS.md"); - let error = open_instruction_file_windows(&path, || { + let error = open_regular_file_windows(&path, || { std::fs::rename(&configured, &moved).unwrap(); std::fs::create_dir(&configured).unwrap(); std::fs::write(configured.join("AGENTS.md"), "replacement guidance").unwrap(); diff --git a/crates/webcodex-runner/src/webcodex_runner/runner_instructions.rs b/crates/webcodex-runner/src/webcodex_runner/runner_instructions.rs index 150b0cba5..ec3f1581c 100644 --- a/crates/webcodex-runner/src/webcodex_runner/runner_instructions.rs +++ b/crates/webcodex-runner/src/webcodex_runner/runner_instructions.rs @@ -1,11 +1,9 @@ use super::config::InstructionsConfig; -use super::configured_skills::metadata_is_link_like; +use super::file_access::open_regular_file; use super::CommandResult; use sha2::{Digest, Sha256}; use std::collections::HashSet; -use std::fs::File; use std::io::{self, Read}; -use std::path::Path; use std::time::Instant; use webcodex_core::project_instructions::{ InstructionSourceScope, LoadedInstructionCandidate, ProjectInstructionsSnapshot, @@ -16,15 +14,6 @@ use webcodex_core::runner_instruction::{ }; const MAX_CONFIGURED_INSTRUCTION_FILE_BYTES: u64 = 1024 * 1024; -#[cfg(windows)] -const WINDOWS_FILE_DIRECTORY_FILE: u32 = 0x0000_0001; -#[cfg(windows)] -const WINDOWS_FILE_NON_DIRECTORY_FILE: u32 = 0x0000_0040; -#[cfg(windows)] -const WINDOWS_OBJ_CASE_INSENSITIVE: u32 = 0x0000_0040; -#[cfg(windows)] -const WINDOWS_OBJ_DONT_REPARSE: u32 = 0x0000_1000; - pub(crate) fn handle_runner_instruction_request( generation: u64, config: &InstructionsConfig, @@ -45,7 +34,7 @@ fn snapshot(generation: u64, config: &InstructionsConfig, started: Instant) -> C let mut scan_complete = true; for (index, configured) in config.files.iter().enumerate() { - let file = match open_instruction_file(configured) { + let file = match open_regular_file(configured) { Ok(file) => file, // A confirmed missing file withdraws its guidance. Permission and // other read failures remain unavailable, not deletion evidence. @@ -124,457 +113,6 @@ fn snapshot(generation: u64, config: &InstructionsConfig, started: Instant) -> C } } -// Instruction authority names ordinary files, not redirectable filesystem -// trees. Resolve the path through pinned directory handles so an ancestor -// cannot be swapped to a symlink/reparse point between validation and open. -fn open_instruction_file(path: &Path) -> io::Result { - #[cfg(unix)] - { - open_instruction_file_unix(path, || {}) - } - #[cfg(windows)] - { - open_instruction_file_windows(path, || {}) - } - #[cfg(not(any(unix, windows)))] - { - unsupported_instruction_file_open(path) - } -} - -#[cfg(any(test, not(any(unix, windows))))] -fn unsupported_instruction_file_open(_path: &Path) -> io::Result { - Err(io::Error::new( - io::ErrorKind::Unsupported, - "secure configured instruction reads are unsupported on this platform", - )) -} - -#[cfg(unix)] -fn open_instruction_file_unix(path: &Path, before_leaf: impl FnOnce()) -> io::Result { - use std::ffi::CString; - use std::os::fd::{AsRawFd, FromRawFd}; - - let parent_path = path - .parent() - .ok_or_else(|| io::Error::other("instruction path must name an absolute file"))?; - let parent = open_unix_directory(parent_path).map_err(|error| { - if error.kind() == io::ErrorKind::NotFound { - io::Error::other("instruction parent is unavailable") - } else { - error - } - })?; - let leaf = path - .file_name() - .ok_or_else(|| io::Error::other("instruction path must name an absolute file"))?; - let leaf = CString::new(std::os::unix::ffi::OsStrExt::as_bytes(leaf)) - .map_err(|_| io::Error::other("instruction path contains NUL"))?; - - before_leaf(); - - let fd = unsafe { - libc::openat( - parent.as_raw_fd(), - leaf.as_ptr(), - libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC, - ) - }; - if fd < 0 { - let error = io::Error::last_os_error(); - if error.kind() == io::ErrorKind::NotFound - && !unix_parent_still_current(parent_path, parent.as_raw_fd()) - { - return Err(io::Error::other( - "instruction parent changed during observation", - )); - } - return Err(error); - } - let file = unsafe { File::from_raw_fd(fd) }; - let metadata = file.metadata()?; - if !metadata.is_file() || metadata_is_link_like(&metadata) { - return Err(io::Error::other( - "instruction handle is not an ordinary file", - )); - } - if !unix_parent_still_current(parent_path, parent.as_raw_fd()) { - return Err(io::Error::other( - "instruction parent changed during observation", - )); - } - Ok(file) -} - -#[cfg(unix)] -fn open_unix_directory(path: &Path) -> io::Result { - use std::ffi::CString; - use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; - use std::os::unix::ffi::OsStrExt; - use std::path::Component; - - if !path.is_absolute() { - return Err(io::Error::other("instruction parent must be absolute")); - } - let root_fd = unsafe { libc::open(b"/\0".as_ptr().cast(), unix_directory_open_flags()) }; - if root_fd < 0 { - return Err(io::Error::last_os_error()); - } - let mut directory = unsafe { OwnedFd::from_raw_fd(root_fd) }; - for component in path.components() { - let name = match component { - Component::RootDir | Component::CurDir => continue, - Component::Normal(name) => name, - Component::ParentDir | Component::Prefix(_) => { - return Err(io::Error::other( - "instruction path must be absolute without parent traversal", - )); - } - }; - let name = CString::new(name.as_bytes()) - .map_err(|_| io::Error::other("instruction path contains NUL"))?; - let fd = unsafe { - libc::openat( - directory.as_raw_fd(), - name.as_ptr(), - unix_directory_open_flags(), - ) - }; - if fd < 0 { - return Err(io::Error::last_os_error()); - } - directory = unsafe { OwnedFd::from_raw_fd(fd) }; - } - Ok(directory) -} - -#[cfg(unix)] -fn unix_parent_still_current(path: &Path, expected_fd: std::os::fd::RawFd) -> bool { - use std::os::fd::AsRawFd; - - let Ok(current) = open_unix_directory(path) else { - return false; - }; - unix_fd_identity(current.as_raw_fd()) - .zip(unix_fd_identity(expected_fd)) - .is_some_and(|(current, expected)| current == expected) -} - -#[cfg(unix)] -fn unix_fd_identity(fd: std::os::fd::RawFd) -> Option<(u64, u64)> { - let mut stat = std::mem::MaybeUninit::::uninit(); - if unsafe { libc::fstat(fd, stat.as_mut_ptr()) } != 0 { - return None; - } - let stat = unsafe { stat.assume_init() }; - Some((stat.st_dev as u64, stat.st_ino as u64)) -} - -#[cfg(unix)] -fn unix_directory_open_flags() -> libc::c_int { - #[cfg(any(target_os = "linux", target_os = "android"))] - let access = libc::O_PATH; - #[cfg(any( - target_vendor = "apple", - target_os = "freebsd", - target_os = "netbsd", - target_os = "solaris", - target_os = "illumos", - target_os = "aix", - target_os = "fuchsia" - ))] - let access = libc::O_SEARCH; - #[cfg(not(any( - target_os = "linux", - target_os = "android", - target_vendor = "apple", - target_os = "freebsd", - target_os = "netbsd", - target_os = "solaris", - target_os = "illumos", - target_os = "aix", - target_os = "fuchsia" - )))] - let access = libc::O_RDONLY; - - access | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC -} - -#[cfg(windows)] -fn open_instruction_file_windows(path: &Path, before_leaf: impl FnOnce()) -> io::Result { - use std::os::windows::io::AsRawHandle; - use windows_sys::Win32::Foundation::HANDLE; - use windows_sys::Win32::Storage::FileSystem::{FILE_GENERIC_READ, FILE_SHARE_READ}; - - let parent_path = path - .parent() - .ok_or_else(|| io::Error::other("instruction path must name an absolute file"))?; - let parent = match open_windows_directory(parent_path) { - Ok(handle) => File::from(handle), - Err(error) if error.kind() == io::ErrorKind::NotFound => { - return Err(io::Error::other("instruction parent is unavailable")); - } - Err(error) => return Err(error), - }; - let parent_identity = windows_file_identity(&parent)?; - let leaf = path - .file_name() - .ok_or_else(|| io::Error::other("instruction path must name an absolute file"))?; - - before_leaf(); - - let file = match windows_nt_open_relative( - parent.as_raw_handle() as HANDLE, - leaf, - FILE_GENERIC_READ, - FILE_SHARE_READ, - WINDOWS_FILE_NON_DIRECTORY_FILE, - ) { - Ok(handle) => File::from(handle), - Err(error) if error.kind() == io::ErrorKind::NotFound => { - if !windows_parent_still_current(parent_path, parent_identity) { - return Err(io::Error::other( - "instruction parent changed during observation", - )); - } - return Err(error); - } - Err(error) => return Err(error), - }; - let metadata = file.metadata()?; - if !metadata.is_file() || metadata_is_link_like(&metadata) { - return Err(io::Error::other( - "instruction handle is not an ordinary file", - )); - } - if !windows_parent_still_current(parent_path, parent_identity) { - return Err(io::Error::other( - "instruction parent changed during observation", - )); - } - Ok(file) -} - -#[cfg(windows)] -fn windows_parent_still_current(path: &Path, expected: (u64, [u8; 16])) -> bool { - open_windows_directory(path) - .map(File::from) - .and_then(|file| windows_file_identity(&file)) - .is_ok_and(|current| current == expected) -} - -#[cfg(windows)] -fn windows_file_identity(file: &File) -> io::Result<(u64, [u8; 16])> { - use std::mem::size_of; - use std::os::windows::io::AsRawHandle; - use windows_sys::Win32::Storage::FileSystem::{ - FileIdInfo, GetFileInformationByHandleEx, FILE_ID_INFO, - }; - - let mut info = std::mem::MaybeUninit::::zeroed(); - if unsafe { - GetFileInformationByHandleEx( - file.as_raw_handle().cast(), - FileIdInfo, - info.as_mut_ptr().cast(), - size_of::() as u32, - ) - } == 0 - { - return Err(io::Error::last_os_error()); - } - let info = unsafe { info.assume_init() }; - Ok((info.VolumeSerialNumber, info.FileId.Identifier)) -} - -#[cfg(windows)] -fn open_windows_directory(path: &Path) -> io::Result { - use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle}; - use std::path::Component; - use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; - use windows_sys::Win32::Storage::FileSystem::{ - CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, - FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, - }; - - let mut components = path.components(); - let prefix = match components.next() { - Some(Component::Prefix(prefix)) => prefix, - _ => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "instruction path must be an absolute Windows path", - )) - } - }; - if !matches!(components.next(), Some(Component::RootDir)) { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "instruction path must include a Windows root", - )); - } - - let mut root = std::path::PathBuf::from(prefix.as_os_str()); - root.push(r"\"); - let wide = std::os::windows::ffi::OsStrExt::encode_wide(root.as_os_str()) - .chain(std::iter::once(0)) - .collect::>(); - // Resolve only the Windows drive/share root through Win32 namespace rules. - // Every real filesystem descendant is then opened relative to the pinned - // parent handle with OBJ_DONT_REPARSE, so junction/reparse ancestors remain - // fail-closed without rejecting the ordinary DOS drive mapping itself. - let root_handle: HANDLE = unsafe { - CreateFileW( - wide.as_ptr(), - FILE_READ_ATTRIBUTES, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - std::ptr::null(), - OPEN_EXISTING, - FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, - std::ptr::null_mut(), - ) - }; - if root_handle.is_null() || root_handle == INVALID_HANDLE_VALUE { - return Err(io::Error::last_os_error()); - } - let mut directory = unsafe { OwnedHandle::from_raw_handle(root_handle as RawHandle) }; - - for component in components { - let name = match component { - Component::Normal(name) => name, - Component::CurDir => continue, - Component::ParentDir | Component::Prefix(_) | Component::RootDir => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "instruction path must not contain parent traversal", - )) - } - }; - directory = windows_nt_open_relative( - directory.as_raw_handle() as HANDLE, - name, - FILE_READ_ATTRIBUTES, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - WINDOWS_FILE_DIRECTORY_FILE, - )?; - } - - Ok(directory) -} - -#[cfg(windows)] -fn windows_nt_open_relative( - root: windows_sys::Win32::Foundation::HANDLE, - name: &std::ffi::OsStr, - desired_access: u32, - share_access: u32, - create_options: u32, -) -> io::Result { - use std::os::windows::ffi::OsStrExt; - - let mut name = name.encode_wide().collect::>(); - windows_nt_open( - root, - &mut name, - desired_access, - share_access, - create_options, - ) -} - -#[cfg(windows)] -fn windows_nt_open( - root: windows_sys::Win32::Foundation::HANDLE, - name: &mut [u16], - desired_access: u32, - share_access: u32, - create_options: u32, -) -> io::Result { - use std::ffi::c_void; - use std::mem::size_of; - use std::os::windows::io::{FromRawHandle, OwnedHandle, RawHandle}; - use std::ptr; - use windows_sys::Win32::Foundation::{ - RtlNtStatusToDosError, HANDLE, INVALID_HANDLE_VALUE, NTSTATUS, UNICODE_STRING, - }; - use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL; - use windows_sys::Win32::System::IO::IO_STATUS_BLOCK; - - const FILE_OPEN: u32 = 1; - const FILE_SYNCHRONOUS_IO_NONALERT: u32 = 0x0000_0020; - const SYNCHRONIZE_ACCESS: u32 = 0x0010_0000; - - #[repr(C)] - struct ObjectAttributes { - length: u32, - root_directory: HANDLE, - object_name: *const UNICODE_STRING, - attributes: u32, - security_descriptor: *const c_void, - security_quality_of_service: *const c_void, - } - - #[link(name = "ntdll")] - unsafe extern "system" { - fn NtCreateFile( - file_handle: *mut HANDLE, - desired_access: u32, - object_attributes: *const ObjectAttributes, - io_status_block: *mut IO_STATUS_BLOCK, - allocation_size: *const i64, - file_attributes: u32, - share_access: u32, - create_disposition: u32, - create_options: u32, - ea_buffer: *const c_void, - ea_length: u32, - ) -> NTSTATUS; - } - - let name_length = u16::try_from(name.len().saturating_mul(size_of::())) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "instruction path is too long"))?; - let object_name = UNICODE_STRING { - Length: name_length, - MaximumLength: name_length, - Buffer: name.as_mut_ptr(), - }; - let object_attributes = ObjectAttributes { - length: size_of::() as u32, - root_directory: root, - object_name: &object_name, - attributes: WINDOWS_OBJ_CASE_INSENSITIVE | WINDOWS_OBJ_DONT_REPARSE, - security_descriptor: ptr::null(), - security_quality_of_service: ptr::null(), - }; - let mut io_status = std::mem::MaybeUninit::::zeroed(); - let mut handle: HANDLE = std::ptr::null_mut(); - let status = unsafe { - NtCreateFile( - &mut handle, - desired_access | SYNCHRONIZE_ACCESS, - &object_attributes, - io_status.as_mut_ptr(), - ptr::null(), - FILE_ATTRIBUTE_NORMAL, - share_access, - FILE_OPEN, - create_options | FILE_SYNCHRONOUS_IO_NONALERT, - ptr::null(), - 0, - ) - }; - if status < 0 { - let code = unsafe { RtlNtStatusToDosError(status) }; - return Err(io::Error::from_raw_os_error(code as i32)); - } - if handle.is_null() || handle == INVALID_HANDLE_VALUE { - return Err(io::Error::other( - "NtCreateFile returned an invalid instruction handle", - )); - } - Ok(unsafe { OwnedHandle::from_raw_handle(handle as RawHandle) }) -} - fn read_instruction_bytes(reader: impl Read) -> io::Result> { let mut bytes = Vec::new(); // A pre-read metadata length is not a resource bound: the file can grow. diff --git a/crates/webcodex-runner/src/webcodex_runner/transport/registration.rs b/crates/webcodex-runner/src/webcodex_runner/transport/registration.rs index c7c1cf5c8..200035767 100644 --- a/crates/webcodex-runner/src/webcodex_runner/transport/registration.rs +++ b/crates/webcodex-runner/src/webcodex_runner/transport/registration.rs @@ -246,6 +246,9 @@ pub(crate) fn runner_register_capabilities(cfg: &RunnerConfig) -> RunnerCapabili capabilities.set(RunnerCapabilityId::StructuredGoTestJson, true); capabilities.set(RunnerCapabilityId::ProjectValidation, true); capabilities.set(RunnerCapabilityId::ProjectBuild, true); + // Static config cannot claim the format lifecycle before the worker, + // guarded commit and durable mutation report are implemented together. + capabilities.set(RunnerCapabilityId::ProjectFormat, false); capabilities.set(RunnerCapabilityId::ProjectDependencyPolicy, true); // Go project gateways pin GO111MODULE=on and GOWORK=off after prepared shell/profile env is // applied. Keep this independent for mixed Server/Runner rolling upgrades. diff --git a/src/runner_quic.rs b/src/runner_quic.rs index f46c84dc7..234c09134 100644 --- a/src/runner_quic.rs +++ b/src/runner_quic.rs @@ -612,6 +612,7 @@ mod tests { structured_go_test_json: true, project_validation_v1: false, project_build_v1: false, + project_format_v1: false, project_dependency_policy_v1: false, project_go_single_module_v1: false, project_validation_package_scope_v1: false, diff --git a/src/runner_ws.rs b/src/runner_ws.rs index 75c4e1852..9931c0adc 100644 --- a/src/runner_ws.rs +++ b/src/runner_ws.rs @@ -423,6 +423,7 @@ mod tests { structured_go_test_json: true, project_validation_v1: false, project_build_v1: false, + project_format_v1: false, project_dependency_policy_v1: false, project_go_single_module_v1: false, project_validation_package_scope_v1: false, diff --git a/src/tool_runtime/tests/metadata.rs b/src/tool_runtime/tests/metadata.rs index 8303cdf74..da5b99fe1 100644 --- a/src/tool_runtime/tests/metadata.rs +++ b/src/tool_runtime/tests/metadata.rs @@ -513,6 +513,7 @@ async fn register_agent_projects_for_auth( structured_go_test_json: true, project_validation_v1: false, project_build_v1: false, + project_format_v1: false, project_dependency_policy_v1: false, project_go_single_module_v1: false, project_validation_package_scope_v1: false, From b7b03c52b40bf7fc78c4296a8a15d2b1119b7e8b Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:08:30 +0800 Subject: [PATCH 04/12] fix(project): bound formatter candidate lifetimes and source witnesses --- crates/webcodex-core/src/lib.rs | 2 +- crates/webcodex-core/src/project_format.rs | 80 +++-- .../webcodex-core/src/project_format_tests.rs | 98 ++++-- crates/webcodex-core/src/runner_operation.rs | 11 +- .../src/webcodex_runner/file_access.rs | 20 +- .../src/webcodex_runner/mod.rs | 6 +- .../src/webcodex_runner/project_format.rs | 197 ++++++++---- .../webcodex_runner/project_format_tests.rs | 285 +++++++++++++++--- .../runner_instruction_tests.rs | 2 +- src/runner_ws.rs | 2 +- src/tool_runtime/tests/metadata.rs | 2 +- 11 files changed, 557 insertions(+), 148 deletions(-) diff --git a/crates/webcodex-core/src/lib.rs b/crates/webcodex-core/src/lib.rs index a21c5a382..4f7b66688 100644 --- a/crates/webcodex-core/src/lib.rs +++ b/crates/webcodex-core/src/lib.rs @@ -19,10 +19,10 @@ pub mod memory_contract; pub mod model_reference; pub mod plugin; pub mod project_build; +pub mod project_context_contract; pub mod project_format; #[cfg(test)] mod project_format_tests; -pub mod project_context_contract; pub mod project_instructions; pub mod project_listing; mod project_operation; diff --git a/crates/webcodex-core/src/project_format.rs b/crates/webcodex-core/src/project_format.rs index f11d4972a..e1d00a9a1 100644 --- a/crates/webcodex-core/src/project_format.rs +++ b/crates/webcodex-core/src/project_format.rs @@ -12,7 +12,9 @@ pub const PROJECT_FORMAT_MANIFEST_MAX_BYTES: usize = 256 * 1024; pub const PROJECT_FORMAT_PLAN_MAX_BYTES: usize = 32 * 1024; pub const PROJECT_FORMAT_TIMEOUT_MAX_SECS: u64 = 120; -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, +)] #[serde(rename_all = "snake_case")] pub enum ProjectFormatAdapter { #[default] @@ -108,16 +110,43 @@ impl ProjectFormatProfile { let (executable, args) = match self { Self::Rust { edition } if recipe_relative_file.ends_with(".rs") => ( "rustfmt", - vec!["--emit=stdout".into(), format!("--edition={edition}"), "--config".into(), "skip_children=true".into()], - ), - Self::Python { .. } if recipe_relative_file.ends_with(".py") || recipe_relative_file.ends_with(".pyi") => ( - "python", - vec!["-I", "-B", "-m", "ruff", "format", "--no-cache", "--config", "pyproject.toml", "--stdin-filename", recipe_relative_file, "-"] - .into_iter().map(str::to_string).collect(), + vec![ + "--emit=stdout".into(), + format!("--edition={edition}"), + "--config".into(), + "skip_children=true".into(), + ], ), + Self::Python { .. } + if recipe_relative_file.ends_with(".py") + || recipe_relative_file.ends_with(".pyi") => + { + ( + "python", + vec![ + "-I", + "-B", + "-m", + "ruff", + "format", + "--no-cache", + "--config", + "pyproject.toml", + "--stdin-filename", + recipe_relative_file, + "-", + ] + .into_iter() + .map(str::to_string) + .collect(), + ) + } _ => return Err("project format scope mismatch"), }; - Ok(ShellProcessArgv { executable: executable.into(), args }) + Ok(ShellProcessArgv { + executable: executable.into(), + args, + }) } } @@ -143,22 +172,37 @@ pub struct ProjectFormatPlan { impl ProjectFormatPlan { pub fn is_valid(&self) -> bool { - let digest = |s: &str| s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)); + let digest = |s: &str| { + s.len() == 64 + && s.bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + }; self.request.validate().is_ok() && valid_format_relative_path(&self.recipe_root, true) && digest(&self.root_digest) && digest(&self.manifest_digest) && self.profile.is_valid() - && (self.request.adapter == ProjectFormatAdapter::Auto || self.request.adapter == self.profile.adapter()) + && (self.request.adapter == ProjectFormatAdapter::Auto + || self.request.adapter == self.profile.adapter()) && self.files.len() == self.request.files.len() - && self.files.iter().zip(&self.request.files).all(|(witness, path)| { - witness.path == *path - && witness.bytes <= PROJECT_FORMAT_FILE_MAX_BYTES - && digest(&witness.sha256) - && digest(&witness.identity_digest) - && self.recipe_relative_file(path).is_some_and(|relative| self.profile.process(relative).is_ok()) - }) - && self.files.iter().try_fold(0usize, |total, file| total.checked_add(file.bytes)).is_some_and(|total| total <= PROJECT_FORMAT_TOTAL_MAX_BYTES) + && self + .files + .iter() + .zip(&self.request.files) + .all(|(witness, path)| { + witness.path == *path + && witness.bytes <= PROJECT_FORMAT_FILE_MAX_BYTES + && digest(&witness.sha256) + && digest(&witness.identity_digest) + && self + .recipe_relative_file(path) + .is_some_and(|relative| self.profile.process(relative).is_ok()) + }) + && self + .files + .iter() + .try_fold(0usize, |total, file| total.checked_add(file.bytes)) + .is_some_and(|total| total <= PROJECT_FORMAT_TOTAL_MAX_BYTES) } pub fn recipe_relative_file<'a>(&self, path: &'a str) -> Option<&'a str> { diff --git a/crates/webcodex-core/src/project_format_tests.rs b/crates/webcodex-core/src/project_format_tests.rs index 64a22432d..3d9144ec2 100644 --- a/crates/webcodex-core/src/project_format_tests.rs +++ b/crates/webcodex-core/src/project_format_tests.rs @@ -4,16 +4,28 @@ use crate::runner_protocol::{RunnerCapabilities, RunnerCapabilityId, RunnerReque fn request() -> ProjectFormatRequest { ProjectFormatRequest { - project_id: "demo".into(), cwd: None, adapter: ProjectFormatAdapter::Auto, + project_id: "demo".into(), + cwd: None, + adapter: ProjectFormatAdapter::Auto, files: vec!["src/main.rs".into()], } } fn plan() -> ProjectFormatPlan { ProjectFormatPlan { - request: request(), recipe_root: ".".into(), root_digest: "a".repeat(64), - manifest_digest: "b".repeat(64), profile: ProjectFormatProfile::Rust { edition: "2021".into() }, - files: vec![ProjectFormatFileWitness { path: "src/main.rs".into(), bytes: 12, sha256: "c".repeat(64), identity_digest: "d".repeat(64) }], + request: request(), + recipe_root: ".".into(), + root_digest: "a".repeat(64), + manifest_digest: "b".repeat(64), + profile: ProjectFormatProfile::Rust { + edition: "2021".into(), + }, + files: vec![ProjectFormatFileWitness { + path: "src/main.rs".into(), + bytes: 12, + sha256: "c".repeat(64), + identity_digest: "d".repeat(64), + }], } } @@ -21,10 +33,20 @@ fn plan() -> ProjectFormatPlan { fn project_format_request_is_closed_bounded_and_single_language() { assert!(request().validate().is_ok()); for files in [ - vec![], vec!["x.rs"; 9], vec!["x.rs", "x.rs"], vec!["x.rs", "x.py"], - vec!["../x.rs"], vec!["/x.rs"], vec!["C:/x.rs"], vec!["a\\x.rs"], - vec!["./x.rs"], vec!["a//x.rs"], vec!["x\n.rs"], vec!["x\0.rs"], - vec!["x.js"], vec!["x.RS"], + vec![], + vec!["x.rs"; 9], + vec!["x.rs", "x.rs"], + vec!["x.rs", "x.py"], + vec!["../x.rs"], + vec!["/x.rs"], + vec!["C:/x.rs"], + vec!["a\\x.rs"], + vec!["./x.rs"], + vec!["a//x.rs"], + vec!["x\n.rs"], + vec!["x\0.rs"], + vec!["x.js"], + vec!["x.RS"], ] { let mut invalid = request(); invalid.files = files.into_iter().map(str::to_string).collect(); @@ -53,7 +75,11 @@ fn project_format_plan_fences_order_bytes_profile_and_recipe_root() { 0 => updated.files[0].sha256 = "d".repeat(64), 1 => updated.files[0].bytes += 1, 2 => updated.manifest_digest = "d".repeat(64), - _ => updated.profile = ProjectFormatProfile::Rust { edition: "2024".into() }, + _ => { + updated.profile = ProjectFormatProfile::Rust { + edition: "2024".into(), + } + } } assert_ne!(updated.digest().unwrap(), digest); } @@ -65,7 +91,11 @@ fn project_format_plan_fences_order_bytes_profile_and_recipe_root() { 2 => invalid.files[0].sha256 = "invalid".into(), 3 => invalid.files[0].path = "other.rs".into(), 4 => invalid.files.clear(), - _ => invalid.profile = ProjectFormatProfile::Python { target_version: "py311".into() }, + _ => { + invalid.profile = ProjectFormatProfile::Python { + target_version: "py311".into(), + } + } } assert!(invalid.digest().is_err()); } @@ -78,20 +108,53 @@ fn project_format_plan_fences_order_bytes_profile_and_recipe_root() { fn project_format_commands_have_only_fixed_stdin_profiles() { let rust = plan().profile.process("src/main.rs").unwrap(); assert_eq!(rust.executable, "rustfmt"); - assert_eq!(rust.args, ["--emit=stdout", "--edition=2021", "--config", "skip_children=true"]); - let python = ProjectFormatProfile::Python { target_version: "py311".into() }; + assert_eq!( + rust.args, + [ + "--emit=stdout", + "--edition=2021", + "--config", + "skip_children=true" + ] + ); + let python = ProjectFormatProfile::Python { + target_version: "py311".into(), + }; let python = python.process("package/code.pyi").unwrap(); - assert_eq!(python.args, ["-I", "-B", "-m", "ruff", "format", "--no-cache", "--config", "pyproject.toml", "--stdin-filename", "package/code.pyi", "-"]); + assert_eq!( + python.args, + [ + "-I", + "-B", + "-m", + "ruff", + "format", + "--no-cache", + "--config", + "pyproject.toml", + "--stdin-filename", + "package/code.pyi", + "-" + ] + ); } #[test] fn project_format_wire_roundtrip_rejects_execution_and_oversized_payloads() { let wire = RunnerRequest::from_operation( - RunnerInvocationMetadata { request_id: "r".into(), client_id: "runner".into(), requested_by: "test".into(), created_at: 0 }, + RunnerInvocationMetadata { + request_id: "r".into(), + client_id: "runner".into(), + requested_by: "test".into(), + created_at: 0, + }, RunnerOperation::PlanProjectFormat(request()), - ).unwrap(); + ) + .unwrap(); assert_eq!(wire.kind, "plan_project_format"); - assert!(matches!(wire.decode_operation().unwrap(), RunnerOperation::PlanProjectFormat(decoded) if decoded == request())); + assert!( + matches!(wire.decode_operation().unwrap(), RunnerOperation::PlanProjectFormat(decoded) if decoded == request()) + ); for field in ["command", "stdin", "cwd", "path", "job_id", "content"] { let mut invalid = wire.clone(); match field { @@ -116,7 +179,8 @@ fn project_format_capability_and_mutation_truth_default_closed() { (ProjectFormatMutationReport::Unchanged, Some(false)), (ProjectFormatMutationReport::Changed, Some(true)), ] { - let retained: ProjectFormatMutationReport = serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap(); + let retained: ProjectFormatMutationReport = + serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap(); assert_eq!(retained.state_changed(), changed); } } diff --git a/crates/webcodex-core/src/runner_operation.rs b/crates/webcodex-core/src/runner_operation.rs index 5057485a5..ddd5760a9 100644 --- a/crates/webcodex-core/src/runner_operation.rs +++ b/crates/webcodex-core/src/runner_operation.rs @@ -1528,10 +1528,13 @@ fn decode_operation(wire: &RunnerRequest) -> Result { { return Err("project format planning contains execution fields".into()); } - let payload: crate::project_format::ProjectFormatRequest = serde_json::from_str( - bounded_content(wire, crate::project_format::PROJECT_FORMAT_PLAN_MAX_BYTES, "project format request")?, - ) - .map_err(|e| e.to_string())?; + let payload: crate::project_format::ProjectFormatRequest = + serde_json::from_str(bounded_content( + wire, + crate::project_format::PROJECT_FORMAT_PLAN_MAX_BYTES, + "project format request", + )?) + .map_err(|e| e.to_string())?; payload.validate()?; Ok(RunnerOperation::PlanProjectFormat(payload)) } diff --git a/crates/webcodex-runner/src/webcodex_runner/file_access.rs b/crates/webcodex-runner/src/webcodex_runner/file_access.rs index 5e58f89b8..74c2848d3 100644 --- a/crates/webcodex-runner/src/webcodex_runner/file_access.rs +++ b/crates/webcodex-runner/src/webcodex_runner/file_access.rs @@ -21,7 +21,10 @@ pub(crate) fn file_identity(file: &File) -> io::Result> { #[cfg(not(any(unix, windows)))] { let _ = file; - Err(io::Error::new(io::ErrorKind::Unsupported, "file identity unavailable")) + Err(io::Error::new( + io::ErrorKind::Unsupported, + "file identity unavailable", + )) } } @@ -48,7 +51,10 @@ pub(crate) fn file_link_count(file: &File) -> io::Result { #[cfg(not(any(unix, windows)))] { let _ = file; - Err(io::Error::new(io::ErrorKind::Unsupported, "file link count unavailable")) + Err(io::Error::new( + io::ErrorKind::Unsupported, + "file link count unavailable", + )) } } @@ -64,7 +70,10 @@ pub(crate) fn directory_identity(path: &Path) -> io::Result> { #[cfg(not(any(unix, windows)))] { let _ = path; - Err(io::Error::new(io::ErrorKind::Unsupported, "directory identity unavailable")) + Err(io::Error::new( + io::ErrorKind::Unsupported, + "directory identity unavailable", + )) } } @@ -254,7 +263,10 @@ fn unix_directory_open_flags() -> libc::c_int { } #[cfg(windows)] -pub(crate) fn open_regular_file_windows(path: &Path, before_leaf: impl FnOnce()) -> io::Result { +pub(crate) fn open_regular_file_windows( + path: &Path, + before_leaf: impl FnOnce(), +) -> io::Result { use std::os::windows::io::AsRawHandle; use windows_sys::Win32::Foundation::HANDLE; use windows_sys::Win32::Storage::FileSystem::{FILE_GENERIC_READ, FILE_SHARE_READ}; diff --git a/crates/webcodex-runner/src/webcodex_runner/mod.rs b/crates/webcodex-runner/src/webcodex_runner/mod.rs index f0cb9c44a..a6e7ec542 100644 --- a/crates/webcodex-runner/src/webcodex_runner/mod.rs +++ b/crates/webcodex-runner/src/webcodex_runner/mod.rs @@ -13,8 +13,8 @@ pub(crate) mod execution_io; #[cfg(windows)] pub(crate) mod exit_diagnostics; pub(crate) mod external_tools; -pub(crate) mod file_dispatch; pub(crate) mod file_access; +pub(crate) mod file_dispatch; pub(crate) mod files; pub(crate) mod job_manager; pub(crate) mod lsp; @@ -26,11 +26,11 @@ pub(crate) mod patches; pub(crate) mod persistent_shell; pub(crate) mod plugin; pub(crate) mod project_build; +#[cfg(test)] +mod project_build_tests; pub(crate) mod project_format; #[cfg(test)] mod project_format_tests; -#[cfg(test)] -mod project_build_tests; pub(crate) mod projects; pub(crate) mod runner_instructions; pub(crate) mod runner_skills; diff --git a/crates/webcodex-runner/src/webcodex_runner/project_format.rs b/crates/webcodex-runner/src/webcodex_runner/project_format.rs index ae3dc9459..57ecbcacb 100644 --- a/crates/webcodex-runner/src/webcodex_runner/project_format.rs +++ b/crates/webcodex-runner/src/webcodex_runner/project_format.rs @@ -2,11 +2,11 @@ //! not confer write authority or advertise an executable formatting capability. use super::config::RunnerPolicy; +use super::file_access::{directory_identity, file_identity, file_link_count, open_regular_file}; use super::projects::find_project_shell_context_by_id; use super::shell::cwd_allowed; use sha2::{Digest, Sha256}; use std::fs; -use super::file_access::{directory_identity, file_identity, file_link_count, open_regular_file}; use std::io::Read; use std::path::{Path, PathBuf}; use webcodex_core::apply_edits_shared::is_sensitive_edit_path; @@ -50,7 +50,9 @@ fn validate_marker_chain(root: &Path, relative: &str) -> Result<(), &'static str let mut found = false; for marker in ["Cargo.toml", "pyproject.toml", "package.json", "go.mod"] { match fs::symlink_metadata(parent.join(marker)) { - Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => found = true, + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => { + found = true + } Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} _ => return Err("format_manifest_invalid"), } @@ -83,7 +85,11 @@ fn checked_path(root: &Path, relative: &str, directory: bool) -> Result Result<(String, Strin } let identity = file_identity(&file).map_err(|_| "format_file_unavailable")?; let mut bytes = Vec::new(); - file.take(max as u64 + 1).read_to_end(&mut bytes).map_err(|_| "format_file_unavailable")?; + file.take(max as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| "format_file_unavailable")?; if bytes.len() > max { return Err("format_input_too_large"); } if checked_path(root, relative, false)? != path - || file_identity(&open_regular_file(&path).map_err(|_| "format_file_unavailable")?).map_err(|_| "format_file_unavailable")? != identity + || file_identity(&open_regular_file(&path).map_err(|_| "format_file_unavailable")?) + .map_err(|_| "format_file_unavailable")? + != identity { return Err("format_plan_stale"); } - Ok((String::from_utf8(bytes).map_err(|_| "format_invalid_utf8")?, digest(&identity))) + Ok(( + String::from_utf8(bytes).map_err(|_| "format_invalid_utf8")?, + digest(&identity), + )) } fn rust_profile(manifest: &toml::Value) -> Result { - let package = manifest.get("package").and_then(toml::Value::as_table) + let package = manifest + .get("package") + .and_then(toml::Value::as_table) .ok_or("format_manifest_unsupported")?; // Inherited edition and explicit external workspace topology require an // independent profile. Do not silently choose a default edition for them. @@ -121,7 +136,9 @@ fn rust_profile(manifest: &toml::Value) -> Result "2015", Some(value) => value.as_str().ok_or("format_manifest_unsupported")?, }; - let profile = ProjectFormatProfile::Rust { edition: edition.into() }; + let profile = ProjectFormatProfile::Rust { + edition: edition.into(), + }; if !profile.is_valid() { return Err("format_manifest_invalid"); } @@ -129,14 +146,21 @@ fn rust_profile(manifest: &toml::Value) -> Result Result { - let ruff = manifest.get("tool").and_then(|tool| tool.get("ruff")) - .and_then(toml::Value::as_table).ok_or("format_manifest_unsupported")?; + let ruff = manifest + .get("tool") + .and_then(|tool| tool.get("ruff")) + .and_then(toml::Value::as_table) + .ok_or("format_manifest_unsupported")?; if ruff.contains_key("extend") { return Err("format_manifest_invalid"); } - let target_version = ruff.get("target-version").and_then(toml::Value::as_str) + let target_version = ruff + .get("target-version") + .and_then(toml::Value::as_str) .ok_or("format_manifest_invalid")?; - let profile = ProjectFormatProfile::Python { target_version: target_version.into() }; + let profile = ProjectFormatProfile::Python { + target_version: target_version.into(), + }; if !profile.is_valid() { return Err("format_manifest_invalid"); } @@ -174,7 +198,9 @@ pub(crate) fn plan( registry: &Path, request: &ProjectFormatRequest, ) -> Result { - request.validate().map_err(|_| unavailable("invalid_arguments"))?; + request + .validate() + .map_err(|_| unavailable("invalid_arguments"))?; let project = find_project_shell_context_by_id(registry, &request.project_id) .ok_or_else(|| unavailable("unknown_project"))?; if !project.allow_patch || !policy.allow_raw_shell { @@ -182,13 +208,16 @@ pub(crate) fn plan( } let root = PathBuf::from(&project.path); cwd_allowed(policy, &root).map_err(|_| unavailable("invalid_project_path"))?; - let root = root.canonicalize().map_err(|_| unavailable("invalid_project_path"))?; + let root = root + .canonicalize() + .map_err(|_| unavailable("invalid_project_path"))?; let initial_root_digest = root_digest(&root).map_err(unavailable)?; checked_path(&root, request.cwd.as_deref().unwrap_or("."), true).map_err(unavailable)?; // No manifestless Python fallback and no explicit-adapter bypass of an // ambiguous root. Formatting needs a concrete local manifest in all cases. validate_marker_chain(&root, request.cwd.as_deref().unwrap_or(".")).map_err(unavailable)?; - let resolved = resolve_project_recipe_root(&root, request.cwd.as_deref(), None).map_err(recipe_error)?; + let resolved = + resolve_project_recipe_root(&root, request.cwd.as_deref(), None).map_err(recipe_error)?; let adapter = match resolved.recipe { ProjectRecipeId::Rust => ProjectFormatAdapter::Rust, ProjectRecipeId::Python => ProjectFormatAdapter::Python, @@ -197,10 +226,16 @@ pub(crate) fn plan( if request.adapter != ProjectFormatAdapter::Auto && request.adapter != adapter { return Err(unavailable("format_recipe_mismatch")); } - let marker = resolved.marker_path().strip_prefix(&root) - .map_err(|_| unavailable("format_path_invalid"))?.to_string_lossy().replace('\\', "/"); - let (manifest, manifest_identity) = read_source(&root, &marker, PROJECT_FORMAT_MANIFEST_MAX_BYTES).map_err(unavailable)?; - let value: toml::Value = toml::from_str(&manifest).map_err(|_| unavailable("format_manifest_invalid"))?; + let marker = resolved + .marker_path() + .strip_prefix(&root) + .map_err(|_| unavailable("format_path_invalid"))? + .to_string_lossy() + .replace('\\', "/"); + let (manifest, manifest_identity) = + read_source(&root, &marker, PROJECT_FORMAT_MANIFEST_MAX_BYTES).map_err(unavailable)?; + let value: toml::Value = + toml::from_str(&manifest).map_err(|_| unavailable("format_manifest_invalid"))?; let profile = match adapter { ProjectFormatAdapter::Rust => { reject_custom_rustfmt_config(&resolved.absolute_root).map_err(unavailable)?; @@ -215,15 +250,25 @@ pub(crate) fn plan( let mut total = 0usize; for relative in &request.files { let path = checked_path(&root, relative, false).map_err(unavailable)?; - let parent = path.parent().ok_or_else(|| unavailable("format_path_invalid"))?; - let parent = parent.strip_prefix(&root).map_err(|_| unavailable("format_path_invalid"))?; - let parent = if parent.as_os_str().is_empty() { ".".into() } else { parent.to_string_lossy().replace('\\', "/") }; + let parent = path + .parent() + .ok_or_else(|| unavailable("format_path_invalid"))?; + let parent = parent + .strip_prefix(&root) + .map_err(|_| unavailable("format_path_invalid"))?; + let parent = if parent.as_os_str().is_empty() { + ".".into() + } else { + parent.to_string_lossy().replace('\\', "/") + }; validate_marker_chain(&root, &parent).map_err(unavailable)?; - let owner = resolve_project_recipe_root(&root, Some(&parent), None).map_err(recipe_error)?; + let owner = + resolve_project_recipe_root(&root, Some(&parent), None).map_err(recipe_error)?; if owner != resolved { return Err(unavailable("format_scope_mismatch")); } - let (source, identity) = read_source(&root, relative, PROJECT_FORMAT_FILE_MAX_BYTES).map_err(unavailable)?; + let (source, identity) = + read_source(&root, relative, PROJECT_FORMAT_FILE_MAX_BYTES).map_err(unavailable)?; if identities.contains(&identity) { return Err(unavailable("format_duplicate_file")); } @@ -232,19 +277,33 @@ pub(crate) fn plan( if total > PROJECT_FORMAT_TOTAL_MAX_BYTES { return Err(unavailable("format_input_too_large")); } - files.push(ProjectFormatFileWitness { path: relative.clone(), bytes: source.len(), sha256: digest(source.as_bytes()), identity_digest: identity }); + files.push(ProjectFormatFileWitness { + path: relative.clone(), + bytes: source.len(), + sha256: digest(source.as_bytes()), + identity_digest: identity, + }); sources.push(source); } if root_digest(&root).map_err(unavailable)? != initial_root_digest { return Err(unavailable("format_plan_stale")); } let plan = ProjectFormatPlan { - request: request.clone(), recipe_root: resolved.relative_root, + request: request.clone(), + recipe_root: resolved.relative_root, root_digest: initial_root_digest, - manifest_digest: digest(&[manifest_identity.as_bytes(), manifest.as_bytes()].concat()), profile, files, + manifest_digest: digest(&[manifest_identity.as_bytes(), manifest.as_bytes()].concat()), + profile, + files, }; - plan.digest().map_err(|_| unavailable("format_scope_mismatch"))?; - Ok(PlannedFormat { plan, root, cwd: resolved.absolute_root, sources }) + plan.digest() + .map_err(|_| unavailable("format_scope_mismatch"))?; + Ok(PlannedFormat { + plan, + root, + cwd: resolved.absolute_root, + sources, + }) } /// Admission and worker must independently obtain fresh snapshots through this @@ -283,66 +342,96 @@ pub(crate) fn handle( } } -/// Dry-run formatter candidate generation. -/// Runs the trusted installed formatter (e.g. rustfmt) on stdin and captures stdout. -/// Never mutates project files, never runs arbitrary scripts, and does not leak sources into logs. +/// Generate bounded formatter candidates without changing project files. +/// +/// Runner-owned Job execution supplies its generation, total deadline and stop +/// signal. A single request never receives a fresh timeout for every file. pub(crate) fn format_candidates( policy: &RunnerPolicy, shell: &super::config::ShellConfig, registry: &Path, planned: &PlannedFormat, cache: &super::shell::PreparedShellProfileCache, + generation: u64, + timeout_secs: u64, + stop_requested: Option<&std::sync::atomic::AtomicBool>, ) -> Result, &'static str> { - if !planned.plan.is_valid() { - return Err("format_plan_invalid"); + use std::sync::atomic::Ordering; + use std::time::{Duration, Instant}; + use webcodex_core::runner_protocol::ShellCommandExecutionState; + + if !(1..=PROJECT_FORMAT_TIMEOUT_MAX_SECS).contains(&timeout_secs) { + return Err("format_timeout_invalid"); } - if planned.sources.len() != planned.plan.files.len() { + if !planned.plan.is_valid() || planned.sources.len() != planned.plan.files.len() { return Err("format_plan_invalid"); } + let started = Instant::now(); + let total_budget = Duration::from_secs(timeout_secs); + let mut total_output = 0usize; let mut candidates = Vec::with_capacity(planned.sources.len()); - for (index, file_witness) in planned.plan.files.iter().enumerate() { + for (index, witness) in planned.plan.files.iter().enumerate() { + if stop_requested.is_some_and(|stop| stop.load(Ordering::SeqCst)) { + return Err("format_cancelled"); + } + let remaining = total_budget.saturating_sub(started.elapsed()).as_secs(); + if remaining == 0 { + return Err("format_timeout"); + } let source = &planned.sources[index]; - let recipe_relative = planned + let relative = planned .plan - .recipe_relative_file(&file_witness.path) + .recipe_relative_file(&witness.path) .ok_or("format_path_invalid")?; - let process_cmd = planned + let command = planned .plan .profile - .process(recipe_relative) + .process(relative) .map_err(|_| "format_profile_invalid")?; + let cwd = planned.cwd.to_str().ok_or("format_path_invalid")?; - let cwd_str = planned.cwd.to_str().ok_or("format_path_invalid")?; let result = super::shell::run_process_with_profiles_and_execution_state( - 1, + generation, policy, shell, registry, cache, - Some(cwd_str), - &process_cmd.executable, - &process_cmd.args, + Some(cwd), + &command.executable, + &command.args, Some(source), - PROJECT_FORMAT_TIMEOUT_MAX_SECS, - None, + remaining, + stop_requested, ); - + if stop_requested.is_some_and(|stop| stop.load(Ordering::SeqCst)) { + return Err("format_cancelled"); + } + match result.execution_state { + ShellCommandExecutionState::Completed => {} + ShellCommandExecutionState::NotStarted => return Err("format_process_failed"), + ShellCommandExecutionState::TimedOut => return Err("format_timeout"), + ShellCommandExecutionState::OutcomeUnknown => return Err("format_execution_unknown"), + } if result.stdout_truncated || result.stderr_truncated { return Err("format_output_truncated"); } - - if result.result.exit_code != Some(0) { + if result.result.exit_code != Some(0) || result.result.error.is_some() { return Err("format_process_failed"); } - let stdout = result.result.stdout.ok_or("format_process_failed")?; - if stdout.len() > PROJECT_FORMAT_FILE_MAX_BYTES { + total_output = total_output + .checked_add(stdout.len()) + .ok_or("format_candidate_too_large")?; + if stdout.len() > PROJECT_FORMAT_FILE_MAX_BYTES + || total_output > PROJECT_FORMAT_TOTAL_MAX_BYTES + { return Err("format_candidate_too_large"); } - + if started.elapsed() > total_budget { + return Err("format_timeout"); + } candidates.push(stdout); } - Ok(candidates) } diff --git a/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs b/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs index 6aa6f71c3..94511d551 100644 --- a/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs @@ -25,20 +25,37 @@ impl Fixture { fs::create_dir(®istry).unwrap(); fs::write(root.join("Cargo.toml"), MANIFEST).unwrap(); fs::write(root.join("src/main.rs"), "fn main( ){ }\n").unwrap(); - let fixture = Self { _temp: temp, root: root.clone(), registry, policy: RunnerPolicy { allowed_roots: vec![root], ..Default::default() } }; + let fixture = Self { + _temp: temp, + root: root.clone(), + registry, + policy: RunnerPolicy { + allowed_roots: vec![root], + ..Default::default() + }, + }; fixture.register(true); fixture } fn register(&self, allow_patch: bool) { - fs::write(self.registry.join("demo.toml"), format!( - "id='demo'\npath={}\nallow_patch={allow_patch}\n", - serde_json::to_string(self.root.to_str().unwrap()).unwrap(), - )).unwrap(); + fs::write( + self.registry.join("demo.toml"), + format!( + "id='demo'\npath={}\nallow_patch={allow_patch}\n", + serde_json::to_string(self.root.to_str().unwrap()).unwrap(), + ), + ) + .unwrap(); } fn request(&self) -> ProjectFormatRequest { - ProjectFormatRequest { project_id: "demo".into(), cwd: None, adapter: ProjectFormatAdapter::Auto, files: vec!["src/main.rs".into()] } + ProjectFormatRequest { + project_id: "demo".into(), + cwd: None, + adapter: ProjectFormatAdapter::Auto, + files: vec!["src/main.rs".into()], + } } fn plan(&self) -> PlannedFormat { @@ -61,12 +78,21 @@ fn project_format_plan_keeps_source_private_and_replans_exact_bytes() { assert_eq!(original.root, fixture.root.canonicalize().unwrap()); assert_eq!(original.cwd, original.root); assert!(original.plan.is_valid()); - assert!(!serde_json::to_string(&original.plan).unwrap().contains("fn main")); + assert!(!serde_json::to_string(&original.plan) + .unwrap() + .contains("fn main")); assert!(replan(&fixture.policy, &fixture.registry, &original.plan).is_ok()); fs::write(fixture.root.join("src/main.rs"), "fn main() {}\n").unwrap(); - assert!(matches!(replan(&fixture.policy, &fixture.registry, &original.plan), Err("format_plan_stale"))); + assert!(matches!( + replan(&fixture.policy, &fixture.registry, &original.plan), + Err("format_plan_stale") + )); let updated = fixture.plan(); - fs::write(fixture.root.join("Cargo.toml"), MANIFEST.replace("2021", "2024")).unwrap(); + fs::write( + fixture.root.join("Cargo.toml"), + MANIFEST.replace("2021", "2024"), + ) + .unwrap(); assert!(replan(&fixture.policy, &fixture.registry, &updated.plan).is_err()); } @@ -89,12 +115,21 @@ fn project_format_requires_exact_enabled_writable_project_and_process_policy() { let mut fixture = Fixture::new(); let mut unknown = fixture.request(); unknown.project_id = "other".into(); - assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &unknown)), "unknown_project"); + assert_eq!( + rejection(plan(&fixture.policy, &fixture.registry, &unknown)), + "unknown_project" + ); fixture.register(false); - assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), "permission_denied"); + assert_eq!( + rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), + "permission_denied" + ); fixture.register(true); fixture.policy.allow_raw_shell = false; - assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), "permission_denied"); + assert_eq!( + rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), + "permission_denied" + ); } #[test] @@ -105,33 +140,69 @@ fn project_format_rejects_cross_recipe_and_ambiguous_roots_without_adapter_fallb fs::write(fixture.root.join("nested/main.rs"), "fn main() {}\n").unwrap(); let mut request = fixture.request(); request.files.push("nested/main.rs".into()); - assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &request)), "format_scope_mismatch"); + assert_eq!( + rejection(plan(&fixture.policy, &fixture.registry, &request)), + "format_scope_mismatch" + ); request.files.remove(0); request.cwd = Some("nested".into()); - assert_eq!(plan(&fixture.policy, &fixture.registry, &request).unwrap().plan.recipe_root, "nested"); - fs::write(fixture.root.join("nested/pyproject.toml"), "[tool.ruff]\ntarget-version='py311'\n").unwrap(); + assert_eq!( + plan(&fixture.policy, &fixture.registry, &request) + .unwrap() + .plan + .recipe_root, + "nested" + ); + fs::write( + fixture.root.join("nested/pyproject.toml"), + "[tool.ruff]\ntarget-version='py311'\n", + ) + .unwrap(); request.adapter = ProjectFormatAdapter::Rust; - assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &request)), "format_recipe_ambiguous"); + assert_eq!( + rejection(plan(&fixture.policy, &fixture.registry, &request)), + "format_recipe_ambiguous" + ); } #[test] fn project_format_invalid_nested_markers_never_fall_back_to_parent() { let fixture = Fixture::new(); fs::create_dir(fixture.root.join("src/Cargo.toml")).unwrap(); - assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), "format_manifest_invalid"); + assert_eq!( + rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), + "format_manifest_invalid" + ); } #[test] fn project_format_rejects_custom_config_inherited_edition_and_large_inputs() { let fixture = Fixture::new(); fs::write(fixture.root.join("rustfmt.toml"), "max_width=80\n").unwrap(); - assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), "format_config_unsupported"); + assert_eq!( + rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), + "format_config_unsupported" + ); fs::remove_file(fixture.root.join("rustfmt.toml")).unwrap(); - fs::write(fixture.root.join("Cargo.toml"), "[package]\nname='demo'\nedition.workspace=true\n").unwrap(); - assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), "format_manifest_unsupported"); + fs::write( + fixture.root.join("Cargo.toml"), + "[package]\nname='demo'\nedition.workspace=true\n", + ) + .unwrap(); + assert_eq!( + rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), + "format_manifest_unsupported" + ); fs::write(fixture.root.join("Cargo.toml"), MANIFEST).unwrap(); - fs::write(fixture.root.join("src/main.rs"), vec![b' '; PROJECT_FORMAT_FILE_MAX_BYTES + 1]).unwrap(); - assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), "format_input_too_large"); + fs::write( + fixture.root.join("src/main.rs"), + vec![b' '; PROJECT_FORMAT_FILE_MAX_BYTES + 1], + ) + .unwrap(); + assert_eq!( + rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), + "format_input_too_large" + ); } #[test] @@ -141,12 +212,22 @@ fn project_format_bounds_total_bytes_and_rejects_invalid_utf8() { request.files.clear(); for index in 0..5 { let path = format!("src/file{index}.rs"); - fs::write(fixture.root.join(&path), vec![b' '; PROJECT_FORMAT_FILE_MAX_BYTES]).unwrap(); + fs::write( + fixture.root.join(&path), + vec![b' '; PROJECT_FORMAT_FILE_MAX_BYTES], + ) + .unwrap(); request.files.push(path); } - assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &request)), "format_input_too_large"); + assert_eq!( + rejection(plan(&fixture.policy, &fixture.registry, &request)), + "format_input_too_large" + ); fs::write(fixture.root.join("src/main.rs"), [0xff]).unwrap(); - assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), "format_invalid_utf8"); + assert_eq!( + rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), + "format_invalid_utf8" + ); } #[cfg(feature = "runner-real-process-tests")] @@ -156,21 +237,52 @@ fn runner_real_process_project_format_rustfmt_stdin_leaves_sources_untouched() { use super::config::ShellConfig; use super::shell::{run_process_with_profiles_and_execution_state, PreparedShellProfileCache}; let fixture = Fixture::new(); - fs::write(fixture.root.join("src/main.rs"), "mod child;\nfn main( ){ }\n").unwrap(); + fs::write( + fixture.root.join("src/main.rs"), + "mod child;\nfn main( ){ }\n", + ) + .unwrap(); fs::write(fixture.root.join("child.rs"), "fn child( ){ }\n").unwrap(); let planned = fixture.plan(); let command = planned.plan.profile.process("src/main.rs").unwrap(); let result = run_process_with_profiles_and_execution_state( - 1, &fixture.policy, &ShellConfig::default(), &fixture.registry, - &PreparedShellProfileCache::default(), planned.cwd.to_str(), - &command.executable, &command.args, Some(&planned.sources[0]), 10, None, + 1, + &fixture.policy, + &ShellConfig::default(), + &fixture.registry, + &PreparedShellProfileCache::default(), + planned.cwd.to_str(), + &command.executable, + &command.args, + Some(&planned.sources[0]), + 10, + None, + ); + assert_eq!( + result.result.exit_code, + Some(0), + "{:?}", + result.result.error + ); + assert_eq!( + result.result.stdout.as_deref(), + Some("mod child;\nfn main() {}\n") ); - assert_eq!(result.result.exit_code, Some(0), "{:?}", result.result.error); - assert_eq!(result.result.stdout.as_deref(), Some("mod child;\nfn main() {}\n")); assert!(!result.stdout_truncated && !result.stderr_truncated); - assert!(result.result.stderr.as_deref().unwrap_or_default().is_empty()); - assert_eq!(fs::read_to_string(fixture.root.join("src/main.rs")).unwrap(), planned.sources[0]); - assert_eq!(fs::read_to_string(fixture.root.join("child.rs")).unwrap(), "fn child( ){ }\n"); + assert!(result + .result + .stderr + .as_deref() + .unwrap_or_default() + .is_empty()); + assert_eq!( + fs::read_to_string(fixture.root.join("src/main.rs")).unwrap(), + planned.sources[0] + ); + assert_eq!( + fs::read_to_string(fixture.root.join("child.rs")).unwrap(), + "fn child( ){ }\n" + ); } #[test] @@ -181,16 +293,36 @@ fn project_format_python_pins_local_target_and_rejects_extend() { let mut request = fixture.request(); request.adapter = ProjectFormatAdapter::Python; request.files = vec!["src/main.py".into()]; - for manifest in ["[tool.ruff]\n", "[tool.ruff]\ntarget-version='ambient'\n", "[tool.ruff]\ntarget-version='py311'\nextend='other.toml'\n"] { + for manifest in [ + "[tool.ruff]\n", + "[tool.ruff]\ntarget-version='ambient'\n", + "[tool.ruff]\ntarget-version='py311'\nextend='other.toml'\n", + ] { fs::write(fixture.root.join("pyproject.toml"), manifest).unwrap(); - assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &request)), "format_manifest_invalid"); + assert_eq!( + rejection(plan(&fixture.policy, &fixture.registry, &request)), + "format_manifest_invalid" + ); } - fs::write(fixture.root.join("pyproject.toml"), "[tool.ruff]\ntarget-version='py311'\n").unwrap(); + fs::write( + fixture.root.join("pyproject.toml"), + "[tool.ruff]\ntarget-version='py311'\n", + ) + .unwrap(); let planned = plan(&fixture.policy, &fixture.registry, &request).unwrap(); - assert_eq!(planned.plan.profile, ProjectFormatProfile::Python { target_version: "py311".into() }); + assert_eq!( + planned.plan.profile, + ProjectFormatProfile::Python { + target_version: "py311".into() + } + ); // A valid static profile is not evidence of installed formatter support. let result = handle(&fixture.policy, &fixture.registry, &request); - assert_eq!(serde_json::from_str::(result.stdout.as_deref().unwrap()).unwrap()["code"], "format_execution_unavailable"); + assert_eq!( + serde_json::from_str::(result.stdout.as_deref().unwrap()).unwrap() + ["code"], + "format_execution_unavailable" + ); } #[cfg(unix)] @@ -201,15 +333,28 @@ fn project_format_rejects_symlink_hardlink_and_fifo_inputs() { let mut request = fixture.request(); symlink("main.rs", fixture.root.join("src/link.rs")).unwrap(); request.files = vec!["src/link.rs".into()]; - assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &request)), "format_path_invalid"); - fs::hard_link(fixture.root.join("src/main.rs"), fixture.root.join("src/alias.rs")).unwrap(); + assert_eq!( + rejection(plan(&fixture.policy, &fixture.registry, &request)), + "format_path_invalid" + ); + fs::hard_link( + fixture.root.join("src/main.rs"), + fixture.root.join("src/alias.rs"), + ) + .unwrap(); request.files = vec!["src/main.rs".into()]; // Even a single selected path must not mutate an inode with other aliases. - assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &request)), "format_alias_unavailable"); + assert_eq!( + rejection(plan(&fixture.policy, &fixture.registry, &request)), + "format_alias_unavailable" + ); let fifo = fixture.root.join("src/pipe.rs"); make_fifo(&fifo); request.files = vec!["src/pipe.rs".into()]; - assert_eq!(rejection(plan(&fixture.policy, &fixture.registry, &request)), "format_path_invalid"); + assert_eq!( + rejection(plan(&fixture.policy, &fixture.registry, &request)), + "format_path_invalid" + ); // Deterministically replace a checked regular leaf at the protected open // boundary. Opening the FIFO must return an error without waiting for stdin. let target = fixture.root.canonicalize().unwrap().join("src/main.rs"); @@ -239,6 +384,9 @@ fn project_format_candidates_returns_formatted_output_and_unchanged_detection() &fixture.registry, &planned, &cache, + 1, + 10, + None, ) .expect("dry-run rustfmt candidate formatting succeeded"); assert_eq!(candidates.len(), 1); @@ -254,6 +402,9 @@ fn project_format_candidates_returns_formatted_output_and_unchanged_detection() &fixture.registry, &planned_formatted, &cache, + 1, + 10, + None, ) .expect("dry-run formatting formatted source succeeds"); assert_eq!(candidates_formatted.len(), 1); @@ -279,6 +430,9 @@ fn project_format_candidates_fails_on_nonzero_exit_syntax_error() { &fixture.registry, &planned, &cache, + 1, + 10, + None, ); assert_eq!(result, Err("format_process_failed")); } @@ -298,6 +452,9 @@ fn project_format_candidates_fails_on_truncated_output() { &fixture.registry, &planned, &cache, + 1, + 10, + None, ); assert_eq!(result, Err("format_output_truncated")); } @@ -308,7 +465,9 @@ fn project_format_candidates_fails_on_missing_rustfmt() { let planned = fixture.plan(); let mut shell = ShellConfig::default(); // Restrict PATH to an empty directory so rustfmt cannot be located - shell.env.insert("PATH".into(), "/nonexistent_bin_path_12345".into()); + shell + .env + .insert("PATH".into(), "/nonexistent_bin_path_12345".into()); shell.environment_mode = super::config::ShellEnvironmentMode::Isolated; let cache = PreparedShellProfileCache::default(); let result = format_candidates( @@ -317,6 +476,44 @@ fn project_format_candidates_fails_on_missing_rustfmt() { &fixture.registry, &planned, &cache, + 1, + 10, + None, ); assert_eq!(result, Err("format_process_failed")); } + +#[test] +fn project_format_candidates_enforces_deadline_and_stop_before_dispatch() { + let fixture = Fixture::new(); + let planned = fixture.plan(); + let shell = ShellConfig::default(); + let cache = PreparedShellProfileCache::default(); + let cancel = std::sync::atomic::AtomicBool::new(true); + let cancelled = format_candidates( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &cache, + 1, + 10, + Some(&cancel), + ); + assert_eq!(cancelled, Err("format_cancelled")); + let invalid_budget = format_candidates( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &cache, + 1, + 0, + None, + ); + assert_eq!(invalid_budget, Err("format_timeout_invalid")); + assert_eq!( + fs::read_to_string(fixture.root.join("src/main.rs")).unwrap(), + planned.sources[0] + ); +} diff --git a/crates/webcodex-runner/src/webcodex_runner/runner_instruction_tests.rs b/crates/webcodex-runner/src/webcodex_runner/runner_instruction_tests.rs index b63c61627..4ea541249 100644 --- a/crates/webcodex-runner/src/webcodex_runner/runner_instruction_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/runner_instruction_tests.rs @@ -1,9 +1,9 @@ use super::*; -use crate::webcodex_runner::file_access::unsupported_regular_file_open; #[cfg(unix)] use crate::webcodex_runner::file_access::open_regular_file_unix; #[cfg(windows)] use crate::webcodex_runner::file_access::open_regular_file_windows; +use crate::webcodex_runner::file_access::unsupported_regular_file_open; use std::path::Path; fn observe(path: &Path) -> RunnerInstructionSnapshotResponse { diff --git a/src/runner_ws.rs b/src/runner_ws.rs index 9931c0adc..b1522a5b7 100644 --- a/src/runner_ws.rs +++ b/src/runner_ws.rs @@ -423,7 +423,7 @@ mod tests { structured_go_test_json: true, project_validation_v1: false, project_build_v1: false, - project_format_v1: false, + project_format_v1: false, project_dependency_policy_v1: false, project_go_single_module_v1: false, project_validation_package_scope_v1: false, diff --git a/src/tool_runtime/tests/metadata.rs b/src/tool_runtime/tests/metadata.rs index da5b99fe1..b34b0c003 100644 --- a/src/tool_runtime/tests/metadata.rs +++ b/src/tool_runtime/tests/metadata.rs @@ -513,7 +513,7 @@ async fn register_agent_projects_for_auth( structured_go_test_json: true, project_validation_v1: false, project_build_v1: false, - project_format_v1: false, + project_format_v1: false, project_dependency_policy_v1: false, project_go_single_module_v1: false, project_validation_package_scope_v1: false, From d48face9c365cc4d9702144f3043d27d481088f8 Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:08:07 +0800 Subject: [PATCH 05/12] feat(project): complete guarded project_format lifecycle --- crates/webcodex-core/src/project_format.rs | 39 + .../webcodex-core/src/project_format_tests.rs | 129 ++++ .../webcodex-core/src/runner_job_receipt.rs | 16 +- crates/webcodex-core/src/runner_operation.rs | 77 +- crates/webcodex-core/src/runner_protocol.rs | 4 + .../webcodex-core/src/runner_protocol/job.rs | 10 + .../src/job_updates.rs | 77 ++ crates/webcodex-runner-registry/src/jobs.rs | 10 + .../webcodex-runner-registry/src/receipts.rs | 39 +- .../src/reconciliation.rs | 17 + .../webcodex-runner-registry/src/requests.rs | 51 ++ crates/webcodex-runner-registry/src/state.rs | 5 + .../src/tests/connection_lease.rs | 2 + .../src/tests/instance_lease.rs | 8 + .../src/tests/job_lifecycle.rs | 2 + .../src/tests/job_log_wait.rs | 8 + .../src/tests/reconciliation.rs | 4 + crates/webcodex-runner/src/main_tests.rs | 9 + .../src/main_tests/registration.rs | 30 + .../src/main_tests/runner_sink.rs | 2 + .../src/webcodex_runner/detached_job/model.rs | 1 + .../src/webcodex_runner/dispatch.rs | 1 + .../src/webcodex_runner/file_access.rs | 50 +- .../src/webcodex_runner/job_manager.rs | 2 + .../webcodex_runner/job_manager/admission.rs | 21 + .../webcodex_runner/job_manager/delivery.rs | 6 + .../webcodex_runner/job_manager/lifecycle.rs | 1 + .../webcodex_runner/job_manager/process.rs | 107 +++ .../webcodex_runner/job_manager/retained.rs | 4 + .../job_manager/shell_worker.rs | 1 + .../src/webcodex_runner/job_manager_tests.rs | 2 + .../src/webcodex_runner/project_format.rs | 207 +++++- .../webcodex_runner/project_format_tests.rs | 700 +++++++++++++++++- .../src/webcodex_runner/shell.rs | 5 +- .../src/webcodex_runner/shell/commands.rs | 51 ++ .../src/webcodex_runner/shell/execution.rs | 101 ++- .../src/webcodex_runner/shell/process.rs | 78 +- .../webcodex_runner/transport/registration.rs | 9 +- .../transport_tests/stream_telemetry.rs | 1 + .../src/registry/output_schemas/jobs.rs | 12 + .../src/tests/canonical_names.rs | 3 +- .../src/tool_call/canonical_enum.rs | 20 + .../src/tool_call/parsing.rs | 4 + .../src/tool_catalog.rs | 1 + .../src/tool_definition.rs | 12 +- .../src/tool_definition/jobs.rs | 20 + .../src/tool_audit.rs | 11 + src/mcp_tests/model_ergonomics.rs | 1 + src/mcp_tests/request_policy.rs | 1 + src/mcp_tests/result_app.rs | 2 + src/runner_quic.rs | 1 + src/tool_runtime/dispatch/execution.rs | 21 + src/tool_runtime/dispatch/governance.rs | 1 + src/tool_runtime/dispatch/preflight.rs | 1 + src/tool_runtime/dispatch/routing.rs | 1 + src/tool_runtime/jobs.rs | 10 +- src/tool_runtime/mod.rs | 1 + src/tool_runtime/project_format.rs | 237 ++++++ .../result_projection/execution.rs | 3 +- src/tool_runtime/runner_authorization.rs | 4 + src/tool_runtime/tests/job_attention.rs | 3 + .../tests/job_attention/telemetry.rs | 1 + src/tool_runtime/tests/job_terminal_wait.rs | 1 + src/tool_runtime/tests/jobs.rs | 2 + src/tool_runtime/tests/mod.rs | 1 + src/tool_runtime/tests/observe_jobs.rs | 5 + src/tool_runtime/tests/process.rs | 2 + .../tests/project_build_handoff.rs | 1 + .../tests/project_format_handoff.rs | 514 +++++++++++++ src/tool_runtime/tests/read_files.rs | 1 + src/tool_runtime/tests/runtime_info.rs | 1 + src/tool_runtime/tests/script.rs | 1 + src/tool_runtime/tests/startup_brief.rs | 2 + src/tool_runtime/tests/support/runner.rs | 2 + src/tool_runtime/tests/validation_events.rs | 2 + src/tool_runtime/tests/validation_handoff.rs | 1 + src/tool_runtime/tests/validation_identity.rs | 2 + 77 files changed, 2758 insertions(+), 38 deletions(-) create mode 100644 src/tool_runtime/project_format.rs create mode 100644 src/tool_runtime/tests/project_format_handoff.rs diff --git a/crates/webcodex-core/src/project_format.rs b/crates/webcodex-core/src/project_format.rs index e1d00a9a1..2c39cc757 100644 --- a/crates/webcodex-core/src/project_format.rs +++ b/crates/webcodex-core/src/project_format.rs @@ -252,3 +252,42 @@ impl ProjectFormatMutationReport { } } } + +/// Safe Job correlation only; never executable instructions or source bytes. +pub fn structured_metadata() -> crate::runner_protocol::ShellJobStructuredExecutionMetadata { + crate::runner_protocol::ShellJobStructuredExecutionMetadata { + execution_source: "project_format".into(), + language: None, + script_bytes: None, + arg_count: 0, + stdin_present: false, + validation_identity: None, + validation_tool: None, + assertion_name: None, + } +} + +/// Missing historical receipts remain unknown. Known reports require complete +/// executor success and can never be attached to another kind of Job. +pub fn validate_mutation_report( + kind: &str, + status: &str, + exit_code: Option, + execution: Option, + report: Option, + error: Option<&str>, +) -> Result<(), &'static str> { + if let Some(report) = report { + if kind != "project_format" + || (report != ProjectFormatMutationReport::Unknown + && (status != "completed" + || exit_code != Some(0) + || execution + != Some(crate::runner_protocol::ShellCommandExecutionState::Completed) + || error.is_some())) + { + return Err("format_mutation_invalid"); + } + } + Ok(()) +} diff --git a/crates/webcodex-core/src/project_format_tests.rs b/crates/webcodex-core/src/project_format_tests.rs index 3d9144ec2..a52dd0d26 100644 --- a/crates/webcodex-core/src/project_format_tests.rs +++ b/crates/webcodex-core/src/project_format_tests.rs @@ -184,3 +184,132 @@ fn project_format_capability_and_mutation_truth_default_closed() { assert_eq!(retained.state_changed(), changed); } } + +#[test] +fn project_format_pf01_job_wire_is_closed_and_preserves_identity() { + use crate::runner_operation::{RunnerJobFormatOperation, RunnerJobOperation}; + let context = crate::runner_protocol::ShellJobContext { + runtime_project_id: Some("agent:runner:demo".into()), + workflow_session_id: None, + ssh_resource: None, + project_cwd: Some(".".into()), + cwd: Some("/project".into()), + purpose: Some("format".into()), + shell: Some("direct_argv".into()), + command_preview: "project_format selected files".into(), + validation_steps: vec![], + validation: None, + structured_execution: Some(structured_metadata()), + }; + let format_op = RunnerJobFormatOperation { + job_id: "job-format".into(), + cwd: context.cwd.clone(), + plan: plan(), + timeout_secs: 60, + context, + }; + let operation = RunnerOperation::Job(RunnerJobOperation::StartFormat(format_op.clone())); + let wire = RunnerRequest::from_operation( + RunnerInvocationMetadata { + request_id: "r".into(), + client_id: "runner".into(), + requested_by: "test".into(), + created_at: 1, + }, + operation, + ) + .unwrap(); + assert_eq!(wire.kind, "start_format_job"); + assert!( + matches!(wire.decode_operation().unwrap(), RunnerOperation::Job(RunnerJobOperation::StartFormat(decoded)) if decoded == format_op) + ); + for field in ["command", "stdin", "content", "timeout_secs"] { + let mut forged = wire.clone(); + match field { + "command" => forged.command = "arbitrary".into(), + "stdin" => forged.stdin = Some("source must remain private".into()), + "content" => forged.content = Some("{}".into()), + _ => forged.timeout_secs = 121, + } + assert!(forged.decode_operation().is_err(), "{field}"); + } +} + +#[test] +fn project_format_pf12_mutation_report_cannot_borrow_exit_success() { + use crate::runner_protocol::ShellCommandExecutionState as State; + assert_eq!(ProjectFormatMutationReport::default().state_changed(), None); + for report in [ + ProjectFormatMutationReport::Changed, + ProjectFormatMutationReport::Unchanged, + ] { + assert!(validate_mutation_report( + "project_format", + "completed", + Some(0), + Some(State::Completed), + Some(report), + None + ) + .is_ok()); + for (kind, status, exit, state, error) in [ + ( + "project_build", + "completed", + Some(0), + Some(State::Completed), + None, + ), + ( + "project_format", + "failed", + Some(0), + Some(State::Completed), + None, + ), + ( + "project_format", + "completed", + Some(1), + Some(State::Completed), + None, + ), + ( + "project_format", + "completed", + Some(0), + Some(State::OutcomeUnknown), + None, + ), + ( + "project_format", + "completed", + Some(0), + Some(State::Completed), + Some("format_write_conflict"), + ), + ] { + assert!( + validate_mutation_report(kind, status, exit, state, Some(report), error).is_err() + ); + } + } + assert!(validate_mutation_report( + "project_format", + "completed", + Some(0), + Some(State::Completed), + None, + None + ) + .is_ok()); + assert!(validate_mutation_report( + "project_format", + "failed", + Some(1), + Some(State::OutcomeUnknown), + None, + Some("format_process_failed") + ) + .is_ok()); +} diff --git a/crates/webcodex-core/src/runner_job_receipt.rs b/crates/webcodex-core/src/runner_job_receipt.rs index adaf160b3..aceb1393a 100644 --- a/crates/webcodex-core/src/runner_job_receipt.rs +++ b/crates/webcodex-core/src/runner_job_receipt.rs @@ -43,7 +43,7 @@ impl RetainedJobReceipt { || !text(&self.kind, 128) || !matches!( self.kind.as_str(), - "shell" | "project_build" | "run_process" | "run_script" + "shell" | "project_build" | "project_format" | "run_process" | "run_script" ) || self.terminal_observed_at <= 0 || self.terminal_observed_at > now @@ -91,6 +91,20 @@ impl RetainedJobReceipt { { return Err("invalid receipt terminal snapshot"); } + if self.kind == "project_format" + && snapshot.context.structured_execution.as_ref() + != Some(&crate::project_format::structured_metadata()) + { + return Err("invalid format receipt context"); + } + crate::project_format::validate_mutation_report( + &self.kind, + &snapshot.status, + snapshot.exit_code, + snapshot.command_execution_state, + snapshot.format_mutation, + snapshot.error.as_deref(), + )?; let context = &snapshot.context; if context.runtime_project_id.as_ref().is_some_and(|project| { project diff --git a/crates/webcodex-core/src/runner_operation.rs b/crates/webcodex-core/src/runner_operation.rs index ddd5760a9..cb57c632c 100644 --- a/crates/webcodex-core/src/runner_operation.rs +++ b/crates/webcodex-core/src/runner_operation.rs @@ -104,6 +104,15 @@ pub struct RunnerJobValidationOperation { pub context: ShellJobContext, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RunnerJobFormatOperation { + pub job_id: String, + pub cwd: Option, + pub plan: crate::project_format::ProjectFormatPlan, + pub timeout_secs: u64, + pub context: ShellJobContext, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RunnerJobBuildOperation { pub job_id: String, @@ -148,6 +157,7 @@ pub enum RunnerJobOperation { StartShell(RunnerJobShellOperation), StartValidation(RunnerJobValidationOperation), StartBuild(RunnerJobBuildOperation), + StartFormat(RunnerJobFormatOperation), StartProcess(RunnerJobProcessOperation), StartInteractiveProcess(RunnerJobProcessOperation), StartDetachedProcess(RunnerJobProcessOperation), @@ -162,6 +172,7 @@ impl RunnerJobOperation { Self::StartShell(operation) => &operation.job_id, Self::StartValidation(operation) => &operation.job_id, Self::StartBuild(operation) => &operation.job_id, + Self::StartFormat(operation) => &operation.job_id, Self::StartProcess(operation) | Self::StartInteractiveProcess(operation) | Self::StartDetachedProcess(operation) => &operation.job_id, @@ -176,6 +187,7 @@ impl RunnerJobOperation { Self::StartShell(operation) => Some(&operation.context), Self::StartValidation(operation) => Some(&operation.context), Self::StartBuild(operation) => Some(&operation.context), + Self::StartFormat(operation) => Some(&operation.context), Self::StartProcess(operation) | Self::StartInteractiveProcess(operation) | Self::StartDetachedProcess(operation) => Some(&operation.context), @@ -190,6 +202,7 @@ impl RunnerJobOperation { Self::StartShell(operation) => operation.cwd.as_deref(), Self::StartValidation(operation) => operation.cwd.as_deref(), Self::StartBuild(operation) => operation.cwd.as_deref(), + Self::StartFormat(operation) => operation.cwd.as_deref(), Self::StartProcess(operation) | Self::StartInteractiveProcess(operation) | Self::StartDetachedProcess(operation) => operation.cwd.as_deref(), @@ -223,6 +236,7 @@ impl RunnerJobOperation { }) .unwrap_or((None, None, None)); match self { + Self::StartFormat(_) => Some(crate::project_format::structured_metadata()), Self::StartBuild(operation) => Some(ShellJobStructuredExecutionMetadata { execution_source: "project_build".to_string(), language: None, @@ -757,6 +771,7 @@ impl RunnerOperation { RunnerJobOperation::StartShell(_) => "start_job", RunnerJobOperation::StartValidation(_) => "start_validation_job", RunnerJobOperation::StartBuild(_) => "start_build_job", + RunnerJobOperation::StartFormat(_) => "start_format_job", RunnerJobOperation::StartProcess(_) => "start_process_job", RunnerJobOperation::StartInteractiveProcess(_) => "start_interactive_process_job", RunnerJobOperation::StartDetachedProcess(_) => "start_detached_process_job", @@ -1105,6 +1120,7 @@ fn encode_job_operation( RunnerJobOperation::StartShell(_) => "start_job", RunnerJobOperation::StartValidation(_) => "start_validation_job", RunnerJobOperation::StartBuild(_) => "start_build_job", + RunnerJobOperation::StartFormat(_) => "start_format_job", RunnerJobOperation::StartProcess(_) => "start_process_job", RunnerJobOperation::StartInteractiveProcess(_) => "start_interactive_process_job", RunnerJobOperation::StartDetachedProcess(_) => "start_detached_process_job", @@ -1148,6 +1164,30 @@ fn encode_job_operation( wire.timeout_secs = operation.timeout_secs; wire.job_context = Some(operation.context); } + RunnerJobOperation::StartFormat(operation) => { + validate_structured_job_common( + operation.cwd.as_deref(), + None, + operation.timeout_secs, + crate::project_format::PROJECT_FORMAT_TIMEOUT_MAX_SECS, + )?; + validate_job_context_coherence(operation.cwd.as_deref(), &operation.context)?; + operation.plan.digest().map_err(str::to_string)?; + if operation.context.ssh_resource.is_some() + || operation.context.structured_execution + != Some(crate::project_format::structured_metadata()) + || !operation.context.validation_steps.is_empty() + || operation.context.validation.is_some() + { + return Err("invalid project format Job context".into()); + } + wire.job_id = Some(operation.job_id); + wire.cwd = operation.cwd; + wire.content = + Some(serde_json::to_string(&operation.plan).map_err(|_| "invalid format plan")?); + wire.timeout_secs = operation.timeout_secs; + wire.job_context = Some(operation.context); + } RunnerJobOperation::StartBuild(operation) => { validate_structured_job_common( operation.cwd.as_deref(), @@ -1373,6 +1413,7 @@ fn decode_operation(wire: &RunnerRequest) -> Result { "start_job" | "start_validation_job" | "start_build_job" + | "start_format_job" | "start_process_job" | "start_interactive_process_job" | "start_detached_process_job" @@ -1680,7 +1721,7 @@ fn decode_operation(wire: &RunnerRequest) -> Result { fn decode_job_operation(wire: &RunnerRequest) -> Result { if matches!( wire.kind.as_str(), - "start_skill_resource_job" | "start_build_job" + "start_skill_resource_job" | "start_build_job" | "start_format_job" ) { ensure_no_file_fields_except_content(wire)?; } else { @@ -1748,6 +1789,40 @@ fn decode_job_operation(wire: &RunnerRequest) -> Result { + ensure_special_payloads_absent(wire)?; + if !wire.command.is_empty() + || wire.stdin.is_some() + || context.ssh_resource.is_some() + || context.validation.is_some() + || !context.validation_steps.is_empty() + || context.structured_execution + != Some(crate::project_format::structured_metadata()) + { + return Err("invalid project format Job context".into()); + } + validate_structured_job_common( + wire.cwd.as_deref(), + None, + wire.timeout_secs, + crate::project_format::PROJECT_FORMAT_TIMEOUT_MAX_SECS, + )?; + let plan: crate::project_format::ProjectFormatPlan = + serde_json::from_str(bounded_content( + wire, + crate::project_format::PROJECT_FORMAT_PLAN_MAX_BYTES, + "project format plan", + )?) + .map_err(|_| "invalid project format plan")?; + plan.digest().map_err(str::to_string)?; + Ok(RunnerJobOperation::StartFormat(RunnerJobFormatOperation { + job_id, + cwd: wire.cwd.clone(), + plan, + timeout_secs: wire.timeout_secs, + context, + })) + } "start_build_job" => { ensure_only_process_payload(wire)?; if !wire.command.is_empty() || wire.stdin.is_some() || context.ssh_resource.is_some() { diff --git a/crates/webcodex-core/src/runner_protocol.rs b/crates/webcodex-core/src/runner_protocol.rs index b791e1603..a626a3473 100644 --- a/crates/webcodex-core/src/runner_protocol.rs +++ b/crates/webcodex-core/src/runner_protocol.rs @@ -1984,6 +1984,7 @@ pub const SCRIPT_TIMEOUT_MAX_SECS: u64 = 7 * 24 * 60 * 60; /// validation, shell, and Skill Job kinds retain the shared 1-hour ceiling. pub fn job_execution_timeout_max_secs(kind: &str) -> u64 { match kind { + "project_format" => crate::project_format::PROJECT_FORMAT_TIMEOUT_MAX_SECS, "project_build" | "run_process" | "run_detached_process" => PROCESS_TIMEOUT_MAX_SECS, "run_script" => SCRIPT_TIMEOUT_MAX_SECS, _ => STRUCTURED_EXECUTION_TIMEOUT_MAX_SECS, @@ -3304,6 +3305,7 @@ mod envelope_tests { stderr: ShellJobStreamSnapshot::default(), validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, }], } @@ -3989,6 +3991,7 @@ mod envelope_tests { command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }, @@ -4317,6 +4320,7 @@ mod envelope_tests { command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }; diff --git a/crates/webcodex-core/src/runner_protocol/job.rs b/crates/webcodex-core/src/runner_protocol/job.rs index 988bc9ab1..7b61acf81 100644 --- a/crates/webcodex-core/src/runner_protocol/job.rs +++ b/crates/webcodex-core/src/runner_protocol/job.rs @@ -131,6 +131,9 @@ pub struct RunnerJobUpdateRequest { pub validation_progress: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub test_count_evidence: Option, + /// Executor-owned formatting truth. Missing historical evidence is unknown. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format_mutation: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub activity: Option, #[serde(default)] @@ -1101,6 +1104,7 @@ impl ShellJobStructuredExecutionMetadata { && self.validation_tool.is_none() && self.assertion_name.is_none() } + "project_format" => self == &crate::project_format::structured_metadata(), "project_build" => { self.language.is_none() && self.script_bytes.is_none() @@ -1251,6 +1255,9 @@ pub struct ShellJobSnapshot { pub validation_progress: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub test_count_evidence: Option, + /// Executor-owned formatting truth. Missing historical evidence is unknown. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format_mutation: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub activity: Option, } @@ -1335,6 +1342,9 @@ pub struct ShellJobInfo { pub validation_progress: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub test_count_evidence: Option, + /// Executor-owned formatting truth. Missing historical evidence is unknown. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format_mutation: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub activity: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/webcodex-runner-registry/src/job_updates.rs b/crates/webcodex-runner-registry/src/job_updates.rs index 9a2d292f4..74e5151d0 100644 --- a/crates/webcodex-runner-registry/src/job_updates.rs +++ b/crates/webcodex-runner-registry/src/job_updates.rs @@ -355,6 +355,7 @@ struct JobPublicMutationSignature { command_execution_state: Option, validation_progress: Option, test_count_evidence: Option, + format_mutation: Option, activity: Option, recovered_after_server_restart: bool, reconciled_at: Option, @@ -375,6 +376,7 @@ fn public_mutation_signature(job: &ShellJobRecord) -> JobPublicMutationSignature command_execution_state: job.command_execution_state, validation_progress: job.validation_progress.clone(), test_count_evidence: job.test_count_evidence.clone(), + format_mutation: job.format_mutation, activity: job.activity, recovered_after_server_restart: job.recovery.recovered_after_server_restart, reconciled_at: job.recovery.reconciled_at, @@ -639,6 +641,10 @@ pub struct ShellJobStartMetadata { #[derive(Debug, Clone)] pub enum StructuredJobExecution { ProjectBuild(webcodex_core::project_build::ProjectBuildPlan), + ProjectFormat { + plan: webcodex_core::project_format::ProjectFormatPlan, + runner_instance_id: String, + }, Process(ShellProcessArgv), InteractiveProcess(ShellProcessArgv), DetachedProcess(ShellProcessArgv), @@ -829,6 +835,12 @@ impl RunnerRegistry { structured_execution.as_ref(), Some(StructuredJobExecution::SkillResource(_)) ); + let format_runner_instance = match structured_execution.as_ref() { + Some(StructuredJobExecution::ProjectFormat { + runner_instance_id, .. + }) => Some(runner_instance_id.clone()), + _ => None, + }; let project_build_request = matches!( structured_execution.as_ref(), Some(StructuredJobExecution::ProjectBuild(_)) @@ -909,6 +921,28 @@ impl RunnerRegistry { let (safe_command_preview, structured_metadata, job_kind) = match structured_execution .as_ref() { + Some(StructuredJobExecution::ProjectFormat { plan, .. }) => { + plan.digest().map_err(str::to_string)?; + if structured_stdin.is_some() + || metadata.ssh_resource.is_some() + || metadata.visibility != ShellJobVisibility::Public + || metadata.project_id.as_deref() + != Some(format!("agent:{client_id}:{}", plan.request.project_id).as_str()) + { + return Err("invalid project format Job authority".into()); + } + validate_structured_job_common( + normalized_job_cwd.as_deref(), + None, + timeout_secs, + webcodex_core::project_format::PROJECT_FORMAT_TIMEOUT_MAX_SECS, + )?; + ( + "project_format selected files".into(), + Some(webcodex_core::project_format::structured_metadata()), + "project_format", + ) + } Some(StructuredJobExecution::ProjectBuild(plan)) => { if !plan.is_valid() || structured_stdin.is_some() { return Err("invalid typed project build Job plan".to_string()); @@ -1149,6 +1183,17 @@ impl RunnerRegistry { Some(StructuredJobExecution::InteractiveProcess(_)) ); let job_operation = match structured_execution { + Some(StructuredJobExecution::ProjectFormat { plan, .. }) => { + RunnerJobOperation::StartFormat( + webcodex_core::runner_operation::RunnerJobFormatOperation { + job_id: job_id.clone(), + cwd: normalized_job_cwd.clone(), + plan, + timeout_secs, + context: job_context, + }, + ) + } Some(StructuredJobExecution::ProjectBuild(plan)) => { RunnerJobOperation::StartBuild(RunnerJobBuildOperation { job_id: job_id.clone(), @@ -1280,6 +1325,23 @@ impl RunnerRegistry { "capability_unavailable: runner {client_id} does not support structured_execution_jobs" )); } + if let Some(expected_instance) = format_runner_instance { + if !self.inner.has_receipt_store() { + return Err("format_execution_unavailable".into()); + } + if !runner + .runner_features + .supports(RunnerFeature::ProjectFormat) + { + return Err(capability_upgrade_error( + &client_id, + RunnerFeature::ProjectFormat, + )); + } + if runner.runner_instance_id != expected_instance { + return Err("format_runner_replaced".into()); + } + } if project_build_request && !runner.runner_features.supports(RunnerFeature::ProjectBuild) { return Err(capability_upgrade_error( &client_id, @@ -1652,6 +1714,7 @@ impl RunnerRegistry { validation, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, last_update_seq: 0, visibility: metadata.visibility, @@ -3027,6 +3090,17 @@ impl RunnerRegistry { ); if let Err(error) = validate_validation_progress(job, &body, incoming_lifecycle) .and_then(|_| validate_test_count_evidence(job, &body, incoming_lifecycle)) + .and_then(|_| { + webcodex_core::project_format::validate_mutation_report( + &job.kind, + incoming_lifecycle.as_wire(), + body.exit_code, + body.command_execution_state, + body.format_mutation, + body.error.as_deref(), + ) + .map_err(|_| ValidationProtocolError("format_mutation_invalid")) + }) .and_then(|_| validate_command_execution_state(job, &body, incoming_lifecycle)) .and_then(|_| validate_job_activity(job, &body, incoming_lifecycle)) { @@ -3058,6 +3132,9 @@ impl RunnerRegistry { if body.validation_progress.is_some() { job.validation_progress = body.validation_progress.clone(); } + if body.format_mutation.is_some() { + job.format_mutation = body.format_mutation; + } if body.test_count_evidence.is_some() { job.test_count_evidence = body.test_count_evidence.clone(); } diff --git a/crates/webcodex-runner-registry/src/jobs.rs b/crates/webcodex-runner-registry/src/jobs.rs index c7b5acccf..a026bad14 100644 --- a/crates/webcodex-runner-registry/src/jobs.rs +++ b/crates/webcodex-runner-registry/src/jobs.rs @@ -293,6 +293,16 @@ pub(super) fn job_view(job: &ShellJobRecord) -> ShellJobInfo { result, validation_progress: job.validation_progress.clone(), test_count_evidence: job.test_count_evidence.clone(), + format_mutation: if job.kind == "project_format" + && !job + .observation + .receipt_persisted + .load(std::sync::atomic::Ordering::Acquire) + { + Some(webcodex_core::project_format::ProjectFormatMutationReport::Unknown) + } else { + job.format_mutation + }, activity: job.activity, validation: job.validation.clone(), recovery_state: job.recovery.public_state().map(str::to_string), diff --git a/crates/webcodex-runner-registry/src/receipts.rs b/crates/webcodex-runner-registry/src/receipts.rs index b60a67d18..2dc86d478 100644 --- a/crates/webcodex-runner-registry/src/receipts.rs +++ b/crates/webcodex-runner-registry/src/receipts.rs @@ -11,7 +11,9 @@ use webcodex_core::runner_protocol::{ /// Synchronous, best-effort historical persistence, invoked only after unlocking /// registry state. Implementations must keep writes bounded and first-write-wins; -/// errors must never affect the accepted execution verdict. No execution methods. +/// errors must never affect the accepted execution verdict for ordinary Jobs. +/// Formatting exposes known mutation only after this same store commits its +/// receipt. Write failure leaves its projection unknown. No execution methods. pub trait JobReceiptStore: std::fmt::Debug + Send + Sync { fn upsert(&self, receipt: &RetainedJobReceipt) -> Result<(), String>; fn load(&self, now: i64) -> Result, String>; @@ -89,6 +91,10 @@ impl ReceiptRegistryState { } } + pub(crate) fn has_receipt_store(&self) -> bool { + self.store.is_some() + } + pub(crate) fn capture_candidates(&self) -> Option { self.store.as_ref().map(|_| self.candidates.clone()) } @@ -148,6 +154,11 @@ impl DerefMut for ReceiptRegistryGuard<'_> { self.guard.as_mut().unwrap() } } +impl ReceiptRegistryGuard<'_> { + pub(crate) fn has_receipt_store(&self) -> bool { + self.state.has_receipt_store() + } +} impl Drop for ReceiptRegistryGuard<'_> { fn drop(&mut self) { // Publish only touched Job lifecycle changes; reads never scan terminal history. @@ -157,7 +168,10 @@ impl Drop for ReceiptRegistryGuard<'_> { std::mem::take(&mut *self.state.terminal_event_candidates.lock().unwrap()); let receipts: Vec<_> = ids .iter() - .filter_map(|id| self.jobs_by_id.get(id).and_then(capture)) + .filter_map(|id| { + let job = self.jobs_by_id.get(id)?; + Some((capture(job)?, job.observation.clone())) + }) .collect(); let terminal_events: Vec<_> = terminal_ids .iter() @@ -172,10 +186,25 @@ impl Drop for ReceiptRegistryGuard<'_> { if let Some(store) = &self.state.store { let mut failed = 0; let mut retry_ids = Vec::new(); - for receipt in receipts { + for (receipt, observation) in receipts { if store.upsert(&receipt).is_err() { failed += 1; retry_ids.push(receipt.snapshot.job_id); + } else if !observation + .receipt_persisted + .swap(true, std::sync::atomic::Ordering::AcqRel) + && receipt.kind == "project_format" + { + // Publish durability only after unlocking and committing. + // This advances observation, never execution or a retry. + let revision = observation + .revision + .fetch_add(1, std::sync::atomic::Ordering::SeqCst) + + 1; + observation + .last_meaningful_revision + .store(revision, std::sync::atomic::Ordering::SeqCst); + observation.notify.notify_waiters(); } } if !retry_ids.is_empty() { @@ -341,6 +370,7 @@ fn capture(job: &ShellJobRecord) -> Option { // A test count without that provenance is not reusable correctness // evidence, so keep it only in Runner-owned snapshots/inventory. test_count_evidence: None, + format_mutation: job.format_mutation, activity: None, }, }; @@ -412,6 +442,9 @@ impl RunnerRegistry { job.kind = receipt.kind; job.observation.terminal_observed_at = Some(receipt.terminal_observed_at); job.observation.receipt_expires_at = Some(receipt.expires_at); + job.observation + .receipt_persisted + .store(true, std::sync::atomic::Ordering::Release); crate::jobs::replace_log_from_snapshot(&mut job.stdout, &receipt.snapshot.stdout); crate::jobs::replace_log_from_snapshot(&mut job.stderr, &receipt.snapshot.stderr); // Only a terminal record; no mapping, waiter, queue, intent or lease. diff --git a/crates/webcodex-runner-registry/src/reconciliation.rs b/crates/webcodex-runner-registry/src/reconciliation.rs index 3e07c5417..7f4e8d323 100644 --- a/crates/webcodex-runner-registry/src/reconciliation.rs +++ b/crates/webcodex-runner-registry/src/reconciliation.rs @@ -275,6 +275,20 @@ fn validate_snapshot( return Err("job inventory command_execution_state is inconsistent".to_string()); } } + webcodex_core::project_format::validate_mutation_report( + snapshot + .context + .structured_execution + .as_ref() + .map(|m| m.execution_source.as_str()) + .unwrap_or("shell"), + &snapshot.status, + snapshot.exit_code, + snapshot.command_execution_state, + snapshot.format_mutation, + snapshot.error.as_deref(), + ) + .map_err(str::to_string)?; validate_context(client_id, projects, require_project_membership, snapshot)?; validate_stream_snapshot(&snapshot.stdout, "stdout")?; validate_stream_snapshot(&snapshot.stderr, "stderr")?; @@ -741,6 +755,7 @@ pub(crate) fn record_from_snapshot( validation: context.validation.clone(), validation_progress: snapshot.validation_progress.clone(), test_count_evidence: snapshot.test_count_evidence.clone(), + format_mutation: snapshot.format_mutation, activity: snapshot.activity, visibility: super::state::ShellJobVisibility::Public, last_update_seq: snapshot.update_seq, @@ -779,6 +794,7 @@ fn apply_snapshot( job.structured_execution = snapshot.context.structured_execution.clone(); job.validation_progress = snapshot.validation_progress.clone(); job.test_count_evidence = snapshot.test_count_evidence.clone(); + job.format_mutation = snapshot.format_mutation; job.activity = snapshot.activity; job.validation = snapshot.context.validation.clone(); replace_log_from_snapshot(&mut job.stdout, &snapshot.stdout); @@ -1098,6 +1114,7 @@ pub(super) fn reconcile_inventory_locked( ) { existing.validation = snapshot.context.validation.clone(); existing.test_count_evidence = snapshot.test_count_evidence.clone(); + existing.format_mutation = snapshot.format_mutation; notify_job_update(existing); summary.updated += 1; } diff --git a/crates/webcodex-runner-registry/src/requests.rs b/crates/webcodex-runner-registry/src/requests.rs index 4434f9f86..a08f6ba93 100644 --- a/crates/webcodex-runner-registry/src/requests.rs +++ b/crates/webcodex-runner-registry/src/requests.rs @@ -2671,6 +2671,57 @@ impl RunnerRegistry { Ok((request_id, rx)) } + /// Read-only planning plus an instance fence for the later mutating Job. + pub async fn enqueue_project_format_plan( + &self, + client_id: String, + payload: webcodex_core::project_format::ProjectFormatRequest, + access: Option<&crate::RunnerAccess>, + ) -> Result<(String, String, oneshot::Receiver), String> { + payload.validate().map_err(str::to_string)?; + let request_id = next_request_id(); + let (tx, rx) = oneshot::channel(); + let request = encode_runner_operation( + &request_id, + &client_id, + "tool_runtime".into(), + RunnerOperation::PlanProjectFormat(payload.clone()), + )?; + let mut inner = self.inner.lock().await; + self.prune_expired_shared_key_runners_locked(&mut inner, now_ts()); + if !inner.has_receipt_store() { + return Err("format_execution_unavailable".into()); + } + let runner = inner.runners.get(&client_id).ok_or("unknown Runner")?; + assert_runner_access(access, runner)?; + if !runner + .runner_features + .supports(RunnerFeature::ProjectFormat) + { + return Err(capability_upgrade_error( + &client_id, + RunnerFeature::ProjectFormat, + )); + } + if !runner.projects.iter().any(|project| { + project.id == payload.project_id && !project.disabled && project.allow_patch + }) { + return Err("format_project_unavailable".into()); + } + let instance = runner.runner_instance_id.clone(); + enqueue_pending_request_locked( + self.telemetry.as_ref(), + &mut inner, + &client_id, + request_id.clone(), + request, + Some(tx), + None, + )?; + notify_runner_locked(&inner, &client_id); + Ok((request_id, instance, rx)) + } + /// Resolve a declarative project validation plan on the authorized Runner. /// Additive fields require their own capability before anything is enqueued. pub async fn enqueue_project_validation_plan( diff --git a/crates/webcodex-runner-registry/src/state.rs b/crates/webcodex-runner-registry/src/state.rs index 82499f2e0..3e5238a37 100644 --- a/crates/webcodex-runner-registry/src/state.rs +++ b/crates/webcodex-runner-registry/src/state.rs @@ -409,6 +409,9 @@ pub(super) struct JobObservationState { pub(super) terminal_event_candidates: Option, /// Fixed historical deadline, also identifies a receipt with no live lease. pub(super) receipt_expires_at: Option, + /// Durability visibility barrier shared with the post-unlock receipt writer. + /// This is storage acknowledgement, not a second mutation verdict. + pub(super) receipt_persisted: Arc, } impl JobObservationState { @@ -422,6 +425,7 @@ impl JobObservationState { receipt_candidates: None, terminal_event_candidates: None, receipt_expires_at: None, + receipt_persisted: Arc::new(std::sync::atomic::AtomicBool::new(false)), } } } @@ -485,6 +489,7 @@ pub(super) struct ShellJobRecord { pub(super) validation: Option, pub(super) validation_progress: Option, pub(super) test_count_evidence: Option, + pub(super) format_mutation: Option, /// Last Runner-authoritative bounded activity for an active Job. Cleared on /// terminal/recovery transitions; never used as execution authority. pub(super) activity: Option, diff --git a/crates/webcodex-runner-registry/src/tests/connection_lease.rs b/crates/webcodex-runner-registry/src/tests/connection_lease.rs index 82d85a23b..035c672c4 100644 --- a/crates/webcodex-runner-registry/src/tests/connection_lease.rs +++ b/crates/webcodex-runner-registry/src/tests/connection_lease.rs @@ -786,6 +786,7 @@ async fn late_job_update_on_stale_connection_is_accepted_without_refreshing_live command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }, @@ -828,6 +829,7 @@ async fn late_job_update_on_stale_connection_is_accepted_without_refreshing_live command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }) diff --git a/crates/webcodex-runner-registry/src/tests/instance_lease.rs b/crates/webcodex-runner-registry/src/tests/instance_lease.rs index bbbda047c..5f940727d 100644 --- a/crates/webcodex-runner-registry/src/tests/instance_lease.rs +++ b/crates/webcodex-runner-registry/src/tests/instance_lease.rs @@ -260,6 +260,7 @@ async fn lease_stale_instance_job_update_rejected() { command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }) @@ -289,6 +290,7 @@ async fn lease_stale_instance_job_update_rejected() { command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }) @@ -319,6 +321,7 @@ async fn lease_stale_instance_job_update_rejected() { command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }) @@ -461,6 +464,7 @@ async fn lease_reconcile_disconnect_stale_instance_is_noop() { command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }) @@ -637,6 +641,7 @@ async fn lease_replacement_transfers_exact_detached_inventory_to_new_instance() command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }) @@ -675,6 +680,7 @@ async fn lease_replacement_transfers_exact_detached_inventory_to_new_instance() stderr: Default::default(), validation_progress: record.validation_progress.clone(), test_count_evidence: record.test_count_evidence.clone(), + format_mutation: record.format_mutation, activity: record.activity, } }; @@ -737,6 +743,7 @@ async fn lease_replacement_transfers_exact_detached_inventory_to_new_instance() command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }) @@ -761,6 +768,7 @@ async fn lease_replacement_transfers_exact_detached_inventory_to_new_instance() command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }) diff --git a/crates/webcodex-runner-registry/src/tests/job_lifecycle.rs b/crates/webcodex-runner-registry/src/tests/job_lifecycle.rs index 7a99cc2f9..c995e2e0e 100644 --- a/crates/webcodex-runner-registry/src/tests/job_lifecycle.rs +++ b/crates/webcodex-runner-registry/src/tests/job_lifecycle.rs @@ -210,6 +210,7 @@ async fn job_update_rejects_mismatched_request_id_without_mutating_target_job() command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }; @@ -305,6 +306,7 @@ async fn dispatched_raw_shell_accepts_prestart_not_started_terminal_update() { command_execution_state: Some(ShellCommandExecutionState::NotStarted), validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }) diff --git a/crates/webcodex-runner-registry/src/tests/job_log_wait.rs b/crates/webcodex-runner-registry/src/tests/job_log_wait.rs index 534ac3adc..227b0c30d 100644 --- a/crates/webcodex-runner-registry/src/tests/job_log_wait.rs +++ b/crates/webcodex-runner-registry/src/tests/job_log_wait.rs @@ -45,6 +45,7 @@ fn wait_job_update( command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished, } @@ -487,6 +488,7 @@ async fn job_log_wait_sequenced_update_changes_token_even_when_snapshot_is_same( command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }) @@ -523,6 +525,7 @@ async fn job_log_wait_sequenced_update_changes_token_even_when_snapshot_is_same( command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }) @@ -632,6 +635,7 @@ async fn job_log_wait_unsequenced_update_between_calls_and_noop_update() { command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }; @@ -731,6 +735,7 @@ async fn job_log_wait_activity_only_legacy_transition_advances_revision_and_wake command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }; @@ -908,6 +913,7 @@ async fn agent_job_log_observation_is_baseline_then_independent_deltas() { command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }) @@ -1105,6 +1111,7 @@ async fn agent_job_log_replays_partial_lines_until_each_stream_completes() { command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }) @@ -1142,6 +1149,7 @@ async fn agent_job_log_replays_partial_lines_until_each_stream_completes() { command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }) diff --git a/crates/webcodex-runner-registry/src/tests/reconciliation.rs b/crates/webcodex-runner-registry/src/tests/reconciliation.rs index 18d533058..e3712102f 100644 --- a/crates/webcodex-runner-registry/src/tests/reconciliation.rs +++ b/crates/webcodex-runner-registry/src/tests/reconciliation.rs @@ -351,6 +351,7 @@ fn snapshot_from_request( stderr: ShellJobStreamSnapshot::default(), validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, } } @@ -379,6 +380,7 @@ fn update( command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished, } @@ -540,6 +542,7 @@ async fn validation_progress_accepts_sequence_only_heartbeats_and_coalesced_gaps failed_step: None, }), test_count_evidence: None, + format_mutation: None, activity, finished, } @@ -2853,6 +2856,7 @@ fn standalone_snapshot(job_id: &str, status: &str) -> ShellJobSnapshot { stderr: ShellJobStreamSnapshot::default(), validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, } } diff --git a/crates/webcodex-runner/src/main_tests.rs b/crates/webcodex-runner/src/main_tests.rs index 864a3b33e..ea3b41460 100644 --- a/crates/webcodex-runner/src/main_tests.rs +++ b/crates/webcodex-runner/src/main_tests.rs @@ -161,6 +161,15 @@ fn detached_process_capability_matches_supported_native_backends() { ); } +#[test] +fn project_format_capability_matches_supported_native_backends() { + let capabilities = runner_register_capabilities(&test_config(PathBuf::new())); + assert_eq!( + capabilities.project_format_v1, + cfg!(any(target_os = "linux", target_os = "macos", windows)) + ); +} + #[test] fn runner_config_control_capability_is_platform_neutral() { let capabilities = runner_register_capabilities(&test_config(PathBuf::new())); diff --git a/crates/webcodex-runner/src/main_tests/registration.rs b/crates/webcodex-runner/src/main_tests/registration.rs index 90e4db98f..bb94963c1 100644 --- a/crates/webcodex-runner/src/main_tests/registration.rs +++ b/crates/webcodex-runner/src/main_tests/registration.rs @@ -215,6 +215,11 @@ fn computer_register_request_announces_platform_capabilities_and_generation() { assert!(caps.lsp_call_hierarchy); assert!(caps.project_lifecycle); assert!(caps.project_path_registration); + assert_eq!( + caps.project_format_v1, + cfg!(any(target_os = "linux", target_os = "macos", windows)), + "project formatting capability is advertised on supported native platforms" + ); assert_eq!( caps.computer_observe, cfg!(any(target_os = "macos", windows)), @@ -369,6 +374,31 @@ fn capability_catalog_does_not_enable_configured_off_implementations() { capabilities.supports(RunnerCapabilityId::ComputerControl), cfg!(any(target_os = "macos", windows)) ); + assert_eq!( + capabilities.supports(RunnerCapabilityId::ProjectFormat), + cfg!(any(target_os = "linux", target_os = "macos", windows)) + ); +} + +#[test] +fn project_format_register_request_announces_platform_capability() { + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = test_config(tmp.path().join("config/project-registry")); + cfg.capabilities = Some(RunnerCapabilities { + project_format_v1: false, + ..Default::default() + }); + let body = build_register_request(&cfg, "inst-format", 0); + assert_eq!( + body.capabilities.project_format_v1, + cfg!(any(target_os = "linux", target_os = "macos", windows)), + "project formatting capability replaces static config with real platform support" + ); + assert_eq!( + body.capabilities + .supports(RunnerCapabilityId::ProjectFormat), + cfg!(any(target_os = "linux", target_os = "macos", windows)) + ); } #[test] diff --git a/crates/webcodex-runner/src/main_tests/runner_sink.rs b/crates/webcodex-runner/src/main_tests/runner_sink.rs index 910395945..f7d3b7053 100644 --- a/crates/webcodex-runner/src/main_tests/runner_sink.rs +++ b/crates/webcodex-runner/src/main_tests/runner_sink.rs @@ -62,6 +62,7 @@ fn sink_send_job_update_sends_job_update_envelope() { command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }; @@ -122,6 +123,7 @@ fn sink_try_send_job_update_preserves_full_ws_and_quic_queue_for_retry() { command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }; diff --git a/crates/webcodex-runner/src/webcodex_runner/detached_job/model.rs b/crates/webcodex-runner/src/webcodex_runner/detached_job/model.rs index 5a47b4f9c..00e497e56 100644 --- a/crates/webcodex-runner/src/webcodex_runner/detached_job/model.rs +++ b/crates/webcodex-runner/src/webcodex_runner/detached_job/model.rs @@ -629,6 +629,7 @@ pub(crate) fn snapshot_from_detached_record( stderr: stream(&record.stderr), validation_progress: None, test_count_evidence: None, + format_mutation: None, activity, }) } diff --git a/crates/webcodex-runner/src/webcodex_runner/dispatch.rs b/crates/webcodex-runner/src/webcodex_runner/dispatch.rs index a48bf3ea4..92ce67662 100644 --- a/crates/webcodex-runner/src/webcodex_runner/dispatch.rs +++ b/crates/webcodex-runner/src/webcodex_runner/dispatch.rs @@ -215,6 +215,7 @@ fn submit_invalid_job_start(sink: &RunnerSink, request: &RunnerRequest, error: S command_execution_state, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }); diff --git a/crates/webcodex-runner/src/webcodex_runner/file_access.rs b/crates/webcodex-runner/src/webcodex_runner/file_access.rs index 74c2848d3..1cb483b95 100644 --- a/crates/webcodex-runner/src/webcodex_runner/file_access.rs +++ b/crates/webcodex-runner/src/webcodex_runner/file_access.rs @@ -104,6 +104,22 @@ pub(crate) fn open_regular_file(path: &Path) -> io::Result { } } +/// Open the same pinned ordinary object for compare-and-write, without truncation. +pub(crate) fn open_regular_file_for_write(path: &Path) -> io::Result { + #[cfg(unix)] + { + open_regular_file_unix_access(path, true, || {}) + } + #[cfg(windows)] + { + open_regular_file_windows_access(path, true, || {}) + } + #[cfg(not(any(unix, windows)))] + { + unsupported_regular_file_open(path) + } +} + #[cfg(any(test, not(any(unix, windows))))] pub(crate) fn unsupported_regular_file_open(_path: &Path) -> io::Result { Err(io::Error::new( @@ -114,6 +130,15 @@ pub(crate) fn unsupported_regular_file_open(_path: &Path) -> io::Result { #[cfg(unix)] pub(crate) fn open_regular_file_unix(path: &Path, before_leaf: impl FnOnce()) -> io::Result { + open_regular_file_unix_access(path, false, before_leaf) +} + +#[cfg(unix)] +fn open_regular_file_unix_access( + path: &Path, + writable: bool, + before_leaf: impl FnOnce(), +) -> io::Result { use std::ffi::CString; use std::os::fd::{AsRawFd, FromRawFd}; @@ -139,7 +164,13 @@ pub(crate) fn open_regular_file_unix(path: &Path, before_leaf: impl FnOnce()) -> libc::openat( parent.as_raw_fd(), leaf.as_ptr(), - libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC, + (if writable { + libc::O_RDWR + } else { + libc::O_RDONLY + }) | libc::O_NOFOLLOW + | libc::O_NONBLOCK + | libc::O_CLOEXEC, ) }; if fd < 0 { @@ -266,10 +297,21 @@ fn unix_directory_open_flags() -> libc::c_int { pub(crate) fn open_regular_file_windows( path: &Path, before_leaf: impl FnOnce(), +) -> io::Result { + open_regular_file_windows_access(path, false, before_leaf) +} + +#[cfg(windows)] +fn open_regular_file_windows_access( + path: &Path, + writable: bool, + before_leaf: impl FnOnce(), ) -> io::Result { use std::os::windows::io::AsRawHandle; use windows_sys::Win32::Foundation::HANDLE; - use windows_sys::Win32::Storage::FileSystem::{FILE_GENERIC_READ, FILE_SHARE_READ}; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_SHARE_READ, FILE_SHARE_WRITE, + }; let parent_path = path .parent() @@ -291,8 +333,8 @@ pub(crate) fn open_regular_file_windows( let file = match windows_nt_open_relative( parent.as_raw_handle() as HANDLE, leaf, - FILE_GENERIC_READ, - FILE_SHARE_READ, + FILE_GENERIC_READ | if writable { FILE_GENERIC_WRITE } else { 0 }, + FILE_SHARE_READ | if writable { FILE_SHARE_WRITE } else { 0 }, WINDOWS_FILE_NON_DIRECTORY_FILE, ) { Ok(handle) => File::from(handle), diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager.rs index ce8043daf..226209340 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager.rs @@ -299,6 +299,7 @@ fn test_job_snapshot(job_id: &str) -> ShellJobSnapshot { stderr: ShellJobStreamSnapshot::default(), validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, } } @@ -335,6 +336,7 @@ struct RunnerJobDelta { stream_limit_bytes: Option, validation_progress: Option, test_count_evidence: Option, + format_mutation: Option, activity: Option, finished: bool, } diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager/admission.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager/admission.rs index f386472fc..d546488e7 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager/admission.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager/admission.rs @@ -139,6 +139,17 @@ pub(super) fn validate_runner_job_context_operation( RunnerJobOperation::StartShell(operation) => { runner_protocol::validate_raw_shell_wire_command(&operation.command)?; } + RunnerJobOperation::StartFormat(operation) => { + if context.ssh_resource.is_some() || !operation.plan.is_valid() { + return Err("invalid project format Job".into()); + } + validate_runner_structured_common( + operation.cwd.as_deref(), + None, + operation.timeout_secs, + webcodex_core::project_format::PROJECT_FORMAT_TIMEOUT_MAX_SECS, + )?; + } RunnerJobOperation::StartBuild(operation) => { if context.ssh_resource.is_some() { return Err("typed project build Job request shape is invalid".to_string()); @@ -324,6 +335,7 @@ impl JobManager { command_execution_state, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }); @@ -364,6 +376,13 @@ impl JobManager { &start.operation, ) }) + .and_then(|_| { + crate::webcodex_runner::project_format::fence( + &start.policy, + &start.project_registry_dir, + &start.operation, + ) + }) .err(); let immediate_failure = if let Some(error) = admission_failure { Some(error) @@ -411,6 +430,7 @@ impl JobManager { stderr: ShellJobStreamSnapshot::default(), validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, }, child: None, @@ -452,6 +472,7 @@ impl JobManager { match &start.operation { RunnerJobOperation::StartInteractiveProcess(_) => self.start_interactive_process(start), RunnerJobOperation::StartDetachedProcess(_) => self.start_detached_process_job(start), + RunnerJobOperation::StartFormat(_) => self.start_format_job(start), RunnerJobOperation::StartBuild(_) | RunnerJobOperation::StartProcess(_) | RunnerJobOperation::StartScript(_) diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager/delivery.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager/delivery.rs index 6689bf3ed..55b68dce1 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager/delivery.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager/delivery.rs @@ -62,6 +62,7 @@ pub(super) struct PendingJobUpdateDelivery { pub(super) command_execution_state: Option, pub(super) validation_progress: Option, pub(super) test_count_evidence: Option, + pub(super) format_mutation: Option, pub(super) activity: Option, pub(super) finished: bool, /// Sequence-only liveness marker. Delivery must not project the current @@ -82,6 +83,7 @@ impl PendingJobUpdateDelivery { command_execution_state: update.command_execution_state, validation_progress: update.validation_progress.clone(), test_count_evidence: update.test_count_evidence.clone(), + format_mutation: update.format_mutation, activity: update.activity, finished: update.finished, liveness_only: false, @@ -98,6 +100,7 @@ impl PendingJobUpdateDelivery { command_execution_state: None, validation_progress: job.snapshot.validation_progress.clone(), test_count_evidence: None, + format_mutation: None, activity: None, finished: false, liveness_only: true, @@ -237,6 +240,7 @@ pub(super) fn job_update_from_delivery( command_execution_state: None, validation_progress: pending.validation_progress.clone(), test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }; @@ -252,6 +256,7 @@ pub(super) fn job_update_from_delivery( update.command_execution_state = pending.command_execution_state; update.validation_progress = pending.validation_progress.clone(); update.test_count_evidence = pending.test_count_evidence.clone(); + update.format_mutation = pending.format_mutation; update.activity = pending.activity; update.finished = pending.finished; update @@ -571,6 +576,7 @@ impl JobManager { command_execution_state: snapshot.command_execution_state, validation_progress: snapshot.validation_progress.clone(), test_count_evidence: snapshot.test_count_evidence.clone(), + format_mutation: snapshot.format_mutation, activity: snapshot.activity, finished: runner_job_is_terminal(&snapshot.status), liveness_only: false, diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager/lifecycle.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager/lifecycle.rs index 0e7dbf0cd..94800d583 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager/lifecycle.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager/lifecycle.rs @@ -87,6 +87,7 @@ pub(super) fn job_prestart_lifecycle( match operation { RunnerJobOperation::StartShell(_) | RunnerJobOperation::StartBuild(_) + | RunnerJobOperation::StartFormat(_) | RunnerJobOperation::StartProcess(_) | RunnerJobOperation::StartInteractiveProcess(_) | RunnerJobOperation::StartDetachedProcess(_) diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager/process.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager/process.rs index b061575c3..b3b9d4827 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager/process.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager/process.rs @@ -175,3 +175,110 @@ impl JobManager { }); } } + +impl JobManager { + pub(super) fn start_format_job(&self, start: PendingJobStart) { + use webcodex_core::project_format::ProjectFormatMutationReport; + let job_id = start.operation.job_id().to_string(); + let stop = { + let _lifecycle = lock_unpoison(&self.lifecycle); + if self.shutting_down.load(Ordering::SeqCst) { + drop(_lifecycle); + self.shutdown_rejection(&start.operation); + return; + } + let mut jobs = lock_unpoison(&self.jobs); + let Some(job) = jobs.get_mut(&job_id) else { + return; + }; + job.slot_reserved = true; + Arc::clone(&job.stop_requested) + }; + let manager = self.clone_for_worker(); + let guard = self.workers.enter(); + std::thread::spawn(move || { + let _guard = guard; + let RunnerJobOperation::StartFormat(operation) = &start.operation else { + unreachable!() + }; + let started = Instant::now(); + let deadline = started + + Duration::from_secs(operation.timeout_secs.min(start.policy.max_timeout_secs)); + let outcome = (|| { + crate::webcodex_runner::project_format::fence( + &start.policy, + &start.project_registry_dir, + &start.operation, + ) + .map_err(|_| "format_plan_stale")?; + let planned = crate::webcodex_runner::project_format::replan( + &start.policy, + &start.project_registry_dir, + &operation.plan, + )?; + // This is a single admitted Job, not one execution per file. + manager.update_and_send( + &job_id, + RunnerJobDelta { + status: "running".into(), + ..Default::default() + }, + ); + let candidates = crate::webcodex_runner::project_format::format_candidates( + &start.policy, + &start.shell, + &start.project_registry_dir, + &planned, + &manager.prepared_profiles, + start.generation, + deadline.saturating_duration_since(Instant::now()).as_secs(), + Some(&stop), + )?; + crate::webcodex_runner::project_format::commit_candidates( + &start.policy, + &start.project_registry_dir, + &planned, + &candidates, + deadline, + &stop, + ) + })(); + let (status, execution, report, error) = match outcome { + Ok(report) => ( + "completed", + ShellCommandExecutionState::Completed, + report, + None, + ), + Err(code) => ( + if code == "format_timeout" { + "timeout" + } else { + "failed" + }, + if code == "format_timeout" { + ShellCommandExecutionState::TimedOut + } else { + ShellCommandExecutionState::OutcomeUnknown + }, + ProjectFormatMutationReport::Unknown, + Some(code.to_string()), + ), + }; + manager.update_and_send( + &job_id, + RunnerJobDelta { + status: status.into(), + command_execution_state: Some(execution), + exit_code: error.is_none().then_some(0), + error, + format_mutation: Some(report), + duration_ms: Some(started.elapsed().as_millis() as u64), + finished: true, + ..Default::default() + }, + ); + manager.start_available_queued(); + }); + } +} diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager/retained.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager/retained.rs index a89f53eb6..58dbdcf92 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager/retained.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager/retained.rs @@ -26,6 +26,7 @@ pub(super) fn job_update_from_snapshot( command_execution_state: snapshot.command_execution_state, validation_progress: snapshot.validation_progress.clone(), test_count_evidence: snapshot.test_count_evidence.clone(), + format_mutation: snapshot.format_mutation, activity: snapshot.activity, finished: runner_job_is_terminal(&snapshot.status), } @@ -204,6 +205,9 @@ impl JobManager { if delta.validation_progress.is_some() { job.snapshot.validation_progress = delta.validation_progress.clone(); } + if delta.format_mutation.is_some() { + job.snapshot.format_mutation = delta.format_mutation; + } if delta.test_count_evidence.is_some() { job.snapshot.test_count_evidence = delta.test_count_evidence.clone(); } diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager/shell_worker.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager/shell_worker.rs index 8209f56f2..7aa9910e0 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager/shell_worker.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager/shell_worker.rs @@ -465,6 +465,7 @@ impl JobManager { stream_limit_bytes: None, validation_progress: final_progress, test_count_evidence, + format_mutation: None, activity: None, finished: true, }, diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager_tests.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager_tests.rs index 3c435ff73..e85c843eb 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager_tests.rs @@ -536,6 +536,7 @@ fn delivery_queue_orders_semantic_truth_and_drops_stale_output_only_updates() { command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }; @@ -688,6 +689,7 @@ fn delivery_worker_waits_for_sequence_barrier_before_selecting_candidate() { command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, liveness_only: false, diff --git a/crates/webcodex-runner/src/webcodex_runner/project_format.rs b/crates/webcodex-runner/src/webcodex_runner/project_format.rs index 57ecbcacb..032cdc330 100644 --- a/crates/webcodex-runner/src/webcodex_runner/project_format.rs +++ b/crates/webcodex-runner/src/webcodex_runner/project_format.rs @@ -326,11 +326,8 @@ pub(crate) fn handle( registry: &Path, request: &ProjectFormatRequest, ) -> super::output::CommandResult { - // Planning is implemented, but a Ready wire response would imply a worker - // can consume it. Keep native execution unavailable until exact output, - // guarded commit and durable mutation-receipt gates are implemented. let result = match plan(policy, registry, request) { - Ok(_) => unavailable("format_execution_unavailable"), + Ok(planned) => ProjectFormatPlanningResult::Ready { plan: planned.plan }, Err(error) => error, }; super::output::CommandResult { @@ -369,6 +366,38 @@ pub(crate) fn format_candidates( let started = Instant::now(); let total_budget = Duration::from_secs(timeout_secs); + let deadline = started + total_budget; + + let (python_executable, env_overrides, env_removals) = match &planned.plan.profile { + ProjectFormatProfile::Rust { .. } => (None, Vec::new(), Vec::new()), + ProjectFormatProfile::Python { .. } => { + let prepared_profile = super::shell::resolve_prepared_shell_profile( + generation, + shell, + registry, + &planned.cwd, + true, + cache, + stop_requested, + ) + .map_err(|_| "format_process_failed")?; + let python_path = super::shell::probe_ruff_formatter( + shell, + prepared_profile.as_deref(), + &planned.cwd, + deadline, + stop_requested, + )?; + let python_exe = python_path + .into_string() + .map_err(|_| "format_path_invalid")?; + ( + Some(python_exe), + vec![("PYTHONDONTWRITEBYTECODE", "1")], + vec!["RUFF_OUTPUT_FILE"], + ) + } + }; let mut total_output = 0usize; let mut candidates = Vec::with_capacity(planned.sources.len()); for (index, witness) in planned.plan.files.iter().enumerate() { @@ -391,22 +420,33 @@ pub(crate) fn format_candidates( .map_err(|_| "format_profile_invalid")?; let cwd = planned.cwd.to_str().ok_or("format_path_invalid")?; - let result = super::shell::run_process_with_profiles_and_execution_state( + let exe = python_executable.as_deref().unwrap_or(&command.executable); + let result = super::shell::run_format_candidate( generation, policy, shell, registry, cache, Some(cwd), - &command.executable, + exe, &command.args, Some(source), remaining, stop_requested, + &env_overrides, + &env_removals, ); if stop_requested.is_some_and(|stop| stop.load(Ordering::SeqCst)) { return Err("format_cancelled"); } + if let Some(code) = result.result.error.as_deref() { + match code { + "format_output_truncated" => return Err("format_output_truncated"), + "format_output_invalid_utf8" => return Err("format_output_invalid_utf8"), + "format_stderr_unexpected" => return Err("format_stderr_unexpected"), + _ => {} + } + } match result.execution_state { ShellCommandExecutionState::Completed => {} ShellCommandExecutionState::NotStarted => return Err("format_process_failed"), @@ -420,6 +460,9 @@ pub(crate) fn format_candidates( return Err("format_process_failed"); } let stdout = result.result.stdout.ok_or("format_process_failed")?; + if stdout.contains('\0') || (!source.trim().is_empty() && stdout.trim().is_empty()) { + return Err("format_candidate_invalid"); + } total_output = total_output .checked_add(stdout.len()) .ok_or("format_candidate_too_large")?; @@ -435,3 +478,155 @@ pub(crate) fn format_candidates( } Ok(candidates) } + +/// Revalidate exact authority and recipe cwd on both admission and the worker. +pub(crate) fn fence( + policy: &RunnerPolicy, + registry: &Path, + operation: &webcodex_core::runner_operation::RunnerJobOperation, +) -> Result<(), String> { + let webcodex_core::runner_operation::RunnerJobOperation::StartFormat(operation) = operation + else { + return Ok(()); + }; + let current = replan(policy, registry, &operation.plan).map_err(str::to_string)?; + if operation.cwd.as_deref() != current.cwd.to_str() { + return Err("format_plan_stale".into()); + } + let context_project = operation + .context + .runtime_project_id + .as_deref() + .and_then(|value| value.strip_prefix("agent:")) + .and_then(|value| value.split_once(':')) + .map(|(_, project)| project); + if context_project != Some(operation.plan.request.project_id.as_str()) { + return Err("format_scope_mismatch".into()); + } + Ok(()) +} + +/// Every error means unknown aggregate mutation, even after an earlier write. +/// There is deliberately no rollback or second write attempt. +pub(crate) fn commit_candidates( + policy: &RunnerPolicy, + registry: &Path, + planned: &PlannedFormat, + candidates: &[String], + deadline: std::time::Instant, + stop: &std::sync::atomic::AtomicBool, +) -> Result { + commit_candidates_with_hook( + policy, + registry, + planned, + candidates, + deadline, + stop, + |_, _| {}, + ) +} + +pub(super) fn commit_candidates_with_hook( + policy: &RunnerPolicy, + registry: &Path, + planned: &PlannedFormat, + candidates: &[String], + deadline: std::time::Instant, + stop: &std::sync::atomic::AtomicBool, + mut boundary: impl FnMut(usize, bool), +) -> Result { + use super::file_access::open_regular_file_for_write; + use std::io::{Seek, SeekFrom, Write}; + let check_stop = || { + if stop.load(std::sync::atomic::Ordering::SeqCst) { + Err("format_cancelled") + } else if std::time::Instant::now() >= deadline { + Err("format_timeout") + } else { + Ok(()) + } + }; + check_stop()?; + replan(policy, registry, &planned.plan)?; + if candidates.len() != planned.sources.len() + || candidates + .iter() + .any(|s| s.len() > PROJECT_FORMAT_FILE_MAX_BYTES || s.contains('\0')) + || candidates.iter().map(String::len).sum::() > PROJECT_FORMAT_TOTAL_MAX_BYTES + { + return Err("format_candidate_invalid"); + } + let mut changed = false; + for (index, (source, candidate)) in planned.sources.iter().zip(candidates).enumerate() { + boundary(index, false); + check_stop()?; + let witness = &planned.plan.files[index]; + if root_digest(&planned.root)? != planned.plan.root_digest { + return Err("format_plan_stale"); + } + let (current, identity) = + read_source(&planned.root, &witness.path, PROJECT_FORMAT_FILE_MAX_BYTES)?; + if ¤t != source || identity != witness.identity_digest { + return Err("format_write_conflict"); + } + if source == candidate { + continue; + } + let path = checked_path(&planned.root, &witness.path, false)?; + let mut file = + open_regular_file_for_write(&path).map_err(|_| "format_write_unavailable")?; + if digest(&file_identity(&file).map_err(|_| "format_write_unavailable")?) != identity + || file_link_count(&file).map_err(|_| "format_write_unavailable")? != 1 + { + return Err("format_write_conflict"); + } + let mut actual = Vec::new(); + (&mut file) + .take(PROJECT_FORMAT_FILE_MAX_BYTES as u64 + 1) + .read_to_end(&mut actual) + .map_err(|_| "format_write_unavailable")?; + if actual != source.as_bytes() || checked_path(&planned.root, &witness.path, false)? != path + { + return Err("format_write_conflict"); + } + // Reopen through pinned ancestors immediately before touching the handle. + let current = open_regular_file_for_write(&path).map_err(|_| "format_write_conflict")?; + if file_identity(¤t).map_err(|_| "format_write_conflict")? + != file_identity(&file).map_err(|_| "format_write_conflict")? + { + return Err("format_write_conflict"); + } + drop(current); + check_stop()?; + file.seek(SeekFrom::Start(0)) + .map_err(|_| "format_write_failed")?; + file.write_all(candidate.as_bytes()) + .map_err(|_| "format_write_failed")?; + file.set_len(candidate.len() as u64) + .map_err(|_| "format_write_failed")?; + file.sync_all().map_err(|_| "format_write_failed")?; + drop(file); + changed = true; + boundary(index, true); + check_stop()?; + } + // Observe the complete selected set, including no-op inputs, after all writes. + // A multi-file operation is never represented as an atomic transaction. + for (witness, candidate) in planned.plan.files.iter().zip(candidates) { + check_stop()?; + let (actual, identity) = + read_source(&planned.root, &witness.path, PROJECT_FORMAT_FILE_MAX_BYTES)?; + if actual != *candidate || identity != witness.identity_digest { + return Err("format_observation_failed"); + } + } + if root_digest(&planned.root)? != planned.plan.root_digest { + return Err("format_observation_failed"); + } + Ok(if changed { + ProjectFormatMutationReport::Changed + } else { + ProjectFormatMutationReport::Unchanged + }) +} diff --git a/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs b/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs index 94511d551..a005493fe 100644 --- a/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs @@ -1,5 +1,7 @@ use super::config::{RunnerPolicy, ShellConfig}; -use super::project_format::{format_candidates, handle, plan, replan, PlannedFormat}; +use super::project_format::{ + commit_candidates, format_candidates, handle, plan, replan, PlannedFormat, +}; use super::shell::PreparedShellProfileCache; use std::fs; #[cfg(unix)] @@ -316,12 +318,12 @@ fn project_format_python_pins_local_target_and_rejects_extend() { target_version: "py311".into() } ); - // A valid static profile is not evidence of installed formatter support. let result = handle(&fixture.policy, &fixture.registry, &request); + let planning: ProjectFormatPlanningResult = + serde_json::from_str(result.stdout.as_deref().unwrap()).unwrap(); assert_eq!( - serde_json::from_str::(result.stdout.as_deref().unwrap()).unwrap() - ["code"], - "format_execution_unavailable" + planning, + ProjectFormatPlanningResult::Ready { plan: planned.plan } ); } @@ -517,3 +519,691 @@ fn project_format_candidates_enforces_deadline_and_stop_before_dispatch() { planned.sources[0] ); } + +fn commit( + fixture: &Fixture, + planned: &PlannedFormat, + candidates: &[String], +) -> Result { + super::project_format::commit_candidates( + &fixture.policy, + &fixture.registry, + planned, + candidates, + std::time::Instant::now() + std::time::Duration::from_secs(10), + &std::sync::atomic::AtomicBool::new(false), + ) +} + +#[test] +fn project_format_pf07_noop_performs_no_write() { + let fixture = Fixture::new(); + let planned = fixture.plan(); + let before = fs::metadata(fixture.root.join("src/main.rs")) + .unwrap() + .modified() + .unwrap(); + assert_eq!( + commit(&fixture, &planned, &planned.sources), + Ok(ProjectFormatMutationReport::Unchanged) + ); + assert_eq!( + fs::metadata(fixture.root.join("src/main.rs")) + .unwrap() + .modified() + .unwrap(), + before + ); +} + +#[test] +fn project_format_pf08_guarded_write_changes_only_selected_files() { + let fixture = Fixture::new(); + fs::write(fixture.root.join("src/other.rs"), "fn other( ){ }\n").unwrap(); + let planned = fixture.plan(); + assert_eq!( + commit(&fixture, &planned, &["fn main() {}\n".into()]), + Ok(ProjectFormatMutationReport::Changed) + ); + assert_eq!( + fs::read_to_string(fixture.root.join("src/main.rs")).unwrap(), + "fn main() {}\n" + ); + assert_eq!( + fs::read_to_string(fixture.root.join("src/other.rs")).unwrap(), + "fn other( ){ }\n" + ); + assert_eq!( + commit(&fixture, &planned, &["fn main() {}\n".into()]), + Err("format_plan_stale") + ); +} + +#[test] +fn project_format_pf09_candidate_commit_detects_source_and_manifest_conflicts() { + for manifest in [false, true] { + let fixture = Fixture::new(); + let planned = fixture.plan(); + if manifest { + fs::write( + fixture.root.join("Cargo.toml"), + MANIFEST.replace("2021", "2024"), + ) + .unwrap(); + } else { + fs::write(fixture.root.join("src/main.rs"), "fn concurrent() {}\n").unwrap(); + } + assert_eq!( + commit(&fixture, &planned, &["fn main() {}\n".into()]), + Err("format_plan_stale") + ); + } +} + +#[test] +fn project_format_pf10_partial_write_and_failed_observation_stay_unknown() { + use std::sync::atomic::{AtomicBool, Ordering}; + for scenario in 0..3 { + let fixture = Fixture::new(); + fs::write(fixture.root.join("src/other.rs"), "fn other( ){ }\n").unwrap(); + let mut request = fixture.request(); + request.files.push("src/other.rs".into()); + let planned = plan(&fixture.policy, &fixture.registry, &request).unwrap(); + let stop = AtomicBool::new(false); + let result = super::project_format::commit_candidates_with_hook( + &fixture.policy, + &fixture.registry, + &planned, + &["fn main() {}\n".into(), "fn other() {}\n".into()], + std::time::Instant::now() + std::time::Duration::from_secs(10), + &stop, + |index, after| { + if index == 0 && after { + match scenario { + 0 => { + fs::write(fixture.root.join("src/other.rs"), "concurrent modification") + .unwrap(); + } + 1 => { + fs::write(fixture.root.join("src/main.rs"), "postwrite conflict") + .unwrap(); + } + _ => stop.store(true, Ordering::SeqCst), + } + } + }, + ); + assert_eq!( + result, + Err(match scenario { + 0 => "format_write_conflict", + 1 => "format_observation_failed", + _ => "format_cancelled", + }) + ); + assert_ne!( + fs::read_to_string(fixture.root.join("src/main.rs")).unwrap(), + planned.sources[0] + ); + } +} + +#[test] +fn project_format_pf09_replaced_object_with_same_contents_never_receives_write() { + let fixture = Fixture::new(); + let planned = fixture.plan(); + let result = super::project_format::commit_candidates_with_hook( + &fixture.policy, + &fixture.registry, + &planned, + &["fn main() {}\n".into()], + std::time::Instant::now() + std::time::Duration::from_secs(10), + &std::sync::atomic::AtomicBool::new(false), + |_, after| { + if !after { + fs::rename( + fixture.root.join("src/main.rs"), + fixture.root.join("src/original.rs"), + ) + .unwrap(); + fs::write(fixture.root.join("src/main.rs"), &planned.sources[0]).unwrap(); + } + }, + ); + assert_eq!(result, Err("format_write_conflict")); + assert_eq!( + fs::read_to_string(fixture.root.join("src/main.rs")).unwrap(), + planned.sources[0] + ); +} + +#[test] +fn project_format_pf06_python_execution_remains_unavailable() { + let fixture = Fixture::new(); + fs::remove_file(fixture.root.join("Cargo.toml")).unwrap(); + fs::write( + fixture.root.join("pyproject.toml"), + "[tool.ruff]\ntarget-version='py311'\n", + ) + .unwrap(); + fs::write(fixture.root.join("main.py"), "x=1\n").unwrap(); + let mut request = fixture.request(); + request.files = vec!["main.py".into()]; + let planned = plan(&fixture.policy, &fixture.registry, &request).unwrap(); + let mut shell = ShellConfig::default(); + // Controlled isolated PATH ensures missing interpreter fail-closed behavior + // without relying on the ambient environment lacking Python. + shell + .env + .insert("PATH".into(), "/nonexistent/isolated/bin".into()); + assert_eq!( + format_candidates( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &PreparedShellProfileCache::default(), + 1, + 10, + None + ), + Err("format_tool_unavailable") + ); + assert_eq!( + fs::read_to_string(fixture.root.join("main.py")).unwrap(), + "x=1\n" + ); +} + +#[test] +fn project_format_handle_returns_ready_for_valid_rust_request() { + let fixture = Fixture::new(); + let request = fixture.request(); + let result = handle(&fixture.policy, &fixture.registry, &request); + assert_eq!(result.exit_code, Some(0)); + let planning: ProjectFormatPlanningResult = + serde_json::from_str(result.stdout.as_deref().unwrap()).unwrap(); + let ProjectFormatPlanningResult::Ready { plan } = planning else { + panic!("expected Ready format plan"); + }; + assert!(plan.is_valid()); + assert_eq!(plan.request, request); +} + +#[test] +fn project_format_fence_validates_authority_and_stale_detection() { + use webcodex_core::runner_operation::{RunnerJobFormatOperation, RunnerJobOperation}; + use webcodex_core::runner_protocol::ShellJobContext; + + let fixture = Fixture::new(); + let planned = fixture.plan(); + let context = ShellJobContext { + runtime_project_id: Some("agent:runner:demo".into()), + workflow_session_id: None, + ssh_resource: None, + project_cwd: Some(".".into()), + cwd: Some(planned.cwd.to_string_lossy().into()), + purpose: Some("format".into()), + shell: Some("direct_argv".into()), + command_preview: "project_format selected files".into(), + validation_steps: vec![], + validation: None, + structured_execution: Some(structured_metadata()), + }; + let operation = RunnerJobOperation::StartFormat(RunnerJobFormatOperation { + job_id: "job-1".into(), + cwd: context.cwd.clone(), + plan: planned.plan.clone(), + timeout_secs: 60, + context: context.clone(), + }); + assert!(super::project_format::fence(&fixture.policy, &fixture.registry, &operation).is_ok()); + + // Mismatched project ID + let mut mismatched_proj = operation.clone(); + if let RunnerJobOperation::StartFormat(ref mut op) = mismatched_proj { + op.context.runtime_project_id = Some("agent:runner:other".into()); + } + assert_eq!( + super::project_format::fence(&fixture.policy, &fixture.registry, &mismatched_proj), + Err("format_scope_mismatch".into()) + ); + + // Mismatched cwd + let mut mismatched_cwd = operation.clone(); + if let RunnerJobOperation::StartFormat(ref mut op) = mismatched_cwd { + op.cwd = Some("/wrong/path".into()); + } + assert_eq!( + super::project_format::fence(&fixture.policy, &fixture.registry, &mismatched_cwd), + Err("format_plan_stale".into()) + ); + + // Stale plan on disk + fs::write(fixture.root.join("src/main.rs"), "fn modified() {}\n").unwrap(); + assert_eq!( + super::project_format::fence(&fixture.policy, &fixture.registry, &operation), + Err("format_plan_stale".into()) + ); +} + +#[test] +fn project_format_candidates_fails_on_missing_python() { + let fixture = Fixture::new(); + fs::remove_file(fixture.root.join("Cargo.toml")).unwrap(); + fs::write( + fixture.root.join("pyproject.toml"), + "[tool.ruff]\ntarget-version='py311'\n", + ) + .unwrap(); + fs::write(fixture.root.join("main.py"), "x=1\n").unwrap(); + let mut request = fixture.request(); + request.files = vec!["main.py".into()]; + let planned = plan(&fixture.policy, &fixture.registry, &request).unwrap(); + let mut shell = ShellConfig::default(); + shell + .env + .insert("PATH".into(), "/nonexistent_bin_path_12345".into()); + shell.environment_mode = super::config::ShellEnvironmentMode::Isolated; + let cache = PreparedShellProfileCache::default(); + assert_eq!( + format_candidates( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &cache, + 1, + 10, + None, + ), + Err("format_tool_unavailable") + ); +} + +#[test] +fn project_format_candidates_fails_on_missing_ruff_module() { + let fixture = Fixture::new(); + fs::remove_file(fixture.root.join("Cargo.toml")).unwrap(); + fs::write( + fixture.root.join("pyproject.toml"), + "[tool.ruff]\ntarget-version='py311'\n", + ) + .unwrap(); + fs::write(fixture.root.join("main.py"), "x=1\n").unwrap(); + let mut request = fixture.request(); + request.files = vec!["main.py".into()]; + let planned = plan(&fixture.policy, &fixture.registry, &request).unwrap(); + let cache = PreparedShellProfileCache::default(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let temp = crate::tests::executable_tempdir(); + // Exit 42 models python present without the ruff module installed + let script = "#!/bin/sh\nexit 42\n"; + let mock_py = temp.path().join("python3"); + fs::write(&mock_py, script).unwrap(); + fs::set_permissions(&mock_py, fs::Permissions::from_mode(0o755)).unwrap(); + + let mut shell = ShellConfig::default(); + shell.path_prepend = vec![temp.path().to_path_buf()]; + assert_eq!( + format_candidates( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &cache, + 1, + 10, + None, + ), + Err("format_tool_unavailable") + ); + } + #[cfg(not(unix))] + { + let mut shell = ShellConfig::default(); + shell + .env + .insert("PATH".into(), "/nonexistent/isolated/bin".into()); + assert_eq!( + format_candidates( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &cache, + 1, + 10, + None, + ), + Err("format_tool_unavailable") + ); + } +} + +#[cfg(unix)] +#[test] +fn project_format_candidates_python_ruff_probe_timeout_fails_closed() { + use std::os::unix::fs::PermissionsExt; + let temp = crate::tests::executable_tempdir(); + // Script sleeps longer than total timeout budget + let script = r#"#!/bin/sh +sleep 2 +exit 0 +"#; + let mock_py = temp.path().join("python3"); + fs::write(&mock_py, script).unwrap(); + fs::set_permissions(&mock_py, fs::Permissions::from_mode(0o755)).unwrap(); + + let fixture = Fixture::new(); + fs::remove_file(fixture.root.join("Cargo.toml")).unwrap(); + fs::write( + fixture.root.join("pyproject.toml"), + "[tool.ruff]\ntarget-version='py311'\n", + ) + .unwrap(); + fs::write(fixture.root.join("main.py"), "x=1\n").unwrap(); + let mut request = fixture.request(); + request.files = vec!["main.py".into()]; + let planned = plan(&fixture.policy, &fixture.registry, &request).unwrap(); + + let mut shell = ShellConfig::default(); + shell.path_prepend = vec![temp.path().to_path_buf()]; + let cache = PreparedShellProfileCache::default(); + + // 1 second timeout budget: probe must count against budget and yield format_timeout + let result = format_candidates( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &cache, + 1, + 1, + None, + ); + assert_eq!(result, Err("format_timeout")); +} + +#[test] +fn project_format_candidates_real_ruff_opt_in_when_installed() { + let fixture = Fixture::new(); + fs::remove_file(fixture.root.join("Cargo.toml")).unwrap(); + fs::write( + fixture.root.join("pyproject.toml"), + "[tool.ruff]\ntarget-version='py311'\n", + ) + .unwrap(); + fs::write(fixture.root.join("main.py"), "x = 1+2\n").unwrap(); + let mut request = fixture.request(); + request.files = vec!["main.py".into()]; + let planned = plan(&fixture.policy, &fixture.registry, &request).unwrap(); + let shell = ShellConfig::default(); + let cache = PreparedShellProfileCache::default(); + let probe_ok = std::process::Command::new("python3") + .args([ + "-I", + "-B", + "-c", + "import sys,importlib.util;sys.exit(0 if sys.version_info.major == 3 and importlib.util.find_spec('ruff') else 42)", + ]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if !probe_ok { + return; + } + let candidates = format_candidates( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &cache, + 1, + 10, + None, + ) + .unwrap(); + assert_eq!(candidates, vec!["x = 1 + 2\n".to_string()]); +} + +#[cfg(unix)] +#[test] +fn project_format_candidates_python_ruff_mock_fixture_formats_and_detects_changes() { + use std::os::unix::fs::PermissionsExt; + let temp = crate::tests::executable_tempdir(); + let script = r#"#!/bin/sh +if [ -n "$RUFF_OUTPUT_FILE" ]; then + echo "RUFF_OUTPUT_FILE was not removed" >&2 + exit 55 +fi +if [ "$PYTHONDONTWRITEBYTECODE" != "1" ]; then + echo "PYTHONDONTWRITEBYTECODE not set" >&2 + exit 56 +fi +case "$*" in + *"find_spec('ruff')"*) + exit 0 + ;; + *"-m ruff format"*) + printf 'formatted = True\n' + exit 0 + ;; + *) + exit 42 + ;; +esac +"#; + let mock_py = temp.path().join("python3"); + fs::write(&mock_py, script).unwrap(); + fs::set_permissions(&mock_py, fs::Permissions::from_mode(0o755)).unwrap(); + + let fixture = Fixture::new(); + fs::remove_file(fixture.root.join("Cargo.toml")).unwrap(); + fs::write( + fixture.root.join("pyproject.toml"), + "[tool.ruff]\ntarget-version='py311'\n", + ) + .unwrap(); + fs::write(fixture.root.join("main.py"), "formatted=False\n").unwrap(); + let mut request = fixture.request(); + request.files = vec!["main.py".into()]; + let planned = plan(&fixture.policy, &fixture.registry, &request).unwrap(); + + let mut shell = ShellConfig::default(); + shell.path_prepend = vec![temp.path().to_path_buf()]; + let cache = PreparedShellProfileCache::default(); + + let candidates = format_candidates( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &cache, + 1, + 10, + None, + ) + .unwrap(); + assert_eq!(candidates, vec!["formatted = True\n".to_string()]); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + let stop = std::sync::atomic::AtomicBool::new(false); + let report = commit_candidates( + &fixture.policy, + &fixture.registry, + &planned, + &candidates, + deadline, + &stop, + ) + .unwrap(); + assert_eq!(report, ProjectFormatMutationReport::Changed); + assert_eq!( + fs::read_to_string(fixture.root.join("main.py")).unwrap(), + "formatted = True\n" + ); +} + +#[cfg(unix)] +#[test] +fn project_format_candidates_python_ruff_syntax_error_fails() { + use std::os::unix::fs::PermissionsExt; + let temp = crate::tests::executable_tempdir(); + let script = r#"#!/bin/sh +case "$*" in + *"find_spec('ruff')"*) + exit 0 + ;; + *"-m ruff format"*) + echo "syntax error: unclosed parenthesis" >&2 + exit 1 + ;; + *) + exit 42 + ;; +esac +"#; + let mock_py = temp.path().join("python3"); + fs::write(&mock_py, script).unwrap(); + fs::set_permissions(&mock_py, fs::Permissions::from_mode(0o755)).unwrap(); + + let fixture = Fixture::new(); + fs::remove_file(fixture.root.join("Cargo.toml")).unwrap(); + fs::write( + fixture.root.join("pyproject.toml"), + "[tool.ruff]\ntarget-version='py311'\n", + ) + .unwrap(); + fs::write(fixture.root.join("main.py"), "def foo(\n").unwrap(); + let mut request = fixture.request(); + request.files = vec!["main.py".into()]; + let planned = plan(&fixture.policy, &fixture.registry, &request).unwrap(); + + let mut shell = ShellConfig::default(); + shell.path_prepend = vec![temp.path().to_path_buf()]; + let cache = PreparedShellProfileCache::default(); + + let result = format_candidates( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &cache, + 1, + 10, + None, + ); + assert_eq!(result, Err("format_process_failed")); +} + +#[cfg(unix)] +#[test] +fn project_format_candidates_fails_on_unexpected_stderr() { + use std::os::unix::fs::PermissionsExt; + let temp = crate::tests::executable_tempdir(); + let script = r#"#!/bin/sh +case "$*" in + *"find_spec('ruff')"*) + exit 0 + ;; + *"-m ruff format"*) + cat > /dev/null + printf 'warning: something unexpected\n' >&2 + printf 'x = 1\n' + exit 0 + ;; + *) + exit 42 + ;; +esac +"#; + let mock_py = temp.path().join("python3"); + fs::write(&mock_py, script).unwrap(); + fs::set_permissions(&mock_py, fs::Permissions::from_mode(0o755)).unwrap(); + + let fixture = Fixture::new(); + fs::remove_file(fixture.root.join("Cargo.toml")).unwrap(); + fs::write( + fixture.root.join("pyproject.toml"), + "[tool.ruff]\ntarget-version='py311'\n", + ) + .unwrap(); + fs::write(fixture.root.join("main.py"), "x=1\n").unwrap(); + let mut request = fixture.request(); + request.files = vec!["main.py".into()]; + let planned = plan(&fixture.policy, &fixture.registry, &request).unwrap(); + + let mut shell = ShellConfig::default(); + shell.path_prepend = vec![temp.path().to_path_buf()]; + let cache = PreparedShellProfileCache::default(); + + let result = format_candidates( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &cache, + 1, + 10, + None, + ); + assert_eq!(result, Err("format_stderr_unexpected")); +} + +#[cfg(unix)] +#[test] +fn project_format_candidates_nonzero_exit_with_truncated_stderr_fails_closed() { + use std::os::unix::fs::PermissionsExt; + let temp = crate::tests::executable_tempdir(); + let script = r#"#!/bin/sh +case "$*" in + *"find_spec('ruff')"*) + exit 0 + ;; + *"-m ruff format"*) + cat > /dev/null + printf 'very long error message exceeding max output bytes\n' >&2 + exit 1 + ;; + *) + exit 42 + ;; +esac +"#; + let mock_py = temp.path().join("python3"); + fs::write(&mock_py, script).unwrap(); + fs::set_permissions(&mock_py, fs::Permissions::from_mode(0o755)).unwrap(); + + let fixture = Fixture::new(); + fs::remove_file(fixture.root.join("Cargo.toml")).unwrap(); + fs::write( + fixture.root.join("pyproject.toml"), + "[tool.ruff]\ntarget-version='py311'\n", + ) + .unwrap(); + fs::write(fixture.root.join("main.py"), "def foo(\n").unwrap(); + let mut request = fixture.request(); + request.files = vec!["main.py".into()]; + let planned = plan(&fixture.policy, &fixture.registry, &request).unwrap(); + + let mut shell = ShellConfig::default(); + shell.path_prepend = vec![temp.path().to_path_buf()]; + let mut policy = fixture.policy.clone(); + policy.max_output_bytes = 10; + let cache = PreparedShellProfileCache::default(); + + let result = format_candidates( + &policy, + &shell, + &fixture.registry, + &planned, + &cache, + 1, + 10, + None, + ); + assert_eq!(result, Err("format_output_truncated")); +} diff --git a/crates/webcodex-runner/src/webcodex_runner/shell.rs b/crates/webcodex-runner/src/webcodex_runner/shell.rs index cb48b4110..7ff7e3b05 100644 --- a/crates/webcodex-runner/src/webcodex_runner/shell.rs +++ b/crates/webcodex-runner/src/webcodex_runner/shell.rs @@ -129,7 +129,7 @@ pub(crate) use commands::{ configured_explicit_shell_command, configured_node_project_check_job_command, configured_prepared_shell_job_command, configured_pytest_job_command, configured_ruff_job_command, configured_shell_job_command, configured_validation_job_command, - explicit_shell_available, shell_quote, + explicit_shell_available, probe_ruff_formatter, shell_quote, }; use commands::{ configured_prepared_shell_command, configured_process_command, configured_process_path, @@ -157,7 +157,8 @@ use output::IncrementalUtf8Validator; use output::{read_bounded_pipe_tail, BoundedPipeTail}; use preparation::{run_prepare_command, stderr_tail}; pub(crate) use process::{ - prepare_detached_process_launch, run_process_with_profiles_and_execution_state, + prepare_detached_process_launch, run_format_candidate, + run_process_with_profiles_and_execution_state, run_process_with_profiles_and_execution_state_with_internal_env_and_start_hook, run_process_with_profiles_and_execution_state_with_start_hook, }; diff --git a/crates/webcodex-runner/src/webcodex_runner/shell/commands.rs b/crates/webcodex-runner/src/webcodex_runner/shell/commands.rs index f52d06f77..cf6a0ff7a 100644 --- a/crates/webcodex-runner/src/webcodex_runner/shell/commands.rs +++ b/crates/webcodex-runner/src/webcodex_runner/shell/commands.rs @@ -254,6 +254,57 @@ pub(crate) fn configured_ruff_job_command( configured_python_module_job_command(shell, profile, &step.args, cwd, stop_requested, true) } +pub(crate) fn probe_ruff_formatter( + shell: &ShellConfig, + profile: Option<&PreparedShellProfile>, + cwd: &Path, + deadline: Instant, + stop_requested: Option<&AtomicBool>, +) -> Result { + if Instant::now() >= deadline { + return Err("format_timeout"); + } + let program = super::scripts::configured_validation_python_interpreter(shell, profile) + .map_err(|_| "format_tool_unavailable")?; + let mut probe = Command::new(&program); + const RUFF_PROBE: &str = "import sys,importlib.util;sys.exit(0 if sys.version_info.major == 3 and importlib.util.find_spec('ruff') else 42)"; + probe.args(["-I", "-B", "-c", RUFF_PROBE]); + probe.current_dir(cwd); + probe.stdin(Stdio::null()); + probe.stdout(Stdio::null()); + probe.stderr(Stdio::null()); + super::scripts::apply_script_environment(&mut probe, shell, profile) + .map_err(|_| "format_tool_unavailable")?; + probe.env_remove("RUFF_OUTPUT_FILE"); + probe.env("PYTHONDONTWRITEBYTECODE", "1"); + if stop_requested.is_some_and(|flag| flag.load(Ordering::SeqCst)) { + return Err("format_cancelled"); + } + let mut child = ManagedChild::spawn(&mut probe).map_err(|_| "format_tool_unavailable")?; + let available = loop { + match child.try_wait() { + Ok(Some(status)) => break status.success(), + Ok(None) + if Instant::now() < deadline + && !stop_requested.is_some_and(|flag| flag.load(Ordering::SeqCst)) => + { + std::thread::sleep(Duration::from_millis(20)); + } + _ => break false, + } + }; + if terminate_child_process_tree(&mut child).is_err() || !available { + if stop_requested.is_some_and(|flag| flag.load(Ordering::SeqCst)) { + return Err("format_cancelled"); + } + if Instant::now() >= deadline { + return Err("format_timeout"); + } + return Err("format_tool_unavailable"); + } + Ok(program) +} + fn configured_python_module_job_command( shell: &ShellConfig, profile: Option<&PreparedShellProfile>, diff --git a/crates/webcodex-runner/src/webcodex_runner/shell/execution.rs b/crates/webcodex-runner/src/webcodex_runner/shell/execution.rs index 537dc9cb8..1f50bf83b 100644 --- a/crates/webcodex-runner/src/webcodex_runner/shell/execution.rs +++ b/crates/webcodex-runner/src/webcodex_runner/shell/execution.rs @@ -14,7 +14,7 @@ pub(super) fn execute_configured_command( spawn_error_prefix: &str, on_started: Option<&dyn Fn()>, ) -> ShellCommandResult { - execute_configured_command_with_prefix( + execute_configured_command_impl( policy, cmd, cwd_path, @@ -25,11 +25,68 @@ pub(super) fn execute_configured_command( spawn_error_prefix, on_started, None, + false, ) } #[allow(clippy::too_many_arguments)] pub(super) fn execute_configured_command_with_prefix( + policy: &RunnerPolicy, + cmd: Command, + cwd_path: &Path, + stdin: Option<&str>, + timeout_secs: u64, + stop_requested: Option<&AtomicBool>, + start: Instant, + spawn_error_prefix: &str, + on_started: Option<&dyn Fn()>, + prefix: Option, +) -> ShellCommandResult { + execute_configured_command_impl( + policy, + cmd, + cwd_path, + stdin, + timeout_secs, + stop_requested, + start, + spawn_error_prefix, + on_started, + prefix, + false, + ) +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn execute_configured_command_with_output( + policy: &RunnerPolicy, + cmd: Command, + cwd_path: &Path, + stdin: Option<&str>, + timeout_secs: u64, + stop_requested: Option<&AtomicBool>, + start: Instant, + spawn_error_prefix: &str, + on_started: Option<&dyn Fn()>, + exact_utf8: bool, +) -> ShellCommandResult { + execute_configured_command_impl( + policy, + cmd, + cwd_path, + stdin, + timeout_secs, + stop_requested, + start, + spawn_error_prefix, + on_started, + None, + exact_utf8, + ) +} + +#[allow(clippy::too_many_arguments)] +fn execute_configured_command_impl( policy: &RunnerPolicy, mut cmd: Command, cwd_path: &Path, @@ -40,6 +97,7 @@ pub(super) fn execute_configured_command_with_prefix( spawn_error_prefix: &str, on_started: Option<&dyn Fn()>, prefix: Option, + exact_utf8: bool, ) -> ShellCommandResult { cmd.current_dir(cwd_path) .stdout(Stdio::piped()) @@ -117,7 +175,7 @@ pub(super) fn execute_configured_command_with_prefix( }; let mut output_bounded = false; loop { - if let Err(error) = poll_stdin_writer(&mut stdin_writer) { + if let Err(error) = poll_stdin_writer(&mut stdin_writer, exact_utf8) { let cleanup = terminate_and_collect_pipes(child, drains).err(); return ShellCommandResult::outcome_unknown(CommandResult { exit_code: None, @@ -222,7 +280,7 @@ pub(super) fn execute_configured_command_with_prefix( } } } - if let Err(error) = finish_stdin_writer(stdin_writer) { + if let Err(error) = finish_stdin_writer(stdin_writer, exact_utf8) { let cleanup = terminate_and_collect_pipes(child, drains).err(); return ShellCommandResult::outcome_unknown(CommandResult { exit_code: None, @@ -238,6 +296,37 @@ pub(super) fn execute_configured_command_with_prefix( let limit_reached = Arc::clone(&drains.stdout_limit_reached); match terminate_and_collect_pipes(child, drains) { Ok((status, stdout, stderr)) => { + if exact_utf8 { + let code = if stdout.raw_truncated + || stderr.raw_truncated + || stdout.bytes.len() > policy.max_output_bytes + || stderr.bytes.len() > policy.max_output_bytes + { + Some("format_output_truncated") + } else if std::str::from_utf8(&stdout.bytes).is_err() + || std::str::from_utf8(&stderr.bytes).is_err() + { + Some("format_output_invalid_utf8") + } else if status.code() == Some(0) && !stderr.bytes.is_empty() { + Some("format_stderr_unexpected") + } else { + None + }; + if let Some(code) = code { + return spawned_output_failure(start, code.into()); + } + return ShellCommandResult::completed(CommandResult { + exit_code: Some(status.code().unwrap_or(-1)), + stdout: Some(String::from_utf8(stdout.bytes).expect("checked UTF-8")), + stderr: if stderr.bytes.is_empty() { + None + } else { + Some(String::from_utf8(stderr.bytes).expect("checked UTF-8")) + }, + duration_ms: Some(start.elapsed().as_millis() as u64), + error: None, + }); + } let (stdout, stdout_truncated) = stdout.normalize_with_truncation(policy.max_output_bytes); let (stderr, stderr_truncated) = @@ -272,6 +361,7 @@ pub(super) fn execute_configured_command_with_prefix( pub(super) fn poll_stdin_writer( receiver: &mut Option>>, + exact_utf8: bool, ) -> Result<(), String> { let Some(active) = receiver.as_ref() else { return Ok(()); @@ -281,7 +371,7 @@ pub(super) fn poll_stdin_writer( *receiver = None; Ok(()) } - Ok(Err(error)) if error.kind() == std::io::ErrorKind::BrokenPipe => { + Ok(Err(error)) if !exact_utf8 && error.kind() == std::io::ErrorKind::BrokenPipe => { // The child may deliberately close stdin before exiting. Its // terminal status and output remain the source of truth. *receiver = None; @@ -301,13 +391,14 @@ pub(super) fn poll_stdin_writer( pub(super) fn finish_stdin_writer( receiver: Option>>, + exact_utf8: bool, ) -> Result<(), String> { let Some(receiver) = receiver else { return Ok(()); }; match receiver.recv_timeout(Duration::from_secs(1)) { Ok(Ok(())) => Ok(()), - Ok(Err(error)) if error.kind() == std::io::ErrorKind::BrokenPipe => Ok(()), + Ok(Err(error)) if !exact_utf8 && error.kind() == std::io::ErrorKind::BrokenPipe => Ok(()), Ok(Err(error)) => Err(error.to_string()), Err(mpsc::RecvTimeoutError::Timeout) => { Err("stdin writer did not finish after process exit".to_string()) diff --git a/crates/webcodex-runner/src/webcodex_runner/shell/process.rs b/crates/webcodex-runner/src/webcodex_runner/shell/process.rs index dff448913..5f48e4e20 100644 --- a/crates/webcodex-runner/src/webcodex_runner/shell/process.rs +++ b/crates/webcodex-runner/src/webcodex_runner/shell/process.rs @@ -143,6 +143,78 @@ pub(crate) fn run_process_with_profiles_and_execution_state_with_internal_env_an stop_requested: Option<&AtomicBool>, env_overrides: &[(&str, &str)], on_started: Option<&dyn Fn()>, +) -> ShellCommandResult { + run_process_with_output( + generation, + policy, + shell, + project_registry_dir, + cache, + cwd, + executable, + args, + stdin, + timeout_secs, + stop_requested, + env_overrides, + &[], + on_started, + false, + ) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn run_format_candidate( + generation: u64, + policy: &RunnerPolicy, + shell: &ShellConfig, + project_registry_dir: &Path, + cache: &PreparedShellProfileCache, + cwd: Option<&str>, + executable: &str, + args: &[String], + stdin: Option<&str>, + timeout_secs: u64, + stop_requested: Option<&AtomicBool>, + env_overrides: &[(&str, &str)], + env_removals: &[&str], +) -> ShellCommandResult { + run_process_with_output( + generation, + policy, + shell, + project_registry_dir, + cache, + cwd, + executable, + args, + stdin, + timeout_secs, + stop_requested, + env_overrides, + env_removals, + None, + true, + ) +} + +#[allow(clippy::too_many_arguments)] +fn run_process_with_output( + generation: u64, + policy: &RunnerPolicy, + shell: &ShellConfig, + project_registry_dir: &Path, + cache: &PreparedShellProfileCache, + cwd: Option<&str>, + executable: &str, + args: &[String], + stdin: Option<&str>, + timeout_secs: u64, + stop_requested: Option<&AtomicBool>, + env_overrides: &[(&str, &str)], + env_removals: &[&str], + on_started: Option<&dyn Fn()>, + exact_utf8: bool, ) -> ShellCommandResult { // Structured execution intentionally receives the same policy treatment // as run_shell. Absence of shell syntax is not a permission bypass. @@ -207,10 +279,13 @@ pub(crate) fn run_process_with_profiles_and_execution_state_with_internal_env_an }) } }; + for key in env_removals { + cmd.env_remove(key); + } for (key, value) in env_overrides { cmd.env(key, value); } - execute_configured_command( + super::execution::execute_configured_command_with_output( policy, cmd, &cwd_path, @@ -220,5 +295,6 @@ pub(crate) fn run_process_with_profiles_and_execution_state_with_internal_env_an start, "failed to spawn structured process", on_started, + exact_utf8, ) } diff --git a/crates/webcodex-runner/src/webcodex_runner/transport/registration.rs b/crates/webcodex-runner/src/webcodex_runner/transport/registration.rs index 200035767..ed3f8e78d 100644 --- a/crates/webcodex-runner/src/webcodex_runner/transport/registration.rs +++ b/crates/webcodex-runner/src/webcodex_runner/transport/registration.rs @@ -246,9 +246,12 @@ pub(crate) fn runner_register_capabilities(cfg: &RunnerConfig) -> RunnerCapabili capabilities.set(RunnerCapabilityId::StructuredGoTestJson, true); capabilities.set(RunnerCapabilityId::ProjectValidation, true); capabilities.set(RunnerCapabilityId::ProjectBuild, true); - // Static config cannot claim the format lifecycle before the worker, - // guarded commit and durable mutation report are implemented together. - capabilities.set(RunnerCapabilityId::ProjectFormat, false); + // Complete format lifecycle with bounded planning, worker, guarded commit + // and durable mutation report is supported across native platforms. + capabilities.set( + RunnerCapabilityId::ProjectFormat, + cfg!(any(target_os = "linux", target_os = "macos", windows)), + ); capabilities.set(RunnerCapabilityId::ProjectDependencyPolicy, true); // Go project gateways pin GO111MODULE=on and GOWORK=off after prepared shell/profile env is // applied. Keep this independent for mixed Server/Runner rolling upgrades. diff --git a/crates/webcodex-runner/src/webcodex_runner/transport_tests/stream_telemetry.rs b/crates/webcodex-runner/src/webcodex_runner/transport_tests/stream_telemetry.rs index 318d16910..5ced4a126 100644 --- a/crates/webcodex-runner/src/webcodex_runner/transport_tests/stream_telemetry.rs +++ b/crates/webcodex-runner/src/webcodex_runner/transport_tests/stream_telemetry.rs @@ -143,6 +143,7 @@ fn test_job_update() -> RunnerJobUpdateRequest { command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, } diff --git a/crates/webcodex-tool-contracts/src/registry/output_schemas/jobs.rs b/crates/webcodex-tool-contracts/src/registry/output_schemas/jobs.rs index dace9b5ca..d9c8be251 100644 --- a/crates/webcodex-tool-contracts/src/registry/output_schemas/jobs.rs +++ b/crates/webcodex-tool-contracts/src/registry/output_schemas/jobs.rs @@ -458,6 +458,7 @@ fn job_structured_execution_metadata_schema() -> Value { "run_detached_process", "run_script", "project_build", + "project_format", "run_skill_resource" ] }, @@ -988,6 +989,17 @@ pub(super) fn output_schema_for_tool(name: &str) -> Option { } Some(schema) } + "project_format" => { + let mut schema = wrapped_output_schema(vec![ + ("state_changed", json!({"type":["boolean","null"], "description":"Executor-verified aggregate mutation. Null is unknown, never retry permission."})), + ("failure_kind", json!({"type":"string", "maxLength":80})), + ("execution_state", json!({"type":"string", "enum":["not_started","outcome_unknown"]})), + ("job_id", schema_type("string", "Original Job identity, never a replacement execution.")), + ("continuation", observe_job_continuation_schema()), + ]); + schema["properties"]["output"]["additionalProperties"] = json!(false); + Some(schema) + } "project_build" => { let mut schema = output_schema_for_tool("run_process") .expect("run_process output schema must exist"); diff --git a/crates/webcodex-tool-contracts/src/tests/canonical_names.rs b/crates/webcodex-tool-contracts/src/tests/canonical_names.rs index 1f46a262f..2c7c26780 100644 --- a/crates/webcodex-tool-contracts/src/tests/canonical_names.rs +++ b/crates/webcodex-tool-contracts/src/tests/canonical_names.rs @@ -122,7 +122,7 @@ fn canonical_operation_names_are_unique_verb_first_and_route_independent() { "manage_", "work_on_", ]; - // Provider-specific validation and the two documented portable orchestration + // Provider-specific validation and the three documented portable orchestration // entries are not noun-first CRUD. Gateways name a distinct callable namespace. let domain = [ "cargo_fmt", @@ -130,6 +130,7 @@ fn canonical_operation_names_are_unique_verb_first_and_route_independent() { "cargo_test", "go_test", "project_build", + "project_format", "project_validate", "plugin_tool", ]; diff --git a/crates/webcodex-tool-contracts/src/tool_call/canonical_enum.rs b/crates/webcodex-tool-contracts/src/tool_call/canonical_enum.rs index f700f8654..4447483f3 100644 --- a/crates/webcodex-tool-contracts/src/tool_call/canonical_enum.rs +++ b/crates/webcodex-tool-contracts/src/tool_call/canonical_enum.rs @@ -1514,6 +1514,26 @@ pub enum ToolCall { sync_wait_secs: Option, }, + /// Format an explicit bounded set of source files in one Runner Project. + ProjectFormat { + /// Exact registered Runner Project. + project: String, + #[serde(default)] + session_id: Option, + /// Optional Project-relative recipe selection directory. + #[serde(default)] + cwd: Option, + #[serde(default)] + adapter: Option, + /// Ordered Project-relative files, all under one recipe root. No globbing. + #[schemars(length(min = 1, max = 8))] + files: Vec, + /// Total Job budget, 1..120 seconds. Pending continues the original Job. + #[serde(default)] + #[schemars(range(min = 1, max = 120))] + timeout_secs: Option, + }, + /// Run one portable project build. The Runner resolves the nearest supported /// Rust/Go recipe, plans canonical argv, and admits one typed build Job. ProjectBuild { diff --git a/crates/webcodex-tool-contracts/src/tool_call/parsing.rs b/crates/webcodex-tool-contracts/src/tool_call/parsing.rs index 4630b4d34..3b1004382 100644 --- a/crates/webcodex-tool-contracts/src/tool_call/parsing.rs +++ b/crates/webcodex-tool-contracts/src/tool_call/parsing.rs @@ -489,6 +489,7 @@ impl ToolCall { Self::CargoCheck { .. } => "cargo_check", Self::CargoTest { .. } => "cargo_test", Self::ProjectBuild { .. } => "project_build", + Self::ProjectFormat { .. } => "project_format", Self::ProjectValidate { .. } => "project_validate", Self::GoTest { .. } => "go_test", Self::ReadFiles { .. } => "read_files", @@ -653,6 +654,7 @@ impl ToolCall { | Self::CargoCheck { session_id, .. } | Self::CargoTest { session_id, .. } | Self::ProjectBuild { session_id, .. } + | Self::ProjectFormat { session_id, .. } | Self::ProjectValidate { session_id, .. } | Self::GoTest { session_id, .. } | Self::ReadFiles { session_id, .. } @@ -754,6 +756,7 @@ impl ToolCall { | Self::RunScript { cwd, .. } | Self::RunSkillResource { cwd, .. } | Self::ProjectBuild { cwd, .. } + | Self::ProjectFormat { cwd, .. } if cwd.is_none() => { *cwd = execution_context.default_cwd.clone(); @@ -811,6 +814,7 @@ impl ToolCall { | Self::CargoCheck { project, .. } | Self::CargoTest { project, .. } | Self::ProjectBuild { project, .. } + | Self::ProjectFormat { project, .. } | Self::ProjectValidate { project, .. } | Self::GoTest { project, .. } | Self::ReadFiles { project, .. } diff --git a/crates/webcodex-tool-contracts/src/tool_catalog.rs b/crates/webcodex-tool-contracts/src/tool_catalog.rs index 4dc967f54..ce3aabcc4 100644 --- a/crates/webcodex-tool-contracts/src/tool_catalog.rs +++ b/crates/webcodex-tool-contracts/src/tool_catalog.rs @@ -306,6 +306,7 @@ pub const CODING_INTENT_TOOL_NAMES: &[&str] = &[ "execute_mutating_code_mode", // Portable project build plus ordinary execution specialists. "project_build", + "project_format", "run_process", "run_script", "run_shell", diff --git a/crates/webcodex-tool-contracts/src/tool_definition.rs b/crates/webcodex-tool-contracts/src/tool_definition.rs index 620d06545..4beac125a 100644 --- a/crates/webcodex-tool-contracts/src/tool_definition.rs +++ b/crates/webcodex-tool-contracts/src/tool_definition.rs @@ -83,9 +83,10 @@ use webcodex_core::runner_protocol::{ RUNNER_CAPABILITY_FILE_WRITE, RUNNER_CAPABILITY_GIT, RUNNER_CAPABILITY_INTERNAL_POSIX_SCRIPT, RUNNER_CAPABILITY_LSP_CALL_HIERARCHY, RUNNER_CAPABILITY_LSP_READ_ONLY_NAVIGATION, RUNNER_CAPABILITY_PERSISTENT_SHELL, RUNNER_CAPABILITY_PROJECT_BUILD, - RUNNER_CAPABILITY_RUNNER_CONFIG_CONTROL, RUNNER_CAPABILITY_SHELL, - RUNNER_CAPABILITY_SKILL_MANAGEMENT, RUNNER_CAPABILITY_SKILL_RESOURCE_EXECUTION, - RUNNER_CAPABILITY_STRUCTURED_PROCESS_ARGV, RUNNER_CAPABILITY_STRUCTURED_SCRIPT_PAYLOAD, + RUNNER_CAPABILITY_PROJECT_FORMAT, RUNNER_CAPABILITY_RUNNER_CONFIG_CONTROL, + RUNNER_CAPABILITY_SHELL, RUNNER_CAPABILITY_SKILL_MANAGEMENT, + RUNNER_CAPABILITY_SKILL_RESOURCE_EXECUTION, RUNNER_CAPABILITY_STRUCTURED_PROCESS_ARGV, + RUNNER_CAPABILITY_STRUCTURED_SCRIPT_PAYLOAD, }; /// Runner capability or owner-boundary requirement that must hold before a @@ -103,6 +104,7 @@ pub enum RunnerCapabilityRequirement { /// Runner-owned portable Rust/Go project build planning + typed StartBuild. /// Never inferred from generic process or validation support. ProjectBuild, + ProjectFormat, /// Runner-owned trusted Skill resource execution with package identity. /// Never infer this from generic structured process or Skill read support. SkillResourceExecution, @@ -179,6 +181,7 @@ impl RunnerCapabilityRequirement { Self::Shell => RUNNER_CAPABILITY_SHELL, Self::StructuredProcess => RUNNER_CAPABILITY_STRUCTURED_PROCESS_ARGV, Self::ProjectBuild => RUNNER_CAPABILITY_PROJECT_BUILD, + Self::ProjectFormat => RUNNER_CAPABILITY_PROJECT_FORMAT, Self::SkillResourceExecution => RUNNER_CAPABILITY_SKILL_RESOURCE_EXECUTION, Self::DetachedProcess => RUNNER_CAPABILITY_DETACHED_PROCESS_JOBS, Self::StructuredScript => RUNNER_CAPABILITY_STRUCTURED_SCRIPT_PAYLOAD, @@ -217,6 +220,7 @@ impl RunnerCapabilityRequirement { Self::Shell => &[RUNNER_CAPABILITY_SHELL], Self::StructuredProcess => &[RUNNER_CAPABILITY_STRUCTURED_PROCESS_ARGV], Self::ProjectBuild => &[RUNNER_CAPABILITY_PROJECT_BUILD], + Self::ProjectFormat => &[RUNNER_CAPABILITY_PROJECT_FORMAT], Self::SkillResourceExecution => &[RUNNER_CAPABILITY_SKILL_RESOURCE_EXECUTION], Self::DetachedProcess => &[RUNNER_CAPABILITY_DETACHED_PROCESS_JOBS], Self::StructuredScript => &[RUNNER_CAPABILITY_STRUCTURED_SCRIPT_PAYLOAD], @@ -724,6 +728,7 @@ pub enum ToolExecutionForm { TypedScript, ShellCommand, ProjectBuild, + ProjectFormat, StructuredValidation, PersistentShellCommand, } @@ -735,6 +740,7 @@ impl ToolExecutionForm { Self::TypedScript => "typed_script", Self::ShellCommand => "shell_command", Self::ProjectBuild => "project_build", + Self::ProjectFormat => "project_format", Self::StructuredValidation => "structured_validation", Self::PersistentShellCommand => "persistent_shell_command", } diff --git a/crates/webcodex-tool-contracts/src/tool_definition/jobs.rs b/crates/webcodex-tool-contracts/src/tool_definition/jobs.rs index 66ade1954..abb31c945 100644 --- a/crates/webcodex-tool-contracts/src/tool_definition/jobs.rs +++ b/crates/webcodex-tool-contracts/src/tool_definition/jobs.rs @@ -16,6 +16,26 @@ use crate::metadata::{ use webcodex_core::authority::SCOPE_JOB_DETACH; pub(super) const EXECUTION_DEFINITIONS: &[ToolDefinition] = &[ + require_all_scopes( + permission_risk( + adaptive_runtime_direct( + model_spec( + def("project_format", super::ToolAuditPolicy::TYPED_CANONICAL, + ModelVisible, TOOL_CATEGORY_EXECUTION, Some(super::RunnerCapabilityRequirement::ProjectFormat), + TOOL_PROVIDER_RUNNER, + super::ToolSemanticContract { effect: super::ToolEffect::Mutate, + risk: crate::metadata::ToolRisk::ProjectWrite, + approval: super::ToolApprovalPolicy::Standard, + idempotency: super::ToolIdempotency::NonIdempotent }, + Some(crate::metadata::PROJECT_WRITE), true, NoPath, true, false, + super::ToolSessionEvidencePolicy::NONE), + "Intentionally format 1..8 ordered Project-relative source files under one Runner-resolved recipe root. Requires project:write, job:run and project_format_v1. Rust uses bounded Cargo edition and installed rustfmt stdin/stdout with skip_children; custom configuration and unsupported topology fail closed. Python uses bounded pyproject target-version and isolated Ruff stdin/stdout; extend configuration and unsupported topology fail closed. No workspace traversal, installs, package scripts, arbitrary flags or implied network permission. Candidate bytes stay private; Runner compares source and file identity before each guarded write. A multi-file write is not atomic: failure, cancellation, timeout, conflicting edits or a lost receipt can leave mutation unknown. Known state_changed comes only from the verified executor report, never exit status. One admitted Job owns every phase; retain and observe its exact continuation when pending or uncertain. Never automatically retry or redispatch. Review source before any new mutation. Formatting does not supply the edit_project_files receipt required by Code Mode dependent validation.", + ).with_execution(super::ToolExecutionContract::new(super::ToolExecutionForm::ProjectFormat, + super::ToolExecutionLifetime::Runner, super::ToolExecutionStart::SyncFirst, + super::ToolExecutionContinuation::ObserveJobs)), + 93, super::ToolDirectReason::CoreWorkflow), + super::PERMISSION_RISK_WRITE), + &[crate::metadata::PROJECT_WRITE, JOB_RUN]), adaptive_runtime_direct( model_spec( def("write_job_input", super::ToolAuditPolicy::TYPED_CANONICAL, diff --git a/crates/webcodex-tool-runtime-contracts/src/tool_audit.rs b/crates/webcodex-tool-runtime-contracts/src/tool_audit.rs index dacda8389..3dca6d3c9 100644 --- a/crates/webcodex-tool-runtime-contracts/src/tool_audit.rs +++ b/crates/webcodex-tool-runtime-contracts/src/tool_audit.rs @@ -565,6 +565,17 @@ impl ToolCallAuditProjection for ToolCall { } out } + Self::ProjectFormat { + project, + cwd, + adapter, + files, + timeout_secs, + .. + } => serde_json::json!({ + "project": project, "cwd": cwd, "adapter": adapter, + "file_count": files.len(), "timeout_secs": timeout_secs, + }), Self::ProjectBuild { project, cwd, diff --git a/src/mcp_tests/model_ergonomics.rs b/src/mcp_tests/model_ergonomics.rs index 3a951b200..a9100c278 100644 --- a/src/mcp_tests/model_ergonomics.rs +++ b/src/mcp_tests/model_ergonomics.rs @@ -134,6 +134,7 @@ async fn model_ergonomics_normalization_reaches_api_and_mcp_action_audit_without command_execution_state: Some(ShellCommandExecutionState::Completed), validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }) diff --git a/src/mcp_tests/request_policy.rs b/src/mcp_tests/request_policy.rs index ee416f677..2ae943759 100644 --- a/src/mcp_tests/request_policy.rs +++ b/src/mcp_tests/request_policy.rs @@ -411,6 +411,7 @@ async fn request_policy_pending_wait_and_terminal_observe_keep_one_execution_wit command_execution_state: Some(ShellCommandExecutionState::Completed), validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }) diff --git a/src/mcp_tests/result_app.rs b/src/mcp_tests/result_app.rs index ec4b0fecb..36edb6530 100644 --- a/src/mcp_tests/result_app.rs +++ b/src/mcp_tests/result_app.rs @@ -1927,6 +1927,7 @@ async fn set_job_state( .then_some(crate::runner_protocol::ShellCommandExecutionState::Completed), validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished, }) @@ -1983,6 +1984,7 @@ async fn complete_result_app_validation_job( failed_step: None, }), test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }) diff --git a/src/runner_quic.rs b/src/runner_quic.rs index 234c09134..c6bb17fd4 100644 --- a/src/runner_quic.rs +++ b/src/runner_quic.rs @@ -1194,6 +1194,7 @@ mod tests { command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }, diff --git a/src/tool_runtime/dispatch/execution.rs b/src/tool_runtime/dispatch/execution.rs index 599da071e..c7c8d16c9 100644 --- a/src/tool_runtime/dispatch/execution.rs +++ b/src/tool_runtime/dispatch/execution.rs @@ -64,6 +64,27 @@ impl ToolRuntime { .await } + ToolCall::ProjectFormat { + project, + session_id, + cwd, + adapter, + files, + timeout_secs, + } => { + self.project_format( + project, + session_id, + cwd, + adapter, + files, + timeout_secs, + structured_handoff_max_secs, + ssh_resource, + auth, + ) + .await + } ToolCall::ProjectBuild { project, session_id, diff --git a/src/tool_runtime/dispatch/governance.rs b/src/tool_runtime/dispatch/governance.rs index 7ef1246ac..c6b6d7537 100644 --- a/src/tool_runtime/dispatch/governance.rs +++ b/src/tool_runtime/dispatch/governance.rs @@ -201,6 +201,7 @@ impl ToolRuntime { | ToolCall::CargoCheck { .. } | ToolCall::CargoTest { .. } | ToolCall::ProjectBuild { .. } + | ToolCall::ProjectFormat { .. } | ToolCall::ProjectValidate { .. } | ToolCall::GoTest { .. } ) { diff --git a/src/tool_runtime/dispatch/preflight.rs b/src/tool_runtime/dispatch/preflight.rs index c4768a9dc..1bc8dc256 100644 --- a/src/tool_runtime/dispatch/preflight.rs +++ b/src/tool_runtime/dispatch/preflight.rs @@ -54,6 +54,7 @@ pub(super) fn canonical_execution_project_binding( | ToolCall::CargoCheck { project, .. } | ToolCall::CargoTest { project, .. } | ToolCall::ProjectBuild { project, .. } + | ToolCall::ProjectFormat { project, .. } | ToolCall::ProjectValidate { project, .. } | ToolCall::GoTest { project, .. } | ToolCall::ListProjectFiles { project, .. } diff --git a/src/tool_runtime/dispatch/routing.rs b/src/tool_runtime/dispatch/routing.rs index 4e67b06b4..9c4104cf8 100644 --- a/src/tool_runtime/dispatch/routing.rs +++ b/src/tool_runtime/dispatch/routing.rs @@ -178,6 +178,7 @@ impl ToolRuntime { | ToolCall::RunScript { .. } | ToolCall::RunShell { .. } | ToolCall::ProjectBuild { .. } + | ToolCall::ProjectFormat { .. } | ToolCall::OpenSessionShell { .. } | ToolCall::SessionShellExec { .. } | ToolCall::SessionShellStatus { .. } diff --git a/src/tool_runtime/jobs.rs b/src/tool_runtime/jobs.rs index 0aa926a4f..e12548cba 100644 --- a/src/tool_runtime/jobs.rs +++ b/src/tool_runtime/jobs.rs @@ -856,7 +856,7 @@ fn model_facing_structured_execution_metadata( /// Build a bounded job summary `Value` for an agent-known job. Never includes /// stdout/stderr bodies. pub(crate) fn agent_job_summary_value(job: &ShellJobInfo) -> Value { - json!({ + let mut summary = json!({ "job_id": job.job_id, "kind": job.kind, "status": job.status, @@ -883,7 +883,11 @@ pub(crate) fn agent_job_summary_value(job: &ShellJobInfo) -> Value { job.recovery_state.as_deref(), job.recovery_reason_code.as_deref(), ), - }) + }); + if job.kind == "project_format" { + summary["state_changed"] = json!(job.format_mutation.unwrap_or_default().state_changed()); + } + summary } impl ToolRuntime { @@ -2924,6 +2928,7 @@ mod recovery_projection_tests { result: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, validation: None, recovery_state: None, @@ -2976,6 +2981,7 @@ mod recovery_projection_tests { result: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, validation: None, recovery_state: Some("lost_after_reconcile".to_string()), diff --git a/src/tool_runtime/mod.rs b/src/tool_runtime/mod.rs index 527cb957e..2552b27bc 100644 --- a/src/tool_runtime/mod.rs +++ b/src/tool_runtime/mod.rs @@ -83,6 +83,7 @@ pub(crate) mod permissions; mod presentation; mod process; mod project_build; +mod project_format; mod project_resolution; mod projection_text; pub(crate) mod window_collaboration; diff --git a/src/tool_runtime/project_format.rs b/src/tool_runtime/project_format.rs new file mode 100644 index 000000000..ba7cef2a1 --- /dev/null +++ b/src/tool_runtime/project_format.rs @@ -0,0 +1,237 @@ +//! Explicit-file formatter gateway. Native Job admission owns every effect. +use super::helpers::{project_relative_runner_cwd, resolve_runner_cwd}; +use super::structured_execution::{ + await_hidden_structured_job, HiddenStructuredJobWait, StructuredExecutionBudget, +}; +use super::{ToolResult, ToolRuntime}; +use crate::auth::AuthContext; +use crate::runner_http::{ShellJobStartMetadata, ShellJobVisibility, StructuredJobExecution}; +use crate::runner_protocol::{ShellCommandExecutionState, ShellJobInfo, ShellJobOpRequest}; +use serde_json::json; +use std::time::Duration; +use webcodex_core::project_format::*; + +fn rejected(code: &str) -> ToolResult { + ToolResult::err_with_output( + "Project formatting was not admitted.", + json!({ + "failure_kind": code, "execution_state": "not_started", "state_changed": false, + }), + ) +} + +pub(super) fn terminal_result(job: &ShellJobInfo) -> ToolResult { + let report = job.format_mutation.unwrap_or_default(); + let known = report.state_changed(); + let success = job.status == "completed" + && job.exit_code == Some(0) + && job.command_execution_state == Some(ShellCommandExecutionState::Completed) + && job.error.is_none() + && known.is_some(); + let mut output = json!({"state_changed": known}); + if success { + return ToolResult::ok(output); + } + // Only the closed executor failure code may reach the model; never source, + // candidates, process diagnostics or an inferred validation summary. + let code = job + .error + .as_deref() + .filter(|code| valid_failure_code(code)) + .unwrap_or("format_execution_unknown"); + output["failure_kind"] = json!(code); + output["execution_state"] = json!("outcome_unknown"); + output["job_id"] = json!(job.job_id); + output["continuation"] = + super::jobs::observe_job_continuation(&job.job_id, job.observation_token.as_deref()); + ToolResult::err_with_output("Formatting did not produce a complete verified mutation receipt. Observe the original Job and source before another mutation.", output) +} + +fn valid_failure_code(code: &str) -> bool { + code.len() <= 80 + && (code.starts_with("format_") + || code == "capability_unavailable" + || code == "invalid_arguments" + || code == "permission_denied" + || code == "unknown_project" + || code == "invalid_project_path") + && code + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte == b'_') +} + +impl ToolRuntime { + #[allow(clippy::too_many_arguments)] + pub(crate) async fn project_format( + &self, + project: String, + session_id: Option, + cwd: Option, + adapter: Option, + files: Vec, + timeout_secs: Option, + handoff_max_secs: Option, + ssh_resource: Option<&str>, + auth: Option<&AuthContext>, + ) -> ToolResult { + let timeout = timeout_secs.unwrap_or(60); + if !(1..=PROJECT_FORMAT_TIMEOUT_MAX_SECS).contains(&timeout) { + return rejected("invalid_arguments"); + } + let budget = match StructuredExecutionBudget::resolve_process_with_sync_wait( + Some(timeout), + handoff_max_secs, + ) { + Ok(budget) => budget, + Err(_) => return rejected("invalid_arguments"), + }; + if ssh_resource.is_some() { + return rejected("format_resource_unavailable"); + } + let resolved = match self.resolve_project_input_for_auth(&project, auth).await { + Ok(resolved) => resolved, + Err(_) => return rejected("format_project_unavailable"), + }; + let Some((_, project_id)) = resolved + .resolved_id + .strip_prefix("agent:") + .and_then(|v| v.split_once(':')) + else { + return rejected("format_project_unavailable"); + }; + let request = ProjectFormatRequest { + project_id: project_id.into(), + cwd, + adapter: adapter.unwrap_or_default(), + files, + }; + if request.validate().is_err() { + return rejected("invalid_arguments"); + } + let access = crate::runner_http::runner_access_from_auth(auth); + let (request_id, runner_instance_id, rx) = match self + .runner_registry + .enqueue_project_format_plan( + resolved.config.client_id.clone(), + request.clone(), + access.as_ref(), + ) + .await + { + Ok(value) => value, + Err(error) => { + let code = if valid_failure_code(&error) { + &error + } else if error.starts_with("capability_unavailable:") { + "capability_unavailable" + } else { + "format_planning_unavailable" + }; + return rejected(code); + } + }; + let response = match tokio::time::timeout(Duration::from_secs(32), rx).await { + Ok(Ok(response)) => response, + _ => { + self.runner_registry.cancel_request(&request_id).await; + return rejected("format_planning_unavailable"); + } + }; + if let Some(err) = response.error.as_deref() { + let code = if err.contains("replaced") { + "format_runner_replaced" + } else { + "format_plan_invalid" + }; + return rejected(code); + } + if response + .stdout + .as_ref() + .is_none_or(|s| s.len() > PROJECT_FORMAT_PLAN_MAX_BYTES + 128) + { + return rejected("format_plan_invalid"); + } + let plan = match serde_json::from_str::( + response.stdout.as_deref().unwrap_or(""), + ) { + Ok(ProjectFormatPlanningResult::Ready { plan }) + if plan.is_valid() && plan.request == request => + { + plan + } + Ok(ProjectFormatPlanningResult::Unavailable { code }) if valid_failure_code(&code) => { + return rejected(&code) + } + _ => return rejected("format_plan_invalid"), + }; + let effective_cwd = match resolve_runner_cwd(&resolved.config, Some(&plan.recipe_root)) { + Ok(cwd) => cwd, + Err(_) => return rejected("format_path_invalid"), + }; + let project_cwd = project_relative_runner_cwd(&resolved.config, &effective_cwd).ok(); + let job = match self + .runner_registry + .start_job_with_metadata_for_access( + ShellJobOpRequest { + login: false, + op: "start".into(), + client_id: Some(resolved.config.client_id.clone()), + cwd: Some(effective_cwd), + command: Some(String::new()), + timeout_secs: Some(timeout), + job_id: None, + since_stdout_line: None, + since_stderr_line: None, + tail_lines: None, + limit: None, + codex: None, + }, + "tool_runtime".into(), + ShellJobStartMetadata { + project_id: Some(resolved.resolved_id), + session_id, + project_cwd, + purpose: Some("format".into()), + shell: Some("direct_argv".into()), + // Mutation evidence must survive a dropped synchronous response and + // terminal success; hidden-terminal cleanup must never delete it. + visibility: ShellJobVisibility::Public, + structured_execution: Some(StructuredJobExecution::ProjectFormat { + plan, + runner_instance_id, + }), + ..Default::default() + }, + access.as_ref(), + None, + ) + .await + { + Ok(job) => job, + Err(error) => { + let code = if valid_failure_code(&error) { + &error + } else if error.starts_with("capability_unavailable:") { + "capability_unavailable" + } else { + "format_admission_unavailable" + }; + return rejected(code); + } + }; + match await_hidden_structured_job(self.runner_registry.clone(), job.job_id.clone(), + self.structured_execution_sync_wait.min(Duration::from_secs(budget.sync_wait_secs)), auth.cloned()).await { + Ok(HiddenStructuredJobWait::Terminal { job, .. }) => terminal_result(&job), + Ok(HiddenStructuredJobWait::Continued { observation, .. }) => ToolResult::ok(json!({ + "state_changed": null, "job_id": observation.job.job_id, + "continuation": super::jobs::observe_job_continuation(&observation.job.job_id, observation.job.observation_token.as_deref()), + })), + Err(_) => ToolResult::err_with_output("Formatting delivery is uncertain; observe the original Job before another mutation.", json!({ + "state_changed": null, "failure_kind": "format_execution_unknown", "execution_state": "outcome_unknown", + "job_id": job.job_id, + "continuation": super::jobs::observe_job_continuation(&job.job_id, job.observation_token.as_deref()), + })), + } + } +} diff --git a/src/tool_runtime/result_projection/execution.rs b/src/tool_runtime/result_projection/execution.rs index 0b9177ec4..c80c40fbe 100644 --- a/src/tool_runtime/result_projection/execution.rs +++ b/src/tool_runtime/result_projection/execution.rs @@ -25,7 +25,8 @@ pub(super) fn capture(call: &ToolCall) -> Option { no_run: *no_run, min_tests: *min_tests, }, - ToolCall::ProjectBuild { .. } + ToolCall::ProjectFormat { .. } + | ToolCall::ProjectBuild { .. } | ToolCall::RunProcess { .. } | ToolCall::RunSkillResource { .. } | ToolCall::RunScript { .. } diff --git a/src/tool_runtime/runner_authorization.rs b/src/tool_runtime/runner_authorization.rs index 9a7f70159..5f4811554 100644 --- a/src/tool_runtime/runner_authorization.rs +++ b/src/tool_runtime/runner_authorization.rs @@ -147,6 +147,7 @@ impl ToolRuntime { required, RunnerCapabilityRequirement::StructuredProcess | RunnerCapabilityRequirement::ProjectBuild + | RunnerCapabilityRequirement::ProjectFormat | RunnerCapabilityRequirement::SkillResourceExecution | RunnerCapabilityRequirement::DetachedProcess | RunnerCapabilityRequirement::StructuredScript @@ -157,6 +158,9 @@ impl ToolRuntime { } else if matches!(required, RunnerCapabilityRequirement::ProjectBuild) { "project build" + } else if matches!(required, RunnerCapabilityRequirement::ProjectFormat) + { + "project formatting" } else if matches!( required, RunnerCapabilityRequirement::SkillResourceExecution diff --git a/src/tool_runtime/tests/job_attention.rs b/src/tool_runtime/tests/job_attention.rs index 64ebe3cd2..6e673b6ba 100644 --- a/src/tool_runtime/tests/job_attention.rs +++ b/src/tool_runtime/tests/job_attention.rs @@ -235,6 +235,7 @@ async fn passive_attention_requires_exact_business_relation_and_deduplicates_sta command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }) @@ -359,6 +360,7 @@ async fn initiating_handoff_is_cursor_baseline_then_terminal_is_delivered_once() command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }) @@ -438,6 +440,7 @@ async fn passive_attention_projects_process_failure_stop_and_timeout_without_log command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }) diff --git a/src/tool_runtime/tests/job_attention/telemetry.rs b/src/tool_runtime/tests/job_attention/telemetry.rs index 326493bba..9ec326bb0 100644 --- a/src/tool_runtime/tests/job_attention/telemetry.rs +++ b/src/tool_runtime/tests/job_attention/telemetry.rs @@ -446,6 +446,7 @@ async fn job_convergence_terminal_delivery_uses_existing_server_timestamp() { command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }) diff --git a/src/tool_runtime/tests/job_terminal_wait.rs b/src/tool_runtime/tests/job_terminal_wait.rs index ff65dcf1b..c41f0b9c6 100644 --- a/src/tool_runtime/tests/job_terminal_wait.rs +++ b/src/tool_runtime/tests/job_terminal_wait.rs @@ -123,6 +123,7 @@ async fn complete_job( command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }) diff --git a/src/tool_runtime/tests/jobs.rs b/src/tool_runtime/tests/jobs.rs index 4d6e7ccc2..f13d0aad8 100644 --- a/src/tool_runtime/tests/jobs.rs +++ b/src/tool_runtime/tests/jobs.rs @@ -709,6 +709,7 @@ async fn update_agent_shell_job( command_execution_state, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished, }) @@ -2277,6 +2278,7 @@ pub(super) async fn mark_next_agent_job_running(runtime: &ToolRuntime, client_id command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }) diff --git a/src/tool_runtime/tests/mod.rs b/src/tool_runtime/tests/mod.rs index 0f8a1f57b..807d56d68 100644 --- a/src/tool_runtime/tests/mod.rs +++ b/src/tool_runtime/tests/mod.rs @@ -55,6 +55,7 @@ mod peer_collaboration; mod permission_gate; mod process; mod project_build_handoff; +mod project_format_handoff; mod project_references; mod read_files; mod reconnect; diff --git a/src/tool_runtime/tests/observe_jobs.rs b/src/tool_runtime/tests/observe_jobs.rs index 540186c6b..44822cf6a 100644 --- a/src/tool_runtime/tests/observe_jobs.rs +++ b/src/tool_runtime/tests/observe_jobs.rs @@ -172,6 +172,7 @@ async fn update_observed_job( command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity, finished, }) @@ -204,6 +205,7 @@ async fn update_sequenced_observed_job( command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }) @@ -1795,6 +1797,7 @@ async fn ordinary_receipts_production_sqlite_dual_restart_observe_and_list_filte command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }) @@ -1962,6 +1965,7 @@ async fn observe_jobs_terminal_policy_coalesces_noisy_jobs_with_one_deadline_and command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: Some(process_activity()), finished: false, }) @@ -2323,6 +2327,7 @@ async fn observe_jobs_generic_failed_test_identity_survives_small_model_tail() { ), validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }) diff --git a/src/tool_runtime/tests/process.rs b/src/tool_runtime/tests/process.rs index 34b4b9ffa..e1c3dd29f 100644 --- a/src/tool_runtime/tests/process.rs +++ b/src/tool_runtime/tests/process.rs @@ -230,6 +230,7 @@ async fn update_process_job( command_execution_state: state, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity, finished: state.is_some(), }) @@ -1063,6 +1064,7 @@ async fn detached_process_lost_initiation_after_server_restart_recovers_same_job stderr: Default::default(), validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: Some(ShellJobActivity { state: ShellJobActivityState::Working, phase: ShellJobActivityPhase::ProcessRunning, diff --git a/src/tool_runtime/tests/project_build_handoff.rs b/src/tool_runtime/tests/project_build_handoff.rs index 0f741e2d0..981af538b 100644 --- a/src/tool_runtime/tests/project_build_handoff.rs +++ b/src/tool_runtime/tests/project_build_handoff.rs @@ -129,6 +129,7 @@ async fn finish_build_job( command_execution_state: Some(state), validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }) diff --git a/src/tool_runtime/tests/project_format_handoff.rs b/src/tool_runtime/tests/project_format_handoff.rs new file mode 100644 index 000000000..71c89e709 --- /dev/null +++ b/src/tool_runtime/tests/project_format_handoff.rs @@ -0,0 +1,514 @@ +use super::support::*; +use crate::runner_http::RunnerRegistry; +use crate::runner_protocol::{ + RunnerCapabilities, RunnerJobUpdateRequest, RunnerPollRequest, RunnerResultPayload, + RunnerResultRequest, ShellCommandExecutionState, +}; +use crate::tool_runtime::{RuntimeInfo, ToolCall, ToolRuntime}; +use serde_json::json; +use std::sync::Arc; +use webcodex_core::project_format::{ + ProjectFormatAdapter, ProjectFormatFileWitness, ProjectFormatMutationReport, ProjectFormatPlan, + ProjectFormatPlanningResult, ProjectFormatProfile, ProjectFormatRequest, +}; +use webcodex_runner_registry::{JobReceiptStore, NoopRunnerRegistryTelemetry, RetainedJobReceipt}; + +const CLIENT: &str = "project-format"; + +#[derive(Debug, Default)] +struct MemoryReceiptStore(std::sync::Mutex>); + +impl JobReceiptStore for MemoryReceiptStore { + fn upsert(&self, receipt: &RetainedJobReceipt) -> Result<(), String> { + self.0.lock().unwrap().push(receipt.clone()); + Ok(()) + } + fn load(&self, _now: i64) -> Result, String> { + Ok(self.0.lock().unwrap().clone()) + } + fn prune(&self, _now: i64) -> Result<(), String> { + Ok(()) + } +} + +async fn setup(grace_ms: u64, with_store: bool) -> ToolRuntime { + let registry = if with_store { + RunnerRegistry::with_job_receipt_store( + Arc::new(NoopRunnerRegistryTelemetry), + Arc::new(MemoryReceiptStore::default()), + ) + .await + } else { + RunnerRegistry::default() + }; + let runtime = ToolRuntime::new(Arc::new(registry), Arc::new(RuntimeInfo::default())) + .with_structured_execution_sync_wait(std::time::Duration::from_millis(grace_ms)); + register_agent( + &runtime, + CLIENT, + None, + RunnerCapabilities { + project_format_v1: true, + ..Default::default() + }, + ) + .await; + runtime +} + +fn call() -> ToolCall { + ToolCall::ProjectFormat { + project: agent_test_project_id(CLIENT), + session_id: None, + cwd: None, + adapter: Some(ProjectFormatAdapter::Rust), + files: vec!["src/main.rs".into()], + timeout_secs: Some(60), + } +} + +async fn wait_for_runner_request(runtime: &ToolRuntime) -> crate::runner_protocol::RunnerRequest { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + if let Some(request) = probe_patch_agent_request(runtime, CLIENT).await { + return request; + } + assert!( + tokio::time::Instant::now() < deadline, + "Runner request was not enqueued within 10 seconds" + ); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } +} + +async fn reply_plan(runtime: &ToolRuntime) -> (crate::runner_protocol::RunnerRequest, String) { + let request = wait_for_runner_request(runtime).await; + assert_eq!(request.kind, "plan_project_format"); + let semantic: ProjectFormatRequest = + serde_json::from_str(request.content.as_deref().unwrap()).unwrap(); + assert_eq!(semantic.project_id, "agent-proj"); + let plan = ProjectFormatPlan { + request: semantic, + recipe_root: ".".into(), + root_digest: "a".repeat(64), + manifest_digest: "b".repeat(64), + profile: ProjectFormatProfile::Rust { + edition: "2021".into(), + }, + files: vec![ProjectFormatFileWitness { + path: "src/main.rs".into(), + bytes: 15, + sha256: "c".repeat(64), + identity_digest: "d".repeat(64), + }], + }; + runtime + .runner_registry + .complete(RunnerResultPayload { + result: RunnerResultRequest { + client_id: CLIENT.into(), + runner_instance_id: "inst".into(), + request_id: request.request_id, + exit_code: Some(0), + stdout: Some( + serde_json::to_string(&ProjectFormatPlanningResult::Ready { plan }).unwrap(), + ), + stderr: Some(String::new()), + stdout_truncated: false, + stderr_truncated: false, + duration_ms: Some(5), + error: None, + }, + command_execution_state: Some(ShellCommandExecutionState::Completed), + mcp_gateway: None, + plugin_gateway: None, + coding_agent: None, + }) + .await + .unwrap(); + let start = wait_for_runner_request(runtime).await; + assert_eq!(start.kind, "start_format_job"); + let job_id = start.job_id.clone().expect("start_format_job job id"); + (start, job_id) +} + +#[allow(clippy::too_many_arguments)] +async fn finish_format_job( + runtime: &ToolRuntime, + request: &crate::runner_protocol::RunnerRequest, + job_id: &str, + status: &str, + state: ShellCommandExecutionState, + exit_code: Option, + error: Option<&str>, + mutation: Option, +) { + runtime + .runner_registry + .update_job(RunnerJobUpdateRequest { + client_id: CLIENT.into(), + runner_instance_id: "inst".into(), + update_seq: None, + job_id: job_id.into(), + request_id: Some(request.request_id.clone()), + status: status.into(), + stdout_chunk: None, + stderr_chunk: None, + log_snapshot: None, + exit_code, + duration_ms: Some(25), + error: error.map(str::to_string), + command_execution_state: Some(state), + validation_progress: None, + test_count_evidence: None, + format_mutation: mutation, + activity: None, + finished: true, + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn project_format_fast_success_commits_and_returns_state_changed_true() { + let runtime = setup(500, true).await; + let task = tokio::spawn({ + let runtime = runtime.clone(); + async move { + runtime + .dispatch_with_auth(call(), Some(&auth_context(None, true))) + .await + } + }); + + let (request, job_id) = reply_plan(&runtime).await; + let operation = request.decode_operation().unwrap(); + let webcodex_core::runner_operation::RunnerOperation::Job( + webcodex_core::runner_operation::RunnerJobOperation::StartFormat(format_op), + ) = operation + else { + panic!("expected typed StartFormat"); + }; + assert_eq!(format_op.plan.recipe_root, "."); + assert_eq!( + format_op + .context + .structured_execution + .as_ref() + .unwrap() + .execution_source, + "project_format" + ); + + finish_format_job( + &runtime, + &request, + &job_id, + "completed", + ShellCommandExecutionState::Completed, + Some(0), + None, + Some(ProjectFormatMutationReport::Changed), + ) + .await; + + let result = task.await.unwrap(); + assert!(result.success, "expected Ok result: {:?}", result.output); + assert_eq!(result.output["state_changed"], json!(true)); +} + +#[tokio::test] +async fn project_format_fast_success_noop_returns_state_changed_false() { + let runtime = setup(500, true).await; + let task = tokio::spawn({ + let runtime = runtime.clone(); + async move { + runtime + .dispatch_with_auth(call(), Some(&auth_context(None, true))) + .await + } + }); + + let (request, job_id) = reply_plan(&runtime).await; + finish_format_job( + &runtime, + &request, + &job_id, + "completed", + ShellCommandExecutionState::Completed, + Some(0), + None, + Some(ProjectFormatMutationReport::Unchanged), + ) + .await; + + let result = task.await.unwrap(); + assert!(result.success, "expected Ok result: {:?}", result.output); + assert_eq!(result.output["state_changed"], json!(false)); +} + +#[tokio::test] +async fn project_format_missing_capability_rejects_without_dispatch() { + let registry = RunnerRegistry::with_job_receipt_store( + Arc::new(NoopRunnerRegistryTelemetry), + Arc::new(MemoryReceiptStore::default()), + ) + .await; + let runtime = ToolRuntime::new(Arc::new(registry), Arc::new(RuntimeInfo::default())); + register_agent( + &runtime, + CLIENT, + None, + RunnerCapabilities { + project_format_v1: false, + ..Default::default() + }, + ) + .await; + + let result = runtime + .dispatch_with_auth(call(), Some(&auth_context(None, true))) + .await; + assert!(!result.success); + assert_eq!( + result.output["failure_kind"], + json!("capability_unavailable") + ); + assert!(probe_patch_agent_request(&runtime, CLIENT).await.is_none()); +} + +#[tokio::test] +async fn project_format_runner_replaced_rejects_without_executing() { + let runtime = setup(500, true).await; + let task = tokio::spawn({ + let runtime = runtime.clone(); + async move { + runtime + .dispatch_with_auth(call(), Some(&auth_context(None, true))) + .await + } + }); + + let request = wait_for_runner_request(&runtime).await; + assert_eq!(request.kind, "plan_project_format"); + let semantic: ProjectFormatRequest = + serde_json::from_str(request.content.as_deref().unwrap()).unwrap(); + let plan = ProjectFormatPlan { + request: semantic, + recipe_root: ".".into(), + root_digest: "a".repeat(64), + manifest_digest: "b".repeat(64), + profile: ProjectFormatProfile::Rust { + edition: "2021".into(), + }, + files: vec![ProjectFormatFileWitness { + path: "src/main.rs".into(), + bytes: 15, + sha256: "c".repeat(64), + identity_digest: "d".repeat(64), + }], + }; + + // Simulate runner restart/replacement with new runner_instance_id before completing plan + register_agent_with_instance( + &runtime, + CLIENT, + "inst-2", + None, + RunnerCapabilities { + project_format_v1: true, + ..Default::default() + }, + ) + .await; + + let complete_result = runtime + .runner_registry + .complete(RunnerResultPayload { + result: RunnerResultRequest { + client_id: CLIENT.into(), + runner_instance_id: "inst".into(), + request_id: request.request_id, + exit_code: Some(0), + stdout: Some( + serde_json::to_string(&ProjectFormatPlanningResult::Ready { plan }).unwrap(), + ), + stderr: Some(String::new()), + stdout_truncated: false, + stderr_truncated: false, + duration_ms: Some(5), + error: None, + }, + command_execution_state: Some(ShellCommandExecutionState::Completed), + mcp_gateway: None, + plugin_gateway: None, + coding_agent: None, + }) + .await; + assert!( + complete_result.is_err(), + "expected stale complete rejection" + ); + assert!( + complete_result + .as_ref() + .unwrap_err() + .contains("stale or replaced"), + "expected stale or replaced error, got: {:?}", + complete_result + ); + + let result = task.await.unwrap(); + assert!(!result.success); + assert_eq!( + result.output["failure_kind"], + json!("format_runner_replaced") + ); + let replacement_request = runtime + .runner_registry + .poll(RunnerPollRequest { + client_id: CLIENT.to_string(), + runner_instance_id: "inst-2".to_string(), + }) + .await + .unwrap(); + assert!( + replacement_request.is_none(), + "no StartFormat dispatch to replacement Runner" + ); + assert!(runtime.runner_registry.list_jobs(Some(10)).await.is_empty()); +} + +#[tokio::test] +async fn project_format_failed_job_returns_failure_kind_and_continuation() { + let runtime = setup(500, true).await; + let task = tokio::spawn({ + let runtime = runtime.clone(); + async move { + runtime + .dispatch_with_auth(call(), Some(&auth_context(None, true))) + .await + } + }); + + let (request, job_id) = reply_plan(&runtime).await; + finish_format_job( + &runtime, + &request, + &job_id, + "failed", + ShellCommandExecutionState::OutcomeUnknown, + Some(1), + Some("format_write_conflict"), + Some(ProjectFormatMutationReport::Unknown), + ) + .await; + + let result = task.await.unwrap(); + assert!(!result.success); + assert_eq!( + result.output["failure_kind"], + json!("format_write_conflict") + ); + assert_eq!(result.output["execution_state"], json!("outcome_unknown")); + assert_eq!(result.output["state_changed"], json!(null)); + assert_eq!(result.output["job_id"], json!(job_id)); + assert!(result.output.get("continuation").is_some()); +} + +#[tokio::test] +async fn project_format_contradictory_completed_error_mutation_fails_closed() { + let runtime = setup(500, true).await; + let task = tokio::spawn({ + let runtime = runtime.clone(); + async move { + runtime + .dispatch_with_auth(call(), Some(&auth_context(None, true))) + .await + } + }); + + let (request, job_id) = reply_plan(&runtime).await; + finish_format_job( + &runtime, + &request, + &job_id, + "completed", + ShellCommandExecutionState::Completed, + Some(0), + Some("format_write_conflict"), + Some(ProjectFormatMutationReport::Changed), + ) + .await; + + let result = task.await.unwrap(); + assert!(!result.success); + assert_eq!(result.output["execution_state"], json!("outcome_unknown")); +} + +#[tokio::test] +async fn project_format_invalid_arguments_reject_without_dispatch() { + let runtime = setup(500, true).await; + + // 0 files + let zero_files = ToolCall::ProjectFormat { + project: agent_test_project_id(CLIENT), + session_id: None, + cwd: None, + adapter: Some(ProjectFormatAdapter::Rust), + files: vec![], + timeout_secs: Some(60), + }; + let result = runtime + .dispatch_with_auth(zero_files, Some(&auth_context(None, true))) + .await; + assert!(!result.success); + assert_eq!(result.output["failure_kind"], json!("invalid_arguments")); + + // 9 files (max is 8) + let too_many_files = ToolCall::ProjectFormat { + project: agent_test_project_id(CLIENT), + session_id: None, + cwd: None, + adapter: Some(ProjectFormatAdapter::Rust), + files: (0..9).map(|i| format!("src/file_{i}.rs")).collect(), + timeout_secs: Some(60), + }; + let result = runtime + .dispatch_with_auth(too_many_files, Some(&auth_context(None, true))) + .await; + assert!(!result.success); + assert_eq!(result.output["failure_kind"], json!("invalid_arguments")); + + // Invalid timeout + let invalid_timeout = ToolCall::ProjectFormat { + project: agent_test_project_id(CLIENT), + session_id: None, + cwd: None, + adapter: Some(ProjectFormatAdapter::Rust), + files: vec!["src/main.rs".into()], + timeout_secs: Some(0), + }; + let result = runtime + .dispatch_with_auth(invalid_timeout, Some(&auth_context(None, true))) + .await; + assert!(!result.success); + assert_eq!(result.output["failure_kind"], json!("invalid_arguments")); + + assert!(probe_patch_agent_request(&runtime, CLIENT).await.is_none()); +} + +#[tokio::test] +async fn project_format_without_receipt_store_fails_closed() { + let runtime = setup(500, false).await; + let result = runtime + .dispatch_with_auth(call(), Some(&auth_context(None, true))) + .await; + assert!(!result.success); + assert_eq!( + result.output["failure_kind"], + json!("format_execution_unavailable") + ); + assert!(probe_patch_agent_request(&runtime, CLIENT).await.is_none()); + assert!(runtime.runner_registry.list_jobs(Some(10)).await.is_empty()); +} diff --git a/src/tool_runtime/tests/read_files.rs b/src/tool_runtime/tests/read_files.rs index e748dfa5f..c481609d7 100644 --- a/src/tool_runtime/tests/read_files.rs +++ b/src/tool_runtime/tests/read_files.rs @@ -2299,6 +2299,7 @@ async fn ordinary_read_delivers_terminal_attention_without_host_continuation_sup command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }) diff --git a/src/tool_runtime/tests/runtime_info.rs b/src/tool_runtime/tests/runtime_info.rs index a99b85ee2..b65dc8b1c 100644 --- a/src/tool_runtime/tests/runtime_info.rs +++ b/src/tool_runtime/tests/runtime_info.rs @@ -56,6 +56,7 @@ fn runner_concurrency_counts_jobs_across_projects_for_one_client() { result: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, validation: None, recovery_state: None, diff --git a/src/tool_runtime/tests/script.rs b/src/tool_runtime/tests/script.rs index 12a392ff7..c75e135cf 100644 --- a/src/tool_runtime/tests/script.rs +++ b/src/tool_runtime/tests/script.rs @@ -136,6 +136,7 @@ async fn update_script_job( command_execution_state: state, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: state.is_some(), }) diff --git a/src/tool_runtime/tests/startup_brief.rs b/src/tool_runtime/tests/startup_brief.rs index 754010c39..e5bbe2c1f 100644 --- a/src/tool_runtime/tests/startup_brief.rs +++ b/src/tool_runtime/tests/startup_brief.rs @@ -1139,6 +1139,7 @@ async fn startup_uses_project_scoped_lifecycle_aware_job_summary() { command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }) @@ -1277,6 +1278,7 @@ async fn startup_uses_project_scoped_lifecycle_aware_job_summary() { command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }) diff --git a/src/tool_runtime/tests/support/runner.rs b/src/tool_runtime/tests/support/runner.rs index a51c68e0c..2bd4dac21 100644 --- a/src/tool_runtime/tests/support/runner.rs +++ b/src/tool_runtime/tests/support/runner.rs @@ -1098,6 +1098,7 @@ pub(in crate::tool_runtime::tests) async fn seed_session_projection_job( command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }) @@ -1146,6 +1147,7 @@ pub(in crate::tool_runtime::tests) async fn finish_session_projection_job( command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }) diff --git a/src/tool_runtime/tests/validation_events.rs b/src/tool_runtime/tests/validation_events.rs index e239ddadf..5140e3bcd 100644 --- a/src/tool_runtime/tests/validation_events.rs +++ b/src/tool_runtime/tests/validation_events.rs @@ -153,6 +153,7 @@ async fn completed_run_job_validation_enters_handoff_from_job_authority() { command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }) @@ -286,6 +287,7 @@ async fn promoted_run_process_cargo_test_materializes_canonical_validation_evide command_execution_state: Some(crate::runner_protocol::ShellCommandExecutionState::Completed), validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }) diff --git a/src/tool_runtime/tests/validation_handoff.rs b/src/tool_runtime/tests/validation_handoff.rs index d28df9fae..a9ddb158d 100644 --- a/src/tool_runtime/tests/validation_handoff.rs +++ b/src/tool_runtime/tests/validation_handoff.rs @@ -287,6 +287,7 @@ pub(super) fn cargo_test_update( command_execution_state: None, validation_progress: Some(progress), test_count_evidence: None, + format_mutation: None, activity, finished, } diff --git a/src/tool_runtime/tests/validation_identity.rs b/src/tool_runtime/tests/validation_identity.rs index 6ea64c21d..cfdab8291 100644 --- a/src/tool_runtime/tests/validation_identity.rs +++ b/src/tool_runtime/tests/validation_identity.rs @@ -143,6 +143,7 @@ async fn promoted_run_shell_preserves_assertion_identity_in_terminal_validation_ command_execution_state: None, validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: false, }) @@ -172,6 +173,7 @@ async fn promoted_run_shell_preserves_assertion_identity_in_terminal_validation_ ), validation_progress: None, test_count_evidence: None, + format_mutation: None, activity: None, finished: true, }) From 07c7933db456eb91841de7b4c4f7d8c628480a6e Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:40:05 +0800 Subject: [PATCH 06/12] fix(project): preserve format handoffs and bounded formatter execution --- .../webcodex_runner/job_manager/process.rs | 33 ++-- .../src/webcodex_runner/project_format.rs | 169 +++++++++++++----- .../webcodex_runner/project_format_tests.rs | 124 ++++++++++++- .../src/registry/output_schemas/jobs.rs | 2 +- .../src/tests/output_schemas.rs | 9 + src/tool_runtime/control_sidecar.rs | 20 +++ src/tool_runtime/orchestration_host.rs | 31 ++++ src/tool_runtime/project_format.rs | 4 +- .../tests/project_format_handoff.rs | 32 ++++ 9 files changed, 367 insertions(+), 57 deletions(-) diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager/process.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager/process.rs index b3b9d4827..4d796b9c6 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager/process.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager/process.rs @@ -205,17 +205,26 @@ impl JobManager { let deadline = started + Duration::from_secs(operation.timeout_secs.min(start.policy.max_timeout_secs)); let outcome = (|| { + if Instant::now() >= deadline { + return Err("format_timeout"); + } crate::webcodex_runner::project_format::fence( &start.policy, &start.project_registry_dir, &start.operation, ) .map_err(|_| "format_plan_stale")?; + if Instant::now() >= deadline { + return Err("format_timeout"); + } let planned = crate::webcodex_runner::project_format::replan( &start.policy, &start.project_registry_dir, &operation.plan, )?; + if Instant::now() >= deadline { + return Err("format_timeout"); + } // This is a single admitted Job, not one execution per file. manager.update_and_send( &job_id, @@ -224,16 +233,20 @@ impl JobManager { ..Default::default() }, ); - let candidates = crate::webcodex_runner::project_format::format_candidates( - &start.policy, - &start.shell, - &start.project_registry_dir, - &planned, - &manager.prepared_profiles, - start.generation, - deadline.saturating_duration_since(Instant::now()).as_secs(), - Some(&stop), - )?; + if Instant::now() >= deadline { + return Err("format_timeout"); + } + let candidates = + crate::webcodex_runner::project_format::format_candidates_with_deadline( + &start.policy, + &start.shell, + &start.project_registry_dir, + &planned, + &manager.prepared_profiles, + start.generation, + deadline, + Some(&stop), + )?; crate::webcodex_runner::project_format::commit_candidates( &start.policy, &start.project_registry_dir, diff --git a/crates/webcodex-runner/src/webcodex_runner/project_format.rs b/crates/webcodex-runner/src/webcodex_runner/project_format.rs index 032cdc330..41a515381 100644 --- a/crates/webcodex-runner/src/webcodex_runner/project_format.rs +++ b/crates/webcodex-runner/src/webcodex_runner/project_format.rs @@ -168,8 +168,9 @@ fn python_profile(manifest: &toml::Value) -> Result Result<(), &'static str> { - // rustfmt searches parents for configuration. Only metadata is inspected - // outside the Project; no external configuration is read or authorized. + // rustfmt searches parents for configuration. Reject any configuration in + // the project hierarchy; user-level and external configurations are separately + // pinned and fenced to a verified closed default during candidate formatting. for (depth, parent) in root.ancestors().enumerate() { if depth >= 64 { return Err("format_scope_unavailable"); @@ -343,6 +344,7 @@ pub(crate) fn handle( /// /// Runner-owned Job execution supplies its generation, total deadline and stop /// signal. A single request never receives a fresh timeout for every file. +#[allow(dead_code)] pub(crate) fn format_candidates( policy: &RunnerPolicy, shell: &super::config::ShellConfig, @@ -353,61 +355,129 @@ pub(crate) fn format_candidates( timeout_secs: u64, stop_requested: Option<&std::sync::atomic::AtomicBool>, ) -> Result, &'static str> { - use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; - use webcodex_core::runner_protocol::ShellCommandExecutionState; if !(1..=PROJECT_FORMAT_TIMEOUT_MAX_SECS).contains(&timeout_secs) { return Err("format_timeout_invalid"); } + let deadline = Instant::now() + Duration::from_secs(timeout_secs); + format_candidates_with_deadline( + policy, + shell, + registry, + planned, + cache, + generation, + deadline, + stop_requested, + ) +} + +pub(crate) fn format_candidates_with_deadline( + policy: &RunnerPolicy, + shell: &super::config::ShellConfig, + registry: &Path, + planned: &PlannedFormat, + cache: &super::shell::PreparedShellProfileCache, + generation: u64, + deadline: std::time::Instant, + stop_requested: Option<&std::sync::atomic::AtomicBool>, +) -> Result, &'static str> { + use std::sync::atomic::Ordering; + use std::time::Instant; + use webcodex_core::runner_protocol::ShellCommandExecutionState; + + if Instant::now() >= deadline { + return Err("format_timeout"); + } if !planned.plan.is_valid() || planned.sources.len() != planned.plan.files.len() { return Err("format_plan_invalid"); } - let started = Instant::now(); - let total_budget = Duration::from_secs(timeout_secs); - let deadline = started + total_budget; - - let (python_executable, env_overrides, env_removals) = match &planned.plan.profile { - ProjectFormatProfile::Rust { .. } => (None, Vec::new(), Vec::new()), - ProjectFormatProfile::Python { .. } => { - let prepared_profile = super::shell::resolve_prepared_shell_profile( - generation, - shell, - registry, - &planned.cwd, - true, - cache, - stop_requested, - ) - .map_err(|_| "format_process_failed")?; - let python_path = super::shell::probe_ruff_formatter( - shell, - prepared_profile.as_deref(), - &planned.cwd, - deadline, - stop_requested, - )?; - let python_exe = python_path - .into_string() - .map_err(|_| "format_path_invalid")?; - ( - Some(python_exe), - vec![("PYTHONDONTWRITEBYTECODE", "1")], - vec!["RUFF_OUTPUT_FILE"], - ) - } - }; + let (python_executable, env_overrides, env_removals, rust_config_arg, _rust_fence) = + match &planned.plan.profile { + ProjectFormatProfile::Rust { .. } => { + let fence_dir = tempfile::tempdir().map_err(|_| "format_process_failed")?; + let config_path = fence_dir.path().join("rustfmt.toml"); + fs::write(&config_path, b"").map_err(|_| "format_process_failed")?; + let fence_str = fence_dir + .path() + .to_str() + .ok_or("format_path_invalid")? + .to_string(); + let config_str = config_path + .to_str() + .ok_or("format_path_invalid")? + .to_string(); + ( + None, + vec![ + ("XDG_CONFIG_HOME".to_string(), fence_str.clone()), + ("HOME".to_string(), fence_str.clone()), + ("USERPROFILE".to_string(), fence_str.clone()), + ("APPDATA".to_string(), fence_str), + ], + vec!["HOMEDRIVE", "HOMEPATH"], + Some(config_str), + Some(fence_dir), + ) + } + ProjectFormatProfile::Python { .. } => { + let prepared_profile = super::shell::resolve_prepared_shell_profile( + generation, + shell, + registry, + &planned.cwd, + true, + cache, + stop_requested, + ) + .map_err(|_| "format_process_failed")?; + let python_path = super::shell::probe_ruff_formatter( + shell, + prepared_profile.as_deref(), + &planned.cwd, + deadline, + stop_requested, + )?; + let python_exe = python_path + .into_string() + .map_err(|_| "format_path_invalid")?; + ( + Some(python_exe), + vec![("PYTHONDONTWRITEBYTECODE".to_string(), "1".to_string())], + vec!["RUFF_OUTPUT_FILE"], + None, + None, + ) + } + }; + let env_override_refs: Vec<(&str, &str)> = env_overrides + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); let mut total_output = 0usize; let mut candidates = Vec::with_capacity(planned.sources.len()); for (index, witness) in planned.plan.files.iter().enumerate() { if stop_requested.is_some_and(|stop| stop.load(Ordering::SeqCst)) { return Err("format_cancelled"); } - let remaining = total_budget.saturating_sub(started.elapsed()).as_secs(); - if remaining == 0 { + let now = Instant::now(); + if now >= deadline { + return Err("format_timeout"); + } + let remaining_duration = deadline.duration_since(now); + if remaining_duration.is_zero() { return Err("format_timeout"); } + let remaining_secs = + remaining_duration + .as_secs() + .saturating_add(if remaining_duration.subsec_nanos() > 0 { + 1 + } else { + 0 + }); let source = &planned.sources[index]; let relative = planned .plan @@ -421,6 +491,11 @@ pub(crate) fn format_candidates( let cwd = planned.cwd.to_str().ok_or("format_path_invalid")?; let exe = python_executable.as_deref().unwrap_or(&command.executable); + let mut args = command.args; + if let Some(config_path) = rust_config_arg.as_deref() { + args.push("--config-path".into()); + args.push(config_path.into()); + } let result = super::shell::run_format_candidate( generation, policy, @@ -429,16 +504,19 @@ pub(crate) fn format_candidates( cache, Some(cwd), exe, - &command.args, + &args, Some(source), - remaining, + remaining_secs, stop_requested, - &env_overrides, + &env_override_refs, &env_removals, ); if stop_requested.is_some_and(|stop| stop.load(Ordering::SeqCst)) { return Err("format_cancelled"); } + if Instant::now() >= deadline { + return Err("format_timeout"); + } if let Some(code) = result.result.error.as_deref() { match code { "format_output_truncated" => return Err("format_output_truncated"), @@ -471,11 +549,14 @@ pub(crate) fn format_candidates( { return Err("format_candidate_too_large"); } - if started.elapsed() > total_budget { + if Instant::now() >= deadline { return Err("format_timeout"); } candidates.push(stdout); } + if Instant::now() >= deadline { + return Err("format_timeout"); + } Ok(candidates) } diff --git a/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs b/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs index a005493fe..ececdaead 100644 --- a/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs @@ -1,6 +1,7 @@ use super::config::{RunnerPolicy, ShellConfig}; use super::project_format::{ - commit_candidates, format_candidates, handle, plan, replan, PlannedFormat, + commit_candidates, format_candidates, format_candidates_with_deadline, handle, plan, replan, + PlannedFormat, }; use super::shell::PreparedShellProfileCache; use std::fs; @@ -419,6 +420,42 @@ fn project_format_candidates_returns_formatted_output_and_unchanged_detection() ); } +#[cfg(unix)] +#[test] +fn project_format_candidate_does_not_inherit_user_rustfmt_config() { + let fixture = Fixture::new(); + fs::write( + fixture.root.join("src/main.rs"), + "fn main(){if true{println!(\"test\");}}\n", + ) + .unwrap(); + let planned = fixture.plan(); + let home_config = tempfile::tempdir().unwrap(); + let rustfmt_config = home_config.path().join("rustfmt"); + fs::create_dir(&rustfmt_config).unwrap(); + fs::write(rustfmt_config.join("rustfmt.toml"), "hard_tabs = true\n").unwrap(); + let mut shell = ShellConfig::default(); + shell.env.insert( + "XDG_CONFIG_HOME".into(), + home_config.path().to_string_lossy().into_owned(), + ); + let output = format_candidates( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &PreparedShellProfileCache::default(), + 1, + 10, + None, + ) + .expect("closed rustfmt profile should not inherit user configuration"); + assert_eq!( + output, + vec!["fn main() {\n if true {\n println!(\"test\");\n }\n}\n".to_string()] + ); +} + #[test] fn project_format_candidates_fails_on_nonzero_exit_syntax_error() { let fixture = Fixture::new(); @@ -1207,3 +1244,88 @@ esac ); assert_eq!(result, Err("format_output_truncated")); } + +#[test] +fn project_format_candidates_succeeds_with_one_second_timeout_budget() { + let fixture = Fixture::new(); + let planned = fixture.plan(); + let shell = ShellConfig::default(); + let cache = PreparedShellProfileCache::default(); + let candidates = format_candidates( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &cache, + 1, + 1, + None, + ) + .expect("valid 1s timeout budget must not fail to floor or spurious validation error"); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0], "fn main() {}\n"); +} + +#[test] +fn project_format_candidates_with_deadline_fails_closed_when_deadline_exhausted() { + let fixture = Fixture::new(); + let planned = fixture.plan(); + let shell = ShellConfig::default(); + let cache = PreparedShellProfileCache::default(); + let past_deadline = std::time::Instant::now() - std::time::Duration::from_millis(50); + let result = format_candidates_with_deadline( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &cache, + 1, + past_deadline, + None, + ); + assert_eq!(result, Err("format_timeout")); +} + +#[test] +fn project_format_candidates_with_deadline_respects_cancellation() { + let fixture = Fixture::new(); + let planned = fixture.plan(); + let shell = ShellConfig::default(); + let cache = PreparedShellProfileCache::default(); + let stop = std::sync::atomic::AtomicBool::new(true); + let future_deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + let result = format_candidates_with_deadline( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &cache, + 1, + future_deadline, + Some(&stop), + ); + assert_eq!(result, Err("format_cancelled")); +} + +#[test] +fn project_format_candidates_with_deadline_fails_closed_on_deadline_across_multiple_files() { + let fixture = Fixture::new(); + fs::write(fixture.root.join("src/lib.rs"), "pub fn lib( ){ }\n").unwrap(); + let mut request = fixture.request(); + request.files = vec!["src/main.rs".into(), "src/lib.rs".into()]; + let planned = plan(&fixture.policy, &fixture.registry, &request).unwrap(); + let shell = ShellConfig::default(); + let cache = PreparedShellProfileCache::default(); + let expired_deadline = std::time::Instant::now() - std::time::Duration::from_millis(1); + let result = format_candidates_with_deadline( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &cache, + 1, + expired_deadline, + None, + ); + assert_eq!(result, Err("format_timeout")); +} diff --git a/crates/webcodex-tool-contracts/src/registry/output_schemas/jobs.rs b/crates/webcodex-tool-contracts/src/registry/output_schemas/jobs.rs index d9c8be251..cc002ef91 100644 --- a/crates/webcodex-tool-contracts/src/registry/output_schemas/jobs.rs +++ b/crates/webcodex-tool-contracts/src/registry/output_schemas/jobs.rs @@ -993,7 +993,7 @@ pub(super) fn output_schema_for_tool(name: &str) -> Option { let mut schema = wrapped_output_schema(vec![ ("state_changed", json!({"type":["boolean","null"], "description":"Executor-verified aggregate mutation. Null is unknown, never retry permission."})), ("failure_kind", json!({"type":"string", "maxLength":80})), - ("execution_state", json!({"type":"string", "enum":["not_started","outcome_unknown"]})), + ("execution_state", json!({"type":"string", "enum":["not_started","outcome_unknown","pending"]})), ("job_id", schema_type("string", "Original Job identity, never a replacement execution.")), ("continuation", observe_job_continuation_schema()), ]); diff --git a/crates/webcodex-tool-contracts/src/tests/output_schemas.rs b/crates/webcodex-tool-contracts/src/tests/output_schemas.rs index 1e32b6491..47032150f 100644 --- a/crates/webcodex-tool-contracts/src/tests/output_schemas.rs +++ b/crates/webcodex-tool-contracts/src/tests/output_schemas.rs @@ -85,6 +85,15 @@ fn project_build_schema_accepts_same_job_queued_handoff() { .expect("project_build queued handoff must remain the same typed Job"); } +#[test] +fn project_format_output_schema_admits_pending_job_handoff() { + let schema = output_schema_for_tool("project_format"); + let states = schema["properties"]["output"]["properties"]["execution_state"]["enum"] + .as_array() + .expect("project_format lifecycle states"); + assert!(states.contains(&json!("pending"))); +} + #[test] fn suggested_tool_call_schema_recognizer_is_strict_and_structural() { let valid_generated = json!({ diff --git a/src/tool_runtime/control_sidecar.rs b/src/tool_runtime/control_sidecar.rs index 481e62cec..bc66a9f71 100644 --- a/src/tool_runtime/control_sidecar.rs +++ b/src/tool_runtime/control_sidecar.rs @@ -857,4 +857,24 @@ mod tests { let control = json!({"main": {"success": true, "execution_state": "succeeded", "state_changed": null}, "after_success": projection}); webcodex_tool_contracts::test_support::validate_schema_instance(&control, &schema).unwrap(); } + + #[test] + fn project_format_pending_main_execution_state_becomes_started() { + let result = ToolResult::ok(json!({ + "execution_state": "pending", + "state_changed": null, + "job_id": "job-format-pending", + "continuation": { + "tool": "observe_jobs", + "arguments": { + "items": [{ "job_id": "job-format-pending" }] + } + } + })); + assert_eq!(main_execution_state(&result, true), "started"); + assert_eq!( + main_execution_state(&result, false), + "definitely_not_started" + ); + } } diff --git a/src/tool_runtime/orchestration_host.rs b/src/tool_runtime/orchestration_host.rs index 0d7d7c261..61e6307d5 100644 --- a/src/tool_runtime/orchestration_host.rs +++ b/src/tool_runtime/orchestration_host.rs @@ -1090,4 +1090,35 @@ mod receipt_tests { assert_eq!(receipt.children[0].state_changed, None); } } + + #[test] + fn project_format_pending_handoff_emits_job_handoff_receipt() { + let mut effects = OrchestrationEffectAccumulator::default(); + effects.begin_if_consequential(1, "project_format"); + let result = super::super::ToolResult::ok(serde_json::json!({ + "execution_state": "pending", + "state_changed": null, + "job_id": "job-format-test", + "continuation": { + "tool": "observe_jobs", + "arguments": { + "items": [{ "job_id": "job-format-test" }] + } + } + })); + effects.finish(1, &result, None); + let receipt = effects.receipt(); + assert_eq!(receipt.consequential_calls, 1); + assert_eq!(receipt.job_handoffs, 1); + assert_eq!(receipt.known_results, 0); + assert_eq!(receipt.outcome_unknown, 0); + assert_eq!( + receipt.children[0].outcome, + ConsequentialChildOutcome::JobHandoff + ); + assert_eq!( + receipt.children[0].job_id.as_deref(), + Some("job-format-test") + ); + } } diff --git a/src/tool_runtime/project_format.rs b/src/tool_runtime/project_format.rs index ba7cef2a1..c1328c4dc 100644 --- a/src/tool_runtime/project_format.rs +++ b/src/tool_runtime/project_format.rs @@ -224,7 +224,9 @@ impl ToolRuntime { self.structured_execution_sync_wait.min(Duration::from_secs(budget.sync_wait_secs)), auth.cloned()).await { Ok(HiddenStructuredJobWait::Terminal { job, .. }) => terminal_result(&job), Ok(HiddenStructuredJobWait::Continued { observation, .. }) => ToolResult::ok(json!({ - "state_changed": null, "job_id": observation.job.job_id, + "execution_state": "pending", + "state_changed": null, + "job_id": observation.job.job_id, "continuation": super::jobs::observe_job_continuation(&observation.job.job_id, observation.job.observation_token.as_deref()), })), Err(_) => ToolResult::err_with_output("Formatting delivery is uncertain; observe the original Job before another mutation.", json!({ diff --git a/src/tool_runtime/tests/project_format_handoff.rs b/src/tool_runtime/tests/project_format_handoff.rs index 71c89e709..3b48b7b1a 100644 --- a/src/tool_runtime/tests/project_format_handoff.rs +++ b/src/tool_runtime/tests/project_format_handoff.rs @@ -247,6 +247,38 @@ async fn project_format_fast_success_noop_returns_state_changed_false() { assert_eq!(result.output["state_changed"], json!(false)); } +#[tokio::test] +async fn project_format_pending_preserves_original_job_and_pending_lifecycle() { + let runtime = setup(1, true).await; + let task = tokio::spawn({ + let runtime = runtime.clone(); + async move { + runtime + .dispatch_with_auth(call(), Some(&auth_context(None, true))) + .await + } + }); + + let (_request, job_id) = reply_plan(&runtime).await; + let result = tokio::time::timeout(std::time::Duration::from_secs(5), task) + .await + .expect("pending handoff must be bounded") + .unwrap(); + assert!( + result.success, + "pending is not terminal success: {:?}", + result.output + ); + assert_eq!(result.output["execution_state"], json!("pending")); + assert_eq!(result.output["state_changed"], json!(null)); + assert_eq!(result.output["job_id"], json!(job_id)); + assert_eq!(result.output["continuation"]["tool"], json!("observe_jobs")); + assert_eq!( + result.output["continuation"]["arguments"]["items"][0]["job_id"], + json!(job_id) + ); +} + #[tokio::test] async fn project_format_missing_capability_rejects_without_dispatch() { let registry = RunnerRegistry::with_job_receipt_store( From 08323e19f596617e6a02d01a642b2c47b82aab95 Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:05:03 +0800 Subject: [PATCH 07/12] fix(project): preserve formatting config and job observation truth --- .../src/webcodex_runner/project_format.rs | 56 ++++- .../webcodex_runner/project_format_tests.rs | 60 +++++ .../src/registry/output_schemas/jobs.rs | 8 +- src/tool_runtime/jobs.rs | 14 +- src/tool_runtime/observe_jobs.rs | 5 + src/tool_runtime/project_format.rs | 36 ++- .../tests/observe_jobs/summary.rs | 25 ++ .../tests/project_format_handoff.rs | 222 ++++++++++++++++++ 8 files changed, 401 insertions(+), 25 deletions(-) diff --git a/crates/webcodex-runner/src/webcodex_runner/project_format.rs b/crates/webcodex-runner/src/webcodex_runner/project_format.rs index 41a515381..1c86aebe6 100644 --- a/crates/webcodex-runner/src/webcodex_runner/project_format.rs +++ b/crates/webcodex-runner/src/webcodex_runner/project_format.rs @@ -167,6 +167,37 @@ fn python_profile(manifest: &toml::Value) -> Result Result<(), &'static str> { + for name in ["rustfmt.toml", ".rustfmt.toml"] { + match fs::symlink_metadata(dir.join(name)) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + _ => return Err("format_config_unsupported"), + } + } + Ok(()) +} + +fn reject_file_local_rustfmt_config( + recipe_root: &Path, + file_path: &Path, +) -> Result<(), &'static str> { + let mut current = file_path.parent().ok_or("format_path_invalid")?; + for depth in 0..64 { + if !current.starts_with(recipe_root) || current == recipe_root { + break; + } + check_no_rustfmt_config_in_dir(current)?; + match current.parent() { + Some(p) => current = p, + None => break, + } + if depth == 63 { + return Err("format_scope_unavailable"); + } + } + Ok(()) +} + fn reject_custom_rustfmt_config(root: &Path) -> Result<(), &'static str> { // rustfmt searches parents for configuration. Reject any configuration in // the project hierarchy; user-level and external configurations are separately @@ -175,12 +206,7 @@ fn reject_custom_rustfmt_config(root: &Path) -> Result<(), &'static str> { if depth >= 64 { return Err("format_scope_unavailable"); } - for name in ["rustfmt.toml", ".rustfmt.toml"] { - match fs::symlink_metadata(parent.join(name)) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - _ => return Err("format_config_unsupported"), - } - } + check_no_rustfmt_config_in_dir(parent)?; } Ok(()) } @@ -251,6 +277,10 @@ pub(crate) fn plan( let mut total = 0usize; for relative in &request.files { let path = checked_path(&root, relative, false).map_err(unavailable)?; + if adapter == ProjectFormatAdapter::Rust { + reject_file_local_rustfmt_config(&resolved.absolute_root, &path) + .map_err(unavailable)?; + } let parent = path .parent() .ok_or_else(|| unavailable("format_path_invalid"))?; @@ -411,13 +441,8 @@ pub(crate) fn format_candidates_with_deadline( .to_string(); ( None, - vec![ - ("XDG_CONFIG_HOME".to_string(), fence_str.clone()), - ("HOME".to_string(), fence_str.clone()), - ("USERPROFILE".to_string(), fence_str.clone()), - ("APPDATA".to_string(), fence_str), - ], - vec!["HOMEDRIVE", "HOMEPATH"], + vec![("XDG_CONFIG_HOME".to_string(), fence_str)], + Vec::new(), Some(config_str), Some(fence_dir), ) @@ -655,6 +680,11 @@ pub(super) fn commit_candidates_with_hook( continue; } let path = checked_path(&planned.root, &witness.path, false)?; + if matches!(planned.plan.profile, ProjectFormatProfile::Rust { .. }) { + reject_custom_rustfmt_config(&planned.cwd).map_err(|_| "format_plan_stale")?; + reject_file_local_rustfmt_config(&planned.cwd, &path) + .map_err(|_| "format_plan_stale")?; + } let mut file = open_regular_file_for_write(&path).map_err(|_| "format_write_unavailable")?; if digest(&file_identity(&file).map_err(|_| "format_write_unavailable")?) != identity diff --git a/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs b/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs index ececdaead..458b3aa52 100644 --- a/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs @@ -178,6 +178,27 @@ fn project_format_invalid_nested_markers_never_fall_back_to_parent() { ); } +#[test] +fn project_format_rejects_file_local_rustfmt_config_and_stale_plan() { + let fixture = Fixture::new(); + let planned = fixture.plan(); + for config in ["src/rustfmt.toml", "src/.rustfmt.toml"] { + let path = fixture.root.join(config); + fs::write(&path, "hard_tabs = true\n").unwrap(); + assert_eq!( + rejection(plan(&fixture.policy, &fixture.registry, &fixture.request())), + "format_config_unsupported", + "a selected source must not silently ignore {config}" + ); + assert_eq!( + replan(&fixture.policy, &fixture.registry, &planned.plan).err(), + Some("format_plan_stale"), + "new source-local format configuration invalidates the existing plan" + ); + fs::remove_file(path).unwrap(); + } +} + #[test] fn project_format_rejects_custom_config_inherited_edition_and_large_inputs() { let fixture = Fixture::new(); @@ -456,6 +477,45 @@ fn project_format_candidate_does_not_inherit_user_rustfmt_config() { ); } +#[cfg(unix)] +#[test] +fn project_format_candidate_preserves_home_for_rustup_style_formatter() { + use std::os::unix::fs::PermissionsExt; + let temp = crate::tests::executable_tempdir(); + let home = temp.path().join("home"); + fs::create_dir_all(home.join(".rustup")).unwrap(); + fs::write(home.join(".rustup/settings.toml"), "configured\n").unwrap(); + let fake_rustfmt = temp.path().join("rustfmt"); + fs::write( + &fake_rustfmt, + "#!/bin/sh\nif [ ! -f \"$HOME/.rustup/settings.toml\" ]; then\n echo 'rustup home unavailable' >&2\n exit 42\nfi\ncat\n", + ) + .unwrap(); + fs::set_permissions(&fake_rustfmt, fs::Permissions::from_mode(0o755)).unwrap(); + let fixture = Fixture::new(); + let planned = fixture.plan(); + let mut shell = ShellConfig::default(); + shell.path_prepend = vec![temp.path().to_path_buf()]; + shell + .env + .insert("HOME".into(), home.to_string_lossy().into_owned()); + let actual = format_candidates( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &PreparedShellProfileCache::default(), + 1, + 10, + None, + ); + assert_eq!( + actual, + Ok(planned.sources), + "formatter toolchain lookup must retain its configured HOME" + ); +} + #[test] fn project_format_candidates_fails_on_nonzero_exit_syntax_error() { let fixture = Fixture::new(); diff --git a/crates/webcodex-tool-contracts/src/registry/output_schemas/jobs.rs b/crates/webcodex-tool-contracts/src/registry/output_schemas/jobs.rs index cc002ef91..67729a10c 100644 --- a/crates/webcodex-tool-contracts/src/registry/output_schemas/jobs.rs +++ b/crates/webcodex-tool-contracts/src/registry/output_schemas/jobs.rs @@ -591,7 +591,9 @@ fn observe_jobs_output_schema() -> Value { {"type": "object", "additionalProperties": true}, {"type": "null"} ] - } + }, + "state_changed": nullable_schema("boolean", "Authoritative file mutation outcome for project_format."), + "failure_kind": nullable_schema("string", "Safe closed failure kind for project_format.") }, "required": [ "job_id", "status", "exit_code", "stdout_tail", "stderr_tail", @@ -666,7 +668,9 @@ fn observe_jobs_output_schema() -> Value { "purpose": nullable_schema("string", "Declared purpose retained on baseline/reset observations for disambiguation."), "command_summary": nullable_schema("string", "Bounded safe command summary retained on baseline/reset observations for disambiguation."), "detected_summary": {"type": "object", "additionalProperties": true}, - "validation": validation_job_projection_schema() + "validation": validation_job_projection_schema(), + "state_changed": nullable_schema("boolean", "Authoritative file mutation outcome for project_format."), + "failure_kind": nullable_schema("string", "Safe closed failure kind for project_format.") }, "required": [ "job_id", "status", "terminal", "changed", "meaningful_changed", "heartbeat_changed", "log_delta_status", diff --git a/src/tool_runtime/jobs.rs b/src/tool_runtime/jobs.rs index e12548cba..6c2b3b78b 100644 --- a/src/tool_runtime/jobs.rs +++ b/src/tool_runtime/jobs.rs @@ -886,6 +886,8 @@ pub(crate) fn agent_job_summary_value(job: &ShellJobInfo) -> Value { }); if job.kind == "project_format" { summary["state_changed"] = json!(job.format_mutation.unwrap_or_default().state_changed()); + summary["failure_kind"] = + json!(super::project_format::project_format_safe_failure_kind(job)); } summary } @@ -1854,7 +1856,7 @@ impl ToolRuntime { ) { validation["validation_target_id"] = json!(target_id); } - ToolResult::ok(json!({ + let mut output = json!({ "job_id": job.job_id, "project": job.project_id, "status": job.status, @@ -1911,7 +1913,15 @@ impl ToolRuntime { "command_summary": command_summary, "detected_summary": detected_summary, "validation": validation, - })) + }); + if job.kind == "project_format" { + output["state_changed"] = + json!(job.format_mutation.unwrap_or_default().state_changed()); + output["failure_kind"] = json!( + super::project_format::project_format_safe_failure_kind(&job) + ); + } + ToolResult::ok(output) } Err(error) => agent_job_log_error_result(&job_id, error), } diff --git a/src/tool_runtime/observe_jobs.rs b/src/tool_runtime/observe_jobs.rs index e3f2be7ba..559d9a20c 100644 --- a/src/tool_runtime/observe_jobs.rs +++ b/src/tool_runtime/observe_jobs.rs @@ -644,6 +644,11 @@ fn sparse_success_item(item: &Value) -> Option { copy_non_null(observation, &mut sparse, key); } + if observation.contains_key("state_changed") || observation.contains_key("failure_kind") { + copy_present(observation, &mut sparse, "state_changed"); + copy_present(observation, &mut sparse, "failure_kind"); + } + if log_delta_status != "unchanged" { copy_present(observation, &mut sparse, "stdout_tail"); copy_present(observation, &mut sparse, "stderr_tail"); diff --git a/src/tool_runtime/project_format.rs b/src/tool_runtime/project_format.rs index c1328c4dc..baefd06bf 100644 --- a/src/tool_runtime/project_format.rs +++ b/src/tool_runtime/project_format.rs @@ -10,6 +10,7 @@ use crate::runner_protocol::{ShellCommandExecutionState, ShellJobInfo, ShellJobO use serde_json::json; use std::time::Duration; use webcodex_core::project_format::*; +use webcodex_core::runner_job_lifecycle::RunnerJobLifecycle; fn rejected(code: &str) -> ToolResult { ToolResult::err_with_output( @@ -32,13 +33,7 @@ pub(super) fn terminal_result(job: &ShellJobInfo) -> ToolResult { if success { return ToolResult::ok(output); } - // Only the closed executor failure code may reach the model; never source, - // candidates, process diagnostics or an inferred validation summary. - let code = job - .error - .as_deref() - .filter(|code| valid_failure_code(code)) - .unwrap_or("format_execution_unknown"); + let code = project_format_safe_failure_kind(job).unwrap_or("format_execution_unknown"); output["failure_kind"] = json!(code); output["execution_state"] = json!("outcome_unknown"); output["job_id"] = json!(job.job_id); @@ -47,7 +42,7 @@ pub(super) fn terminal_result(job: &ShellJobInfo) -> ToolResult { ToolResult::err_with_output("Formatting did not produce a complete verified mutation receipt. Observe the original Job and source before another mutation.", output) } -fn valid_failure_code(code: &str) -> bool { +pub(crate) fn valid_failure_code(code: &str) -> bool { code.len() <= 80 && (code.starts_with("format_") || code == "capability_unavailable" @@ -60,6 +55,31 @@ fn valid_failure_code(code: &str) -> bool { .all(|byte| byte.is_ascii_lowercase() || byte == b'_') } +pub(crate) fn project_format_safe_failure_kind(job: &ShellJobInfo) -> Option<&str> { + let report = job.format_mutation.unwrap_or_default(); + let known = report.state_changed(); + let success = job.status == "completed" + && job.exit_code == Some(0) + && job.command_execution_state == Some(ShellCommandExecutionState::Completed) + && job.error.is_none() + && known.is_some(); + let active = job.status == "recovering" + || job.recovery_state.as_deref() == Some("recovering") + || RunnerJobLifecycle::from_wire(&job.status).is_ok_and(RunnerJobLifecycle::is_active); + if success { + None + } else if active && job.error.is_none() { + None + } else { + Some( + job.error + .as_deref() + .filter(|code| valid_failure_code(code)) + .unwrap_or("format_execution_unknown"), + ) + } +} + impl ToolRuntime { #[allow(clippy::too_many_arguments)] pub(crate) async fn project_format( diff --git a/src/tool_runtime/tests/observe_jobs/summary.rs b/src/tool_runtime/tests/observe_jobs/summary.rs index 30a881314..b8c7afb54 100644 --- a/src/tool_runtime/tests/observe_jobs/summary.rs +++ b/src/tool_runtime/tests/observe_jobs/summary.rs @@ -60,6 +60,31 @@ fn without_observation_refs(output: &Value) -> Value { normalized } +#[test] +fn project_format_sparse_observation_preserves_one_mutation_evidence_location() { + let mut observation = canonical_observation( + "format-single-evidence", + "completed", + "baseline", + "", + "", + false, + true, + ); + observation["state_changed"] = json!(true); + observation["failure_kind"] = Value::Null; + let canonical = canonical_batch(vec![canonical_success_item(0, observation)], "terminal", 0); + let sparse = compact_projection(&canonical); + let item = &sparse.output["items"][0]; + assert_eq!(item["state_changed"], json!(true)); + assert_eq!(item["failure_kind"], Value::Null); + assert!( + item.get("output").is_none(), + "no redundant nested formatter mutation evidence" + ); + validate_result(&sparse); +} + #[test] fn summary_only_is_opt_in_closed_and_audited_without_tokens() { for value in [None, Some(json!(false)), Some(json!(true))] { diff --git a/src/tool_runtime/tests/project_format_handoff.rs b/src/tool_runtime/tests/project_format_handoff.rs index 3b48b7b1a..57c6aa7cb 100644 --- a/src/tool_runtime/tests/project_format_handoff.rs +++ b/src/tool_runtime/tests/project_format_handoff.rs @@ -279,6 +279,228 @@ async fn project_format_pending_preserves_original_job_and_pending_lifecycle() { ); } +#[tokio::test] +async fn project_format_pending_observation_does_not_report_failure() { + let runtime = setup(1, true).await; + let auth = auth_context(None, true); + let task = tokio::spawn({ + let runtime = runtime.clone(); + let auth = auth.clone(); + async move { runtime.dispatch_with_auth(call(), Some(&auth)).await } + }); + let (_request, job_id) = reply_plan(&runtime).await; + let pending = task.await.unwrap(); + assert_eq!(pending.output["execution_state"], json!("pending")); + let detailed = runtime + .job_log_for_auth(job_id.clone(), None, Some(20), Some(&auth), None, None) + .await; + assert!(detailed.success); + assert_eq!(detailed.output["terminal"], json!(false)); + assert_eq!(detailed.output["state_changed"], json!(null)); + assert_eq!( + detailed.output["failure_kind"], + json!(null), + "an active Job is not a known failure: {}", + detailed.output["status"] + ); + let call = + ToolCall::from_tool_name("observe_jobs", json!({"items": [{"job_id": job_id}]})).unwrap(); + let mut observed = runtime.dispatch_with_auth(call, Some(&auth)).await; + assert_eq!( + observed.output["items"][0]["output"]["failure_kind"], + json!(null) + ); + super::super::observe_jobs::sparsify_observe_jobs_model_result(&mut observed); + assert_eq!(observed.output["items"][0]["failure_kind"], json!(null)); +} + +#[tokio::test] +async fn project_format_active_running_and_stop_requested_observation_does_not_report_failure() { + for status in ["running", "stop_requested"] { + let runtime = setup(1, true).await; + let auth = auth_context(None, true); + let task = tokio::spawn({ + let runtime = runtime.clone(); + let auth = auth.clone(); + async move { runtime.dispatch_with_auth(call(), Some(&auth)).await } + }); + let (request, job_id) = reply_plan(&runtime).await; + let pending = task.await.unwrap(); + assert_eq!(pending.output["execution_state"], json!("pending")); + + runtime + .runner_registry + .update_job(RunnerJobUpdateRequest { + client_id: CLIENT.into(), + runner_instance_id: "inst".into(), + update_seq: None, + job_id: job_id.clone(), + request_id: Some(request.request_id.clone()), + status: status.into(), + stdout_chunk: None, + stderr_chunk: None, + log_snapshot: None, + exit_code: None, + duration_ms: None, + error: None, + command_execution_state: None, + validation_progress: None, + test_count_evidence: None, + format_mutation: None, + activity: None, + finished: false, + }) + .await + .unwrap(); + + let detailed = runtime + .job_log_for_auth(job_id.clone(), None, Some(20), Some(&auth), None, None) + .await; + assert!(detailed.success); + assert_eq!(detailed.output["terminal"], json!(false)); + assert_eq!(detailed.output["state_changed"], json!(null)); + assert_eq!( + detailed.output["failure_kind"], + json!(null), + "an active Job is not a known failure: {}", + detailed.output["status"] + ); + + let call = ToolCall::from_tool_name("observe_jobs", json!({"items": [{"job_id": job_id}]})) + .unwrap(); + let mut observed = runtime.dispatch_with_auth(call, Some(&auth)).await; + assert_eq!( + observed.output["items"][0]["output"]["failure_kind"], + json!(null) + ); + super::super::observe_jobs::sparsify_observe_jobs_model_result(&mut observed); + assert_eq!(observed.output["items"][0]["failure_kind"], json!(null)); + } +} + +#[tokio::test] +async fn project_format_continuation_observes_authoritative_mutation_and_failure() { + for (status, state, exit_code, error, mutation, changed, failure_kind) in [ + ( + "completed", + ShellCommandExecutionState::Completed, + Some(0), + None, + ProjectFormatMutationReport::Changed, + json!(true), + json!(null), + ), + ( + "completed", + ShellCommandExecutionState::Completed, + Some(0), + None, + ProjectFormatMutationReport::Unchanged, + json!(false), + json!(null), + ), + ( + "failed", + ShellCommandExecutionState::OutcomeUnknown, + Some(1), + Some("format_write_conflict"), + ProjectFormatMutationReport::Unknown, + json!(null), + json!("format_write_conflict"), + ), + ] { + let runtime = setup(1, true).await; + let auth = auth_context(None, true); + let task = tokio::spawn({ + let runtime = runtime.clone(); + let auth = auth.clone(); + async move { runtime.dispatch_with_auth(call(), Some(&auth)).await } + }); + let (request, job_id) = reply_plan(&runtime).await; + let pending = task.await.unwrap(); + assert!(pending.success); + assert_eq!(pending.output["execution_state"], json!("pending")); + finish_format_job( + &runtime, + &request, + &job_id, + status, + state, + exit_code, + error, + Some(mutation), + ) + .await; + + let detailed = runtime + .job_log_for_auth(job_id.clone(), None, Some(20), Some(&auth), None, None) + .await; + assert!(detailed.success, "{detailed:?}"); + assert_eq!(detailed.output["state_changed"], changed); + assert_eq!(detailed.output["failure_kind"], failure_kind); + + for summary_only in [false, true] { + let args = json!({ + "items": [{"job_id": job_id}], + "summary_only": summary_only, + }); + let call = ToolCall::from_tool_name("observe_jobs", args).unwrap(); + let mut observed = runtime.dispatch_with_auth(call, Some(&auth)).await; + assert!(observed.success, "{observed:?}"); + assert_eq!( + observed.output["items"][0]["output"]["state_changed"], + changed + ); + assert_eq!( + observed.output["items"][0]["output"]["failure_kind"], + failure_kind + ); + super::super::observe_jobs::sparsify_observe_jobs_model_result(&mut observed); + assert_eq!(observed.output["items"][0]["state_changed"], changed); + assert_eq!(observed.output["items"][0]["failure_kind"], failure_kind); + } + } +} + +#[tokio::test] +async fn project_format_sparse_observation_has_single_mutation_evidence_path() { + let runtime = setup(1, true).await; + let auth = auth_context(None, true); + let task = tokio::spawn({ + let runtime = runtime.clone(); + let auth = auth.clone(); + async move { runtime.dispatch_with_auth(call(), Some(&auth)).await } + }); + let (request, job_id) = reply_plan(&runtime).await; + assert_eq!( + task.await.unwrap().output["execution_state"], + json!("pending") + ); + finish_format_job( + &runtime, + &request, + &job_id, + "completed", + ShellCommandExecutionState::Completed, + Some(0), + None, + Some(ProjectFormatMutationReport::Changed), + ) + .await; + + let call = + ToolCall::from_tool_name("observe_jobs", json!({"items": [{"job_id": job_id}]})).unwrap(); + let mut observed = runtime.dispatch_with_auth(call, Some(&auth)).await; + super::super::observe_jobs::sparsify_observe_jobs_model_result(&mut observed); + let item = &observed.output["items"][0]; + assert_eq!(item["state_changed"], json!(true)); + assert_eq!(item["failure_kind"], json!(null)); + assert!( + item.get("output").is_none(), + "sparse observation must expose one canonical mutation evidence path" + ); +} + #[tokio::test] async fn project_format_missing_capability_rejects_without_dispatch() { let registry = RunnerRegistry::with_job_receipt_store( From 288af2ad7fe0f400765d382fe8bb54700be25c71 Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:05:41 +0800 Subject: [PATCH 08/12] fix(project-format): bound profile preparation and align denial contracts --- .../src/webcodex_runner/project_format.rs | 41 ++++-- .../webcodex_runner/project_format_tests.rs | 120 ++++++++++++++++++ .../src/webcodex_runner/shell.rs | 4 +- .../src/webcodex_runner/shell/process.rs | 7 +- .../src/webcodex_runner/shell/profiles.rs | 81 ++++++++++-- .../src/registry/output_schemas/jobs.rs | 46 +++++++ .../src/tests/definitions.rs | 15 +++ .../src/tests/output_schemas.rs | 30 +++++ .../src/tool_definition/jobs.rs | 2 +- docs/TESTING.md | 1 + src/tool_runtime/project_format.rs | 2 +- .../tests/project_format_handoff.rs | 53 ++++++++ 12 files changed, 374 insertions(+), 28 deletions(-) diff --git a/crates/webcodex-runner/src/webcodex_runner/project_format.rs b/crates/webcodex-runner/src/webcodex_runner/project_format.rs index 1c86aebe6..0bb4304a7 100644 --- a/crates/webcodex-runner/src/webcodex_runner/project_format.rs +++ b/crates/webcodex-runner/src/webcodex_runner/project_format.rs @@ -424,6 +424,36 @@ pub(crate) fn format_candidates_with_deadline( return Err("format_plan_invalid"); } + let prepared_profile = match super::shell::resolve_prepared_shell_profile_with_deadline( + generation, + shell, + registry, + &planned.cwd, + true, + cache, + stop_requested, + Some(deadline), + ) { + Ok(profile) => { + if stop_requested.is_some_and(|stop| stop.load(Ordering::SeqCst)) { + return Err("format_cancelled"); + } + if Instant::now() >= deadline { + return Err("format_timeout"); + } + profile + } + Err(_) => { + if stop_requested.is_some_and(|stop| stop.load(Ordering::SeqCst)) { + return Err("format_cancelled"); + } + if Instant::now() >= deadline { + return Err("format_timeout"); + } + return Err("format_process_failed"); + } + }; + let (python_executable, env_overrides, env_removals, rust_config_arg, _rust_fence) = match &planned.plan.profile { ProjectFormatProfile::Rust { .. } => { @@ -448,16 +478,6 @@ pub(crate) fn format_candidates_with_deadline( ) } ProjectFormatProfile::Python { .. } => { - let prepared_profile = super::shell::resolve_prepared_shell_profile( - generation, - shell, - registry, - &planned.cwd, - true, - cache, - stop_requested, - ) - .map_err(|_| "format_process_failed")?; let python_path = super::shell::probe_ruff_formatter( shell, prepared_profile.as_deref(), @@ -535,6 +555,7 @@ pub(crate) fn format_candidates_with_deadline( stop_requested, &env_override_refs, &env_removals, + Some(deadline), ); if stop_requested.is_some_and(|stop| stop.load(Ordering::SeqCst)) { return Err("format_cancelled"); diff --git a/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs b/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs index 458b3aa52..662781375 100644 --- a/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs @@ -980,6 +980,126 @@ fn project_format_candidates_fails_on_missing_ruff_module() { } } +#[cfg(unix)] +#[test] +#[ignore = "real shell-profile subprocess timing; run the explicit project format deadline evidence lane"] +fn project_format_rust_profile_preparation_obeys_total_job_deadline() { + use super::config::ShellProfileConfig; + use std::time::{Duration, Instant}; + let fixture = Fixture::new(); + let planned = fixture.plan(); + let mut shell = ShellConfig::default(); + shell.default_profile = Some("slow".into()); + shell.profiles.insert( + "slow".into(), + ShellProfileConfig { + init_script: Some("sleep 5".into()), + ..Default::default() + }, + ); + let start = Instant::now(); + let result = format_candidates( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &PreparedShellProfileCache::default(), + 1, + 1, + None, + ); + assert_eq!(result, Err("format_timeout")); + assert!( + start.elapsed() < Duration::from_secs(3), + "rust profile preparation exceeded the total Job deadline" + ); +} + +#[cfg(unix)] +#[test] +#[ignore = "real shell-profile subprocess timing; run the explicit project format deadline evidence lane"] +fn project_format_python_profile_preparation_obeys_total_job_deadline() { + use super::config::ShellProfileConfig; + use std::time::{Duration, Instant}; + let fixture = Fixture::new(); + fs::remove_file(fixture.root.join("Cargo.toml")).unwrap(); + fs::write( + fixture.root.join("pyproject.toml"), + "[tool.ruff]\ntarget-version='py311'\n", + ) + .unwrap(); + fs::write(fixture.root.join("main.py"), "x=1\n").unwrap(); + let mut request = fixture.request(); + request.files = vec!["main.py".into()]; + let planned = plan(&fixture.policy, &fixture.registry, &request).unwrap(); + + let mut shell = ShellConfig::default(); + shell.default_profile = Some("slow".into()); + shell.profiles.insert( + "slow".into(), + ShellProfileConfig { + init_script: Some("sleep 5".into()), + ..Default::default() + }, + ); + let start = Instant::now(); + let result = format_candidates( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &PreparedShellProfileCache::default(), + 1, + 1, + None, + ); + assert_eq!(result, Err("format_timeout")); + assert!( + start.elapsed() < Duration::from_secs(3), + "shell profile preparation exceeded the 1-second Job deadline" + ); +} + +#[cfg(unix)] +#[test] +fn project_format_python_profile_error_text_is_not_job_timeout() { + use super::config::ShellProfileConfig; + let fixture = Fixture::new(); + fs::remove_file(fixture.root.join("Cargo.toml")).unwrap(); + fs::write( + fixture.root.join("pyproject.toml"), + "[tool.ruff]\ntarget-version='py311'\n", + ) + .unwrap(); + fs::write(fixture.root.join("main.py"), "x=1\n").unwrap(); + let mut request = fixture.request(); + request.files = vec!["main.py".into()]; + let planned = plan(&fixture.policy, &fixture.registry, &request).unwrap(); + let mut shell = ShellConfig::default(); + shell.default_profile = Some("invalid".into()); + shell.profiles.insert( + "invalid".into(), + ShellProfileConfig { + init_script: Some("printf 'timed out from profile' >&2; exit 42".into()), + ..Default::default() + }, + ); + assert_eq!( + format_candidates( + &fixture.policy, + &shell, + &fixture.registry, + &planned, + &PreparedShellProfileCache::default(), + 1, + 10, + None, + ), + Err("format_process_failed"), + "a profile's stderr text cannot impersonate the total Job timeout" + ); +} + #[cfg(unix)] #[test] fn project_format_candidates_python_ruff_probe_timeout_fails_closed() { diff --git a/crates/webcodex-runner/src/webcodex_runner/shell.rs b/crates/webcodex-runner/src/webcodex_runner/shell.rs index 7ff7e3b05..86d2c4996 100644 --- a/crates/webcodex-runner/src/webcodex_runner/shell.rs +++ b/crates/webcodex-runner/src/webcodex_runner/shell.rs @@ -168,7 +168,9 @@ use process_tree::{ }; #[cfg(test)] use profiles::powershell_profile_prepare_script; -pub(crate) use profiles::resolve_prepared_shell_profile; +pub(crate) use profiles::{ + resolve_prepared_shell_profile, resolve_prepared_shell_profile_with_deadline, +}; #[cfg(test)] use run::run_shell_impl; pub(crate) use run::run_shell_with_profiles_and_execution_state; diff --git a/crates/webcodex-runner/src/webcodex_runner/shell/process.rs b/crates/webcodex-runner/src/webcodex_runner/shell/process.rs index 5f48e4e20..416b13ab8 100644 --- a/crates/webcodex-runner/src/webcodex_runner/shell/process.rs +++ b/crates/webcodex-runner/src/webcodex_runner/shell/process.rs @@ -160,6 +160,7 @@ pub(crate) fn run_process_with_profiles_and_execution_state_with_internal_env_an &[], on_started, false, + None, ) } @@ -178,6 +179,7 @@ pub(crate) fn run_format_candidate( stop_requested: Option<&AtomicBool>, env_overrides: &[(&str, &str)], env_removals: &[&str], + deadline: Option, ) -> ShellCommandResult { run_process_with_output( generation, @@ -195,6 +197,7 @@ pub(crate) fn run_format_candidate( env_removals, None, true, + deadline, ) } @@ -215,6 +218,7 @@ fn run_process_with_output( env_removals: &[&str], on_started: Option<&dyn Fn()>, exact_utf8: bool, + deadline: Option, ) -> ShellCommandResult { // Structured execution intentionally receives the same policy treatment // as run_shell. Absence of shell syntax is not a permission bypass. @@ -241,7 +245,7 @@ fn run_process_with_output( } let timeout_secs = timeout_secs.min(policy.max_timeout_secs).max(1); let start = Instant::now(); - let profile = match resolve_prepared_shell_profile( + let profile = match resolve_prepared_shell_profile_with_deadline( generation, shell, project_registry_dir, @@ -249,6 +253,7 @@ fn run_process_with_output( cwd.is_some(), cache, stop_requested, + deadline, ) { Ok(profile) => profile, Err(error) => { diff --git a/crates/webcodex-runner/src/webcodex_runner/shell/profiles.rs b/crates/webcodex-runner/src/webcodex_runner/shell/profiles.rs index dfb081e3a..a55cc0837 100644 --- a/crates/webcodex-runner/src/webcodex_runner/shell/profiles.rs +++ b/crates/webcodex-runner/src/webcodex_runner/shell/profiles.rs @@ -101,10 +101,22 @@ pub(super) fn capture_profile_env_snapshot( prepare_cwd: &Path, initial_env: HashMap, stop_requested: Option<&AtomicBool>, + deadline: Option, ) -> Result, String> { let Some(init_script) = profile.init_script.as_deref() else { return Ok(initial_env); }; + let cap = Duration::from_secs(SHELL_PROFILE_PREPARE_TIMEOUT_SECS); + let timeout = match deadline { + Some(deadline) => { + let now = Instant::now(); + if now >= deadline { + return Err("profile prepare timed out before launch".to_string()); + } + cap.min(deadline.duration_since(now)) + } + None => cap, + }; let marker = format!("__WEBCODEX_ENV_START_{}__", uuid::Uuid::new_v4().simple()); let prepare_script = match dialect { ShellDialect::Posix => posix_profile_prepare_script(init_script, &marker), @@ -121,19 +133,15 @@ pub(super) fn capture_profile_env_snapshot( for (key, value) in initial_env { cmd.env(key, value); } - let (status, stdout, stderr) = run_prepare_command( - cmd, - Duration::from_secs(SHELL_PROFILE_PREPARE_TIMEOUT_SECS), - stop_requested, - ) - .map_err(|e| { - format!( - "failed to prepare shell profile '{}' at {}: {}", - profile_name, - prepare_cwd.display(), - e - ) - })?; + let (status, stdout, stderr) = + run_prepare_command(cmd, timeout, stop_requested).map_err(|e| { + format!( + "failed to prepare shell profile '{}' at {}: {}", + profile_name, + prepare_cwd.display(), + e + ) + })?; if !status.success() { return Err(format!( "failed to prepare shell profile '{}' at {}: exit code {}; stderr tail: {}", @@ -179,6 +187,27 @@ impl PreparedShellProfileCache { project_key: String, prepare_cwd: &Path, stop_requested: Option<&AtomicBool>, + ) -> Result, String> { + self.get_or_prepare_with_deadline( + generation, + shell, + profile_name, + project_key, + prepare_cwd, + stop_requested, + None, + ) + } + + pub(super) fn get_or_prepare_with_deadline( + &self, + generation: u64, + shell: &ShellConfig, + profile_name: &str, + project_key: String, + prepare_cwd: &Path, + stop_requested: Option<&AtomicBool>, + deadline: Option, ) -> Result, String> { let key = PreparedShellProfileKey { generation, @@ -218,6 +247,7 @@ impl PreparedShellProfileCache { prepare_cwd, initial_env, stop_requested, + deadline, )?; let prepared = Arc::new(PreparedShellProfile { profile_name: profile_name.to_string(), @@ -335,6 +365,28 @@ pub(crate) fn resolve_prepared_shell_profile( request_has_cwd: bool, cache: &PreparedShellProfileCache, stop_requested: Option<&AtomicBool>, +) -> Result>, String> { + resolve_prepared_shell_profile_with_deadline( + generation, + shell, + project_registry_dir, + cwd_path, + request_has_cwd, + cache, + stop_requested, + None, + ) +} + +pub(crate) fn resolve_prepared_shell_profile_with_deadline( + generation: u64, + shell: &ShellConfig, + project_registry_dir: &Path, + cwd_path: &Path, + request_has_cwd: bool, + cache: &PreparedShellProfileCache, + stop_requested: Option<&AtomicBool>, + deadline: Option, ) -> Result>, String> { let project = request_has_cwd .then(|| find_project_shell_context(project_registry_dir, cwd_path)) @@ -365,13 +417,14 @@ pub(crate) fn resolve_prepared_shell_profile( &prepare_cwd, ); cache - .get_or_prepare( + .get_or_prepare_with_deadline( generation, shell, profile_name, project_key, &prepare_cwd, stop_requested, + deadline, ) .map(Some) } diff --git a/crates/webcodex-tool-contracts/src/registry/output_schemas/jobs.rs b/crates/webcodex-tool-contracts/src/registry/output_schemas/jobs.rs index 67729a10c..d1ae227bb 100644 --- a/crates/webcodex-tool-contracts/src/registry/output_schemas/jobs.rs +++ b/crates/webcodex-tool-contracts/src/registry/output_schemas/jobs.rs @@ -1000,8 +1000,54 @@ pub(super) fn output_schema_for_tool(name: &str) -> Option { ("execution_state", json!({"type":"string", "enum":["not_started","outcome_unknown","pending"]})), ("job_id", schema_type("string", "Original Job identity, never a replacement execution.")), ("continuation", observe_job_continuation_schema()), + ("error_kind", json!({"type":"string", "maxLength":80, "description":"Safe closed error classification or kernel rejection code."})), + ("permission_reason", json!({"type":"string", "maxLength":128, "description":"Authoritative kernel rejection reason."})), + ("session_id", json!({"type":"string", "maxLength":128, "description":"Active Workflow Session identity associated with rejection or execution."})), + ("command_started", schema_type("boolean", "Whether any underlying process or command started.")), + ("command_completed", schema_type("boolean", "Whether any underlying process or command completed.")), + ("command_ok", schema_type("boolean", "False on pre-start tool failures.")), + ("exit_code", nullable_schema("integer", "Process exit code; null for unstarted responses.")), + ("tool_failure", schema_type("boolean", "True for kernel or Runner initiation and capability failures.")), + ("tool_name", json!({"type":"string", "maxLength":64, "description":"Authoritative runtime tool name associated with rejection."})), + ("guard", json!({"type":"string", "maxLength":64, "description":"Active workflow session guard policy that blocked admission."})), + ("mode", json!({"type":"string", "maxLength":32, "description":"Active workflow session mode at admission check."})), + ("lifecycle", json!({"type":"string", "maxLength":32, "description":"Workflow session lifecycle state at rejection."})), + ("session_project", json!({"type":"string", "maxLength":128, "description":"Bound workflow session project identity."})), + ("request_project", json!({"type":"string", "maxLength":128, "description":"Request project identity."})), + ("message_id", json!({"type":"string", "maxLength":128, "description":"Workflow session message identity."})), ]); schema["properties"]["output"]["additionalProperties"] = json!(false); + schema["allOf"] + .as_array_mut() + .expect("wrapped_output_schema provides allOf array") + .push(json!({ + "if": {"properties": {"success": {"const": true}}, "required": ["success"]}, + "then": { + "properties": { + "output": { + "not": { + "anyOf": [ + {"required": ["command_started"]}, + {"required": ["command_completed"]}, + {"required": ["command_ok"]}, + {"required": ["exit_code"]}, + {"required": ["tool_failure"]}, + {"required": ["tool_name"]}, + {"required": ["guard"]}, + {"required": ["mode"]}, + {"required": ["lifecycle"]}, + {"required": ["session_project"]}, + {"required": ["request_project"]}, + {"required": ["message_id"]}, + {"required": ["error_kind"]}, + {"required": ["failure_kind"]}, + {"required": ["permission_reason"]}, + ] + } + } + } + } + })); Some(schema) } "project_build" => { diff --git a/crates/webcodex-tool-contracts/src/tests/definitions.rs b/crates/webcodex-tool-contracts/src/tests/definitions.rs index 8ffc5a1c9..fe4b4fc25 100644 --- a/crates/webcodex-tool-contracts/src/tests/definitions.rs +++ b/crates/webcodex-tool-contracts/src/tests/definitions.rs @@ -1,5 +1,20 @@ use super::*; +#[test] +fn project_format_preserves_session_shell_and_write_guards() { + let definition = lookup_tool_definition("project_format").expect("project_format definition"); + assert!( + definition.is_write_like(), + "formatter can mutate source files" + ); + assert!( + definition.is_shell_like(), + "formatter and profile initialization launch native processes" + ); + assert_eq!(definition.metadata().risk, ToolRisk::ProjectWrite); + assert_eq!(definition.permission_risk(), PERMISSION_RISK_WRITE); +} + #[test] fn tool_definition_source_has_no_module_wide_dead_code_allowance() { let source = include_str!("../tool_definition.rs"); diff --git a/crates/webcodex-tool-contracts/src/tests/output_schemas.rs b/crates/webcodex-tool-contracts/src/tests/output_schemas.rs index 47032150f..9705d2fe0 100644 --- a/crates/webcodex-tool-contracts/src/tests/output_schemas.rs +++ b/crates/webcodex-tool-contracts/src/tests/output_schemas.rs @@ -94,6 +94,36 @@ fn project_format_output_schema_admits_pending_job_handoff() { assert!(states.contains(&json!("pending"))); } +#[test] +fn project_format_schema_accepts_common_kernel_rejection_fields() { + let schema = output_schema_for_tool("project_format"); + let denied = json!({ + "success": false, + "output": { + "state_changed": false, + "execution_state": "not_started", + "failure_kind": "permission_denied", + "error_kind": "permission_denied", + "permission_reason": "session_guard", + "session_id": "wc_sess_example", + }, + "error": "Project formatting was not admitted." + }); + test_support::validate_schema_instance(&denied, &schema) + .expect("shared kernel denial metadata must fit the project_format output schema"); +} + +#[test] +fn project_format_schema_rejects_unknown_success_payload_fields() { + let schema = output_schema_for_tool("project_format"); + let unexpected = json!({ + "success": true, + "output": {"state_changed": false, "unrecognized_metadata": "not part of the formatter contract"}, + "error": null + }); + assert!(test_support::validate_schema_instance(&unexpected, &schema).is_err()); +} + #[test] fn suggested_tool_call_schema_recognizer_is_strict_and_structural() { let valid_generated = json!({ diff --git a/crates/webcodex-tool-contracts/src/tool_definition/jobs.rs b/crates/webcodex-tool-contracts/src/tool_definition/jobs.rs index abb31c945..28f121c5f 100644 --- a/crates/webcodex-tool-contracts/src/tool_definition/jobs.rs +++ b/crates/webcodex-tool-contracts/src/tool_definition/jobs.rs @@ -27,7 +27,7 @@ pub(super) const EXECUTION_DEFINITIONS: &[ToolDefinition] = &[ risk: crate::metadata::ToolRisk::ProjectWrite, approval: super::ToolApprovalPolicy::Standard, idempotency: super::ToolIdempotency::NonIdempotent }, - Some(crate::metadata::PROJECT_WRITE), true, NoPath, true, false, + Some(crate::metadata::PROJECT_WRITE), true, NoPath, true, true, super::ToolSessionEvidencePolicy::NONE), "Intentionally format 1..8 ordered Project-relative source files under one Runner-resolved recipe root. Requires project:write, job:run and project_format_v1. Rust uses bounded Cargo edition and installed rustfmt stdin/stdout with skip_children; custom configuration and unsupported topology fail closed. Python uses bounded pyproject target-version and isolated Ruff stdin/stdout; extend configuration and unsupported topology fail closed. No workspace traversal, installs, package scripts, arbitrary flags or implied network permission. Candidate bytes stay private; Runner compares source and file identity before each guarded write. A multi-file write is not atomic: failure, cancellation, timeout, conflicting edits or a lost receipt can leave mutation unknown. Known state_changed comes only from the verified executor report, never exit status. One admitted Job owns every phase; retain and observe its exact continuation when pending or uncertain. Never automatically retry or redispatch. Review source before any new mutation. Formatting does not supply the edit_project_files receipt required by Code Mode dependent validation.", ).with_execution(super::ToolExecutionContract::new(super::ToolExecutionForm::ProjectFormat, diff --git a/docs/TESTING.md b/docs/TESTING.md index d20f476b9..bea4f776b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -128,6 +128,7 @@ cargo test --locked -p webcodex-runner -p webcodex-lsp --features runner-real-pr cargo test --locked -p webcodex-process --test managed_child -- --ignored --test-threads=1 cargo test --locked -p webcodex-persistent-shell -- --ignored --test-threads=1 cargo test --locked -p webcodex --lib tool_runtime_real_process_ -- --ignored --test-threads=1 +cargo test --locked -p webcodex-runner profile_preparation_obeys_total_job_deadline -- --ignored --test-threads=1 cargo test --locked --manifest-path apps/desktop/src-tauri/Cargo.toml desktop_real_process_windows_ -- --ignored --test-threads=1 ``` diff --git a/src/tool_runtime/project_format.rs b/src/tool_runtime/project_format.rs index baefd06bf..12fc144e2 100644 --- a/src/tool_runtime/project_format.rs +++ b/src/tool_runtime/project_format.rs @@ -52,7 +52,7 @@ pub(crate) fn valid_failure_code(code: &str) -> bool { || code == "invalid_project_path") && code .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte == b'_') + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') } pub(crate) fn project_format_safe_failure_kind(job: &ShellJobInfo) -> Option<&str> { diff --git a/src/tool_runtime/tests/project_format_handoff.rs b/src/tool_runtime/tests/project_format_handoff.rs index 57c6aa7cb..9aa9115af 100644 --- a/src/tool_runtime/tests/project_format_handoff.rs +++ b/src/tool_runtime/tests/project_format_handoff.rs @@ -13,6 +13,53 @@ use webcodex_core::project_format::{ }; use webcodex_runner_registry::{JobReceiptStore, NoopRunnerRegistryTelemetry, RetainedJobReceipt}; +#[tokio::test] +async fn project_format_session_shell_guard_rejection_matches_registered_schema() { + let runtime = setup(1, true).await; + let project = agent_test_project_id(CLIENT); + let session = runtime.sessions.start_session_with_guards( + Some(project.clone()), + None, + webcodex_core::workflow_session_contract::SessionMode::Normal, + crate::tool_runtime::sessions::SessionGuards { + deny_write_tools: false, + deny_shell_tools: true, + }, + ); + let call = ToolCall::ProjectFormat { + project, + session_id: Some(session.session_id.clone()), + cwd: None, + adapter: Some(ProjectFormatAdapter::Rust), + files: vec!["src/main.rs".into()], + timeout_secs: Some(60), + }; + let result = runtime + .dispatch_with_auth(call, Some(&auth_context(None, true))) + .await; + assert!(!result.success); + assert_eq!(result.output["error_kind"], json!("session_guard_denied")); + assert_eq!(result.output["guard"], json!("deny_shell_tools")); + assert_eq!(result.output["command_started"], json!(false)); + assert!(probe_patch_agent_request(&runtime, CLIENT).await.is_none()); + let schema = crate::tool_runtime::registry::output_schema_for_tool("project_format"); + crate::tool_runtime::startup_brief::validate_schema_instance_for_test( + &serde_json::to_value(&result).unwrap(), + &schema, + ) + .expect("the real kernel shell-guard rejection must match project_format output schema"); +} + +#[test] +fn project_format_retain_utf8_failure_codes() { + assert!(super::super::project_format::valid_failure_code( + "format_invalid_utf8" + )); + assert!(super::super::project_format::valid_failure_code( + "format_output_invalid_utf8" + )); +} + const CLIENT: &str = "project-format"; #[derive(Debug, Default)] @@ -528,6 +575,12 @@ async fn project_format_missing_capability_rejects_without_dispatch() { result.output["failure_kind"], json!("capability_unavailable") ); + let schema = crate::tool_runtime::registry::output_schema_for_tool("project_format"); + crate::tool_runtime::startup_brief::validate_schema_instance_for_test( + &serde_json::to_value(&result).unwrap(), + &schema, + ) + .expect("real Runner capability denial must match project_format output schema"); assert!(probe_patch_agent_request(&runtime, CLIENT).await.is_none()); } From ddd5ce423708b25dc6155ed37327fe5fcff0962e Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:17:11 +0800 Subject: [PATCH 09/12] fix(project-format): preserve formatter child failure evidence --- .../webcodex_runner/project_format_tests.rs | 2 + .../src/webcodex_runner/shell/execution.rs | 107 ++++++++++++------ .../src/webcodex_runner/shell_tests.rs | 53 +++++++++ 3 files changed, 126 insertions(+), 36 deletions(-) diff --git a/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs b/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs index 662781375..6ac86c9f1 100644 --- a/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs @@ -1205,6 +1205,7 @@ case "$*" in exit 0 ;; *"-m ruff format"*) + cat > /dev/null printf 'formatted = True\n' exit 0 ;; @@ -1275,6 +1276,7 @@ case "$*" in exit 0 ;; *"-m ruff format"*) + cat > /dev/null echo "syntax error: unclosed parenthesis" >&2 exit 1 ;; diff --git a/crates/webcodex-runner/src/webcodex_runner/shell/execution.rs b/crates/webcodex-runner/src/webcodex_runner/shell/execution.rs index 1f50bf83b..e1883c435 100644 --- a/crates/webcodex-runner/src/webcodex_runner/shell/execution.rs +++ b/crates/webcodex-runner/src/webcodex_runner/shell/execution.rs @@ -173,20 +173,29 @@ fn execute_configured_command_impl( }, None => None, }; + let mut stdin_broken_pipe: Option = None; let mut output_bounded = false; loop { - if let Err(error) = poll_stdin_writer(&mut stdin_writer, exact_utf8) { - let cleanup = terminate_and_collect_pipes(child, drains).err(); - return ShellCommandResult::outcome_unknown(CommandResult { - exit_code: None, - stdout: None, - stderr: None, - duration_ms: Some(start.elapsed().as_millis() as u64), - error: Some(with_cleanup_error( - format!("failed to write command stdin: {error}"), - cleanup, - )), - }); + match poll_stdin_writer(&mut stdin_writer, exact_utf8) { + Ok(Some(error)) => { + if stdin_broken_pipe.is_none() { + stdin_broken_pipe = Some(error); + } + } + Ok(None) => {} + Err(error) => { + let cleanup = terminate_and_collect_pipes(child, drains).err(); + return ShellCommandResult::outcome_unknown(CommandResult { + exit_code: None, + stdout: None, + stderr: None, + duration_ms: Some(start.elapsed().as_millis() as u64), + error: Some(with_cleanup_error( + format!("failed to write command stdin: {error}"), + cleanup, + )), + }); + } } if stop_requested .map(|flag| flag.load(Ordering::SeqCst)) @@ -280,23 +289,39 @@ fn execute_configured_command_impl( } } } - if let Err(error) = finish_stdin_writer(stdin_writer, exact_utf8) { - let cleanup = terminate_and_collect_pipes(child, drains).err(); - return ShellCommandResult::outcome_unknown(CommandResult { - exit_code: None, - stdout: None, - stderr: None, - duration_ms: Some(start.elapsed().as_millis() as u64), - error: Some(with_cleanup_error( - format!("failed to write command stdin: {error}"), - cleanup, - )), - }); + match finish_stdin_writer(stdin_writer, exact_utf8) { + Ok(Some(error)) => { + if stdin_broken_pipe.is_none() { + stdin_broken_pipe = Some(error); + } + } + Ok(None) => {} + Err(error) => { + let cleanup = terminate_and_collect_pipes(child, drains).err(); + return ShellCommandResult::outcome_unknown(CommandResult { + exit_code: None, + stdout: None, + stderr: None, + duration_ms: Some(start.elapsed().as_millis() as u64), + error: Some(with_cleanup_error( + format!("failed to write command stdin: {error}"), + cleanup, + )), + }); + } } let limit_reached = Arc::clone(&drains.stdout_limit_reached); match terminate_and_collect_pipes(child, drains) { Ok((status, stdout, stderr)) => { if exact_utf8 { + if let Some(error) = stdin_broken_pipe { + if status.code() == Some(0) { + return spawned_output_failure( + start, + format!("failed to write command stdin: {error}"), + ); + } + } let code = if stdout.raw_truncated || stderr.raw_truncated || stdout.bytes.len() > policy.max_output_bytes @@ -362,26 +387,30 @@ fn execute_configured_command_impl( pub(super) fn poll_stdin_writer( receiver: &mut Option>>, exact_utf8: bool, -) -> Result<(), String> { +) -> Result, String> { let Some(active) = receiver.as_ref() else { - return Ok(()); + return Ok(None); }; match active.try_recv() { Ok(Ok(())) => { *receiver = None; - Ok(()) + Ok(None) } - Ok(Err(error)) if !exact_utf8 && error.kind() == std::io::ErrorKind::BrokenPipe => { - // The child may deliberately close stdin before exiting. Its - // terminal status and output remain the source of truth. + Ok(Err(error)) if error.kind() == std::io::ErrorKind::BrokenPipe => { *receiver = None; - Ok(()) + if exact_utf8 { + Ok(Some(error)) + } else { + // The child may deliberately close stdin before exiting. Its + // terminal status and output remain the source of truth. + Ok(None) + } } Ok(Err(error)) => { *receiver = None; Err(error.to_string()) } - Err(mpsc::TryRecvError::Empty) => Ok(()), + Err(mpsc::TryRecvError::Empty) => Ok(None), Err(mpsc::TryRecvError::Disconnected) => { *receiver = None; Err("stdin writer ended without a result".to_string()) @@ -392,13 +421,19 @@ pub(super) fn poll_stdin_writer( pub(super) fn finish_stdin_writer( receiver: Option>>, exact_utf8: bool, -) -> Result<(), String> { +) -> Result, String> { let Some(receiver) = receiver else { - return Ok(()); + return Ok(None); }; match receiver.recv_timeout(Duration::from_secs(1)) { - Ok(Ok(())) => Ok(()), - Ok(Err(error)) if !exact_utf8 && error.kind() == std::io::ErrorKind::BrokenPipe => Ok(()), + Ok(Ok(())) => Ok(None), + Ok(Err(error)) if error.kind() == std::io::ErrorKind::BrokenPipe => { + if exact_utf8 { + Ok(Some(error)) + } else { + Ok(None) + } + } Ok(Err(error)) => Err(error.to_string()), Err(mpsc::RecvTimeoutError::Timeout) => { Err("stdin writer did not finish after process exit".to_string()) diff --git a/crates/webcodex-runner/src/webcodex_runner/shell_tests.rs b/crates/webcodex-runner/src/webcodex_runner/shell_tests.rs index 45b5dd7dc..b9f442f67 100644 --- a/crates/webcodex-runner/src/webcodex_runner/shell_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/shell_tests.rs @@ -579,6 +579,59 @@ fn runner_real_process_native_search_directory_globs_and_bounds() { assert!(run(&policy, &missing_rg, &payload, None).is_none()); } +#[test] +fn exact_utf8_nonzero_child_exit_after_closed_stdin_is_known_failure() { + let cwd = tempfile::tempdir().unwrap(); + let mut command = Command::new(process_argv_helper()); + command.args(["exit", "2"]); + // More than an OS pipe buffer: the child exits without consuming stdin. + let input = "x".repeat(256 * 1024); + let result = super::execution::execute_configured_command_with_output( + &unrestricted_policy(), + command, + cwd.path(), + Some(&input), + 10, + None, + Instant::now(), + "failed to spawn format fixture", + None, + true, + ); + assert_eq!( + result.execution_state, + ShellCommandExecutionState::Completed, + "a confirmed nonzero child exit must remain a known process failure: {:?}", + result.result.error + ); + assert_eq!(result.result.exit_code, Some(2)); +} + +#[test] +fn exact_utf8_early_zero_exit_without_complete_stdin_fails_closed() { + let cwd = tempfile::tempdir().unwrap(); + let mut command = Command::new(process_argv_helper()); + command.args(["exit", "0"]); + let input = "x".repeat(256 * 1024); + let result = super::execution::execute_configured_command_with_output( + &unrestricted_policy(), + command, + cwd.path(), + Some(&input), + 10, + None, + Instant::now(), + "failed to spawn format fixture", + None, + true, + ); + assert_eq!( + result.execution_state, + ShellCommandExecutionState::OutcomeUnknown, + "a child that did not accept complete formatter stdin cannot claim success" + ); +} + #[cfg(feature = "runner-real-process-tests")] #[test] #[ignore = "real child-process timeout and cleanup; run explicitly with runner-real-process-tests"] From 971af48bf8ce3e8328dd5163cc858f7da5becf5f Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:40:38 +0800 Subject: [PATCH 10/12] fix(project-format): bound discovery cost and platform builds --- .../src/webcodex_runner/shell/execution.rs | 1 + src/mcp_tests/tools.rs | 32 ++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/webcodex-runner/src/webcodex_runner/shell/execution.rs b/crates/webcodex-runner/src/webcodex_runner/shell/execution.rs index e1883c435..7fd4701b7 100644 --- a/crates/webcodex-runner/src/webcodex_runner/shell/execution.rs +++ b/crates/webcodex-runner/src/webcodex_runner/shell/execution.rs @@ -29,6 +29,7 @@ pub(super) fn execute_configured_command( ) } +#[cfg(any(windows, all(test, feature = "runner-real-process-tests")))] #[allow(clippy::too_many_arguments)] pub(super) fn execute_configured_command_with_prefix( policy: &RunnerPolicy, diff --git a/src/mcp_tests/tools.rs b/src/mcp_tests/tools.rs index b29fe5a6b..d64c77db0 100644 --- a/src/mcp_tests/tools.rs +++ b/src/mcp_tests/tools.rs @@ -2607,6 +2607,30 @@ async fn mcp_tools_list_stateless_serialized_size_budget() { // full renderings must preserve the exact same admitted inventory. Byte // ceilings remain intentional product budgets and still catch material // discovery-surface growth. + // + // The model-visible `project_format` gateway is an intentional first-party + // inventory addition. Measuring its exact compact serialized descriptor cost + // (plus array delimiter) and bounding the marginal allowance to <= 2000 bytes + // admits the new gateway while preserving baseline product ceilings for all + // other tools, ensuring any description/schema growth beyond 2k or unrelated + // tool surface expansion still fails. + fn project_format_marginal_allowance(model_tools: &[Value]) -> usize { + let Some(tool) = model_tools + .iter() + .find(|tool| tool["name"] == "project_format") + else { + return 0; + }; + let descriptor_bytes = serde_json::to_vec(tool).unwrap().len(); + let delimiter_bytes = if model_tools.len() > 1 { 1 } else { 0 }; + let marginal_cost = descriptor_bytes + delimiter_bytes; + assert!( + marginal_cost <= 2000, + "project_format marginal cost {marginal_cost} bytes exceeds 2000 byte allowance ceiling" + ); + marginal_cost + } + for (label, auth, model_max_bytes) in [ ("anonymous", None, 75_000), ("scoped", Some(&scoped), 78_000), @@ -2658,9 +2682,15 @@ async fn mcp_tools_list_stateless_serialized_size_budget() { .filter(|definition| definition.name.ends_with("_code_mode")) .count() * 4096; + let project_format_marginal_byte_budget = if compact { + project_format_marginal_allowance(model_tools) + } else { + 0 + }; let model_byte_budget = model_max_bytes + if app_enabled { 4_000 } else { 0 } - + experimental_code_mode_byte_budget; + + experimental_code_mode_byte_budget + + project_format_marginal_byte_budget; // Raw transport includes private App bridge descriptors and Host // presentation metadata. Keep a coarse overhead ceiling so transport // cannot grow silently, but do not force model guidance to pay for it. From 2159b9cdf496340d58b184bdb28d23a35a62a86e Mon Sep 17 00:00:00 2001 From: Zhenyu Huang Date: Mon, 12 Oct 2026 12:14:08 +0800 Subject: [PATCH 11/12] fix(spreadsheet): prefer server envelopes over host projections (#1085) Co-authored-by: yyjeqhc <1772413353@qq.com> --- frontend/src/mcp-apps/spreadsheet-app.js | 45 +++++++++---- frontend/test/spreadsheet-host.test.mjs | 82 ++++++++++++++++++++++++ src/mcp_spreadsheet_app.html | 45 +++++++++---- 3 files changed, 150 insertions(+), 22 deletions(-) diff --git a/frontend/src/mcp-apps/spreadsheet-app.js b/frontend/src/mcp-apps/spreadsheet-app.js index 7b2a85900..a3783b6da 100644 --- a/frontend/src/mcp-apps/spreadsheet-app.js +++ b/frontend/src/mcp-apps/spreadsheet-app.js @@ -40,19 +40,42 @@ } return null; } + function canonicalEnvelope(value) { + return !!value && typeof value === 'object' && !Array.isArray(value) + && typeof value.success === 'boolean' + && (Object.prototype.hasOwnProperty.call(value, 'output') || Object.prototype.hasOwnProperty.call(value, 'error')); + } + function textEnvelope(candidate) { + if (!Array.isArray(candidate.content)) return null; + for (const block of candidate.content) { + if (!block || block.type !== 'text' || typeof block.text !== 'string' || block.text.length > 400000) continue; + try { + const parsed = JSON.parse(block.text); + if (canonicalEnvelope(parsed)) return parsed; + } catch (_) {} + } + return null; + } + // Hosts can project structuredContent independently of the Server envelope. + // Prefer the Server text copy, then the private Server copy, and accept a + // Host projection only when it is still a canonical success/output envelope. function envelope(result, privateKey) { - for (const candidate of [result, result?.result, result?.toolResult, result?.tool_result]) { - if (!candidate || typeof candidate !== 'object') continue; - if (typeof candidate.success === 'boolean') return candidate; - const structured = candidate.structuredContent ?? candidate.structured_content; - if (structured && typeof structured === 'object') return structured; + const candidates = [result, result?.result, result?.toolResult, result?.tool_result] + .filter(candidate => candidate && typeof candidate === 'object' && !Array.isArray(candidate)); + // Apply source precedence across every supported wrapper, not just inside + // one wrapper: an outer Host projection must not mask a nested Server error. + for (const candidate of candidates) { + if (canonicalEnvelope(candidate)) return candidate; + const text = textEnvelope(candidate); + if (text) return text; + } + for (const candidate of candidates) { const privateResult = privateKey && privateMetadata(candidate, privateKey); - if (privateResult) return privateResult; - for (const block of candidate.content || []) { - if (block.type === 'text' && typeof block.text === 'string' && block.text.length <= 400000) { - try { const parsed = JSON.parse(block.text); if (typeof parsed?.success === 'boolean') return parsed; } catch (_) {} - } - } + if (canonicalEnvelope(privateResult)) return privateResult; + } + for (const candidate of candidates) { + const structured = candidate.structuredContent ?? candidate.structured_content; + if (canonicalEnvelope(structured)) return structured; } return null; } diff --git a/frontend/test/spreadsheet-host.test.mjs b/frontend/test/spreadsheet-host.test.mjs index 696d17d3d..fc567069a 100644 --- a/frontend/test/spreadsheet-host.test.mjs +++ b/frontend/test/spreadsheet-host.test.mjs @@ -375,6 +375,88 @@ test('private identity and segment metadata survive Host wrappers and normalized } }); +test('a canonical host projection does not mask the private spreadsheet envelope', async () => { + const view = reader({ manual: true }); + await view.initialize(); + const full = result('projected'); + view.present({ + structuredContent: { success: true, output: { name: 'data.csv' } }, + _meta: full._meta, + }); + await flush(); + assert.equal(view.chunks().length, 1); + assert.equal(view.chunks()[0].params.arguments.path, 'projected.csv'); + assert.equal(view.chunks()[0].params.arguments.bytes, full._meta['webcodex/spreadsheetSource'].output.bytes); + assert.equal(view.chunks()[0].params.arguments.sha256, full._meta['webcodex/spreadsheetSource'].output.sha256); + assert.doesNotMatch(view.nodes.status.textContent, /Invalid|did not deliver/); + await view.teardown(); +}); + +test('a non-canonical host structuredContent does not mask the private spreadsheet envelope', async () => { + const view = reader({ manual: true }); + await view.initialize(); + const full = result('masked'); + view.present({ structuredContent: { projected: true }, _meta: full._meta }); + await flush(); + assert.equal(view.chunks().length, 1); + assert.equal(view.chunks()[0].params.arguments.path, 'masked.csv'); + assert.equal(view.chunks()[0].params.arguments.sha256, full._meta['webcodex/spreadsheetSource'].output.sha256); + assert.doesNotMatch(view.nodes.status.textContent, /did not deliver|Invalid/); + await view.teardown(); +}); + +test('a host structuredContent projection does not mask the artifact chunk text envelope', async () => { + const view = reader({ manual: true }); + await view.initialize(); + view.present(result('chunk')); + await flush(); + const request = view.chunks()[0]; + const chunk = segment(request, bytes); + const canonical = { success: true, output: { artifact_chunk: chunk.page } }; + await view.reply(request, { + structuredContent: { success: true, output: { artifact_chunk: { ...chunk.page, byte_offset: 1 } } }, + content: [{ type: 'text', text: JSON.stringify(canonical) }], + _meta: { 'webcodex/artifactChunk': { content_base64: chunk.encoded } }, + }); + assert.equal(view.workers.length, 1); + assert.doesNotMatch(view.nodes.status.textContent, /complete spreadsheet|did not deliver|Unable to open/); + await view.teardown(); +}); + +test('outer Host projections cannot mask a nested server chunk envelope', async () => { + for (const key of ['result', 'toolResult', 'tool_result']) { + const view = reader({ manual: true }); + await view.initialize(); + view.present(result('nested')); + await flush(); + const request = view.chunks()[0]; + const chunk = segment(request, bytes); + await view.reply(request, { + structuredContent: { success: true, output: { artifact_chunk: { ...chunk.page, byte_offset: 1 } } }, + [key]: segmentEnvelope(chunk, true), + }); + assert.equal(view.workers.length, 1, key); + await view.teardown(); + } +}); + +test('a nested server failure takes precedence over a successful Host projection', async () => { + const view = reader({ manual: true }); + await view.initialize(); + view.present(result('failure')); + await flush(); + const request = view.chunks()[0]; + const chunk = segment(request, bytes); + await view.reply(request, { + structuredContent: { success: true, output: { artifact_chunk: chunk.page } }, + _meta: { 'webcodex/artifactChunk': { content_base64: chunk.encoded } }, + toolResult: { content: [{ type: 'text', text: JSON.stringify({ success: false, error: 'Server rejected this snapshot' }) }] }, + }); + assert.equal(view.workers.length, 0); + assert.match(view.nodes.status.textContent, /Server rejected this snapshot/); + await view.teardown(); +}); + test('paged reads preserve 5 MiB across slow Host round trips with a text-only envelope', async () => { const view = reader({ textOnly: true, stepMs: 15000 }); await view.initialize(); const payload = Buffer.alloc(5 * 1024 * 1024); diff --git a/src/mcp_spreadsheet_app.html b/src/mcp_spreadsheet_app.html index 169a42717..8e2dd0dc7 100644 --- a/src/mcp_spreadsheet_app.html +++ b/src/mcp_spreadsheet_app.html @@ -645,19 +645,42 @@ } return null; } + function canonicalEnvelope(value) { + return !!value && typeof value === 'object' && !Array.isArray(value) + && typeof value.success === 'boolean' + && (Object.prototype.hasOwnProperty.call(value, 'output') || Object.prototype.hasOwnProperty.call(value, 'error')); + } + function textEnvelope(candidate) { + if (!Array.isArray(candidate.content)) return null; + for (const block of candidate.content) { + if (!block || block.type !== 'text' || typeof block.text !== 'string' || block.text.length > 400000) continue; + try { + const parsed = JSON.parse(block.text); + if (canonicalEnvelope(parsed)) return parsed; + } catch (_) {} + } + return null; + } + // Hosts can project structuredContent independently of the Server envelope. + // Prefer the Server text copy, then the private Server copy, and accept a + // Host projection only when it is still a canonical success/output envelope. function envelope(result, privateKey) { - for (const candidate of [result, result?.result, result?.toolResult, result?.tool_result]) { - if (!candidate || typeof candidate !== 'object') continue; - if (typeof candidate.success === 'boolean') return candidate; - const structured = candidate.structuredContent ?? candidate.structured_content; - if (structured && typeof structured === 'object') return structured; + const candidates = [result, result?.result, result?.toolResult, result?.tool_result] + .filter(candidate => candidate && typeof candidate === 'object' && !Array.isArray(candidate)); + // Apply source precedence across every supported wrapper, not just inside + // one wrapper: an outer Host projection must not mask a nested Server error. + for (const candidate of candidates) { + if (canonicalEnvelope(candidate)) return candidate; + const text = textEnvelope(candidate); + if (text) return text; + } + for (const candidate of candidates) { const privateResult = privateKey && privateMetadata(candidate, privateKey); - if (privateResult) return privateResult; - for (const block of candidate.content || []) { - if (block.type === 'text' && typeof block.text === 'string' && block.text.length <= 400000) { - try { const parsed = JSON.parse(block.text); if (typeof parsed?.success === 'boolean') return parsed; } catch (_) {} - } - } + if (canonicalEnvelope(privateResult)) return privateResult; + } + for (const candidate of candidates) { + const structured = candidate.structuredContent ?? candidate.structured_content; + if (canonicalEnvelope(structured)) return structured; } return null; } From e7e5329de8bc117c435843b5685ce94480d842d0 Mon Sep 17 00:00:00 2001 From: yyjeqhc <1772413353@qq.com> Date: Mon, 12 Oct 2026 12:29:19 +0800 Subject: [PATCH 12/12] fix(format): revalidate recipe authority throughout guarded writes --- .../src/webcodex_runner/project_format.rs | 33 +++++ .../webcodex_runner/project_format_tests.rs | 120 ++++++++++++++++++ .../project-format-lifecycle.md | 2 +- .../tests/project_format_handoff.rs | 87 ++++++++++++- 4 files changed, 238 insertions(+), 4 deletions(-) diff --git a/crates/webcodex-runner/src/webcodex_runner/project_format.rs b/crates/webcodex-runner/src/webcodex_runner/project_format.rs index 0bb4304a7..a66979e10 100644 --- a/crates/webcodex-runner/src/webcodex_runner/project_format.rs +++ b/crates/webcodex-runner/src/webcodex_runner/project_format.rs @@ -633,6 +633,35 @@ pub(crate) fn fence( Ok(()) } +/// Reuse the canonical bounded planner to refresh authority and recipe facts. +/// Earlier writes in this same batch legitimately change file witnesses, so +/// compare context separately; each file retains its original compare-and-write +/// fence and the final pass verifies the complete candidate set. +fn revalidate_commit_context( + policy: &RunnerPolicy, + registry: &Path, + expected: &ProjectFormatPlan, +) -> Result<(), &'static str> { + let current = plan(policy, registry, &expected.request).map_err(|_| "format_plan_stale")?; + let ProjectFormatPlan { + request, + recipe_root, + root_digest, + manifest_digest, + profile, + files: _, + } = ¤t.plan; + if request != &expected.request + || recipe_root != &expected.recipe_root + || root_digest != &expected.root_digest + || manifest_digest != &expected.manifest_digest + || profile != &expected.profile + { + return Err("format_plan_stale"); + } + Ok(()) +} + /// Every error means unknown aggregate mutation, even after an earlier write. /// There is deliberately no rollback or second write attempt. pub(crate) fn commit_candidates( @@ -688,6 +717,8 @@ pub(super) fn commit_candidates_with_hook( for (index, (source, candidate)) in planned.sources.iter().zip(candidates).enumerate() { boundary(index, false); check_stop()?; + revalidate_commit_context(policy, registry, &planned.plan)?; + check_stop()?; let witness = &planned.plan.files[index]; if root_digest(&planned.root)? != planned.plan.root_digest { return Err("format_plan_stale"); @@ -756,6 +787,8 @@ pub(super) fn commit_candidates_with_hook( if root_digest(&planned.root)? != planned.plan.root_digest { return Err("format_observation_failed"); } + revalidate_commit_context(policy, registry, &planned.plan)?; + check_stop()?; Ok(if changed { ProjectFormatMutationReport::Changed } else { diff --git a/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs b/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs index 6ac86c9f1..ff6dd8c11 100644 --- a/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/project_format_tests.rs @@ -697,6 +697,126 @@ fn project_format_pf09_candidate_commit_detects_source_and_manifest_conflicts() } } +#[test] +fn project_format_commit_rechecks_context_before_each_file() { + for revoke_permission in [false, true] { + let fixture = Fixture::new(); + fs::write(fixture.root.join("src/other.rs"), "fn other( ){ }\n").unwrap(); + let mut request = fixture.request(); + request.files.push("src/other.rs".into()); + let planned = plan(&fixture.policy, &fixture.registry, &request).unwrap(); + let result = super::project_format::commit_candidates_with_hook( + &fixture.policy, + &fixture.registry, + &planned, + &["fn main() {}\n".into(), "fn other() {}\n".into()], + std::time::Instant::now() + std::time::Duration::from_secs(10), + &std::sync::atomic::AtomicBool::new(false), + |index, after| { + if index == 0 && after { + if revoke_permission { + fixture.register(false); + } else { + fs::write( + fixture.root.join("Cargo.toml"), + MANIFEST.replace("2021", "2024"), + ) + .unwrap(); + } + } + }, + ); + assert_eq!( + result, + Err("format_plan_stale"), + "revoked={revoke_permission}" + ); + assert_eq!( + fs::read_to_string(fixture.root.join("src/main.rs")).unwrap(), + "fn main() {}\n" + ); + assert_eq!( + fs::read_to_string(fixture.root.join("src/other.rs")).unwrap(), + planned.sources[1] + ); + } +} + +#[test] +fn project_format_commit_rechecks_context_before_known_receipt() { + let fixture = Fixture::new(); + let planned = fixture.plan(); + let result = super::project_format::commit_candidates_with_hook( + &fixture.policy, + &fixture.registry, + &planned, + &["fn main() {}\n".into()], + std::time::Instant::now() + std::time::Duration::from_secs(10), + &std::sync::atomic::AtomicBool::new(false), + |_, after| { + if after { + fixture.register(false); + } + }, + ); + assert_eq!(result, Err("format_plan_stale")); +} + +#[test] +fn project_format_commit_rechecks_python_manifest_between_writes() { + let fixture = Fixture::new(); + fs::remove_file(fixture.root.join("Cargo.toml")).unwrap(); + let manifest = "[tool.ruff]\ntarget-version='py311'\n"; + fs::write(fixture.root.join("pyproject.toml"), manifest).unwrap(); + fs::write(fixture.root.join("main.py"), "x=1\n").unwrap(); + fs::write(fixture.root.join("other.py"), "y=2\n").unwrap(); + let mut request = fixture.request(); + request.files = vec!["main.py".into(), "other.py".into()]; + let planned = plan(&fixture.policy, &fixture.registry, &request).unwrap(); + let result = super::project_format::commit_candidates_with_hook( + &fixture.policy, + &fixture.registry, + &planned, + &["x = 1\n".into(), "y = 2\n".into()], + std::time::Instant::now() + std::time::Duration::from_secs(10), + &std::sync::atomic::AtomicBool::new(false), + |index, after| { + if index == 0 && after { + fs::write( + fixture.root.join("pyproject.toml"), + manifest.replace("py311", "py312"), + ) + .unwrap(); + } + }, + ); + assert_eq!(result, Err("format_plan_stale")); + assert_eq!( + fs::read_to_string(fixture.root.join("other.py")).unwrap(), + "y=2\n" + ); +} + +#[test] +fn project_format_commit_accepts_its_own_previous_file_writes() { + let fixture = Fixture::new(); + fs::write(fixture.root.join("src/other.rs"), "fn other( ){ }\n").unwrap(); + let mut request = fixture.request(); + request.files.push("src/other.rs".into()); + let planned = plan(&fixture.policy, &fixture.registry, &request).unwrap(); + let candidates = ["fn main() {}\n".into(), "fn other() {}\n".into()]; + assert_eq!( + commit(&fixture, &planned, &candidates), + Ok(ProjectFormatMutationReport::Changed) + ); + for (file, candidate) in planned.plan.files.iter().zip(&candidates) { + assert_eq!( + fs::read_to_string(fixture.root.join(&file.path)).unwrap(), + *candidate + ); + } +} + #[test] fn project_format_pf10_partial_write_and_failed_observation_stay_unknown() { use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/docs/implementation/project-format-lifecycle.md b/docs/implementation/project-format-lifecycle.md index dcbedf1bd..036968790 100644 --- a/docs/implementation/project-format-lifecycle.md +++ b/docs/implementation/project-format-lifecycle.md @@ -11,7 +11,7 @@ The tool is an intentional source **Mutate** operation with non-idempotent dispa ## Trusted execution - Runner resolves exact registered Project and nearest unambiguous recipe root, refuses cross-root, symlink and nonregular inputs; filenames have length/count bounds, files have per-file and total-byte caps, and inputs stay private. -- Runner computes a closed plan from project/cwd/adapter/selected files, bounded manifest/config, exact fixed command choices and content witnesses. The plan and files are revalidated at admission and immediately before worker execution. Any stale plan is rejected before writes. +- Runner computes a closed plan from project/cwd/adapter/selected files, bounded manifest/config, exact fixed command choices and content witnesses. The plan and files are revalidated at admission and immediately before worker execution. Each selected file and the final known-result boundary also revalidate current Project write permission, recipe ownership, root identity, and manifest/profile provenance through the same bounded planner. Context comparison excludes file witnesses legitimately changed by earlier writes in the same batch; original per-file compare-and-write and final candidate verification remain separate. Changed context stops further writes and leaves a partial batch unknown. - Rust path: selected files only; run trusted installed `rustfmt` on **stdin**, with `--emit=stdout`, fixed edition from bounded local Cargo manifest and `skip_children=true`; project custom rustfmt configs and unsupported source/manifest topology are unavailable unless an exact bounded configuration rule is independently proven. This is **not** workspace `cargo fmt`. - Python path: selected files only; use existing Runner-authorized isolated Python interpreter and installed Ruff module. Run `python -I -B -m ruff format --no-cache --config pyproject.toml --stdin-filename -` with bounded UTF-8 stdin/stdout; inherit the existing Ruff manifest requirements (local `[tool.ruff]`, explicit target-version, no `extend`) and environment sanitization, with no installs/fallback. Validate actual tool version/behavior before adding backend support. Treat stderr failures/oversized output as unknown and never write. - The native formatters generate **candidate bytes**, never edit project files directly. Compare exact original bytes: identical candidate -> known no-op. If changed, Runner performs guarded compare-and-write to each exact selected file, verifying file type, canonical path and source content immediately before commit. Multi-file write is not falsely described as atomic; on a partial failure, interrupted write, changed path/contents, timeout or lost receipt, mutation truth becomes **unknown**, not false/success. Only complete verified outcomes return `state_changed=true|false`. diff --git a/src/tool_runtime/tests/project_format_handoff.rs b/src/tool_runtime/tests/project_format_handoff.rs index 9aa9115af..5eaf391a8 100644 --- a/src/tool_runtime/tests/project_format_handoff.rs +++ b/src/tool_runtime/tests/project_format_handoff.rs @@ -63,15 +63,27 @@ fn project_format_retain_utf8_failure_codes() { const CLIENT: &str = "project-format"; #[derive(Debug, Default)] -struct MemoryReceiptStore(std::sync::Mutex>); +struct MemoryReceiptStore { + rows: std::sync::Mutex>, + fail_writes: std::sync::atomic::AtomicBool, +} impl JobReceiptStore for MemoryReceiptStore { fn upsert(&self, receipt: &RetainedJobReceipt) -> Result<(), String> { - self.0.lock().unwrap().push(receipt.clone()); + if self.fail_writes.load(std::sync::atomic::Ordering::SeqCst) { + return Err("injected receipt write failure".into()); + } + let mut rows = self.rows.lock().unwrap(); + if !rows + .iter() + .any(|row| row.snapshot.job_id == receipt.snapshot.job_id) + { + rows.push(receipt.clone()); + } Ok(()) } fn load(&self, _now: i64) -> Result, String> { - Ok(self.0.lock().unwrap().clone()) + Ok(self.rows.lock().unwrap().clone()) } fn prune(&self, _now: i64) -> Result<(), String> { Ok(()) @@ -88,6 +100,10 @@ async fn setup(grace_ms: u64, with_store: bool) -> ToolRuntime { } else { RunnerRegistry::default() }; + setup_with_registry(grace_ms, registry).await +} + +async fn setup_with_registry(grace_ms: u64, registry: RunnerRegistry) -> ToolRuntime { let runtime = ToolRuntime::new(Arc::new(registry), Arc::new(RuntimeInfo::default())) .with_structured_execution_sync_wait(std::time::Duration::from_millis(grace_ms)); register_agent( @@ -805,6 +821,71 @@ async fn project_format_invalid_arguments_reject_without_dispatch() { assert!(probe_patch_agent_request(&runtime, CLIENT).await.is_none()); } +#[tokio::test] +async fn project_format_receipt_failure_stays_unknown_and_recovery_never_redispatches() { + use std::sync::atomic::Ordering; + for report in [ + ProjectFormatMutationReport::Changed, + ProjectFormatMutationReport::Unchanged, + ] { + let store = Arc::new(MemoryReceiptStore::default()); + store.fail_writes.store(true, Ordering::SeqCst); + let registry = RunnerRegistry::with_job_receipt_store( + Arc::new(NoopRunnerRegistryTelemetry), + store.clone(), + ) + .await; + let runtime = setup_with_registry(1, registry).await; + let task = tokio::spawn({ + let runtime = runtime.clone(); + async move { + runtime + .dispatch_with_auth(call(), Some(&auth_context(None, true))) + .await + } + }); + let (request, job_id) = reply_plan(&runtime).await; + assert_eq!(task.await.unwrap().output["execution_state"], "pending"); + finish_format_job( + &runtime, + &request, + &job_id, + "completed", + ShellCommandExecutionState::Completed, + Some(0), + None, + Some(report), + ) + .await; + let unknown = runtime.runner_registry.get_job(&job_id).await.unwrap(); + assert_eq!( + unknown.format_mutation.unwrap_or_default().state_changed(), + None + ); + assert!(store.rows.lock().unwrap().is_empty()); + assert!(probe_patch_agent_request(&runtime, CLIENT).await.is_none()); + + store.fail_writes.store(false, Ordering::SeqCst); + // Only the existing persistence candidate is retried at unlock. The + // first view can precede that commit; the next view sees its durable fact. + runtime.runner_registry.get_job(&job_id).await.unwrap(); + let known = runtime.runner_registry.get_job(&job_id).await.unwrap(); + assert_eq!(known.format_mutation, Some(report)); + assert!(probe_patch_agent_request(&runtime, CLIENT).await.is_none()); + assert_eq!(store.rows.lock().unwrap().len(), 1); + + let restored = RunnerRegistry::with_job_receipt_store( + Arc::new(NoopRunnerRegistryTelemetry), + store.clone(), + ) + .await; + let historical = restored.get_job(&job_id).await.unwrap(); + assert_eq!(historical.format_mutation, Some(report)); + assert_eq!(historical.status, "completed"); + assert!(restored.list_runners().await.is_empty()); + } +} + #[tokio::test] async fn project_format_without_receipt_store_fails_closed() { let runtime = setup(500, false).await;