Skip to content

Windows runner (experimental) - #18

Merged
lydakis merged 27 commits into
mainfrom
claude/project-thread-0eo80l
Oct 1, 2026
Merged

lydakis merged 27 commits into
mainfrom
claude/project-thread-0eo80l

Conversation

@lydakis

@lydakis lydakis commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Requested by George · project thread

Before: errand builds only for macOS and Linux. A Windows PC can't be a runner.

After: a Windows 10/11 PC runs errand.exe setup and takes jobs from a Mac over Tailscale. errand --on winpc -- cargo test streams logs and returns the real exit code, Ctrl-C ends the whole job tree, and changed files come back with their Mac file modes. Releases include Windows zips. It is marked experimental and documented in docs/WINDOWS.md.

Tested on a real Windows 11 ARM64 machine (a VM, driven from a Mac over Tailscale):

  • A clean errand.exe setup with no manual steps, an upgrade to a second build, and a re-run of setup with the same build all exit 0 with the runner task running.
  • 30 jobs across three runner starts, mixing cmd /c ver with file round trips: exit codes come back, new files return as 0644, and a modified 0755 script keeps its mode.
  • Earlier rounds on the same machine: exit code 7 passes through, Ctrl-C on ping -t leaves nothing running, .cmd shims run and a % argument is refused, the installed binary can be replaced while the runner runs, and the runner stops at sign-out and comes back at sign-in.

What's in it

  • Build and file primitives. Windows versions of file identity (volume serial + file index), locking (LockFileEx), directory sync, no-replace rename and owner checks, in small packages (filelock, durable, fsowner, fsidentity) so shared code stays platform-neutral.
  • Jobs. Each job runs in a Job Object: it starts suspended, joins the job, then resumes, so every descendant is contained and killed together. Programs are found with PATHEXT, jobs get the Windows environment programs need, and .bat/.cmd arguments that cmd.exe would reinterpret are refused.
  • Local socket auth. AF_UNIX peers are checked by process token SID instead of uid.
  • Setup. errand setup registers a Task Scheduler task that starts at sign-in and runs a retained copy of errand.exe, so replacing the installed binary never fights a running daemon. New Windows runners default to tailnet only. Tailscale discovery also looks in Program Files.
  • Service runtime. The task's runner detaches from the console Task Scheduler gives it, with its standard handles moved to the log and NUL, and writes crashes and stop events to its log. A restarted runner waits briefly for the old one's state lock.
  • File modes. NTFS has no POSIX modes. On Windows, files read as 0644 (0444 when read-only), directories as 0755, and mode checks compare only the write bit (internal/fsmode). Collected results keep each path's submitted mode, so exec bits survive and only real edits come back. The errand_logicalmodes build tag exercises this path on Unix.
  • Unsafe names. On Windows, manifests with backslashes, colons, device names (CON, aux.c), trailing dots or spaces, or GIT~N are refused, as are symlink targets with backslashes or drives.
  • Off for now on Windows: named caches, SSH transport, graceful Ctrl-C, restart recovery, and the errand client.
  • Release. GoReleaser builds windows amd64/arm64 zips. The release workflow uploads them and checks each one contains errand.exe.

Unix behavior is unchanged. The Unix paths moved behind small per-OS files (processScope, fsmode, serviceruntime, unixpeer), with the same logic. Two shared fixes apply everywhere: retained trees and corrupt cache blobs are deleted only after their handles close.

CI. The Windows job runs the full tests of every package that supports Windows, plus OnWindows tests elsewhere, including a full job round trip through the daemon and a detached-console runner under constant garbage collection. Other packages' tests still assume Unix fixtures (/bin/sh, /tmp) or client code that isn't ported yet.

Known follow-up: if a runner dies mid-job, the client waits instead of failing quickly.

How: see docs/WINDOWS.md for the user-facing behavior. The plan is in the project thread.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No

First step toward a Windows runner. GOOS=windows now builds and vets,
with real Windows implementations for the primitives the runner needs:

- fsidentity: volume serial and file index, the identity os.SameFile uses
- filelock: LockFileEx behind one API, replacing direct flock calls
- durable: directory syncs skip Windows directories, which can't be flushed
- renameNoReplace: handle-relative NtSetInformationFile, like renameat2
- fsowner: owner SID check in place of the uid check
- proctree: Job Objects; jobs and runtime probes start suspended, join a
  job, then resume, so every descendant is contained and killed together

