@appforge-ci/agent
v0.3.25
Published
The build-runner daemon that turns a Mac mini into a fleet member. It:
Readme
@appforge-ci/agent
The build-runner daemon that turns a Mac mini into a fleet member. It:
- Registers itself against the control plane with a one-time enrollment token generated from the dashboard (Fleet → Add Mac mini).
- Sends a heartbeat every 15s (CPU/mem/disk, current build, Tailscale hostname if installed) and picks up any pending remote management command (drain, resume, reboot, restart, run a named maintenance script) in the same response.
- Long-polls
POST /agents/lease-jobfor work — no inbound port is ever opened on the Mac mini. - Runs one or more builds at a time (
resolveMaxConcurrentBuilds()insrc/lib/config.ts—APPFORGE_MAX_CONCURRENT_BUILDS/AgentConfig.maxConcurrentBuilds, or an auto-detected default from CPU core count, capped at 4), each in its own job-scoped directory: clones the repo, readsappforge.yaml, runs the workflow'sscriptsin order viabash -lc, streams logs back line-by-line, uploads matched artifacts straight to Cloudflare R2 via a presigned URL, then reports success/failure. Seedocs/fleet-management.md's "Concurrent builds" section for the full picture. A regulated VPN build (a lease that carriessandbox) is the exception: it runs alone, and the agent itself refuses to share the Mac with it (src/lib/exclusive-builds.ts,docs/vpn.md, "Regulated mode").
Setup on a Mac mini
If this repo is already cloned on the machine (true for your own laptop,
or any mini you git cloned directly), there's a one-command path — see
docs/self-hosting.md's "Adding a Mac mini" section:
appforge-admin login --api-url <url> --admin-api-url <url> # once
appforge-admin agents add-mac --name "mac-mini-1"That requests the enrollment token, reads this machine's own info,
enrolls, and installs a launchd service (no sudo) in one step. Run
appforge-admin logout on the Mac afterwards: the login it needed is a long-lived
token that tenants' builds on this Mac can read (the doctor's
cli-credentials-off-build-mac check fails while it is there; docs/security.md, "A CLI login
never stays on a build Mac"). The from-scratch path below needs no login on the Mac at all.
For a from-scratch box that doesn't have this repo cloned at all — no
git clone, no pnpm install — install just the two CLIs actually
needed from npm instead:
# 1. Prerequisites (once per machine) — see infra/mac-mini-setup/bootstrap.sh
# for the full list (Homebrew, Xcode CLT, Node, CocoaPods, fastlane,
# Android SDK/adb if you also build Android/Flutter/RN apps), or run
# `appforge-admin agents bootstrap` once it's installed below (skips
# the sudo/system-setting parts unless you pass --full).
npm install -g @appforge-ci/admin-cli @appforge-ci/agent
# ^ needs a version actually published first (tag-triggered
# .github/workflows/publish.yml, gated on an NPM_TOKEN secret that
# isn't set up on this instance yet) — 404s until then. See
# packages/admin-cli/README.md's "Install" section for the
# `npm link`-from-a-checkout path that works today.
# 2. Enroll — get a token with `appforge-admin agents enroll`:
appforge-agent enroll \
--api-url https://your-admin-api-instance.example \
--token afe_xxx \
--name "mac-mini-1"
# Already enrolled? `enroll` refuses unless you pass --force. The control
# plane dedupes on this Mac's hardware UUID, so a re-enroll rotates the
# agent token and keeps the same fleet record — it can't make a duplicate.
# 3. Run it directly to sanity-check...
appforge-agent run
# 4. ...then Ctrl-C and install as a launchd service so it survives
# reboots/logouts — no sudo, no copying files, points straight at
# wherever npm just installed this binary:
appforge-agent install-service
# If another account is this Mac's auto-login account (a Mac that also hosts a
# GitHub Actions runner, say), a LaunchAgent would wait for a manual login after
# every reboot. Install the agent as a boot-time LaunchDaemon instead (asks for
# sudo; runs as your account; `--dry-run` previews every step):
appforge-agent install-service --daemoninstall-service flags: --daemon (boot-time LaunchDaemon, see above), --dry-run (with --daemon:
print the steps and the plist, change nothing), --force (install even though the running agent reports
active builds; the restart cancels them), --drop-env (let the new plist drop operator knobs such as
APPFORGE_SANDBOX_MONITOR that the plist being replaced sets and this shell does not export; without it a
daemon install refuses). A re-run without --daemon keeps whichever form is installed.
Details, rollback and limits: docs/fleet-management.md, "Agent service forms".
Will this Mac come back by itself? appforge-agent doctor
Read-only checks, run on the Mac itself as the user that runs the agent. It answers what
docs/datacenter-readiness.md ends on (pull the power and the agent is back with nobody at the
keyboard) without pulling the power:
appforge-agent doctor # human-readable; exit 1 if anything fails
appforge-agent doctor --json # the same, machine-readable, with a `rebootRecovery` roll-up
appforge-agent doctor --strict # exit 1 on warnings too| Group | Checks |
|---|---|
| Reboot recovery | FileVault is off; the agent's service starts without a login (a LaunchDaemon, or a LaunchAgent of the auto-login account); a co-hosted GitHub runner's service does too; autorestart is on |
| Services | every installed com.appforge.* launchd job is loaded and not crash-looping; every built app (packages/agent, apps/*) can resolve its dependencies; the service does not pin a version-pinned node; the agent is installed as one launchd job (not a LaunchDaemon and a LaunchAgent at once); no control-plane secret is readable in a service configuration (#902); no CLI login token is stored in ~/.appforge-admin or ~/.appforge (cli-credentials-off-build-mac, #904: tenants' builds run as this user and can read it; skipped on a machine with no agent, such as an operator's laptop); the running agent's heartbeat is fresh |
| Host | never sleeps on AC power; a stable host name (macOS HostName set); Tailscale as a daemon, if used; Remote Login; passwordless shutdown -r now for REBOOT_HOST; free disk |
| Network | the default route is not Wi-Fi; Wi-Fi is not joined next to a wired link (two LAN addresses on one Mac); when Wi-Fi is the link, it loses at most 2 % of 20 pings to the gateway (a tart pull does not resume). A colo mini has no Wi-Fi, so a mini that quietly depends on it will not survive the move; skipped on a laptop and when a VPN is the default route |
A laptop (battery present) skips the checks that only matter for an unattended Mac. install-service
and appforge-admin agents add-mac print the reboot-recovery verdict when they finish.
Sandbox self-test (regulated builds)
The agent can run a build's scripts under a macOS Seatbelt sandbox when a job asks for it (nothing asks yet). To check this Mac:
appforge-agent sandbox-selftest # the matrix, against this Mac's real home; exit 0 pass / 1 fail / 2 inconclusive
appforge-agent sandbox-run --proxy-port 18888 -- /bin/bash -lc 'flutter pub get' # one command under the same profileThe agent runs the same matrix before each regulated build, and, only when the operator opted the
monitor in with APPFORGE_SANDBOX_MONITOR=1 (off by default; canary Mac first), at start and every 10
minutes too. It reports enforcementLevel: sandbox only while it passes. It is a regression suite for the bypass
classes it lists, not a proof of confinement. APPFORGE_SANDBOX_DISABLED=1 is the operator kill
switch: set it in the shell you run appforge-agent install-service from (it is baked into the
launchd plist like APPFORGE_MAX_CONCURRENT_BUILDS; a hand edit of the plist is dropped by the next
install). The matrix's unsandboxed controls briefly write to /opt/homebrew/bin and
~/Library/LaunchAgents, a throwaway preferences domain, and submit a launchd job, on every run. Procedure and caveats: docs/manual-sandbox-validation.md.
Remote management, and why it's an allowlist
The dashboard can only ever send one of eight command types
(DRAIN, RESUME, REBOOT_HOST, RESTART_AGENT,
RUN_MAINTENANCE_SCRIPT, UPDATE_AGENT, CANCEL_BUILD, TEST_TUNNEL) — there is
deliberately no "run arbitrary shell command" endpoint. UPDATE_AGENT
drains the agent, waits out every in-flight build, then self-updates and
re-execs; CANCEL_BUILD kills exactly one named build's process group
among this agent's concurrent slots. See docs/fleet-management.md's
command table for the full list. RUN_MAINTENANCE_SCRIPT further
requires the script name to already exist in this machine's local
~/.appforge-agent/config.json under maintenanceScripts, e.g.:
{
"...": "... (written by `enroll`)",
"maintenanceScripts": {
"clear-derived-data": "rm -rf ~/Library/Developer/Xcode/DerivedData/*",
"update-cocoapods": "sudo gem install cocoapods",
"prune-simulators": "xcrun simctl delete unavailable"
}
}REBOOT_HOST shells out to sudo /sbin/shutdown -r now; give the agent's
user a sudoers entry scoped to exactly that command (see
infra/mac-mini-setup/bootstrap.sh), not blanket sudo.
For anything beyond this allowlist — interactive debugging, installing a
new Xcode version, watching a stuck build's screen — use a real remote
desktop tool (Tailscale SSH, Apple Remote Desktop, or tailscale
funnel/VNC). AppForge deliberately doesn't reimplement a remote shell or
screen-share for the host machine; see docs/fleet-management.md. (The
in-browser "Test in browser" feature is different: that streams the
emulator/simulator screen for the built app, not the Mac mini's own
desktop — see apps/device-stream/README.md.)
