@rackbops/ac-agent
v2.0.0-beta.1
Published
The per-machine host agent (installed with npx @rackbops/ac-agent): an HTTPS health server plus install/status/uninstall for a Windows logon task or a systemd user unit.
Readme
@rackbops/ac-agent
The per-machine host agent for artifact-console: a
small HTTPS server, paired to a console over a token, that lets it reach one dev machine's
claude/git/gh and file system through an allowlisted verb API. Console-initiated pairing (a
one-time code exchanged for a per-machine token, #48), repos list/status/pull and open (#49,
49a), exec/claude (with the job registry and its SSE log) plus exec/gh (#49, 49b), and the
context-pressure / subagent-cost session-log scans (#52, 52a), and memory-footprint /
toolchain-inventory (#52, 52b) are all live -- every scan/:kind kind #52 planned has landed --
as are the OneDrive mirror (publish/mirror, #53) and the tunnel-config lint (tunnel/lint, #416).
Every verb also
requires a per-plugin capability grant, pushed by the console and enforced here (#50) -- see
Verbs and Security model below. GET /v1/preflight (#55) -- git/gh/node/claude and (#828)
docker/cloudflared executed, chromium only resolved -- is control plane, not a verb; see Preflight.
Install
npx @rackbops/ac-agent installInstall with the plain command above, no dist-tag: since #564, every release -- prerelease included --
publishes with --tag latest while 2.0 is prerelease-only, so an unversioned npx @rackbops/ac-agent
resolves to the newest release. Two older tags are frozen: alpha (the very first publish,
2.0.0-alpha.8, #268, was done by hand under --tag alpha) still points at 2.0.0-alpha.8, and
next (what release.yml gave every prerelease from #269 through 2.0.0-alpha.40) stays at
2.0.0-alpha.40. Nothing moves either any more, and the maintainer removes or moves them by hand.
latest is expected to go back to stable-only once the first stable 2.0.0 ships.
Node 26 or newer (engines.node: ">=26", #827), the fleet's one Node line and the console image's
major. npx treats engines only as a warning, so the agent checks for itself before any verb: on an
older Node every verb (install, status, stop and uninstall included) does nothing and exits 2,
its stderr ending with the line ac-agent requires Node >=26, found <version>. On Ubuntu, the
distribution's own nodejs is older; Node 26 comes from NodeSource's node_26.x apt repository
(https://deb.nodesource.com/node_26.x).
Move a machine's Node to 26 before its agent updates to a release that carries this floor. The
console's update (POST /v1/update, below) checks the staged bundle by running its version --json
with the agent's own Node, so on an older Node the update is refused (422, naming that line) and the
running agent stays as it was. A plain npx @rackbops/ac-agent install on an older Node is refused the
same way, before anything is copied or registered.
- The installed task or unit keeps the Node
installran under, as an absolute path (process.execPath). An apt or MSI upgrade in place moves that Node with it. A version manager (fnm, nvm, volta) does not: after switching it to 26, runinstallagain under 26, or the agent keeps running the old Node and its updates are refused with that line. - To stop or uninstall from a machine still below 26, run a release from before the floor, e.g.
npx @rackbops/[email protected] stop, or move the machine's Node first.
Upgrade with the Tooling script
For an existing install, an up-to-date Tooling checkout provides
Update-AcAgent.ps1
and its sibling helpers, including Resolve-GhExe.ps1. These examples assume the PowerShell session
is at the root of that checkout:
# Local Windows agent, explicit release
& .\PowerShell\Update-AcAgent.ps1 -Local -Version 2.0.0-alpha.31
# Newest release carrying its matching agent tarball, including prereleases
& .\PowerShell\Update-AcAgent.ps1 -Local -Version latest
# Linux targets reached from this PowerShell session
& .\PowerShell\Update-AcAgent.ps1 -Linux 'user@host-a,user@host-b' -Version 2.0.0-alpha.31
# Preview the same Linux operation
& .\PowerShell\Update-AcAgent.ps1 -Linux 'user@host-a,user@host-b' -Version 2.0.0-alpha.31 -WhatIfThe updater downloads the matching release tarball once into a fresh temporary directory. A selected target already serving that version skips installation. Other targets are read before and after the install, including the endpoint version and served-certificate fingerprint; Linux also checks that the user service is active. Target failures are independent, and any failure makes the script exit nonzero. Temporary-file cleanup is attempted at the end. If the before endpoint was stopped or unreadable, there is no fingerprint to compare, so the run cannot verify that it stayed unchanged.
-WhatIf is a non-mutating preview, not a zero-I/O mode: it still queries releases, downloads the
tarball, and reads target health, but runs no install, scp, or mutating SSH command.
Run Windows -Local from a normal PowerShell window. Every
Windows machine runs -Local for itself; remote Windows installation is unsupported. gh must have
access to the release repository. Linux targets need working noninteractive SSH and node/npx on
their PATH; the target's own Node runs the new bundle, so a release that carries the Node 26 floor
needs Node 26 there too (see above). -Port changes only the health-check port (default 8788); it
does not reconfigure the agent.
Linux: no special npx spelling needed (#278)
The plain npx @rackbops/ac-agent <verb> line above is the one spelling that works on Linux
regardless of npm version -- confirmed on Ubuntu 26.04 with both npm 11.19 and npm 12.0.2. #278 was
originally filed as an npm-11-vs-12 npx regression (alpha.9 silently printing npm notice run and
exiting 0 with no verb output on a npm 12 box), but the real cause was narrower and applied to every
npm version on Linux: dist/ac-agent.mjs's own entry-point guard compared import.meta.url (always
the symlink-resolved real path) against a raw, unresolved process.argv[1]. npm's
node_modules/.bin/<name> bin link is a real filesystem symlink on Linux (whether reached via npx
or npm install -g), so the two paths never matched, main() never ran, and the process exited 0
having done nothing -- on npm 11 and npm 12 alike. Windows never showed this: npm links a bin there
with a .cmd/.ps1 shim that invokes node "<real path>" ... directly, so argv[1] was already the
real path. Fixed by resolving argv[1] through realpathSync before comparing (isEntryPoint in
src/cli.ts) -- no --yes, --package=, or -- flag is needed or helps; none of those spellings
touch the actual bug. Requires a published release built with this fix (past 2.0.0-alpha.26); any
prerelease at or before that still silently no-ops on Linux the same way.
Registers a Windows Task Scheduler task (\ClaudeLocal\ac-agent, a logon trigger for the current
user) or, on Linux, a systemd --user unit (ac-agent.service, enable --now), copies the running
bundle into the state dir (never the npx cache — that can be garbage-collected out from under a
long-running service), writes agent.toml if one doesn't already exist, and generates a self-signed
cert if one doesn't already exist (also when cert.pem/key.pem exist but are not a usable pair, an
empty or mismatched file, with a warning: the new fingerprint must be paired again; install prints
it, serve writes it to agent.log). The certificate, key, token hash, grants, pending pairing code,
agent.toml are written to a temp name (<file>.tmp-<pid>, fsynced) and renamed over; a hard kill
between the write and the rename can leave a stray *.tmp-<pid> beside the file. update.json
(update.json.tmp, not fsynced, a failed write is printed and swallowed) and the embedded
toolchain-inventory script and (Linux) probe are renamed over from a temp name too. Still written in place: the
Windows launch script, the systemd unit, the AppData-virtualization probe files, the scans cache (a
torn one just re-scans) and the self-update staging directory. A file that cannot be read for
any reason other than not existing is an error, not an empty answer (status instead lists it under
unreadable, by file name and error code, and treats it as absent). On an existing install, an explicit --repos-root <abs> narrowly
updates only the top-level repos_root string in agent.toml; comments, ordering, unknown keys,
credentials, grants, and every other setting stay untouched. The old and new roots are compared as
exact strings, so an equal value does not rewrite the file or change its mtime. A malformed file or
a non-string repos_root is refused rather than rewritten. Prints the resolved config/state paths,
the cert's fingerprint, and the firewall rule's status.
Options: --port <n> (default 8788; an integer from 1 to 65535, refused otherwise, and agent.toml's port is held to the same rule), --name <machine> (default os.hostname()), --repos-root
<abs> (default <homedir>/repos; the fleet's roots differ per box — Windows and Linux checkouts
don't share a layout, so every real install passes this explicitly). A relative --repos-root is
rejected before installation side effects on both fresh and existing installs. On an existing
install, --name and --port remain intentionally ignored and print the configured value they
kept; platform registration and restart checks use that existing port. If
AC_AGENT_REPOS_ROOT is non-blank and differs from an explicitly saved --repos-root, install
warns that the environment still wins at runtime until it is unset.
Installing over an already-running agent restarts it (#277): once the task/unit is re-registered
against the new bundle, install stops the old process and starts it again through the same
stop/start machinery the ac-agent stop/ac-agent start commands use, then confirms the new
process is actually answering (on the configured bind when it names one address, else on 127.0.0.1) (a health check, retried for a few seconds — the OS command that
launches a process can report success before that process has actually started listening) before
printing restarted: now serving <version>. Only a health answer reporting the new version
counts (#659), so the old process still answering on the port is not mistaken for the new one.
Before copying the new bundle in over a running agent, install keeps the one it replaces as
bin/ac-agent.prev.mjs in the state dir (#659; one backup, overwritten by the next install). Both
the backup (ac-agent.prev.mjs.tmp) and the new bundle (ac-agent.mjs.tmp) are copied beside their
target and renamed into place, as is the restore, so an install killed mid-copy leaves the old bundle
whole. When the running agent reported its version and node <bundle> version is available, the live
bundle is backed up only if it reports that same version: after an aborted install it may be a newer
file than the process serving, and it must not overwrite a good backup (an existing backup is kept when
it is the running version; otherwise no backup is made and install prints a warning that it cannot
roll back). When the running version is not known the backup is made unchecked, as before. If the
new bundle never answers health at its own version, install rolls back:
- It stops the task/unit again. On Windows this ends
launch.js's retry loop, so it can't rerun the broken bundle in the middle of the restore. - It restores
ac-agent.prev.mjsand starts it. - It confirms the previous version answers, then prints
rolled back: now serving <previous version> (<reason>). The previous version is read from the running agent's own health answer just before it is stopped. If that read got no version (nocert.pemto read it with, no answer, an error answer, or one without a version), any healthy answer is accepted, and the line saysnow serving the previous bundle.
Whenever a rollback is possible, the restart confirmation waits up to a minute instead of a few
seconds, because a false failure would undo a good release (the Windows cold start has never been
timed). This applies to a plain install too, such as Tooling's Update-AcAgent.ps1. A new bundle that
never comes up therefore takes about two minutes to report (a minute waiting for it, up to another
confirming the rollback; a hung connection adds to each attempt). If the install fails before the
restart (registering the task/unit, the certificate) and a backup was made, the running agent's bundle
is copied back onto the disk; the error is reported as before.
The install then fails (exit 1), because it did not take. If the rollback fails too, it prints
rollback failed: ... and also exits 1; ac-agent start is the fallback. Which file holds what depends
on how far the rollback got: ac-agent.prev.mjs always holds the previous bundle, and the live
ac-agent.mjs holds the previous bundle too once the restore copy has run (a failure while starting it or
confirming it), but still the new one when the failure came before that copy. The message names
ac-agent.prev.mjs and says to copy it over the live bundle if it is not already there.
When there was no bundle to go back to, a restart that fails outright still reports the install as a
success, with restart failed: <reason>; run ac-agent stop && ac-agent start, and that manual
sequence is the fallback. A fresh install, with nothing running yet, prints neither line.
install --update is what the agent's own update route launches; it isn't meant to be run by hand.
It differs from a plain install in two ways:
- Its restart confirmation (when an agent is running to restart) is always the minute-long one, even with no backup to roll back to.
- It records each step in
update.jsonin the state dir:{status: "installing" | "ok" | "rolled-back" | "failed", from, target, at, message?}, wherefromis null when the previous version could not be read.- Once
installingis written, the outcome is recorded asok,rolled-backorfailed. That covers a failure before the restart, and no agent running afterwards. - A failure before
installingis written (reading the config, the backup copy) leaves the previous record as it was. - It writes a temp file and renames it over, so a reader never sees a partial record.
- A write that fails (for example a locked file) prints a
warning: could not record update status ...line and never changes the install's outcome. The record then still shows the last write that landed, which may beinstalling, so a reader should treat an oldinstallingas unknown.
- Once
A plain install never writes update.json. ac-agent version --json prints
{"version", "apiVersion"}, which is how a staged bundle's agent API version is read without starting
it.
The firewall step
install tries to add an inbound rule (New-NetFirewallRule, Windows only) so a LAN caller can
reach the port. Creating a firewall rule needs administrator rights; when the process isn't
elevated, install prints the exact one-liner to run in an elevated PowerShell. An existing ac-agent
rule is kept only when its LocalPort is the install's port; a rule on another port (a reinstall with a
different port) is removed and recreated, and when that needs elevation the printed one-liner removes it
first. uninstall leaves the rule in place and prints the elevated Remove-NetFirewallRule command
that removes it. Only install reports the rule's state; status has no firewall field. This is expected on a fresh install run from an ordinary
shell — it is not a failure to work around.
Run install from a normal shell
The Claude desktop app is an MSIX package (Claude_<hash>, e.g. Claude_pzs8sxrjxfjjc). A Claude
Code session it starts — and every process that session spawns, including a PowerShell/Bash tool
shell running npx @rackbops/ac-agent install — inherits the package identity, and MSIX
virtualizes AppData: a write to a subfolder of %LOCALAPPDATA%/%APPDATA% is silently
redirected into the package's private ...\Packages\<pkg>\LocalCache\Local\.../
...\LocalCache\Roaming\..., while the writing process itself believes it wrote the real path.
install writes the bundle copy, launch.js, the cert and agent.toml under the state/config
dirs, so run from such a session those files land somewhere the Windows logon task — launched later
with the user's real, unpackaged token — can never see, and the task exits 1 at once (#263).
install detects this (a nonce probe file, not just the presence of an old package copy, so a stale
VFS leftover from a prior virtualized install never blocks a later normal one) and refuses with:
install ran inside a packaged app (<packageFamily>), which virtualizes AppData: the files landed
under <vfsPath> where the logon task cannot see them. Run ac-agent install from a normal PowerShell
window, not from a session started by the Claude desktop app.Run install from a normal PowerShell window opened from the Start menu, not from a Claude Code
session the desktop app started. status/serve don't refuse the same way (see Status and
serve / stop / start below) — a serve launched from a virtualized session still works, since
everything it reads and writes goes through the same virtualized view within that one session; it's
only a task launched later, outside that session, that can't see the files.
Linux
Run install as the login user you'll actually use the agent as, never root. As root there is
no user session bus for the systemd --user unit to register against —
loginctl show-user/systemctl --user calls fail with Failed to connect to user scope bus via
local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined — and a root install
would in any case put the config/state under /root, where the operator's own shell never looks.
A systemd --user unit only survives past that user's login session — logout, or a reboot — if
lingering is enabled for that user:
sudo loginctl enable-linger "$USER"This can be run before or after install; order doesn't matter, because it governs the user
manager's lifetime (whether systemd --user keeps running with no session attached), not the
ac-agent.service unit itself. Without it, the user manager — and the agent with it — stops the
moment the last session for that user ends.
The unit's HTTPS listener isn't up the instant install returns — a curl fired immediately can
race the bind and report a false failure. Wait a moment, then check:
curl -k https://127.0.0.1:8788/v1/healthA server host that also runs the console (#826)
The machine that runs the console in Docker can pair an agent too, so its own toolchain is graded in the Agents panel's Environment section like any other machine's; nucbox is set up this way and paired (#826, 2026-10-20). It installs the Linux way above, with these differences.
Node 26 from NodeSource's apt repository. The agent needs Node 26 or newer (engines.node, #827;
see Install above), the fleet's one line, and Ubuntu's own nodejs is older (22 on Ubuntu 26.04).
Write NodeSource's key and source by hand rather than piping its setup script to a shell. The key:
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor | sudo tee /usr/share/keyrings/nodesource.gpg >/dev/null
sudo chmod 0644 /usr/share/keyrings/nodesource.gpgIts fingerprint is 6F71 F525 2828 41EE DAF8 51B4 2F59 B5F9 9B1B E0B4. The source, in deb822 form,
is /etc/apt/sources.list.d/nodesource.sources:
Types: deb
URIs: https://deb.nodesource.com/node_26.x
Suites: nodistro
Components: main
Architectures: amd64
Signed-By: /usr/share/keyrings/nodesource.gpgTwo pins make NodeSource's packages win over Ubuntu's: /etc/apt/preferences.d/nodejs (below) and
/etc/apt/preferences.d/nsolid (the same, with Package: nsolid):
Package: nodejs
Pin: origin deb.nodesource.com
Pin-Priority: 600Then sudo apt-get update && sudo apt-get install -y nodejs, which brings npm with it. On nucbox
(2026-10-09) that installed 26.11.1-1nodesource1 with npm 11.20.0 and removed Ubuntu's nodejs 22,
its node-* libraries and nodejs-doc, so check first that nothing else on the host runs on the
system Node. On nucbox nothing else did (its services run in Docker; the plan's read of the box,
2026-10-08), so the agent is the system Node's only user.
An empty repos root. After sudo loginctl enable-linger "$USER":
mkdir -p "$HOME/ac-agent-repos"
npx -y @rackbops/ac-agent install --repos-root "$HOME/ac-agent-repos"The console's host holds no checkout the console should list or pull, so the repos root is an empty
directory. curl -k https://127.0.0.1:8788/v1/health then answers {"ok":true, ...,
"paired":false}.
The console reaches it at the host's LAN address. Pair it as in Pair below, giving the
console's form the URL pair prints. That is the host's LAN address, not 127.0.0.1: the console's
container runs on the same machine, but it calls this agent the way it calls any other, and inside the
container 127.0.0.1 is the container itself. pair and the certificate leave Docker's bridge
interfaces out (docker0, br-*, veth*; #330) unless they are the machine's only addresses, so the
address printed is the real NIC's. A host firewall has to admit the agent's port from the console's
Docker network as well as from the LAN, since the container's traffic to the host arrives from its
bridge address (not verified on nucbox; pairing is the test). Give the pairing form the host's name in
the fleet standard (nucbox): the Upgrade action finds the standard host by the name the console holds
for the machine, and the Environment grading by the name the agent reports (its OS hostname, which an
inventory sidecar's hostname maps to the host; see the agents plugin's README, "Which standard host a
machine is").
The minimal grant set. Opt the machine in to upgrades (toolchain_upgrade = true and the sudoers
rule, Operator setup under POST /v1/toolchain/upgrade; read what that rule grants before adding
it to a production host), restart it (stop, then start), and in the Grants grid give the agents
plugin agent.toolchain.upgrade and nothing else: no agent.exec.claude, no agent.git.*, no
agent.scan.*. The grading needs no grant at all (GET /v1/preflight is control plane, and so is
POST /v1/update, which lets the console update the agent itself). The Upgrade button needs none
either: it shows on any behind row whose tool the standard gives a package for on a Linux server
(among git, gh, node and claude; a docker or cloudflared row gets one too, and the agent
runs that upgrade at the next 05:00 America/New_York, #978). The grant is what lets
a click succeed; without it the agent refuses with agent.toolchain.upgrade is not granted to agents on
<machine>. That granted verb installs a package the standard names: as root, through sudo apt-get
install --only-upgrade and the sudoers rule, or for Claude Code with its own claude update as the
agent's user. No other verb is granted to agents: no agent.exec.*, agent.git.* or agent.scan.*.
nucbox additionally grants agent.tunnel.lint to the admin plugin, and nothing else (the owner's Grants
screenshot, 2026-10-20).
The console no longer asks this machine for what it has not been granted (#843):
- The host's job runner skips a machine that has not granted every capability a job declares: no
job_runsrow, no task and nojob failednotification. The fourscansjobs declareagent.scan.<kind>, so each readsnot grantedon the machine's row instead of a failed run. - The
reposplugin's repo-currency check andprovider-artifacts' hourlypublish.mirrorraise no Needs-attention row for a machine that never had the grant; the card readsnot granted. A grant revoked after a good result raises one row (Repo currency is no longer granted on <machine>, and its mirror-publish counterpart) for 7 days, then nothing. - The Admin Security card's tunnel lint reads the console's own grants and does not call this agent;
it shows
agent.tunnel.lint is not granted to admin on <machine>as a neutral row, not a warning.
Windows: no console window
The task's action is wscript.exe //B "<launch.js>" against a small generated JScript launcher
(written into the state dir alongside the bundle copy), not a bare node invocation and not
conhost.exe --headless (an earlier design that turned out to run nothing at all — verified live,
conhost.exe isn't a generic headless-launch wrapper). launch.js runs
WScript.CreateObject("WScript.Shell").Run("<node> <bundle> serve --config-dir <abs> --state-dir
<abs>", 0, true) in a loop (the two dirs install itself resolved, baked in the same way
<node>/<bundle> already are — see Config and state directories below), retrying on a crash.
Window style 0 keeps node's console from ever appearing (Node has no nodew.exe, the
pythonw.exe-equivalent 1.x used), and waiting (true, not fire-and-forget) keeps wscript.exe
itself alive as the task's tracked process for the agent's entire lifetime, so the task reads
Running while serving and a crash propagates as a non-zero exit, visible afterward as
LastTaskResult. RestartCount 3 does not actually restart a crashed agent — verified live (two
scratch tasks with these exact settings, one killed by pid and one exiting on its own, sampled every
30 s for 150 s): Task Scheduler's restart-on-failure does not act on the action's exit code for this
action shape. The real crash-restart mechanism is launch.js's own retry loop (#238): a non-zero
exit sleeps a minute and runs node again, up to 3 retries in a row (a run that lasted longer than five
minutes starts the count over, so the budget is per crash loop, not per logon session), since the exit code is already in
wscript.exe's own hand; a zero exit (a deliberate shutdown) ends the loop instead. stop/uninstall
stop the task first — ending the loop — before killing the orphaned node by pid, the reverse of the
order that would otherwise let the loop see a bare kill as a crash and resurrect the agent a minute
after stop. Verified live: killing node by pid left the task Running with a fresh node pid
listening within about a minute; stop afterward left the task Ready with LastTaskResult 267014
(0x41306, terminated by user) and nothing listening 150 s later. The launcher is JScript rather than
VBScript specifically because
VBScript is a Windows feature-on-demand Microsoft is phasing out (Insider builds first); JScript and
Windows Script Host itself carry no such notice, so the same mechanism ships without a deprecated
component in the loop. (A PowerShell hidden launcher — Start-Process -WindowStyle Hidden -PassThru
-Wait — was also tried and verified working, but was dropped in favor of keeping Windows Script
Host as the only script host involved.) One thing this package cannot verify by itself — no CI
runner can register a real logon task — is named as unverified rather than assumed: whether any
window is visible at a real logon.
Status
npx @rackbops/ac-agent statusPrints two plain lines, lan address: <ip|(none)> and repos: <n>, and then, as JSON: the config path and whether it came from a file or defaults, the state dir, the
Task Scheduler/systemd service state, whether a pairing token is stored (paired: true/false), the
cert's fingerprint, certExpiry ({validTo, daysLeft, expiringSoon}, null with no readable cert;
expiringSoon is true within 30 days of notAfter or after it, and then a warning: the agent's
certificate ... line goes to stderr), the result of a live GET /v1/health call against itself (pinned
to the cert's fingerprint and nothing else: not a system CA and not the validity dates, so an expired
cert still answers and certExpiry is where the expiry shows — see Security model below), the currently-held grants
({revision, plugins: <count>} | null, from grants.json — null when it is absent or malformed,
the same as a freshly-purged or never-pushed-to agent), the effective reposRoot and a live
reposCount computed from the same immediate-child, .git-entry, realpath-contained listing as
GET /v1/repos (a missing or unreadable root is zero), unreadable ({file: error code} for any file status could not read, {} normally), and virtualized: true/false (#263) — true
when this session's own AppData writes to the resolved config/state dirs are virtualized by a
packaged app (see Run install from a normal shell above), which also prints a warning: this
session's AppData is virtualized (<packageFamily>); the logon task cannot see <stateDir> -- see
README, Install line to stderr. serve logs the same warning line once at startup, in the same
circumstance, without refusing to start. update (#659) is the last install --update's record from
update.json, or null when there is none or it is unreadable. toolchainUpgrade: true/false (#675) is
the machine owner's own opt-in to POST /v1/toolchain/upgrade (toolchain_upgrade in agent.toml, or
AC_AGENT_TOOLCHAIN_UPGRADE), false by default. Like the other fields it is what status loads from
agent.toml and the environment of the shell that ran status: what a start from that file and shell
would load, not what the running agent holds (it read its config once, at start, and an installed
task/unit does not see that shell's variables).
Pair
npx @rackbops/ac-agent pair [--force]Generates a one-time pairing code and prints exactly three lines:
code: <8-char code>
url: <this machine's URL>
fingerprint: <cert fingerprint>The code is 8 characters from an alphabet that excludes ambiguous glyphs (0/O, 1/I, easy
to misread off a small screen or mishear read aloud) and expires in 10 minutes; only its hash is
ever written to disk (<stateDir>/pair.json). Refuses with already paired -- pass --force to
re-pair (mints a fresh code) if a token is already stored — --force starts a fresh pairing
without discarding the old token until the new code is actually consumed, so a stale in-flight
attempt never wins a race against a still-valid one. Requires a certificate to already exist
(install always creates one); refuses with no certificate found -- run install first otherwise.
On the console side, open its Admin → Agents panel (the agents plugin, plugins/agents/src/
ui.tsx) and fill in the pairing form with the three printed values plus an optional machine name:
URL, Fingerprint, Code, Name. Submitting calls POST /api/agents — success adds the machine to the
paired list; the console then reaches it, per capability, once an operator grants that capability to
a plugin for it in the same panel's Grants grid (see Security model below — no verb runs until a
capability is both declared by the plugin and granted here; the control-plane routes, PUT /v1/grants,
GET /v1/preflight, and POST and GET /v1/update, need no grant).
Every other POST verb answers 413 {ok:false,error:"request body too large"} above 64 KiB; publish,
toolchain-upgrade and exec/gh (512 KiB, for one 64 KiB --body) keep their own larger caps.
Unpairing (the Agents panel's Unpair, DELETE /api/agents/:id) is local to the console: it forgets the
machine and its grants and makes no call to the agent, which keeps its stored token hash and keeps accepting
the token. To cut that credential off, run pair --force on the machine and pair it again from the console
(the old token stops working once the new code is consumed), or uninstall, which removes the token hash. A
copy of the console's /state/ac.db taken before the unpair still holds the old token in clear.
Uninstall
npx @rackbops/ac-agent uninstall [--purge]Stops and unregisters the task/unit, removes the copied bundle, cert, key, token hash, pending-pair
file, grants and lock file. Keeps agent.toml unless --purge is given, so a re-install remembers
the machine's chosen port/name.
serve / stop / start
serve is what the registered task/unit actually runs — it isn't meant to be invoked directly
except for debugging. stop/start control the registered service (Stop-/Start-ScheduledTask
or systemctl --user stop/start) without touching the registration itself.
The lock. agent.pid is taken with O_EXCL. A pid left by an unclean exit (a Windows logoff or
reboot runs no shutdown handler) is replaced when it is this process's own pid, is not a valid pid, or
is not running. It is also replaced when nothing answers /v1/health on the configured port with this
machine's own certificate, which is pid reuse. A start is refused while that pinned answer comes from a
running agent; the refusal names its pid, or pid unknown for an agent older than alpha.59. Both outcomes
are written to agent.log, as replaced stale lock (pid N, <why>), already running (pid N, port P) or
already running (pid unknown, port P). GET /v1/health carries the answering agent's pid for this.
stop, uninstall and an install's restart kill only the pid that pinned answer names. When nothing
answers they kill nothing and remove the stale agent.pid, and they print ... nothing was killed if
that pid belongs to some other live process. When the pinned answer has no pid (an agent older than
alpha.59), they refuse and name the port and version instead of killing nothing. An install also
refuses to start while the port still answers after the stop.
Config and state directories
| | Windows | Linux |
|---|---|---|
| Config (agent.toml) | %APPDATA%\ac-agent\ | ~/.config/ac-agent/ |
| State (cert, key, token hash, grants, log, pid, the installed bundle copy and its one backup, update.json) | %LOCALAPPDATA%\ac-agent\ | ~/.local/state/ac-agent/ |
Both are overridable with AC_AGENT_CONFIG_DIR / AC_AGENT_STATE_DIR — absolute paths only; a
relative override throws, naming the value. install resolves these once, in its own process, and
bakes the resolved absolute paths into the registered task/unit as serve --config-dir <abs>
--state-dir <abs>, the same way it already baked in the node/bundle paths — rather than relying on
serve to re-resolve them from its own environment when the task/unit actually runs. This matters
because a Task Scheduler process (and a systemd --user unit) is a separate process tree that does
not see the installing shell's variables — transient or persisted — so before this, an install under
the overrides silently served from the real platform defaults instead, with a different certificate
and the default port (E4 finding 13, fixed in #241). A manual serve with no flags still honours
AC_AGENT_CONFIG_DIR/AC_AGENT_STATE_DIR as before.
agent.toml:
port = 8788
bind = "0.0.0.0"
machine = "melody"
repos_root = "S:/Repos"
claude_projects_dir = "C:/Users/melody/.claude/projects"Unknown keys are ignored, so a future field never breaks an older agent reading a newer file.
repos_root is overridable live with AC_AGENT_REPOS_ROOT the same way port is with
AC_AGENT_PORT (which, unlike the file's port, is not range-checked: it is read as an integer and a value
it cannot read is ignored) — absolute paths only; a relative override throws, naming the value.
claude_projects_dir (#52, 52a; default <homedir>/.claude/projects) is handled identically, with
its own live override AC_AGENT_CLAUDE_PROJECTS_DIR — it names the root the context-pressure and
subagent-cost scans read, overridable so a fixture tree or a non-default CLAUDE_CONFIG_DIR
still resolves. Unlike repos_root, install takes no flag for it and status does not report
it.
tunnel_config and tunnel_preflight (#416; both unset by default, because most machines do not run
the tunnel) are nullable like publish_dir, so agent.toml simply omits them: tunnel_config is
the absolute path of the cloudflared config that GET /v1/tunnel/lint reads, tunnel_preflight
the absolute path of Tooling's console_tunnel_preflight.py. Live overrides AC_AGENT_TUNNEL_CONFIG
and AC_AGENT_TUNNEL_PREFLIGHT. Absolute paths only, because tunnel_preflight is executed and
must not resolve against whatever directory the service starts in: a relative env override throws at
load, naming the value, like every other AC_AGENT_* path; a relative value in the file is loaded as
written (as publish_dir's is, so a typo in an optional key can never stop serve, stop or
uninstall) and the verb then refuses to read or run it, answering tunnel_config is not an absolute
path (or a preflight-unusable warning naming tunnel_preflight). A blank or non-string value in the
file is treated as unset. install takes no flag for either and status does not report them.
toolchain_upgrade (#675; false unless set) is the machine owner's own opt-in to POST
/v1/toolchain/upgrade, the verb that installs software here (see POST /v1/toolchain/upgrade).
It is a TOML boolean, written to agent.toml only when true; only a boolean true counts (a quoted
string, a number or a misspelled key is false; an unquoted word that is not a TOML boolean, such as
True or yes, is invalid TOML and fails the whole config load). Live override
AC_AGENT_TOOLCHAIN_UPGRADE: 1/true or 0/false (case-insensitive); blank is unset; anything
else throws at load, naming the value. The override only reaches the process that reads it: a
foreground serve started from that shell, or a unit/task whose own environment sets it (the
installed task and unit do not see the shell's variables, as the paragraph above explains). A fresh install run with it
set writes toolchain_upgrade = true into the new agent.toml, where it then stays. Like every key it
is read once when the agent starts, so a change needs ac-agent stop and start (or a restart of the
task/unit). status prints toolchainUpgrade: true|false: what a start from agent.toml and the
shell's own environment would load, not what the running agent holds, so after a change restart first
and treat the agent's 403 not-opted-in as the truth. The console's grant alone never turns it on: the
verb answers 403 not-opted-in until this key is set.
Verbs
Everything under /v1/* except /v1/health and /v1/pair requires pairing (see Security
model). The verb registry fills GET /v1/health's capabilities field with the sorted, unique
list of every registered verb's capability name — with every verb group registered, that's
["agent.desktop.open", "agent.exec.claude", "agent.exec.gh", "agent.git.pull", "agent.git.read",
"agent.publish.mirror", "agent.scan.context-pressure", "agent.scan.memory-footprint",
"agent.scan.subagent-cost", "agent.scan.toolchain-inventory", "agent.toolchain.upgrade",
"agent.tunnel.lint"].
Every verb also requires two headers, checked in this order (#50), after the bearer check
passes: X-AC-Plugin (the calling plugin's id) missing or shaped wrong is 400 {ok: false, error:
"missing X-AC-Plugin"} / "invalid X-AC-Plugin"; X-AC-Grants-Revision missing is 400 {ok:
false, error: "missing X-AC-Grants-Revision"}; a value that doesn't match this agent's own current
revision — including a never-pushed null — is 409 {ok: false, error: "grants out of date",
revision: <this agent's own, or null>}, since a stale console must self-heal before its refusal
means anything; only once the agent's own copy is known-current does an ungranted capability get a
real refusal, 403 {ok: false, error: "<capability> is not granted to <plugin> on <machine>",
capability, plugin}. See Security model for how a grant reaches the agent in the first place.
GET /v1/repos(agent.git.read) →{ok, root, repos: [{name, path, ownerName}]}: every immediate child ofrepos_rootwith a.gitentry (a file for a worktree, a directory for a clone), sorted by name. A missing or unreadable root answers{ok: true, repos: [], reason: "root missing"}, never a 500.ownerName(#304) is the checkout'sowner/nameresolved from itsoriginremote (git remote get-url origin, parsed the way 1.x'sinit_config.py:parse_remotedoes), ornullwhen there's no remote or it isn't a GitHub-shaped URL — never an error. This makes the route spawn onegit remote get-urlper repo (parallelized), so it is no longer git-free the way it was before #304. All three repo routes need git: they look for it first (see below), and a machine without it answers502 {ok: false, error: "git not found on PATH"}(or, when PATH holds only a.cmd/.batshim,git is a .cmd shim; install the native binary) before any repo work, a bad:nameincluded.GET /v1/repos/:name/status(agent.git.read) →{ok, name, path, ownerName, branch, defaultBranch, upstream, ahead, behind, dirty, inProgress}(ownerNameas above), read-only (no fetch —pullis what refreshes the refsstatusreads).- git is run by absolute path (#699). Each of the three requests resolves git once, on the
refreshed PATH (the registry's on Windows, the process PATH elsewhere: the search the preflight and
the toolchain upgrade use), and every git spawn of that request names the path it found, made
absolute if PATH gave a relative entry (
exec/claudeandexec/ghresolve their tool on the same refreshed PATH, #716, andexec/claudealso makes its path absolute, the other spawn whose working directory a request chooses). This matters on Windows, where a baregitis looked for in the spawn's working directory first (git.com, thengit.exe), and every call here has a repo checkout as its working directory: agit.exethat a clone, a branch or a fast-forward put in the repo's root would otherwise run as the agent's user, unattended (the dailyrepo.pulljob reaches it). A bare name is looked up on PATH only elsewhere, so Linux was not exposed to that; a relative PATH entry is the one way a working directory could matter there, and the absolute path closes it too. See Security model for the second, general layer. POST /v1/repos/:name/pull(agent.git.pull), body{dryRun?: boolean}→{ok: true, row}whererow.statusisupdated | would | current | skipped | errorandrow.reasonnames which of the six 1.x-derived guards applied (dirty,branch,ahead,in-progress,no-upstream,diverged) when it skipped, ornot-a-repo/fetch-failed/dry-run. Fast-forward only (git merge --ff-only) — a diverged branch is reported, never resolved. Every git call runs withGIT_TERMINAL_PROMPT=0, so a credential prompt refuses instead of hanging a request until its timeout.:nameis an allowlist, not a path — it must match a machine-generated repo name pattern, must not be./.., and must actually be one of the entriesGET /v1/reposwould list; anything else is404 {"error": "no such repo"}before any git call runs (on a machine with git: without it the502above comes first).POST /v1/open(agent.desktop.open), body{url}→{ok: true}. Only ahttps:orclaude:URL is accepted (the console's own deep-link shape,claude://code/new?q=<prompt>&folder=<path>); anything else is400before any process spawns. The200attests only that the launcher (rundll32.exe url.dll,FileProtocolHandleron Windows,xdg-openon Linux) started — never a shell string, always argv — not that a browser or Claude Desktop actually opened; that's visible to the person at the desktop, not to the caller.POST /v1/scan/<kind>(agent.scan.<kind>, one capability per kind), body{}— a pure dispatch shell (#49, 49c) that mounts one verb per kind registered into it; an unregistered<kind>is404 {"error": "no such verb"}, the same as any other unregistered route — there is no separateGET /v1/scanlist, since the kinds a machine implements are already visible asagent.scan.<kind>entries inGET /v1/health'scapabilities. One run per kind at a time: a secondPOSTwhile one is still in flight is409 {ok: false, error: "scan already running"}(these scans read whole~/.claudetrees and must not stack); a kind's ownrun()gets anAbortSignalthat fires at itstimeoutMs— a run that doesn't honour it promptly answers the client504 {ok: false, error: "scan timed out after <n> ms"}while the run keeps going in the background (a laterPOSTfor that kind still gets409, now naming "timed out, still finishing", until the run actually settles); one that never settles at all is abandoned five timeouts later, freeing the kind up rather than locking it for the agent's whole lifetime. A successful run answers200 {ok: true, kind, generatedAt, durationMs, payload}; a rejected one,502 {ok: false, error: "<message>"}.context-pressureandsubagent-cost(#52, 52a;timeoutMs: 900_000— a cold scan can read on the order of a gigabyte of transcripts) both scanclaude_projects_dir(default<homedir>/.claude/projects) and are byte-compatible with the 1.xartifacts-consolescheduled tasks of the same name: same JSON shape, same key order, same incremental(mtime, size)cache, sameos.linesepcache-file line ending.context-pressurewalks<root>/<encoded-project>/<session>.jsonl(main session transcripts only — a subagent's own transcript has its own separate window and is never folded in) and reports each session's peak context-window occupancy (input + cache_read + cache_creationtokens, as a percentage of the model's window) plus its compaction and unmarked-drop counts.subagent-costwalks<root>/<encoded-project>/<session>/subagents/**/agent-*.jsonl(both the plain-Task layout and the nested Workflow one) and reports each subagent's token totals and an API-equivalent dollar figure at list rates (never money actually spent — this box runs Claude Code on a subscription). Both read exactly six fields per transcript record (type,subtype/attributionAgent,timestamp,cwd,message.{id, model, usage});message.content-- prompts, tool results, file contents -- is parsed in memory with the rest of the line but never read or kept, and never leaves the process, in either scan. The cache lands at<stateDir>/scans/context-pressure.json/<stateDir>/scans/subagent-cost.json, created on first write and removed byuninstallregardless of--purge. Beside each cache sits a<kind>.tablesfile holding a digest of the tables its rows were computed from (the context windows; the prices and cache multipliers). A cache whose digest differs, or that has none, is dropped and every transcript re-parsed, and a row naming an unmapped (context-pressure) or unpriced (subagent-cost) model is re-parsed on every scan, so adding the model to the table fixes its rows without a manual cache delete.memory-footprint(#52, 52b; no customtimeoutMs-- a cold run is kilobytes of I/O, not the transcript-scale reads above) is a producer only, computed live on every request and never cached: the fixed 8-layer set Claude Code assembles into a session's preamble (enterprise,user,user-rules-- global, loaded into every session everywhere -- plusproject,project-alt,project-rules,project-local,auto-memoryper repo,auto-memorytruncated to the first 200 lines ofMEMORY.mdunderclaude_projects_dir, same ascontext-pressure/subagent-cost). The repo set isGET /v1/repos' own listing (every immediate child ofrepos_rootwith a.gitentry) -- unlike 1.x, which read the console's configured repo list, so the payload'srepoandnamefields are both the directory name, never anowner/namestring. Tokens are a byte estimate (~4 chars/token; the payload saysestimated: true); only sizes and line counts ever leave this scan -- file content is never read into the payload. Every layer file islstated first: a layer file or the rules directory itself that is a symlink, anything that is not a regular file, or a file over 1 MiB is skipped as if absent; inside the rules directory, symlinked files and directories are not followed, and the walk goes at most 8 directories deep, so a hostile checkout cannot point a layer at/dev/zero, a FIFO or a huge file. Accepted limitation: a skipped layer is silent and reads as absent (the payload has no field for it), so a dotfiles-managed~/.claude/CLAUDE.mdthat is a symlink, or any layer file over 1 MiB, is undercounted.toolchain-inventory(#52, 52b;timeoutMs: 600_000-- about seventy version probes plus a WSL probe) ships and runs 1.x's ownGet-ToolchainInventory.ps1(vendored byte for byte from 1.xmainatda0848f,data/README.md) throughpwsh -NoProfile -File <script> -OutFile <state>/scans/ toolchain-inventory-<hostname>.md -NoRender, with the child'scwdset to<state>/scans-- never a project directory, since the script's ownpnpm --versionprobe rewritespnpm-lock.yamlin whatever directory it runs in. Windows and Linux: on any other platform (darwin) this answers{skipped: true, reason: "toolchain-inventory runs on Windows and Linux only"}without spawning anything. On Linux (LX3, #1042) there is no pwsh: the agent runsbash(502 {"error": "bash not found on PATH"}before any spawn when it is missing) on the vendored probe,data/toolchain-probe.sh-- the bashNew-ToolchainProbeScriptbuilds, kept at<state>/scans/ toolchain-probe.sh(same rewrite-on-change rule as the script) -- parses its output and renders the report itself (src/scans/toolchain-linux/, a TypeScript port pinned byte for byte to the PowerShell's own output). The report is named like a Windows one,toolchain-inventory-<hostname>.{md,json}; its_Generated:line reads<hostname> (ac-agent <version>).resultisunchangedwhen only that line differs from the previous.mdand a readable sidecar exists,writtenotherwise (the.mdis rewritten only when its text changed; a missing or unreadable sidecar is rewritten on its own). The-NoRender/cwddetails above are the pwsh path's; the probe child also runs withcwdset to<state>/scans. A timed-out, aborted or failing probe istoolchain-probe.sh timed out after N ms,scan abortedortoolchain-probe.sh exited N: <last line>. Writes (and reads back)<state>/scans/ toolchain-inventory-<hostname>.mdand its.jsonsidecar, and maintains its own copy of the script at<state>/scans/Get-ToolchainInventory.ps1(rewritten only when the bundled text changes, via a temp file + rename so a killed agent never leaves a half-written copy). A Store-installedpwsh-- an App Execution Alias under%LOCALAPPDATA%\Microsoft\WindowsApps, which a plain Node exists check cannot see -- is found: on Windows the agent's exists check falls back tolstat(#439), the same checkwhichand the preflight use. A missingpwshon PATH is502 {"error": "pwsh not found on PATH; probed: <each PATH directory's pwsh.exe, in PATH order; past 40 directories, the first 20 and the last 20 around ... (+N more) ...>"}before any spawn (the registry PATH lists the machine hive then the user hive, so the tail is the end of the user hive, where WindowsApps is; entries in the middle of a very long PATH can still be left out, and the marker counts them); the request timeout kills only thepwshchild, never a hung probe orwsl.exeprocess it may have spawned (1.x had the same scope). The hostname used for the file name and inside the script's own report isos.hostname()-- never the operator's configuredmachine/--nameoverride -- so the agent's file name always matches 1.x's own for a byte-compare.
POST /v1/exec/claude and the job registry
POST /v1/exec/claude(agent.exec.claude), body{repo, prompt, permissionMode?, model?}→202 {ok: true, jobId}.reporesolves through the same allowlist asGET /v1/repos/:name/status(a 404 before anything spawns);promptis a non-empty string, at most 8 KiB (the WindowsCreateProcesscommand-line limit is 32,767 characters total, and quoting can double a prompt's own backslashes and quotes — 8 KiB leaves headroom for the fixed flags and the repo path in the worst case) with no control character other than\n/\t, and not starting with-(400 prompt must not start with '-':claudewould read it as a flag);permissionModeis one ofplan(the default) /acceptEdits/bypassPermissions;modelis an optional^[A-Za-z0-9._-]{1,64}$string. Any other shape is400before anything spawns.clauderuns asclaude -p <prompt> --permission-mode <mode> --add-dir <repo path> [--model <model>],cwdthe resolved repo.claudeis looked up on the refreshed PATH (the registry's on Windows, the process PATH elsewhere), and the job runs with that PATH, so aclaudeinstalled after the agent started is found without a restart (#716). At most 4claudejobs run at once — a 5th concurrent request is429 {ok: false, error: "too many running jobs"}, checked before spawning, not after. Onlyclaudejobs count: a running toolchain upgrade (#675) neither uses up a slot nor is blocked by a claude job.GET /v1/jobs(agent.exec.claude) →{ok: true, jobs: [view...]}, newest first.GET /v1/jobs/:id→{ok: true, job: view}or404for an unknown id. A job view is{id, kind: "claude", label, status: "running"|"done"|"failed", startedAt, endedAt?, exitCode?: number|null, repo, permissionMode}.POST /v1/jobs/clear-finished→{ok: true, cleared}, dropping every done/failed job (running ones are kept). These routes serveclaudejobs only (#675): the registry also holdstoolchain-upgradejobs, which a plugin holding onlyagent.exec.claudemust not list, read, stream or clear, so another kind's job id is404 no such jobhere, exactly like an unknown one, andclear-finishedleaves them alone.GET /v1/jobs/:id/events(agent.exec.claude) streams a job's log as Server-Sent Events: a404for an unknown id (or another kind's) up front, otherwise a finished job replays its whole buffer and closes, and a running job replays its buffer then streams new lines live as they arrive, closing once the job finishes. Two frame shapes only:event: linewithdatathe JSON-encoded line text andidthe line's absolute position in the job's log (so a reconnecting client can tell whether it missed lines to eviction);event: donewithdata{"status": "done"|"failed", "exitCode": number|null}. These are the exact event names and shapes the host's own/api/tasks/:id/logemits (packages/host/src/tasks/route.ts) withexitCodeadded, since the console's shell code already knows how to consume them.
POST /v1/exec/gh
POST /v1/exec/gh(agent.exec.gh), body{args: string[]}→200 {ok: true, code, stdout, stderr, truncated}(stdout/stderr each capped at 1 MiB,truncated: truewhen either was cut or when collection stopped at the cap, which also killsgh). A non-zeroghexit is still a200— the caller readscode; aghthat cannot be launched answerscode: 127. At most 32 arguments, each at most 4 KiB with no control characters. Only a--bodyvalue (--body <text>or--body=<text>) may be longer, up to 64 KiB (24 KiB on Windows, where the command line is limited) and contain\n/\t(a PR/issue comment body); any other flag's value,--search=<text>included, is held to the 4 KiB and no-control-character rule.The
ghchild gets an allowlisted environment, not the agent's (#714):PATH(the refreshed PATHghwas looked up on, #716), the per-OS profile/temp/locale keys, proxy andSSL_CERT_*keys,GH_HOSTandGH_CONFIG_DIR, plusGH_PROMPT_DISABLED,GH_NO_UPDATE_NOTIFIERandNO_COLOR.GH_TOKEN,GITHUB_TOKEN,GH_ENTERPRISE_TOKENandGITHUB_ENTERPRISE_TOKENare deliberately left out, soghtakes its credential fromgh auth loginon the machine. A--jqprogram that names the environment (env,$ENV) is refused400 invalid value for --jq: ...before anything spawns.Every argument is checked against a fixed allowlist before anything spawns — an unlisted subcommand, flag, or malformed positional is
400 {ok: false, error: "<token and why>"}, logged asgh refused: <error>, additive-only (nothing here is ever loosened without a deliberate change to the table):| Subcommand | Positional | Flags | |---|---|---| |
issue view| one^[0-9]{1,9}$|--repo,--json,--jq| |issue list| none |--repo,--json,--jq,--limit(1-200),--label,--author,--search,--state(open|closed|all) | |issue comment| one^[0-9]{1,9}$|--repo,--body| |pr view| one^[0-9]{1,9}$|--repo,--json,--jq| |pr list| none |--repo,--json,--jq,--limit(1-200),--label,--author,--search,--state(open|closed|merged|all) | |pr checks| one^[0-9]{1,9}$|--repo,--json,--jq| |pr comment| one^[0-9]{1,9}$|--repo,--body| |release view| one^v?[0-9][A-Za-z0-9._-]{0,63}$|--repo| |api| one^/?(repos\|orgs\|users\|rate_limit)(/(?!\.{1,2}(?:/\|$))[A-Za-z0-9._-]+)*$, no query string |--jq,--method(GETonly) |--repomust be^(?!\.{1,2}(?:/|$))[A-Za-z0-9._-]+/(?!\.{1,2}(?:/|$))[A-Za-z0-9._-]+$(a bare./..half is refused the same wayresolveRepoalready refuses a bare./..repo name — a real owner or repo name may still contain a dot, e.g.my.repo);--jsonmust be^[A-Za-z,]+$. Both this and theapirow's path pattern above build the same./..-excluding segment rule from one shared source (SAFE_SEGMENT_SOURCEingh-allowlist.ts), not two independent copies.apiis read-only by construction: no request-body flags at all (-f,-F,--input, or--methodwith anything butGETare refused),searchis deliberately not in the path allowlist since a real search needs aq=query string this table never permits, and a bare./..path segment is refused too — the segment class allows a literal.for a real repo/org name (my.repo), so without this a/repos/../search/issueswould otherwise satisfy the pattern and reach exactly the excluded path space.--flag=valueis split before matching, and a bare-/--is always refused. A flag or subcommand not in this table —repo delete,issue delete,--body-file,--editor,--web,--hostname, anything else — is refused the same way, beforeghis ever resolved or spawned.
The .cmd/.bat shim note (claude and gh)
Both exec/claude and exec/gh resolve their binary by walking PATH themselves rather than
letting a shell do it, because neither ever sets shell: true (request input reaching a shell is
exactly what this agent refuses to do). Node itself cannot spawn a .cmd/.bat shim without
shell: true (EINVAL, since the CVE-2024-27980 fix) — so a claude or gh install that only put
a shim on PATH (common for a global npm install on Windows) answers 502 with a message naming
the shim specifically ("claude is a .cmd shim; install the native Claude Code binary" /
"gh is a .cmd shim; install the native GitHub CLI binary"), distinct from a bare
"<name> not found on PATH" — the operator's fix differs (install the native binary vs. install it
at all). Windows PATH resolution stops at the first directory with any matching extension, so a
shim earlier on PATH is reported even if a native binary exists further down — that is what a real
invocation would actually run, not "helpful" behavior a real shell wouldn't show either.
POST /v1/publish/mirror
POST /v1/publish/mirror (agent.publish.mirror, #53) is the 2.0 home of 1.x's hourly
publish_onedrive.py: the console pushes changed report HTML to this verb rather than the agent
pulling from the console (the console sits behind Cloudflare Access, which an agent cannot pass).
Body {files: [{href, base64}], wanted: string[], pdf: boolean, prune: boolean} — files at most
1000 entries, each href a relative POSIX path (no empty/./.. segment, no drive/UNC anchor)
ending .html, base64 a valid base64 string, decoded total at most 48 MB (a request body over
~66 MB is rejected by the route's own body-size cap (larger than the 16 KiB cap on the pair, grants and update routes) before JSON parsing even runs, 413
{ok: false, error: "request body too large"}); wanted an array of such hrefs (every html href the
caller currently wants mirrored, not just the changed ones in files); pdf/prune booleans. 409
{ok: false, error: "publish target not configured"} when publish_dir is unset (most paired
machines — a Linux box has no OneDrive). One run at a time: 409 {ok: false, error: "publish already
running"}, which also answers while an earlier request is still sending its body (the guard is taken
before the body is read, so a client that opens a request and never finishes it holds it for other callers).
- HTML: each file's bytes are compared against what's on disk (size, then sha256) — unchanged
bytes are never rewritten (OneDrive re-uploads every rewrite); a changed or new file is written to
<publish_dir>/html/<href>.tmp-<pid>then renamed into place. A write/rename failure (a file OneDrive has locked mid-sync) counts asfailed, is named inhtml.failureswith its error's message, and the run continues with the next file. - PDF (when
pdf: true): onepdf/<href>.pdfperwantedreport (never for anindex.htmlpage), rendered with a local headless Chromium-family browser only when missing or older than its html — resolved via the same searchGET /v1/preflight'schromiumrow uses (winPaths, then PATH, then a version manager's bin dir), never executed otherwise. No resolvable browser is not an error: html still mirrors, the response'spdf.browserisnull. A render that produced no file counts asfailedand is named inpdf.failuresby its.pdfhref:timed out after 90swhen the browser launch timed out,exit <code>: <the last 160 characters of its stderr>when it exited non-zero (justexit <code>when it printed nothing), elseno pdf produced within 30s. The poll for the file still decides success, so a browser that exits non-zero, or times out, but wrote the pdf is a success. A report whose html is not on disk is skipped, never named. - Ownership, not "not in my docroot" (1.x issue #230): the publish target is shared across every
paired machine that mirrors to it, so pruning (
prune: true) is scoped to what THIS machine's ledger —<stateDir>/published-ledger.json, machine-local, never inside the publish target itself — claims it published; a file another machine owns is never even considered for deletion. With no ledger yet, a machine adopts conservatively (onlywanted ∩ mirrored). A prune is refused outright (a veto, nothing deleted) whenwantedis empty, or when it would delete ≥5 files AND over half the mirror in one run — the response'shtml.veto/pdf.vetonames the reason; re-run with the same request (or a--force-equivalent isn't exposed here — seepublish-adoptbelow for the one-time claim that closes an adoption gap) once satisfied it's intended. - Response
200 {ok: true, dest, html: {written, unchanged, failed, pruned, veto, failures}, pdf: {rendered, upToDate, failed, pruned, veto, browser, failures}}.failures(added in #376) is[{href, reason}]naming each report that failed to write or render (a pdf by its.pdfhref), capped at 50 per kind (failedkeeps counting past it). It is always present from this agent ([]when nothing failed) and absent from an older one, so a console must treat it as optional. Areasonis one line of at most 200 characters (whitespace collapsed), with this run'spublish_dirreplaced by<dest>and the browser's temporary profile dir by<tmp>wherever they appear as literal text: as configured,path.normalized, with either separator, or percent-encoded as in afile://URL. The response'sdestalready names the former. Only an absolute path is replaced (a relativepublish_diris left alone rather than rewriting every occurrence of its name). A path that differs in case, is a Windows short (8.3) name or is the\\?\UNC\...form