Unix behavior is unchanged: the process-group code moves behind small
processScope methods. Unix-only tests are tagged or skip on Windows, and
CI gains a windows-2025 job that vets, builds and tests the packages
with Windows support so far.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No
@lydakis lydakis self-assigned this Sep 30, 2026
Comment thread internal/daemon/placement_unix.go Fixed
- Executable lookup applies PATHEXT, so "cargo" runs cargo.exe and
  "npm" runs npm.cmd, and accepts either slash in relative paths.
- Jobs get the Windows environment programs rely on (profile, temp,
  system and program folders, PATHEXT, ComSpec), and environment names
  match case-insensitively.
- Arguments cmd.exe would reinterpret when running a .bat or .cmd file
  are refused with a clear error instead of running something else.
- Job cleanup gives the console host a moment to exit on its own.

Tests: Windows-only tests end in OnWindows and run in CI; the gitignore
oracle skips names Windows can't create; the Job Object test allows a
console host in the job.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No
Comment thread internal/daemon/job_exec_unix.go Fixed
Windows AF_UNIX sockets report the peer's PID (SIO_AF_UNIX_GETPEERPID).
The peer's token user SID is compared with this process's, so the
daemon and its client still only trust the same account. Windows has no
numeric uids, so the UID field only records whether the SIDs matched,
and the account name comes from the SID.

Also keeps executableFile where it was in placement.go, now Unix-only,
with the shared placement code in placement_facts.go.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No
Comment thread internal/daemon/placement_facts.go Fixed
Comment thread internal/daemon/placement_facts.go Fixed
Comment thread internal/daemon/placement_facts.go Fixed
serviceruntime keeps a retained errand.exe copy on Windows and checks
ownership instead of POSIX modes. unixpeer.ConnectionRefused recognizes a
stale AF_UNIX socket on Windows (WSAECONNREFUSED) as well as Unix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No
errand setup on Windows registers a task that starts at sign-in and runs
the retained runtime copy with serve --log-file, so replacing errand.exe
never fights a running daemon. Setup stops a running task before
re-registering it, verifies an answering runner is the task's process, and
refuses to replace a task definition it did not write without --force.
New Windows runners default to the tailnet transport because SSH callers
are not supported there yet. Tailscale discovery also looks in Program
Files, and doctor knows the task definition's path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No
- fsmode: NTFS has no POSIX modes, so Windows reads files as 0644 (0444
  when read-only) and directories as 0755, and mode checks compare only
  the write bit. Collected results inherit each path's submitted mode,
  so exec bits from a Mac survive a job and only real edits come back.
  The errand_logicalmodes build tag exercises this path on Unix.
- archive: on Windows, reject paths and symlink targets Windows would
  read differently (backslashes, colons, device names, trailing dots or
  spaces, GIT~N).
- Named caches are off on Windows runners for now.
- Release: GoReleaser builds windows amd64/arm64 zips, the release
  workflow uploads and checks them, RELEASING.md describes them.
- docs/WINDOWS.md covers install, setup and how Windows jobs differ.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No
The named cache store refused its root because Windows directories report
mode 0777, so the daemon could not start on Windows. Private now means
owned by the current user there, as it does for the runtime directory.
A Windows-only daemon test runs a cmd.exe job from a client snapshot and
checks its exit code and retained changes. The temporary CI survey now
prints failure messages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No
The setup tests drive a fake system with Unix paths, including the
Windows task decisions, so they run on Unix. Two runtime tests rely on
POSIX modes or exec and skip on Windows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No
- Change-base capture closes its handle on the staging tree before
  renaming it; Windows refuses to rename a directory with an open handle.
- Staged directories are flushed through durable.Sync, which skips the
  directory flush Windows can't do.
- placement.go is shared again and matches main except for the per-OS
  calls, so code scanning doesn't report its existing lines as new.
- serviceruntime and setup tests run in the Windows job.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No

lydakis commented Sep 30, 2026

Copy link
Copy Markdown
Owner Author

github-advanced-security ("Code scanning AI findings") is red on this PR, and that isn't caused by this change. That job is GitHub's hosted Copilot autofind agent, and it fails on GitHub's side on every errand PR, including ones that are already merged. The regular CodeQL check on 37cadcb passes and reports no new alerts. I can't re-run the job from here (the API returns 403), and no fix exists on our side. I'll keep watching the PR until the rest of CI is green.


