Skip to content

feat(fleet): macOS VM backend — darwin jobs in disposable guests via arcbox-daemon (RUN-31)#360

Draft
PeronGH wants to merge 107 commits into
masterfrom
feat/run-31-runner-vm-provisioning
Draft

feat(fleet): macOS VM backend — darwin jobs in disposable guests via arcbox-daemon (RUN-31)#360
PeronGH wants to merge 107 commits into
masterfrom
feat/run-31-runner-vm-provisioning

Conversation

@PeronGH

@PeronGH PeronGH commented Jul 3, 2026

Copy link
Copy Markdown
Member

Stacked on #354 and #359

This branch is cut from a merge of both dependency branches; only the last 5 commits are this PR's content. Once #354 and #359 land (repo uses rebase-merge), this will be rebased with git rebase --onto origin/master 0c0ac40d and marked ready — the diff then collapses to just the fleet VM backend.

What

The fleet agent runs each darwin job in a disposable macOS guest, consuming arcbox-daemon as a plain gRPC client over its existing socket. No new daemon RPCs: the design (settled after discussion, superseding the RUN-31 issue text) is SSH-exec — the runner is baked into the base image, the agent sshes in and runs run.sh --jitconfig …, the session's exit is job completion, and Remove(force) is teardown and cancellation.

  • vm_mode (auto|enabled|disabled, restart-scoped) + macos_runner_image (target/current) settings across proto/config/state/CLI
  • vm.rs: daemon probe (ImageList + image installed + crashed-agent fleet-* sweep), Create → Start (the daemon's two-guest license cap is the admission signal) → Inspect-poll for the DHCP-lease address → russh password auth (admin/admin, the runner-image contract; guests are host-only reachable via vmnet NAT) → exec
  • Routing: vm capability replaces host_runner for darwin/arm64 when active; run_vm_job mirrors the Docker backend's cancellation discipline, plus a 6.5 h runtime watchdog; vm-backend GetAgentInfo feature flag
  • FleetImageService.Prepare relays the daemon's ImagePull stream for IMAGE_KIND_MACOS_RUNNER_IMAGE and promotes on completion

Verification

  • 74 unit + 1 control-socket integration test, clippy zero warnings workspace-wide
  • End-to-end against a live daemon with the locally-baked tahoe-base@2026.07.02: provision → ssh exec → exit 0 → destroy in 9.7 s, no leftover guests (vm_round_trip_boots_and_execs_over_ssh, ignored; needs a daemon + pulled image)

Closes RUN-31.

Copilot AI review requested due to automatic review settings July 3, 2026 10:16
@linear-code

linear-code Bot commented Jul 3, 2026

Copy link
Copy Markdown

RUN-31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@PeronGH PeronGH force-pushed the feat/run-31-runner-vm-provisioning branch from 2d7e51f to c2a8777 Compare July 7, 2026 02:53
@pullfrog

pullfrog Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

The check suite failed on Step 6: Check formatting (cargo fmt --check). The only violation was in app/arcbox-daemon/src/startup/pipeline.rs:180 — the --no-linux-vm startup println! exceeded the line width and needed to be split across multiple lines. Applied the rustfmt-suggested formatting, verified locally with cargo fmt --check (clean), and pushed to the PR branch.

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

Copilot AI review requested due to automatic review settings July 7, 2026 02:56
@PeronGH PeronGH requested review from Copilot and removed request for Copilot July 7, 2026 02:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings July 8, 2026 05:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings July 9, 2026 00:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings July 9, 2026 09:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@PeronGH

PeronGH commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

Update — changes on this branch since the PR was opened

Draft is still stacked on PR #354 (fleet unenroll — merged) and PR #359. Once #359 lands I'll rebase --onto master 0c0ac40d and mark ready. Summary below groups the 15 commits added since 2026-07-03.

macOS guest support (merged in from feat/macos-guest-vz)

  • fd71279e — harden macOS guest lifecycle against path traversal and create/pull races
  • 892f5c6c — align image pull with the schema-2 chunked CDN layout
  • 3172451e--no-linux-vm on arcbox-daemon for VM-host-only mode
  • 4c5aa32a — merge feat/macos-guest-vz

RUN-31 proper: VM backend in the fleet agent

  • 8a63f1af — new vm_mode and macos_runner_image settings on the agent
  • ef1d72dbVmRunner: probes the local daemon, drives ephemeral macOS-guest job lifecycle
  • 04d0cf6d — darwin jobs now route through the VM backend when available
  • 1a7252e7Prepare for macos_runner_image streams through the daemon's ImagePullService
  • c2a87779 — ignored end-to-end test that boots a real macOS guest against a live daemon

Field ops fixes discovered running the agent against the real gateway

  • eabbaf77 — default gateway pointed at gateway.fleet.arcbox.dev
  • 847894fe — synced vendored gateway proto; the server now acks each heartbeat with Keepalive (PLAT-34: Cloudflare would otherwise idle-cut server→client)
  • 4097ed43 — client sends its first heartbeat before waiting on the Attach response headers (same PLAT-34 story from the client side)
  • e955a0cb — rustfmt on a daemon startup message

Release infrastructure

  • cef3a225 — CI builds and ships arcbox-fleet-agent binaries for macOS arm64 + Linux x86_64/arm64 (paired with an xtask packaging step). Tarballs uploaded as GitHub Release assets alongside the desktop bundle.

Gateway wire redesign (just pushed as f4b000f7, paired with platform PR #112)

Reshapes the handshake around agent build identity:

  • EnrollRequest.protocol_version (uint32) → EnrollRequest.agent_version (semver string).
  • EnrollResponse becomes oneof { Enrolled | AgentUpdate }.
  • Attach handshake moves from x-arcbox-protocol-version metadata to an in-band Attach { agent_version, capabilities, host_info_json } message. AttachResponse gains AttachAccepted, AttachRejected(AgentUpdate), and HeartbeatAck (renamed from Keepalive).
  • Heartbeat shrinks to just HostTelemetry — the only field that actually varies over time. Capabilities and host_info are per-process constants and are declared once on the Attach handshake.
  • On version drift the gateway returns a structured AgentUpdate { expected_version }; the client parks (analogous to the RUN-40 credential-rejection park) instead of tight-looping.
  • AgentUpdate is the same message on both EnrollResponse and AttachResponse, so the future push-update payload (tarball_url, tarball_sha256) grows in one place.

Wire-incompatible on purpose — fleet agent is unshipped, and the paired platform PR pins the wire.

@PeronGH PeronGH force-pushed the feat/run-31-runner-vm-provisioning branch from f4b000f to 0dc1471 Compare July 9, 2026 09:50
Copilot AI review requested due to automatic review settings July 9, 2026 09:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@PeronGH PeronGH force-pushed the feat/run-31-runner-vm-provisioning branch from 0dc1471 to 3c61b1c Compare July 9, 2026 09:55
Copilot AI review requested due to automatic review settings July 9, 2026 09:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings July 9, 2026 10:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

AprilNEA added 6 commits July 10, 2026 10:52
MacOSBootLoader, MacPlatform, and the MacHardwareModel / MacMachineIdentifier /
MacAuxiliaryStorage sub-configurations, plus NSData FFI helpers for the data
representation round-trip. Reuses the existing VirtualMachineConfiguration and
device builders unchanged. Slice 1 of PLAN.md (macOS guest VMs).
MacOSRestoreImage (latest_supported / load_from_url + requirements) backed by a
generic (id, NSError*) completion block, and an examples/macos_validate.rs that
builds and validates a macOS VM configuration end to end. Part of slice 2 of
PLAN.md; unblocks Gate A on signed Apple Silicon.
fetchLatestSupportedWithCompletionHandler: and loadFileURL:completionHandler:
(verified against the framework headers). Gate A now passes on Apple Silicon:
fetch latest restore image -> requirements -> build macOS config -> validate.
MacOSInstaller wraps VZMacOSInstaller (initWithVirtualMachine:restoreImageURL:,
installWithCompletionHandler: reusing the (NSError*) state block) with progress
polling via NSProgress.fractionCompleted. VirtualMachine gains pub(crate) as_ptr
and dispatch_sync so the install runs on the VM's queue. examples/macos_install.rs
builds a macOS config with a fresh system disk, installs from a local IPSW, then
boots to Running (Gate B). Completes slice 2 of PLAN.md.
MacOSRestoreImage::url() returns the IPSW URL (remote for a fetched latest image,
file URL for a loaded one), plus examples/macos_latest_url.rs to print it. Used to
obtain an IPSW for the install flow.
PeronGH added 11 commits July 10, 2026 13:31
New restart-scoped vm_mode (auto|enabled|disabled, mirroring docker_mode)
and prepare-scoped macos_runner_image settings across the control proto,
env config, persisted settings, observable state, validation, and CLI.
Groundwork for the macOS VM backend; nothing consumes them yet.
…, ssh exec

New vm.rs consuming arcbox-daemon's MacosService over its gRPC socket:
startup probe (ImageList answers + runner image installed + leftover
fleet-* sweep), per-job Create/Start, DHCP-lease address wait via Inspect,
runner exec over SSH with the image-contract admin login (russh — password
auth rules out the OpenSSH binary), and Remove(force) as the single
teardown/cancellation primitive with a drop-guard panic net. Routing lands
in the next commit.
vm_mode-gated startup probe (init_vm, mirroring init_docker), the vm
capability replacing host_runner for the native pair when active, run_vm_job
with docker-equivalent cancellation discipline plus a 6.5h runtime watchdog,
Option<VmRunner> threaded through both supervisors, and a vm-backend
GetAgentInfo feature flag.
FleetImageService gains the macOS kind: Prepare relays the daemon's
streamed pull progress and promotes the target on completion; empty
requests expand to every host-supported kind (Linux everywhere, macOS on
macOS hosts). Same promote-when-runtime-absent semantics as the Docker
branch. CLI prepare accepts macos-runner-image; docs cover the fleet
consumption path.
Provision, ssh-exec /usr/bin/true, observe exit 0, destroy — the manual
verification harness for the VM backend. The guest command becomes an
internal parameter of start() to make the cycle exercisable without a
real runner invocation.
The prior default host fleet.arcbox.dev does not resolve; the public
gateway ingress is gateway.fleet.arcbox.dev. Make it the built-in
default so enroll/run work without an ARCBOX_FLEET_GATEWAY override.
…are (PLAT-34)

The gateway now acks every heartbeat with a no-op Keepalive down the
Attach response stream, so a proxy in front of it (Cloudflare) sees
server->client traffic and never idle-times-out the stream. Sync the
vendored proto and handle the new variant explicitly in dispatch.
…ponse

The agent only started heartbeating after receiving the gateway's
response headers, and the gateway only acks a heartbeat once it
receives one — so neither side ever sent a byte first. The connection
sat fully idle until a load balancer in front reset it on its own idle
timeout, delivering the response headers bundled with that reset.

Start heartbeating before the request is even sent so the agent always
speaks first. Confirmed against the live deployment: the previous
build failed identically whether Cloudflare was in front of the
gateway or not (same load balancer, same ~60s idle timeout, either
way); this build holds the stream open indefinitely and a real offer
was dispatched, accepted, and ran to completion.

spawn_heartbeat now returns an AbortOnDropHandle so its task is
cleaned up on every exit path, not just the one this file's original
call site happened to reach.
Adds macOS arm64 (signed) and Linux x86_64/arm64 (native musl) build
jobs, each packaged into its own arcbox-fleet-agent-<platform> tarball
and attached to the GitHub Release alongside the desktop tarball.
Vendored proto synced with platform PR #112: EnrollRequest carries
agent_version (dropping protocol_version), EnrollResponse is a oneof
(Enrolled | AgentUpdate). AttachRequest carries a first-message Attach
{ agent_version, capabilities, host_info_json }; AttachResponse gains
AttachAccepted, AttachRejected(AgentUpdate), and HeartbeatAck (rename
of Keepalive). Heartbeat trims to just telemetry — the only field that
actually changes over time; capabilities and host_info are per-process
constants and belong on the Attach handshake.

connect_and_serve now sends the Attach message as its first outbound
(still speaks-first for PLAT-34) with the full declarative payload,
waits for AttachAccepted, and only then starts the heartbeat loop.
AttachRejected surfaces as an AgentUpdateRequired error that the
reconnect loop parks on (analogous to the RUN-40 credential-rejection
park). enroll() branches on EnrollResponse.result and surfaces the
expected version on rejection instead of a stringly-typed error.

Deletes the x-arcbox-protocol-version metadata header and the
PROTOCOL_VERSION const on both sides of the wire.
… macOS

New `install-service` / `uninstall-service` subcommands render a
LaunchAgent plist against the invoking binary path and the agent's
data-dir log path, write it to `~/Library/LaunchAgents/`, and drive
`launchctl bootstrap` / `bootout`. No sudo, no static plist file to
ship, no manual `launchctl` steps for the operator.

The plist runs `arcbox-fleet-agent serve` at login (RunAtLoad),
respawns only on non-zero exit (KeepAlive.SuccessfulExit=false — a
`launchctl bootout` SIGTERM lets the agent drain runners and exit
cleanly without getting resurrected), and tees launchd's own
stdout/stderr into the same log dir the agent's logger uses so
early-startup output before `arcbox_logging::init` still lands next
to the real logs.

Uninstall is idempotent — a `bootout` with the job not loaded and a
missing plist file both count as success, since the desired end
state is "not installed."

macOS-only for this cut. Linux systemd (user unit) is the natural
follow-up; the CLI shape stays uniform (subcommand exists everywhere,
bails with a clear message on non-macOS) so docs and completions
don't diverge.
@PeronGH PeronGH force-pushed the feat/run-31-runner-vm-provisioning branch from e0682dd to 530b242 Compare July 10, 2026 05:34
Copilot AI review requested due to automatic review settings July 10, 2026 05:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

PeronGH added 2 commits July 10, 2026 14:59
upload/download-artifact v4 strips permissions — every CI artifact
arrives 0644, and the tar builder records on-disk modes, so shipped
tarballs (desktop and fleet) contained non-executable binaries.
Stage binaries through a shared chmod-0755 helper in both packagers;
bundle plist/entitlements files stay 0644. Regression test packages a
0644 input and asserts the tar entry mode.
The platform's fleet vocabulary is GOOS-style everywhere — proto
Capability.arch, RunnerArch, the dashboard's MachineArch — all say
arm64|amd64. The CI slug linux-x86_64 was the only x86_64 in the
system and would force a permanent mapping shim once the gateway
starts constructing AgentUpdate.tarball_url from (os, arch). Rename
to linux-amd64 before the first fleet tarball ships; the Rust build
target is unchanged.
Copilot AI review requested due to automatic review settings July 10, 2026 07:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

PeronGH added 5 commits July 10, 2026 15:28
The agent's version is a wire-level contract (the gateway pins one
expected agent_version at a time), so inheriting the workspace version
meant every desktop release invalidated the entire fleet even with
zero fleet changes. fleet/ becomes its own release-please component
(tags fleet-agent-vX.Y.Z, starting at 0.5.0 to stay disjoint from the
desktop 0.4.x line) and is excluded from the root package, so desktop
releases no longer churn agent_version and fleet hotfixes no longer
ride the desktop release train. The crate is publish = false, so the
coordinated crates.io publish is unaffected.
…kflow

release-fleet-agent.yml builds on fleet-agent-v* tags: signed
darwin-arm64 plus native linux-amd64/arm64 musl builds, published as
bare binaries named arcbox-fleet-agent-<os>-<arch> with .sha256 files
(make_latest: false keeps the desktop release as the repo's latest).
The download URL is now a pure function of (tag, host_os, host_arch) —
the exact shape the gateway will emit in AgentUpdate.binary_url.

The agent ships unarchived because the artifact is a single file: the
checksum covers exactly the bytes the self-updater executes, and the
extraction/exec-bit/inner-directory ceremony of a tarball bought
nothing. xtask package-fleet-tarball becomes release fleet-asset; the
exec-bit regression test moves to package_tarball, where the desktop
tarball still needs it. Fleet jobs leave release.yml.
Vendored fleet.proto synced with the platform auto-update branch:
AgentUpdate gains binary_url/binary_sha256, HeartbeatAck gains a
mid-stream update payload, and Enroll/Attach carry the agent's own
host_os/host_arch (distinct from capabilities — a darwin host serving
linux jobs still needs the darwin binary). The agent now reports its
platform on both handshakes so the gateway can resolve the download
for it.
update.rs implements download → sha256-verify → probe → swap → re-exec.
The gateway-pinned checksum is the integrity root, and the --version
probe of the downloaded binary is the loop-breaker: a mis-registered
release can never install, so the agent can never exec itself into an
update cycle. The swap is two renames (never a write over the live
path), keeping .prev for forensics; exec preserves argv and the PID,
so launchd bookkeeping is undisturbed.

Self-update only manages binaries it owns: install-service now installs
the invoking binary into <data_dir>/bin/arcbox-fleet-agent and points
the plist there, so updates never invalidate the service definition.
Unmanaged binaries (dev builds) refuse with a typed error and park.

An enrollment refused for version mismatch now carries the pushed
download: the CLI enroll path self-updates and re-execs into a retry
on the new build (the join token is multi-use).
The attach loop now feeds every AgentUpdate — AttachRejected at the
handshake or the new HeartbeatAck.update pushed when the pin moves
mid-stream — into one path: drain (no new offers; in-flight jobs and
unacked verdicts settle while the stream stays live), then swap and
re-exec via the update executor. Payloads without a download keep the
existing park behavior, as do unmanaged binaries.

The update drain is a separate supervisor flag from the operator
drain, so a moot update (the pin moved back to this build before the
swap) resumes without cancelling a deliberate local drain. The control
API reports the new ENROLLMENT_UPDATING as Draining.
Copilot AI review requested due to automatic review settings July 10, 2026 08:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread .github/workflows/release-fleet-agent.yml Fixed
Comment thread .github/workflows/release-fleet-agent.yml Fixed
Comment thread .github/workflows/release-fleet-agent.yml Fixed
Ports 9e99c78 from feat/fleet-agent-release (PR #381 review): every
checkout pins the resolved tag and the workflow declares minimal
GITHUB_TOKEN permissions. Keeps this branch's copy identical for the
post-#381 rebase.
Copilot AI review requested due to automatic review settings July 10, 2026 09:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.

4 participants