diff --git a/.github/scripts/tests/runner-windows-workflow.test.mjs b/.github/scripts/tests/runner-windows-workflow.test.mjs new file mode 100644 index 00000000000..e3ed39d0695 --- /dev/null +++ b/.github/scripts/tests/runner-windows-workflow.test.mjs @@ -0,0 +1,48 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const workflow = readFileSync(new URL("../../workflows/runner-windows.yml", import.meta.url), "utf8"); + +function pathFilter(event) { + const block = workflow.split(`\n ${event}:\n`)[1]?.split(/\n \S/)[0]; + assert.ok(block, `${event} trigger is missing`); + return [...block.matchAll(/^\s+- (\S+)$/gm)].map(match => match[1]); +} + +test("the Windows Runner lane runs on pull requests and master for every Runner input", () => { + const pullRequest = pathFilter("pull_request"); + const push = pathFilter("push"); + assert.deepEqual(push, pullRequest); + assert.match(workflow, /push:\s*\n\s*branches: \[master\]/); + for (const path of [ + ".github/workflows/runner-windows.yml", + "packages/paperclip-runner/rust-toolchain.toml", + "packages/paperclip-runner/runner/**", + "packages/paperclip-runner/protocol/**", + ]) assert.ok(pullRequest.includes(path), `missing path filter ${path}`); +}); + +test("the Windows Runner lane builds and tests the locked workspace on hosted Windows x64", () => { + assert.match(workflow, /runs-on: windows-2025\n/); + assert.match(workflow, /shell: bash\n/); + assert.match(workflow, /run: \|\n\s+rustup toolchain install\n/); + assert.match(workflow, /run: >-\n\s+cargo test --release --manifest-path runner\/Cargo.toml --locked --workspace --no-fail-fast --\n\s+--exact --skip /); + assert.doesNotMatch(workflow, /continue-on-error|if: always\(/); +}); + +test("the Windows Runner lane skips only exact named tests", () => { + const skipped = [...workflow.matchAll(/--skip (\S+)/g)].map(match => match[1]); + assert.deepEqual(skipped, ["acpx_provider_backend::tests::binds_sidecar_paths_arguments_and_contents_to_the_runner_profile"]); + assert.match(workflow, /--exact --skip /); +}); + +test("the Windows Runner lane is read-only and keeps its own master-written cache", () => { + assert.match(workflow, /^permissions: \{\}$/m); + assert.match(workflow, /permissions:\n\s+contents: read\n/); + assert.doesNotMatch(workflow, /: write\b|secrets\.|pull_request_target/); + assert.match(workflow, /persist-credentials: false/); + for (const [, ref] of workflow.matchAll(/uses: \S+@(\S+)/g)) assert.match(ref, /^[0-9a-f]{40}$/); + assert.match(workflow, /shared-key: runner-windows-v1\n/); + assert.match(workflow, /save-if: \$\{\{ github.event_name == 'push' \}\}\n/); +}); diff --git a/.github/workflows/runner-windows.yml b/.github/workflows/runner-windows.yml new file mode 100644 index 00000000000..d8a308edd7e --- /dev/null +++ b/.github/workflows/runner-windows.yml @@ -0,0 +1,77 @@ +name: Runner Windows + +# Builds and tests the native Runner on Windows x64. This is a standalone +# workflow because pr.yml runs pr-trusted.yml from master: a lane added there +# would not run on the pull request that adds or changes it. +on: + pull_request: + paths: + - .github/workflows/runner-windows.yml + - packages/paperclip-runner/rust-toolchain.toml + - packages/paperclip-runner/acpx-profiles.json + - packages/paperclip-runner/antigravity-distributions.json + - packages/paperclip-runner/runner/** + - packages/paperclip-runner/protocol/** + - packages/paperclip-runner/test-fixtures/** + - packages/paperclip-runner/test/fixtures/** + - tests/runner-e2e/fixtures/** + push: + branches: [master] + paths: + - .github/workflows/runner-windows.yml + - packages/paperclip-runner/rust-toolchain.toml + - packages/paperclip-runner/acpx-profiles.json + - packages/paperclip-runner/antigravity-distributions.json + - packages/paperclip-runner/runner/** + - packages/paperclip-runner/protocol/** + - packages/paperclip-runner/test-fixtures/** + - packages/paperclip-runner/test/fixtures/** + - tests/runner-e2e/fixtures/** + workflow_dispatch: + +permissions: {} + +concurrency: + group: runner-windows-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + rust: + name: Runner Rust (Windows x64) + runs-on: windows-2025 + timeout-minutes: 60 + permissions: + contents: read + defaults: + run: + shell: bash + working-directory: packages/paperclip-runner + env: + CARGO_TERM_COLOR: always + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + # rust-toolchain.toml pins the channel; install it before rust-cache keys on it. + - name: Install pinned Rust toolchain + run: | + rustup toolchain install + rustup show active-toolchain + # The Linux lanes' caches cannot serve an MSVC build, so this lane keeps its own. + - name: Cache Rust build + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: packages/paperclip-runner/runner -> target + shared-key: runner-windows-v1 + # Restore only on pull requests. Never let one evict master's entry. + save-if: ${{ github.event_name == 'push' }} + # Release mode matches the Linux lane (test:rust); some recovery tests + # have deadlines that unoptimized builds miss on hosted runners. + # The skipped test needs verified process launch, which Windows does not + # have yet. Its file is a Grok profile identity source, so a Windows + # ignore attribute there would change that identity. + - name: Test + run: >- + cargo test --release --manifest-path runner/Cargo.toml --locked --workspace --no-fail-fast -- + --exact --skip acpx_provider_backend::tests::binds_sidecar_paths_arguments_and_contents_to_the_runner_profile diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 525549b23b0..84f96da59df 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -311,7 +311,8 @@ installation journey or a paid provider task on ARM64. Automatic creation selects legacy execution when a Linux host has musl, unknown libc, or a measured GNU libc version below the baseline. Existing agents retain their stored runner. Windows, 32-bit ARM, and Alpine/musl native qualification -remain deferred. +remain deferred. The `Runner Windows` workflow builds and tests the Rust +workspace on Windows x64. It does not qualify Windows agent runs. Native release jobs cache Cargo dependencies per platform and OS baseline, including compiler, lockfile, and external generated-contract inputs. Workspace diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs index 089a85f73dd..fd211ab9a4d 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs @@ -671,10 +671,15 @@ mod tests { args.extend(options.into_iter().map(str::to_owned)); assert!(metadata_with_provider_packs(&args).is_err()); } + let missing_root = if cfg!(windows) { + r"C:\missing-provider-pack" + } else { + "/missing-provider-pack" + }; let metadata = metadata_with_provider_packs(&[ "--build-metadata".to_owned(), "--provider-pack-root".to_owned(), - "/missing-provider-pack".to_owned(), + missing_root.to_owned(), ]) .unwrap(); assert_eq!(metadata["binaryContractVersion"], 2); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs index 50f00d18adf..7670e86b943 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs @@ -4874,6 +4874,7 @@ done } #[test] + #[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")] fn verified_opencode_proxy_executes_the_descriptor_safe_commonjs_bundle() { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs index a0703c45580..437f44f4b69 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs @@ -454,6 +454,7 @@ impl VerifiedProcessLaunch { } } + #[cfg(any(target_os = "linux", target_os = "macos"))] pub(crate) fn spawn_stdio( &self, environment: &std::collections::BTreeMap, diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs index 09368feb54a..581e9de1ac5 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs @@ -6970,7 +6970,12 @@ fn explicit_skill_input_survives_durable_turn_and_cold_restore() { executor.shutdown().unwrap(); drop(executor); let mut restored = CodexCommandExecutor::with_runner_config(&directory, &runner_config); - let skill = json!({"type":"skill", "name":"first-task", "path":"/materialized/skills/first-task/SKILL.md"}); + let skill_path = if cfg!(windows) { + r"C:\materialized\skills\first-task\SKILL.md" + } else { + "/materialized/skills/first-task/SKILL.md" + }; + let skill = json!({"type":"skill", "name":"first-task", "path":skill_path}); restored .execute(&command( "turn", diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index 01e84bf02e8..7be46b23307 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -558,6 +558,7 @@ fn shutdown_recovered_acpx_fixture(executor: &mut NativeProviderCommandExecutor, } #[test] +#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")] fn acpx_response_delivery_survives_crash_before_journaling_without_replaying_the_response() { for mode in [ "permissions-interactive", @@ -633,6 +634,7 @@ fn acpx_response_delivery_survives_crash_before_journaling_without_replaying_the } #[test] +#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")] fn acpx_delivered_response_is_acknowledged_once_before_session_close() { for mode in ["permissions-interactive", "resolutions"] { let directory = temporary_directory("response-ack"); @@ -662,6 +664,7 @@ fn acpx_delivered_response_is_acknowledged_once_before_session_close() { } #[test] +#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")] fn acpx_uncommitted_delivery_expires_after_failed_state_write_without_replay() { for mode in ["permissions-interactive", "resolutions"] { let directory = temporary_directory("response-save-failure"); @@ -743,6 +746,7 @@ fn acpx_uncommitted_delivery_expires_after_failed_state_write_without_replay() { } #[test] +#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")] fn acpx_failed_settlement_ack_replays_the_same_delivered_event_without_expiry() { for mode in ["permissions-interactive", "resolutions"] { let directory = temporary_directory("response-ack-save-failure"); @@ -808,6 +812,7 @@ fn acpx_failed_settlement_ack_replays_the_same_delivered_event_without_expiry() } #[test] +#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")] fn acpx_uncommitted_expiry_and_cancellation_cannot_escape_through_poll_or_drain() { for (mode, close) in [ ("permissions-wrong-ack", false), @@ -879,6 +884,7 @@ fn acpx_uncommitted_expiry_and_cancellation_cannot_escape_through_poll_or_drain( } #[test] +#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")] fn acpx_failed_or_cancelled_responses_retain_one_durable_settlement() { for (mode, close) in [ ("permissions-wrong-ack", false), @@ -937,6 +943,7 @@ fn acpx_failed_or_cancelled_responses_retain_one_durable_settlement() { } #[test] +#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")] fn preserves_acpx_semantic_disposition_in_the_run_terminal() { let directory = temporary_directory("acpx-blocked"); let config = acpx_config(&directory, "turns-reserved-block-terminal"); @@ -1072,6 +1079,7 @@ fn preserves_managed_provider_descriptors_through_the_native_selector() { } #[test] +#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")] fn executes_a_qualified_acpx_profile_through_the_native_selector() { let directory = temporary_directory("acpx"); let config = acpx_config(&directory, "turns-reserved-result-terminal"); @@ -1131,6 +1139,7 @@ fn executes_a_qualified_acpx_profile_through_the_native_selector() { } #[test] +#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")] fn routes_reserved_completion_feedback_with_the_full_controller_catalog() { let directory = temporary_directory("acpx-controller-completion"); let mode = "turns-reserved-feedback-roundtrip"; @@ -1215,6 +1224,7 @@ fn routes_reserved_completion_feedback_with_the_full_controller_catalog() { } #[test] +#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")] fn acpx_transport_failure_retains_its_error_without_reopening_the_provider() { let directory = temporary_directory("acpx-transport-failure"); let mode = "turns-malformed-activity"; @@ -1276,6 +1286,7 @@ fn acpx_transport_failure_retains_its_error_without_reopening_the_provider() { } #[test] +#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")] fn provider_death_expires_input_and_commits_failure_without_querying_the_dead_provider() { let directory = temporary_directory("acpx-provider-death"); let mode = "turns-provider-death"; @@ -1381,6 +1392,7 @@ fn provider_death_expires_input_and_commits_failure_without_querying_the_dead_pr } #[test] +#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")] fn resumes_an_idle_acpx_session_in_a_cold_replacement_runner() { let directory = temporary_directory("acpx-cold-idle-recovery"); let config = acpx_config(&directory, "turns-reserved-result-terminal"); @@ -1432,6 +1444,7 @@ fn resumes_an_idle_acpx_session_in_a_cold_replacement_runner() { } #[test] +#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")] fn keeps_native_acpx_semantic_events_on_the_durable_controller_turn() { let directory = temporary_directory("acpx-durable-turn-correlation"); let config = acpx_config(&directory, "turns-tool"); @@ -1481,6 +1494,7 @@ fn keeps_native_acpx_semantic_events_on_the_durable_controller_turn() { } #[test] +#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")] fn starts_a_distinct_acpx_provider_turn_for_same_run_recovery() { let directory = temporary_directory("acpx-same-run-recovery"); let config = acpx_config(&directory, "turns-reserved-result-terminal"); @@ -1537,6 +1551,7 @@ fn starts_a_distinct_acpx_provider_turn_for_same_run_recovery() { } #[test] +#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")] fn executes_opencode_through_the_local_facade_without_codex_event_labels() { let directory = temporary_directory("opencode"); let config = opencode_config(&directory); @@ -1605,6 +1620,7 @@ fn executes_opencode_through_the_local_facade_without_codex_event_labels() { } #[test] +#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")] fn replacement_shutdown_restores_the_persisted_provider_before_cleanup() { let directory = temporary_directory("opencode-replacement-shutdown"); let config = opencode_config(&directory); @@ -1730,6 +1746,7 @@ fn rejects_pi_without_an_explicit_model_before_starting_a_sidecar() { } #[test] +#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")] fn pi_thinking_change_cannot_attach_to_an_existing_provider_identity() { let directory = temporary_directory("pi-thinking-attach"); let config = pi_acpx_config(&directory, "bootstrap"); @@ -1770,6 +1787,7 @@ fn pi_thinking_change_cannot_attach_to_an_existing_provider_identity() { } #[test] +#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")] fn stops_an_idle_pi_provider_before_suspension_without_waiting_for_its_close_rpc() { let directory = temporary_directory("pi-idle-suspension"); let mut config = pi_acpx_config(&directory, "bootstrap"); @@ -1818,6 +1836,7 @@ fn stops_an_idle_pi_provider_before_suspension_without_waiting_for_its_close_rpc } #[test] +#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")] fn idle_pi_stop_cannot_publish_a_reusable_identity_while_its_lifetime_fence_is_held() { let directory = temporary_directory("pi-idle-held-lifetime"); // Linux can assign port 0 below the identity contract's dynamic-port range. @@ -1886,6 +1905,7 @@ fn idle_pi_stop_cannot_publish_a_reusable_identity_while_its_lifetime_fence_is_h } #[test] +#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")] fn publishes_only_changed_pi_controls_before_the_new_turn() { for (mode, initially_available, turn_available) in [ ("controls-lazy", false, true),