Generated by Claude Code

Windows refuses to delete a file or directory while a handle to it is
open. Two runner paths deleted through a handle they still held:

- changes.RemoveTree removed the tree's root while its os.Root was open.
  That broke startup recovery of interrupted change collection and
  cleanup of failed workspace uploads. It now empties the tree through
  the root, closes it, re-checks the root's identity and removes it.
- The blob cache deleted a corrupt blob while its read handle was open,
  so the blob stayed and every later job re-read it. It now closes the
  handle first and compares identities captured from the handle, not a
  path-based FileInfo that Windows resolves lazily.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No
Comment thread internal/changes/access.go Fixed
The Windows release step (empty through the root, close it, re-check the
root's identity) moves to remove_windows.go. RemoveTree keeps main's
removal by path, which on Windows now finds an empty, released directory.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No
Testing setup on a Windows 11 ARM VM found four failures:

- A fresh install stopped before creating any state: dialing the
  runner's socket in a directory that does not exist yet reports
  WSAENETDOWN, not a missing file. It now counts as no listener.
- An administrator's token without UAC filtering makes
  BUILTIN\Administrators the owner of everything it creates, so errand's
  own runtime and cache directories failed the owner check. Files owned
  by the token's default owner now count as the user's.
- Verifying the scheduled task runs PowerShell, which takes 2-3 seconds
  to start, inside the probe's 2 second deadline. It now has its own
  30 second budget, and doctor's service check gets 10 seconds.
- On upgrade, the new runner started before the stopped one released
  its state lock. A Windows runner now waits up to 10 seconds for it.

The runner also once exited with status 2 and nothing in its log. With
--log-file, fatal runtime errors now go to the log, and a Windows runner
logs the console event that stops it instead of exiting silently.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No
A runner started by Task Scheduler detaches from its console, which
closes the console's handles. Two things still named them: the standard
handles, and the *os.File values serve replaced with the log file. When
the garbage collector finalized those files, it closed whatever objects
had reused the handle values since. On a Windows 11 VM that crashed the
runner with "netpoll failed" during its second job after every start,
which was also the unexplained exit status 2.

The replaced files now stay reachable, and after detaching, the standard
handles point at the log and NUL.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No
The survey listed which packages failed on Windows while the runner was
built. The archive package now passes, so it joins the supported list;
the rest still assume Unix fixtures and are covered by their OnWindows
tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No
@lydakis
lydakis marked this pull request as ready for review October 1, 2026 00:41
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

lydakis and others added 6 commits October 1, 2026 01:02
A Windows runner has no console of its own, so every console program it
starts gets a new visible window. The tailscale CLI runs for each request
to identify the caller, so the desktop showed a Windows Terminal window
flashing for every peers call, job and file transfer.

nowindow.Hide starts a program with CREATE_NO_WINDOW. The tailscale CLI
calls and git merge-file use it, and proctree.Prepare now shares it.
Client commands on Windows got as far as submitting, then failed on a
Unix-only state permission check. They now stop at once and point to
docs/WINDOWS.md; runner commands are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No
Comment thread internal/archive/archive.go Fixed
Comment thread internal/archive/archive.go Fixed
claude and others added 6 commits October 1, 2026 02:21
The directory-link change turned the existing os.Symlink line into an
else branch, which CodeQL reported as two new path alerts. Windows links
now return early, so that line and its context are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No
The workspace version of this test failed on Windows CI: the client's
local state check reads POSIX modes, which Windows reports as writable by
everyone. The Windows client isn't supported yet, so the end-to-end check
now runs as a job, and an archive test covers a pushed link whose target
is known only from the complete manifest.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No
The runner rejects job environment variables without provenance, so the
submission failed with 400 before the PATHEXT case ran.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No
@lydakis
lydakis merged commit b53ca13 into main Oct 1, 2026
8 of 9 checks passed
@lydakis
lydakis deleted the claude/project-thread-0eo80l branch October 1, 2026 03:35
@lydakis
lydakis restored the claude/project-thread-0eo80l branch October 1, 2026 03:35
@lydakis
lydakis deleted the claude/project-thread-0eo80l branch October 1, 2026 03:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants