fix(runner): build on Windows and add a Windows CI lane (#18250)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip Runner (`packages/paperclip-runner`) runs agent sessions
through a native Rust daemon, `paperclip-runnerd`
> - The daemon builds only for macOS and Linux. On Windows x64
(`x86_64-pc-windows-msvc`), the build stops with one compile error
> - No CI lane builds the daemon on Windows, so new Unix-only code can
break the Windows build again without notice
> - This pull request fixes the compile error and adds a Windows CI lane
for the Rust workspace. It is the first of a series of small pull
requests that port the Runner to Windows
> - The benefit is a Windows build that stays green. Each later pull
request in the series can then prove its Windows behavior in CI

## Linked Issues or Issue Description

Related: https://github.com/paperclipai/paperclip/issues/18142 and
https://github.com/paperclipai/paperclip/pull/18144 port the server and
the development scripts to native Windows. That work does not touch the
Rust Runner.

**What happened?**
On Windows x64 with the pinned Rust 1.97.1 MSVC toolchain, `cargo build
--manifest-path packages/paperclip-runner/runner/Cargo.toml --locked
--workspace` fails with `error[E0599]: no method named inherited_command
found` in `process_supervisor.rs`. `VerifiedProcessLaunch::spawn_stdio`
calls `inherited_command`, but that method exists only on Linux and
macOS.

**Expected behavior**
The Runner workspace builds on Windows x64. Features that do not have a
Windows implementation yet fail with a clear error at run time. They do
not stop the build.

**Steps to reproduce**
1. Use a Windows x64 host with Visual Studio 2022 Build Tools and
rustup.
2. Check out `master`.
3. Run `cargo build --manifest-path
packages/paperclip-runner/runner/Cargo.toml --locked --workspace`.

**Paperclip version or commit**
`master` at a8e07500db

## What Changed

- `process_supervisor.rs`: compile `VerifiedProcessLaunch::spawn_stdio`
only on Linux and macOS, the same as `inherited_command`. Its only
caller, the stdio MCP proxy, already returns an explicit error on
non-Unix hosts. Linux and macOS behavior does not change.
- New workflow `.github/workflows/runner-windows.yml`: on
`windows-2025`, it installs the pinned toolchain and runs `cargo test
--release --locked --workspace --no-fail-fast` for changes to Runner
Rust inputs. Release mode matches the Linux lane (`test:rust`). In a
debug build on a hosted runner, one recovery test missed its 5-second
deadline. It is a standalone workflow because `pr.yml` runs
`pr-trusted.yml` from `master`, so a lane added there does not run on
the pull request that adds it. The lane has read-only permissions, uses
SHA-pinned actions, and keeps its own Rust cache that only `master`
writes.
- New policy test
`.github/scripts/tests/runner-windows-workflow.test.mjs` for the
workflow triggers, commands, skip list, permissions, and cache.
- 22 tests need verified process launch, which does not exist on Windows
yet. 21 of them are now `#[cfg_attr(windows, ignore = "verified launch
is not supported on Windows yet")]`. The attribute has no effect on
Linux or macOS. The last one is in `acpx_provider_backend.rs`, which is
a Grok profile identity source file. An attribute there would change the
Grok profile identity, so the Windows lane skips that test by exact name
(`--exact --skip`). The policy test fixes the skip list to that one
name. The next pull requests in the series implement verified launch and
remove the attributes and the skip.
- Two test fixtures used Unix-only absolute paths
(`/missing-provider-pack` and `/materialized/skills/...`). These are not
absolute on Windows. On Windows, the fixtures now use a drive-letter
path. The Unix fixtures do not change.

## Verification

- On Windows x64 (Rust 1.97.1 MSVC), the lane's exact release-mode test
command passes: 686 passed, 0 failed, 23 ignored, and 1 test skipped by
name. The test that timed out in a debug build on the hosted runner
takes 3.6 seconds in release mode and 29.5 seconds in debug mode on a
local machine. The ignored tests are the 21 Windows-only ignores and 2
tests that are ignored on every platform. A partial name does not match
with `--exact`, so the skip cannot hide other tests.
- `cargo fmt --manifest-path packages/paperclip-runner/runner/Cargo.toml
--all -- --check` passes.
- `node packages/paperclip-runner/scripts/generate-acpx-profiles.mjs
--check` and `node
packages/paperclip-runner/scripts/grok-profile-identity.mjs --check`
pass. The Grok profile identity does not change.
- `node --test .github/scripts/tests/runner-windows-workflow.test.mjs`
passes.
- The Linux and macOS lanes in this pull request's CI confirm that Unix
behavior does not change.
- The new "Runner Windows" check runs on this pull request.

## Risks

- Low risk. The Rust changes are `cfg` attributes and test fixtures.
Linux and macOS compile the same code as before.
- The new lane adds one Windows job to pull requests that change Runner
Rust inputs. A cold build takes longer than a cached build. After the
first `master` run, pull requests restore the cache.
- The 22 ignored or skipped tests do not run on Windows until verified
launch exists. They still run on Linux and macOS.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with
tool use (shell, file edits, local builds and tests) and extended
thinking.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Devin Foley authored and GitHub committed 2026-10-30 18:22:47 -07:00
1 parent 493e229d93
commit cdf843371b
8 files changed
+161 -3

No files matched your search

@@ -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/);
});
+77
View File
@@ -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
+2 -1
View File
@@ -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
@@ -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);
@@ -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)
@@ -454,6 +454,7 @@ impl VerifiedProcessLaunch {
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn spawn_stdio(
&self,
environment: &std::collections::BTreeMap<String, String>,
@@ -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",
@@ -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),