Native CI workers: macOS, Linux and Windows hosts
Enroll a Mac, Linux box or Windows machine to run CI targets as native processes, with signed requests, trust rules, preflight and safe drain and revoke.
Last updated
What a native worker is
A native worker is a machine that runs CI targets as ordinary host processes, with no container around them. It can be the Mac you work on, a spare Linux box on your network, a VM, or (later) a Windows machine. Every worker, including the one on your own laptop, enrolls the same way and speaks the same signed protocol, so the local worker gets no shortcut and no weaker checks.
Native workers run CI jobs only. They never choose or run the AI model that writes code for a mission.
Native execution is not a sandbox. A job runs with the operating-system privileges of the account that runs it. Orgabot gives every job its own workspace and a scrubbed environment, which prevents accidents, but a hostile program can still do anything that account can do. That is why untrusted code never lands on a persistent native host automatically, and why your own machine only runs work you approved as trusted when you enrolled it.
Who may run where
Orgabot classifies every job from what it observed about the source, never from anything the job says about itself:
| Source | Trust |
|---|---|
| A fork, or an author who is not a maintainer of the repository | Untrusted |
| A maintainer, in the repository itself | Trusted |
| Trusted, on a protected ref, and the target signs or deploys | Privileged |
The placement rules follow from that:
- Untrusted work never runs on a persistent native host. There is no flag or approval that changes this. It may run only on a disposable, dedicated worker (a VM thrown away after the job), or on another backend such as a GitHub-hosted runner.
- A disposable worker runs exactly one job. It never claims a second one, at the same time or later. When that job finishes or is lost, the controller revokes the worker's key and the agent deletes its local identity and exits. Deleting a workspace does not make a host trustworthy again after untrusted code ran on it, so destroy and reprovision the VM before you mint a new token and enroll it again.
- A developer host (someone's own machine) runs only trusted work, and only when the enrollment token said
--trusted-work. Signing on it also needs--privileged-work. - A worker runs only for the organization it enrolled into and only for the projects it was granted. Being on the LAN, or on localhost, grants nothing.
Where provenance comes from
When you submit or preflight a job, name the source on the forge and Orgabot reads the facts from GitHub itself, as the App installation for that repository:
orgabot native-worker submit job.json --pr 42 # a pull request
orgabot native-worker submit job.json --branch main # a branch in the repository- Pull request. The head and base repository come from the pull request, its head commit must be exactly the job's commit, and the author counts as a maintainer only with admin, maintain or write permission on the repository. A pull request head is never a protected ref, and a pull request from a deleted fork still counts as a fork.
- Branch. The branch must contain the job's commit, and its protection setting decides whether a signing job may get a privileged identity.
- Repository. It is taken from the target's GitHub URL. For a target fetched from a local path, pass
--repo owner/name; a forge reference for a different repository than the one the worker fetches is refused. The repository must also be the one registered for the job's project: the organization must actively map the project, and the project's registered checkout must have that repository as its GitHuborigin. A job for one project that points at a trusted commit in some other repository is refused before GitHub is read, and so is a job whose project has no registered repository.
- Commands. Provenance vouches for a commit, so the commands must come from that commit too. The target's definition (steps, environment, toolchains, platform requirements and limits) must be committed at
.orgabot/native-targets/<target-id>.jsonin the repository, and the job file's definition must match the one at the job's commit exactly. A job file that pairs a trusted commit with different steps or environment is refused.
If GitHub cannot be read, or any fact does not match, the job is refused. A job file's own provenance block is never enough on its own. To run a job you wrote by hand, for example on your own Mac from a local checkout, pass --operator-attests: you are vouching for it, including every command and environment variable in it. The CLI lists the commands you are vouching for, and the job record says the provenance was operator-attested rather than observed and carries a digest of the exact target you attested. Whether a target needs a signing identity always comes from the target itself, so an attestation cannot switch that off.
Enroll a worker
On the controller (the machine running Orgabot), mint a one-time token. The token carries everything the worker will be allowed to do; the worker cannot widen any of it.
orgabot native-worker token senternet --project premail,weatherling \
--host-kind developer --persistence persistent --trusted-work --ttl 15mThe token is printed once and expires (15 minutes by default, 24 hours at most). Start the endpoint workers call:
orgabot native-worker serve # loopback only, for a worker on this same machine
orgabot native-worker serve --host 192.168.1.10 --tls-cert cert.pem --tls-key key.pem # LAN or VM workersBinding anything other than a loopback address requires TLS. For serve, loopback means a literal 127.x.x.x or ::1 address: a host name, localhost included, needs TLS, because a name can resolve to a LAN interface. The worker enforces the same rule from its side: it refuses to enroll with, or talk to, a controller over plain http:// unless the address is loopback (localhost, 127.0.0.1, ::1). Every other controller must be https://, and its certificate is verified (pass --ca for a private one). On the worker host, pass the token through the environment or stdin (never as an argument, so it stays out of shell history and process listings) and enroll:
ORGABOT_NATIVE_WORKER_TOKEN=owe_... orgabot native-worker enroll https://192.168.1.10:7480 \
--name lab-mac --job-root /Volumes/ci/orgabot-jobs --ca controller-ca.pem
orgabot native-worker runEnrollment generates an Ed25519 key pair on the worker. The private key never leaves that host (it is stored with mode 0600), and the controller keeps only the public key. Every later request is signed over its method, path, time, a one-time nonce and a digest of its body, so a captured request cannot be replayed or altered.
How a worker stays connected
The worker only ever connects out to the controller. It opens no listening port, so there is nothing on the worker for anyone to send commands to. It heartbeats every 30 seconds with a fresh capability probe, claims jobs the controller already placed under policy, and reports results.
- Version compatibility. Each request states its protocol version. A worker on an unsupported version is refused with a message saying which side to upgrade, and it stops rather than guessing.
- Restart. Every agent start has a new boot id. When the controller sees one, any job the previous process held is released, and the new process removes the workspaces and temporary keychains its predecessor left behind.
- Offline. A job whose worker stops heartbeating for two minutes is marked lost. Pure verification work is re-queued (twice in total by default). A job that signs or notarizes is never retried automatically, because whether its side effect happened is unknown, so you decide.
- Late reports. A result from a worker that no longer holds the job's lease changes nothing.
Capability probing and preflight
A worker advertises only what a command on that host proved. Saying a machine has Xcode is not enough: the probe runs xcodebuild -version. A failed probe is listed as an error, and the capability is simply absent.
Architecture is reported as what the host CPU is, even when the agent itself runs under Rosetta. Each job then records how its requested architecture was actually met:
| Mode | Meaning |
|---|---|
| native | The host CPU is that architecture |
| emulated | Binaries run through a translator (Rosetta 2, qemu) |
| cross | A toolchain targets that architecture; the output is not run |
| universal | A macOS fat binary covering arm64 and x86_64 |
A target accepts native only unless it lists other modes, and a target is never silently run on a different architecture. Preflight runs on the controller before placement and again on the worker before the first step, so a toolchain that vanished in between is caught before anything executes. Check a target against your workers without running it:
orgabot native-worker preflight job.json --branch main --policy policy.jsonmacOS
The macOS preflight checks the Xcode version and installed SDKs, universal or single-architecture output, Rosetta for x86_64 emulation, notarytool and a stored notarization profile name, a logged-in GUI session for UI tests, and required hardware such as Apple silicon.
Signing never uses your login keychain. When a target signs, the worker creates a temporary keychain inside the job's own directory with a random password held only in memory, imports the approved identity into it, and restores your keychain search list exactly as it was. When the job ends, by success, failure, timeout or cancellation, the keychain is deleted, the worker verifies the file is gone, and it verifies the search list and default keychain match what it recorded at the start. If any of that is not clean the job fails, even when every step passed. If no approved identity was delivered to the job, it fails rather than sign with something else.
Windows
The Windows probe looks for MSVC build tools (through vswhere), Rust and its installed targets, Node, WiX and NSIS, the WebView2 runtime that Tauri needs, signtool, and any cloud signing profile you declared with --signing-profile. No Windows hardware is needed to start: hosted Windows stays the route until you enroll a compatible worker.
Move a target between backends by policy
A target's definition does not change when it moves. Only its ordered backend policy does:
{ "order": [{ "kind": "native_worker" }, { "kind": "github_hosted", "runner": "windows-latest" }], "onNoEligible": "fail" }The first eligible backend wins, and preflight explains every backend it considered. With no Windows worker enrolled, the native entry reports that no enrolled Windows worker exists yet, and the job goes to hosted Windows. Once a compatible worker enrolls, the same policy sends the job to it. Untrusted work skips a persistent native worker even when the policy lists it first.
Workspaces, environment and identity
Every job gets a fresh directory under the worker's job root, with its own src, home and tmp:
- The source is fetched at the exact commit (a full 40-character sha; a branch or tag is refused because it can move), with git hooks disabled, and HEAD is verified.
- The job root may not be your home directory, and may not sit inside or contain a registered project checkout, so a job can never touch your working tree.
- The environment is built from an allowlist: system and declared toolchain directories on
PATH, the job's ownHOMEand temp directory, locale, and the non-secret variables the target declares. Tokens, SSH agent sockets and cloud credentials from the worker's own environment are not passed. A target may not setPATH,HOME,DYLD_*and similar, or anything that looks like a credential. - Each job records clean-environment evidence: the variable names it saw (never values), how many were withheld, the source commit, and each declared toolchain's version as the job itself sees it.
--run-as <user>(macOS and Linux) runs steps as a separate, unprivileged account. The worker shares each job's workspace with that account through the account's primary group (setgid directories, mode 2770), so nothing is ever changed to root ownership and no rule runs anything as root. Give the job account a dedicated primary group and add the worker's own account to it (log the worker out and back in so the membership takes effect). The worker then needs exactly one sudoers rule, which only lets it act as that unprivileged account:worker ALL=(cijob) NOPASSWD: /usr/bin/env. Never grant the worker a root rule such aschown: changing ownership as root over paths the worker controls is equivalent to root access. If the group or the rule is missing, the job fails with a message naming what to fix.
Limits, cancellation and cleanup
Safety limits are set on the token and can be tightened later:
orgabot native-worker limits lab-mac --max-concurrent 2 --max-timeout 45m --max-memory-mb 6144The controller enforces them at placement, and the worker enforces the stricter of its own and the controller's current values: concurrent jobs, job and step timeouts, memory for a job's whole process tree, log size, and a free-disk floor. Each step runs in its own process group on macOS and Linux, and inside a Windows Job Object on Windows, which no child process can leave. Cancellation, a timeout, or a memory breach terminates the whole tree, grandchildren included, and the job reports which one happened. A background process a step leaves behind is terminated when the step ends, on every platform. On Windows the worker checks that the Job Object is empty before it reports the step, and if it cannot confirm that, the job fails rather than claim a clean finish. macOS and Linux have no Job Object, and a process can leave its process group (a daemonizing build tool, or anything started in a new session). So the worker also remembers every process it has seen in the step's tree, and marks the step's environment so it can find a descendant again after its parent exits. When the step ends, the worker terminates every such process still running and reads the process table again until it confirms none is left. It does this before the step is reported and before the worker takes its next job. If it cannot confirm it, because the process table is unreadable, a process will not die, or something it could not find still holds the step's output open, the job fails. This is containment, not a sandbox: a process that detaches, clears its environment and loses its parent between two samples can escape it, which is one more reason untrusted work only runs on disposable hosts. Memory is enforced on macOS, Linux and Windows alike: the worker samples the whole tree's resident memory (on Windows, each process's working set through PowerShell). If the worker cannot read the process table for several samples in a row, it stops the step and fails the job, because a limit it cannot observe is not a limit.
Drain and revoke
orgabot native-worker drain lab-mac # no new jobs; the running one finishes; then drained
orgabot native-worker undrain lab-mac
orgabot native-worker revoke lab-mac --reason "host retired"Revoking takes effect on the worker's next request: its key stops authenticating and the agent stops instead of retrying. A job it held is released, and pure work is re-queued for another worker. A revoked worker cannot be reactivated; enroll it again with a new token.
See the CLI reference for every native-worker subcommand.