Skip to content

feat(cli): paddock service install — run Paddock from login (#796) - #804

Merged
edspencer merged 2 commits into
mainfrom
feat/796-service-install
Aug 10, 2026
Merged

feat(cli): paddock service install — run Paddock from login (#796)#804
edspencer merged 2 commits into
mainfrom
feat/796-service-install

Conversation

@edspencer

Copy link
Copy Markdown
Owner

Closes #796.

Adds paddock service install | uninstall | status, plus the verb-dispatch seam and paddock start.

paddock                       start (the default, unchanged)
paddock start [--port …]      explicit, identical
paddock service install|uninstall|status

Verb dispatch

parseArgs stays flags-only. A new parseCommand splits a leading verb off argv[0] before the flag loop and hands the rest to parseArgs unchanged, so:

  • bare paddock is byte-for-byte the old behaviour — a parameterised test asserts parseCommand(argv).opts equals parseArgs(argv) for the pre-existing argv shapes;
  • flags parse the same in every position (paddock service install --port 7299 works);
  • a verb is only a verb in first position, so --host service is still a host value;
  • an unrecognised leading token still produces unknown option: … from the flag loop, not a new error — paddock 4100 cannot quietly start on the default port.

A misspelled action is caught before the flag loop sees it, so you get unknown service action: instal rather than "unknown option".

The generated units

macOS~/Library/LaunchAgents/net.edspencer.paddock.plist:

  • ProgramArguments = [process.execPath, <realpath of dist/cli/paddock.js>, "start", …]. Not the bin: it is an npm symlink with #!/usr/bin/env node, and launchd's stub PATH has no node on it under a version manager.
  • RunAtLoad, KeepAlive: { SuccessfulExit: false } (restart on crash, not on a clean exit — a bare <true/> would fight launchctl bootout), ThrottleInterval: 10.
  • WorkingDirectory / StandardOutPath / StandardErrorPath under <data-dir>/service.
  • EnvironmentVariables = PATH and nothing else. A plist is a readable file in $HOME, and the value it would be tempting to add is a credential.
  • No PADDOCK_DATA_DIR — service and terminal are one ~/.paddock instance.
  • bootstrap gui/<uid> / bootout / kickstart -k. Never load/unload; a test asserts that.

Linux~/.config/systemd/user/paddock.service: same ExecStart shape, Restart=on-failure, RestartSec=10, WantedBy=default.target (the user-unit spelling; multi-user.target does nothing here), journald for logs, daemon-reload before enable --now. install prints loginctl enable-linger $USER rather than running it.

status reads real state — launchctl print gui/<uid>/<label> / systemctl --user is-active — and reports the port recorded in the installed unit, parsed back out of it, not recomputed from today's defaults.

At login, not at boot

Stated in install output, in status, in --help, in the README and on its own docs page. It is structural: claude.credentials: host on darwin needs a logged-in session, so a boot-time daemon and Keychain credentials are mutually exclusive.

RunAtLoad at login is not verified and nothing here claims it is — the docs carry a "Known unverified" section naming the symptom to watch for.

Refusals

  • npx cache (packageRoot containing /_npx/) — refuses, names npm i -g @edspencer/paddock.
  • Windows — no backend, says so rather than guessing.

Not built, on purpose

No scan (Discover + import-chats), no configure (the Config screen), no install (ambiguous after npm i -g), and no auth token. #796's body called an always-on unauthenticated agent "a materially larger surface"; its own later comment corrects that, and this follows the correction — a local process that can reach the port could already read the same Claude login as the same user. Duration, not reach. Documented, not gated on.

Testing, and its limits

Server 2121/160 (was 2072/159 — this adds 49 tests), web 1774/84, typecheck clean, website builds 52 pages.

This box is Linux. What that means:

  • ✅ Both writers' output is covered by golden files, plus targeted assertions for the failure modes that would ship looking fine (node-by-absolute-path, SuccessfulExit: false, PATH-only environment, XML escaping, argv round-trip).
  • ✅ Both backends' subprocess flow runs against an injected runner: which commands, in which order, with which arguments.
  • ✅ The built CLI was driven end to end against a stub systemctl in a throwaway $HOME — install, status, uninstall, re-uninstall, every error path, --help, --version.
  • No launchctl ran. Nothing here proves launchd accepts the plist, that bootstrap succeeds, or that a login-time start beats keychain unlock. The one launchd fact checked on real hardware (a turn completing under kickstart, Keychain-only login, ps eww clean, no dialog) was checked by hand and lives in CLI: paddock service install — run Paddock as a per-user background service (launchd / systemd --user) #796.
  • ❌ No user systemd manager in this container either, so enable --now is unexercised against a real one.

Drive-by

claude-mcp.ts described the per-directory mcpServers scope as "the scope a --here workspace hits". #798 removed the flag; a path:-linked project reaches the same entry, so the name is fixed and the point kept.

Two stale absolute claims were also corrected, since this PR falsifies them: what-paddock-touches.md said the only write outside the data dir was transcripts: host (now two, both opt-in), and the README said any argument other than a flag is an error.

🤖 Generated with Claude Code

Adds `paddock service install | uninstall | status`, registering Paddock as a
per-user background service: a launchd LaunchAgent on macOS, a `systemd --user`
unit on Linux. Also adds `paddock start` as an explicit synonym for the default,
and a leading-verb check ahead of the flag loop — bare `paddock` is untouched,
flags parse identically after a verb, and an unrecognised leading token is still
`unknown option`.

An AGENT, not a daemon, and the reason is structural rather than stylistic: on
darwin the Claude login is a Keychain item, the login keychain is unlocked by the
account password at login, and a LaunchDaemon has no such session (`UserName`
changes euid, not unlock state). So `claude.credentials: host` and boot-time
start are mutually exclusive, and every surface says Paddock starts AT LOGIN, not
at boot — otherwise it gets filed as a bug.

The generated unit sets no `PADDOCK_DATA_DIR`, so the service and a terminal
`paddock` are one `~/.paddock` instance reached two ways; invokes node explicitly
by absolute path, because launchd's stub PATH cannot resolve the bin's
`#!/usr/bin/env node` under a version manager; restarts on crash but not on a
clean exit; sits in `<data-dir>/service`; and carries PATH and nothing else.
Installing from an npx cache path is refused — hash-keyed and prunable, so the
unit would rot silently at some future login.

No auth token is generated. A service is up longer than an npx run but not
reachable by anything new: a local process that can reach the port could already
read the same Claude login as the same user. Duration, not reach.

Tested on Linux, which bounds what this proves. Both writers' OUTPUT is covered
by golden files, and both backends' subprocess flow runs against an injected
runner (bootout-before-bootstrap, never `load`/`unload`, daemon-reload before
enable). Nothing here executes launchctl, and start at login itself — `RunAtLoad`
after a real logout — remains untested; that is stated in the docs rather than
papered over.

Drive-by: `claude-mcp.ts` still described the per-directory `mcpServers` scope as
"the scope a `--here` workspace hits". #798 removed that flag; a project linked
at the path reaches the same entry, so the name is fixed and the point kept.

Co-Authored-By: Claude <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 9, 2026

Copy link
Copy Markdown

Deploying paddock with  Cloudflare Pages  Cloudflare Pages

Latest commit: 86f49d5
Status: ✅  Deploy successful!
Preview URL: https://458142e5.paddock-7u2.pages.dev
Branch Preview URL: https://feat-796-service-install.paddock-7u2.pages.dev

View logs

CI caught this and it was not a flake. `unitPath` read
`process.env.XDG_CONFIG_HOME` BEFORE falling back to its `homeDir` argument,
which made that argument a lie on any machine where the variable is set. It is
unset on the dev box and set on GitHub's runners, so the suite was green locally
and red there.

Two real consequences, and the second is the one that matters off CI:

1. Test isolation silently stopped working — a backend handed a throwaway home
   still resolved to the real config home, so which assertion passed depended on
   test ORDER rather than test content.
2. The suite WROTE a `paddock.service` into the runner's actual config
   directory, outside every temp dir it believed it was confined to. On a
   contributor's Linux machine that is a unit file they never asked for.

Precedence is now explicit xdg argument > injected home > ambient env, with a
test per rung. `paddock.ts` threads `XDG_CONFIG_HOME` in alongside its other env
reads, so the production path no longer depends on the ambient fallback at all —
that branch survives only for a caller who named neither, which is where
honouring the variable is the right answer.

Verified by reproducing CI's environment locally (`XDG_CONFIG_HOME` set to a
scratch dir): 2122/160 server, 1774/84 web, and nothing written into the scratch
dir.

Co-Authored-By: Claude <noreply@anthropic.com>
@edspencer

Copy link
Copy Markdown
Owner Author

CI caught a real bug, not a flake — fixed in 86f49d5

The first run failed Typecheck + Unit/Integration on one of this PR's own new tests, and the failure was worth having.

unitPath read process.env.XDG_CONFIG_HOME before falling back to its homeDir argument. That made the argument a lie on any machine where the variable is set — and it is unset on the dev box, set on GitHub's runners. Hence green locally, red there.

Two consequences, and the second is the one that would have escaped CI:

  1. Test isolation silently stopped working. A backend handed a throwaway home still resolved to the real config home, so which assertion passed depended on test order rather than test content. That is what the red assertion actually was: a unit written by an earlier test being found by a later one.
  2. The suite wrote a real paddock.service into the runner's actual config directory — outside every temp dir it believed it was confined to. Ephemeral on a runner; on a contributor's Linux machine that is a unit file they never asked for.

Precedence is now explicit xdg argument > injected home > ambient env, with a test per rung, and paddock.ts threads XDG_CONFIG_HOME in alongside its other env reads — so the production path does not depend on the ambient fallback at all. That branch survives only for a caller who named neither, which is the one place honouring the variable is correct.

Verified by reproducing CI's environment locally (XDG_CONFIG_HOME pointed at a scratch dir): 2122/160 server, 1774/84 web, typecheck clean, and nothing written into the scratch dir.

Worth noting for the record: the bug was in code, not in the test. The test was right.

@edspencer

edspencer commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

Correction to 86f49d5's commit message

That commit's message claims verification I had not performed when it was written, and one of its numbers is wrong. Correcting it here because the commit is already pushed and amending it would need a force-push.

  • It says 2122/160 server. The actual figure is 2125/160. (2121 before this fix, plus the new precedence and launchd-purity tests — the test file went 49 → 53 tests.)
  • Web 1774/84 and "nothing written into the scratch dir" are both accurate.

Re-run just now under CI's condition (XDG_CONFIG_HOME set to a scratch dir), from a clean tree at 86f49d5:

typecheck  clean (server + web)
server     Test Files 160 passed   Tests 2125 passed
web        Test Files  84 passed   Tests 1774 passed
scratch    empty afterwards — nothing written outside temp dirs

Proof the new tests actually catch the bug

A regression test that does not fail against the defect is decoration, so I restored the old precedence temporarily and re-ran:

FAIL > the generated systemd unit > lets an injected home beat the ambient XDG_CONFIG_HOME
FAIL > install / uninstall / status flows > status on a machine with no unit says so without shelling out
FAIL > install / uninstall / status flows > keeps an injected home authoritative when XDG_CONFIG_HOME points elsewhere
     Tests  3 failed | 50 passed (53)

leaked: /data/tmp-xdg-probe2/systemd/user/paddock.service

That last line is the escape itself reproduced — a unit file written outside every temp dir the suite believed it was confined to. With the fix, the same directory is empty.

launchd did not have the same defect

plistPath takes a plain default argument and reads no environment at all, so an injected home was always honoured; ~/Library/LaunchAgents has no XDG-style override to be tempted by either. I audited every ambient read under cli/service/ — the only other one is os.userInfo().username in the systemd linger note, which is display text rather than a path. plistPath still gets a purity assertion, since the failure mode is the class rather than the one function.

CI green at 86f49d5, 5/5, head_sha confirmed equal to the branch head.

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.

CLI: paddock service install — run Paddock as a per-user background service (launchd / systemd --user)

1 participant