diff --git a/.changeset/gentle-poems-repeat.md b/.changeset/gentle-poems-repeat.md new file mode 100644 index 00000000..2fcfe157 --- /dev/null +++ b/.changeset/gentle-poems-repeat.md @@ -0,0 +1,30 @@ +--- +"@paddock/server": minor +--- + +`paddock service install | uninstall | status` — keep Paddock running in the background (#796) + +Registers Paddock as a **per-user** background service: a launchd **LaunchAgent** on +macOS (`~/Library/LaunchAgents/net.edspencer.paddock.plist`), a **`systemd --user`** unit +on Linux (`~/.config/systemd/user/paddock.service`). `install` writes the unit and starts +it, `uninstall` stops and removes it, `status` reads real state back out of `launchctl +print` / `systemctl is-active` — including the port the unit was actually installed with. + +It starts **at login, not at boot**, and every surface says so. That is not a limitation +to be worked around: on macOS your Claude login is a Keychain item, the login keychain is +unlocked by your account password at login, and a boot-time `LaunchDaemon` has no such +session — so `claude.credentials: host` and boot-time start are mutually exclusive. On +Linux, `install` prints the `loginctl enable-linger` you need to survive logout rather +than running it for you. + +The generated unit sets **no** `PADDOCK_DATA_DIR`, so the service and a `paddock` typed +into a terminal are the same `~/.paddock` instance reached two ways; invokes `node` +explicitly by absolute path (launchd's stub `PATH` cannot find the bin's +`#!/usr/bin/env node`); restarts on crash but not on a clean exit; sits in +`/service` rather than `$HOME`; and carries `PATH` and nothing else in its +environment. Installing from an npx cache path is **refused** — those are hash-keyed and +prunable, so the unit would rot silently at some future login. + +Also: `paddock start` is now an explicit synonym for the default, and the CLI parses a +leading verb before its flags. Bare `paddock` is unchanged, flags parse the same in every +position, and an unrecognised leading token is still an `unknown option` error. diff --git a/README.md b/README.md index f00760b2..f14cee28 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,14 @@ Needs **Node 22+**. First run downloads ~250 MB — Paddock drives Claude Code, the Agent SDK ships a per-platform binary of that size; later runs reuse the npm cache. For repeated use, `npm i -g @edspencer/paddock` beats bare `npx`. -The full flag set — any other argument is an error, and `--help` is the canonical -short-form reference: +There are two subcommands, and both are optional detail: bare `paddock` starts the +server, `paddock start` is the same thing said out loud, and +[`paddock service`](https://paddock.edspencer.net/guides/running-as-a-service/) +registers it as a background service that comes back at login. Anything else in +leading position is an error. + +The full flag set — they parse the same after a subcommand as without one, and +`--help` is the canonical short-form reference: | Flag | Purpose | |---|---| @@ -122,6 +128,24 @@ short-form reference: Temporal's default frontend port, which is the usual collision. The failure is loud — Paddock names the port and the flag and exits.) +### Always-on: your own laptop + +`npx` is a terminal tab. To keep the same `~/.paddock` instance running in the +background instead: + +```bash +npm i -g @edspencer/paddock +paddock service install # uninstall | status +``` + +A launchd **LaunchAgent** on macOS, a **`systemd --user`** unit on Linux. It starts +**at login, not at boot** — a per-user agent is what lets it read the Claude login +you already have, and on macOS a boot-time daemon structurally cannot (the login +Keychain is unlocked by your password *at login*). On Linux you also want +`loginctl enable-linger $USER`, or logging out stops it. Full detail, including +what the generated unit contains and what is still unverified: +[Keeping Paddock running on your laptop](https://paddock.edspencer.net/guides/running-as-a-service/). + ### Always-on: Docker For a server rather than a laptop, run the published image, point it at a data diff --git a/packages/server/src/claude-mcp.ts b/packages/server/src/claude-mcp.ts index ba224556..f6795449 100644 --- a/packages/server/src/claude-mcp.ts +++ b/packages/server/src/claude-mcp.ts @@ -245,8 +245,10 @@ export interface HostMcpCaveat { * top-level `mcpServers` that applies everywhere, and a * `projects[].mcpServers` that applies only in that directory. The * per-directory one is keyed by the LITERAL absolute path — not the `-`-encoded - * form the transcript folders use — and it is the scope a `--here` workspace - * hits, because `claude mcp add` without `--scope user` writes there. + * form the transcript folders use — and it is the scope a project linked at + * that path hits, because `claude mcp add` without `--scope user` writes there. + * (It used to say "a `--here` workspace"; that flag is gone as of #798, but the + * point survives it — a `path:` project reaches exactly the same entry.) * * {@link declared} is the third contributor and the one that is not the host's at * all: paddock's own top-level `mcpServers:` config block (#691 step 6), resolved diff --git a/packages/server/src/cli/args.ts b/packages/server/src/cli/args.ts index 25577f07..f25dc23f 100644 --- a/packages/server/src/cli/args.ts +++ b/packages/server/src/cli/args.ts @@ -39,6 +39,80 @@ export class CliError extends Error {} export const MIN_NODE_MAJOR = 22; +/** What `paddock service` can be asked to do. */ +export const SERVICE_ACTIONS = ["install", "uninstall", "status"] as const; +export type ServiceAction = (typeof SERVICE_ACTIONS)[number]; + +/** The leading words {@link parseCommand} recognises. Anything else is a flag. */ +export const VERBS = ["start", "service"] as const; + +/** + * A parsed invocation: which verb, plus the flags that followed it. + * + * `action` is optional on `service` for exactly one reason — `paddock service + * --help`, where there is no action to name and printing usage is the whole + * request. It is `undefined` only when `opts.help` is true. + */ +export type Command = + | { verb: "start"; opts: CliOptions } + | { verb: "service"; action: ServiceAction | undefined; opts: CliOptions }; + +function isServiceAction(token: string): token is ServiceAction { + return (SERVICE_ACTIONS as readonly string[]).includes(token); +} + +/** + * Split a leading verb off the argv, then parse the rest as flags. + * + * The dispatch is deliberately a check on `argv[0]` alone rather than a scan for + * the first non-flag token, and it happens BEFORE the flag loop. Two properties + * fall out of that, both of which matter more than the flexibility given up: + * + * - **Bare `paddock` is untouched.** No verb means the whole argv goes to + * {@link parseArgs} exactly as before, so the demo path cannot change + * behaviour, and an unrecognised leading token still produces `unknown + * option:` from the flag loop rather than a new and different error. + * - **Flags parse after a verb**, so `paddock start --port 7299` and + * `paddock service install --port 7299` both work, and the flag grammar is + * the same one in every position. + * + * A verb is only a verb in first position: `paddock --port start` is still a + * missing-value error, and `paddock start start` is still `unknown option`. + */ +export function parseCommand(argv: string[]): Command { + const [first, ...rest] = argv; + + if (first === "service") { + const head = rest[0]; + let action: ServiceAction | undefined; + if (head !== undefined && !head.startsWith("-")) { + // Catch a misspelled action here rather than letting the flag loop call + // it an "unknown option", which sends the reader looking for a flag. + if (!isServiceAction(head)) { + throw new CliError( + `unknown service action: ${head}\n` + + `Expected one of: ${SERVICE_ACTIONS.join(", ")}.\n` + + "Run `paddock service --help` for usage.", + ); + } + action = head; + } + const opts = parseArgs(action === undefined ? rest : rest.slice(1)); + // `--help` wins over a missing action: asking for usage is not a usage error. + if (action === undefined && !opts.help) { + throw new CliError( + `\`paddock service\` needs an action: ${SERVICE_ACTIONS.join(", ")}.\n` + + "Run `paddock service --help` for usage.", + ); + } + return { verb: "service", action, opts }; + } + + if (first === "start") return { verb: "start", opts: parseArgs(rest) }; + + return { verb: "start", opts: parseArgs(argv) }; +} + export function parseArgs(argv: string[]): CliOptions { const opts: CliOptions = { open: false, @@ -130,7 +204,11 @@ export function explainListenError(err: unknown, host: string, port: string): st export const USAGE = `paddock — run a Paddock instance locally Usage - npx @edspencer/paddock [options] + npx @edspencer/paddock [options] start the server (the default) + paddock start [options] the same thing, said out loud + paddock service + run it in the background from login + (\`paddock service --help\`) Options -p, --port HTTP/WS port (default 7233, or $PORT) @@ -213,3 +291,56 @@ Notes a routable interface wide open. See AUTH.md. Docs: https://github.com/edspencer/paddock`; + +export const SERVICE_USAGE = `paddock service — keep Paddock running in the background + +Usage + paddock service install [options] register it and start it now + paddock service uninstall stop it and deregister it + paddock service status is it registered, is it running, where are the logs + +Options (install only — recorded in the generated unit) + -p, --port HTTP/WS port (default 7233) + --host Bind address (default 127.0.0.1) + -d, --data-dir Only if you want an instance SEPARATE from your + terminal one. Omitted by default on purpose, so + \`paddock service\` and a bare \`paddock\` are the same + ~/.paddock instance reached two ways. + --verbose Record the server's own logs, not just warnings + +At login, not at boot + This registers a per-USER service — a launchd LaunchAgent on macOS, a + \`systemd --user\` unit on Linux — so it runs as you, with your own Claude + login. That is not incidental: on macOS your Claude login is a Keychain item + that only a logged-in user session can read. A boot-time system daemon has no + such session and could not use it. + + So Paddock starts when you LOG IN, not when the machine boots. After a + restart that nobody logs into, Paddock is not running. That is the design, not + a fault. + + On Linux, a user service is also stopped when you log out. To keep it up: + + loginctl enable-linger $USER + +Where it lives + macOS ~/Library/LaunchAgents/net.edspencer.paddock.plist + logs in /service/ + Linux ~/.config/systemd/user/paddock.service + logs via journalctl --user -u paddock -f + +Installed from npx? + \`service install\` refuses. An npx cache path is hash-keyed and npm may prune + it, so the unit would work until it silently didn't, at some future login. + Install properly first: + + npm i -g @edspencer/paddock && paddock service install + +A note on access + Paddock binds loopback with authentication off, which is right for a laptop. + A service is up for as long as you are logged in rather than as long as a + terminal tab, so that window is longer — but it is not wider: any local + process that could reach the port could already read the same Claude login as + you. Set PADDOCK_AUTH_MODE if you want a credential on it anyway. + +Docs: https://github.com/edspencer/paddock`; diff --git a/packages/server/src/cli/paddock.ts b/packages/server/src/cli/paddock.ts index 8350f854..60b2b432 100644 --- a/packages/server/src/cli/paddock.ts +++ b/packages/server/src/cli/paddock.ts @@ -24,16 +24,27 @@ import path from "node:path"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { - type CliOptions, + type Command, CliError, USAGE, - parseArgs, + SERVICE_USAGE, + parseCommand, nodeVersionProblem, explainListenError, } from "./args.js"; +import { runService, safeHomeDir } from "./service/index.js"; -/** This module's own directory: `<…>/packages/server/dist/cli`. */ -const moduleDir = path.dirname(fileURLToPath(import.meta.url)); +/** + * This module's own file and directory: `<…>/packages/server/dist/cli/`. + * + * `import.meta.url` is the module's REALPATH, which is why the service unit + * names this rather than `process.argv[1]` — npm installs a `bin` as a symlink, + * and a unit file pointing at `node_modules/.bin/paddock` would depend on that + * symlink surviving. (The same realpath-vs-argv[1] mismatch is what shipped a + * silent no-op to npm three times; see args.ts.) + */ +const entryScript = fileURLToPath(import.meta.url); +const moduleDir = path.dirname(entryScript); /** * Walk up from this module looking for `rel`, returning the containing dir. @@ -144,17 +155,56 @@ function openBrowser(url: string): void { } } +/** + * `paddock service …` — register/inspect the background service (#796). + * + * Split out so `main` stays the start path. Note what it is handed: the + * interpreter and script by absolute path, and `packageRoot` — which is tested + * for npx's cache, because a unit file pointing into a prunable hash-keyed + * directory rots at some future login with nobody watching. + */ +function service(command: Extract): void { + const { action, opts } = command; + // Unreachable: `parseCommand` only omits the action when `--help` was given, + // which `main` has already handled. Typed rather than asserted. + if (action === undefined) { + console.log(SERVICE_USAGE); + return; + } + try { + runService(action, opts, { + platform: process.platform, + nodePath: process.execPath, + scriptPath: entryScript, + packageRoot, + homeDir: safeHomeDir(), + ...(process.env.PADDOCK_DATA_DIR !== undefined + ? { envDataDir: process.env.PADDOCK_DATA_DIR } + : {}), + ...(process.env.XDG_CONFIG_HOME !== undefined + ? { xdgConfigHome: process.env.XDG_CONFIG_HOME } + : {}), + ...(process.env.PATH !== undefined ? { pathEnv: process.env.PATH } : {}), + }); + } catch (err) { + if (err instanceof CliError) fail(err.message); + if (err instanceof Error) fail(err.message); + throw err; + } +} + async function main(): Promise { - let opts: CliOptions; + let command: Command; try { - opts = parseArgs(process.argv.slice(2)); + command = parseCommand(process.argv.slice(2)); } catch (err) { if (err instanceof CliError) fail(err.message); throw err; } + const opts = command.opts; if (opts.help) { - console.log(USAGE); + console.log(command.verb === "service" ? SERVICE_USAGE : USAGE); return; } if (opts.version) { @@ -165,6 +215,11 @@ async function main(): Promise { const problem = nodeVersionProblem(process.versions.node); if (problem !== undefined) fail(problem); + if (command.verb === "service") { + service(command); + return; + } + addBundledBinsToPath(); // NOTHING here reads `process.cwd()`, and that is the point (#798). `--here` diff --git a/packages/server/src/cli/service/backend.ts b/packages/server/src/cli/service/backend.ts new file mode 100644 index 00000000..cd8c9edc --- /dev/null +++ b/packages/server/src/cli/service/backend.ts @@ -0,0 +1,61 @@ +/** + * The seam between "what a Paddock service is" and "how this init system spells + * it" (#796). + * + * Two backends implement this: `launchd.ts` and `systemd.ts`. They differ in + * more than syntax — launchd wants log file paths and systemd sends everything + * to journald; systemd needs `loginctl enable-linger` and launchd has no + * equivalent — so the interface carries those asymmetries explicitly rather + * than pretending the two are the same shape with different quotes. + */ +import { spawnSync } from "node:child_process"; +import type { ServiceSpec } from "./spec.js"; + +export interface RunResult { + status: number | null; + stdout: string; + stderr: string; +} + +/** + * Run an external command. + * + * Injected rather than imported so the install/uninstall/status flows can be + * driven in tests on a box that has neither `launchctl` nor a user systemd. + */ +export type Runner = (cmd: string, args: string[]) => RunResult; + +export const spawnRunner: Runner = (cmd, args) => { + const r = spawnSync(cmd, args, { encoding: "utf8" }); + return { + status: r.status, + stdout: r.stdout ?? "", + stderr: r.stderr ?? "", + }; +}; + +export interface ServiceState { + /** Is a unit file installed for us? */ + registered: boolean; + /** Does the init system say the process is up right now? */ + running: boolean; + pid?: string; + /** The Paddock arguments recorded in the installed unit, interpreter stripped. */ + argv: string[]; +} + +export interface ServiceBackend { + platform: "darwin" | "linux"; + /** Where the plist / unit file goes. */ + unitPath: string; + /** launchd label or systemd unit name — the string a user would type. */ + label: string; + install(spec: ServiceSpec): void; + /** True if something was actually there to remove. */ + uninstall(): boolean; + status(): ServiceState; + /** How to read the logs, as printed lines. */ + logsHint(spec: ServiceSpec): string; + /** The `enable-linger` warning, or undefined where it does not apply. */ + lingerNote(): string | undefined; +} diff --git a/packages/server/src/cli/service/index.ts b/packages/server/src/cli/service/index.ts new file mode 100644 index 00000000..bf7269e3 --- /dev/null +++ b/packages/server/src/cli/service/index.ts @@ -0,0 +1,232 @@ +/** + * `paddock service install | uninstall | status` (#796). + * + * Registers Paddock as a **per-user** background service — a launchd + * LaunchAgent on macOS, a `systemd --user` unit on Linux — so it is up from + * login rather than for as long as a terminal tab. + * + * This file is the dispatch and the human output; the two backends do the + * writing and `spec.ts` holds every decision that is not init-system-specific. + * + * Three things this deliberately does not do: + * + * - **It does not set `PADDOCK_DATA_DIR`.** With nothing named, the service and + * a `paddock` typed into a terminal both land on `~/.paddock`: one instance, + * two ways to reach it. + * - **It does not generate an auth token.** 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. `PADDOCK_AUTH_MODE` is there for anyone who wants one + * anyway, and the bind-safety guard (#435) still refuses a routable interface + * with auth off. + * - **It does not claim to start at boot.** See `launchd.ts`. + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { CliError, type CliOptions, type ServiceAction } from "../args.js"; +import { + buildSpec, + defaultDataDir, + hostFromArgv, + isNpxPath, + NPX_REFUSAL, + portFromArgv, + serviceDir, + type ServiceSpec, +} from "./spec.js"; +import { spawnRunner, type Runner, type ServiceBackend } from "./backend.js"; +import { createLaunchdBackend } from "./launchd.js"; +import { createSystemdBackend } from "./systemd.js"; + +export interface ServiceContext { + /** `process.platform`. */ + platform: NodeJS.Platform; + /** `process.execPath` — the interpreter the unit will name explicitly. */ + nodePath: string; + /** Absolute realpath of `dist/cli/paddock.js`. */ + scriptPath: string; + /** The `findUp("package.json")` root, tested for an npx cache path. */ + packageRoot: string; + homeDir: string; + /** `process.env.PADDOCK_DATA_DIR`, if the installing shell had one. */ + envDataDir?: string; + /** `process.env.XDG_CONFIG_HOME`, if set — where the systemd user unit goes. */ + xdgConfigHome?: string; + pathEnv?: string; + run?: Runner; +} + +/** + * Pick a backend, or explain why there isn't one. + * + * Windows is the honest gap. It has no per-user service concept that maps onto + * either of these without a scheduled task or a service wrapper, and shipping + * an untested third writer would be worse than saying so. + */ +function backendFor(ctx: ServiceContext): ServiceBackend { + const run = ctx.run ?? spawnRunner; + if (ctx.platform === "darwin") return createLaunchdBackend(run, ctx.homeDir); + if (ctx.platform === "linux") return createSystemdBackend(run, ctx.homeDir, ctx.xdgConfigHome); + throw new CliError( + `\`paddock service\` supports macOS (launchd) and Linux (systemd --user), not ${ctx.platform}.\n` + + "Run `paddock` in a terminal, or keep it up with whatever your platform uses.", + ); +} + +/** The data dir the service will use, and whether it has to be named explicitly. */ +function resolveDataDir( + opts: CliOptions, + ctx: ServiceContext, +): { dataDir: string; explicit: string | undefined } { + // Mirrors `paddock.ts`: flag beats env beats default. An env var set in the + // installing shell is an explicit choice too — but it is one the service + // would NOT inherit, so it gets written into the unit rather than silently + // dropped in favour of ~/.paddock. + const chosen = opts.dataDir ?? ctx.envDataDir; + if (chosen !== undefined) { + const resolved = path.resolve(chosen); + return { dataDir: resolved, explicit: resolved }; + } + return { dataDir: defaultDataDir(ctx.homeDir), explicit: undefined }; +} + +function specFor(opts: CliOptions, ctx: ServiceContext): { spec: ServiceSpec; dataDir: string } { + const { dataDir, explicit } = resolveDataDir(opts, ctx); + const spec = buildSpec({ + nodePath: ctx.nodePath, + scriptPath: ctx.scriptPath, + dataDir, + ...(opts.port !== undefined ? { port: opts.port } : {}), + ...(opts.host !== undefined ? { host: opts.host } : {}), + ...(explicit !== undefined ? { dataDirArg: explicit } : {}), + ...(opts.verbose ? { verbose: true } : {}), + ...(ctx.pathEnv !== undefined ? { pathEnv: ctx.pathEnv } : {}), + }); + return { spec, dataDir }; +} + +/** The line every surface has to carry, because otherwise it gets filed as a bug. */ +const AT_LOGIN = + " Paddock starts when you LOG IN, not when the machine boots — it runs as\n" + + " you, so it can use the Claude login you already have. After a restart\n" + + " that nobody logs into, Paddock is not running."; + +function install(opts: CliOptions, ctx: ServiceContext, backend: ServiceBackend): void { + if (isNpxPath(ctx.packageRoot)) throw new CliError(NPX_REFUSAL); + + const { spec, dataDir } = specFor(opts, ctx); + backend.install(spec); + + const url = `http://${hostFromArgv(spec.args)}:${portFromArgv(spec.args)}`; + const kind = + backend.platform === "darwin" ? "a launchd LaunchAgent" : "a systemd --user service"; + const linger = backend.lingerNote(); + + console.log( + [ + "", + ` Paddock is installed as ${kind}, and running now.`, + "", + ` URL: ${url}`, + ` Data: ${dataDir}`, + ` Unit: ${backend.unitPath}`, + backend.logsHint(spec), + "", + AT_LOGIN, + ...(linger !== undefined ? ["", linger] : []), + "", + " paddock service status is it up, and where", + " paddock service uninstall stop it and remove the unit", + "", + ].join("\n"), + ); +} + +function uninstall(ctx: ServiceContext, backend: ServiceBackend): void { + const existed = backend.uninstall(); + console.log( + existed + ? `\n Paddock's ${backend.platform === "darwin" ? "LaunchAgent" : "user service"} is stopped and removed.\n ${backend.unitPath}\n\n Your data is untouched — it was never in there.\n` + : `\n No Paddock service was installed (${backend.unitPath}).\n Nothing to do.\n`, + ); + // Leave the service directory: it holds logs, and the last thing anyone wants + // from `uninstall` is for the record of why they are uninstalling to vanish. + void ctx; +} + +function status(opts: CliOptions, ctx: ServiceContext, backend: ServiceBackend): void { + const state = backend.status(); + if (!state.registered) { + console.log( + [ + "", + " Paddock is not installed as a service.", + ` Looked for: ${backend.unitPath}`, + "", + " paddock service install register it and start it now", + "", + ].join("\n"), + ); + return; + } + + // Everything below is read back out of the INSTALLED unit, not recomputed + // from today's flags — the installed port is the one the URL has to name. + const url = `http://${hostFromArgv(state.argv)}:${portFromArgv(state.argv)}`; + const dataFromUnit = state.argv.indexOf("--data-dir"); + const dataDir = + dataFromUnit >= 0 && state.argv[dataFromUnit + 1] !== undefined + ? state.argv[dataFromUnit + 1] + : defaultDataDir(ctx.homeDir); + const { spec } = specFor(opts, { ...ctx, envDataDir: dataDir }); + const linger = backend.lingerNote(); + + console.log( + [ + "", + state.running + ? ` Paddock is running${state.pid !== undefined ? ` (pid ${state.pid})` : ""}.` + : " Paddock is registered as a service, but not running right now.", + "", + ` URL: ${url}`, + ` Data: ${dataDir}`, + ` Unit: ${backend.unitPath}`, + ` Args: paddock ${state.argv.join(" ")}`, + backend.logsHint(spec), + "", + AT_LOGIN, + ...(linger !== undefined && !state.running ? ["", linger] : []), + "", + ].join("\n"), + ); +} + +export function runService(action: ServiceAction, opts: CliOptions, ctx: ServiceContext): void { + const backend = backendFor(ctx); + if (action === "install") return install(opts, ctx, backend); + if (action === "uninstall") return uninstall(ctx, backend); + return status(opts, ctx, backend); +} + +/** Exported for the entrypoint's context assembly, and for tests. */ +export { serviceDir, isNpxPath }; +export type { ServiceSpec }; + +/** Best-effort home dir; `os.homedir()` throws on a machine with no passwd entry. */ +export function safeHomeDir(): string { + try { + return os.homedir(); + } catch { + return process.env.HOME ?? "/"; + } +} + +/** Realpath, falling back to the input — a broken symlink is not worth a crash. */ +export function realpathOr(p: string): string { + try { + return fs.realpathSync(p); + } catch { + return p; + } +} diff --git a/packages/server/src/cli/service/launchd.ts b/packages/server/src/cli/service/launchd.ts new file mode 100644 index 00000000..b35209dd --- /dev/null +++ b/packages/server/src/cli/service/launchd.ts @@ -0,0 +1,224 @@ +/** + * The darwin backend: a launchd **LaunchAgent** (#796). + * + * ## Why an agent and not a daemon + * + * A LaunchAgent is bootstrapped into the `gui/` domain when that user logs + * in, and runs as them. A LaunchDaemon starts at boot, as root, before anyone + * has logged in. The difference is not convenience — it decides whether Paddock + * can use your Claude login at all. + * + * On macOS that login is a Keychain item, and the login keychain is unlocked by + * your account password *at login*. A daemon has no such session, so the item + * is locked and unreadable; `UserName` in the plist does not help, because it + * changes the effective uid and not the keychain's unlock state. `claude. + * credentials: host` on darwin therefore structurally requires a logged-in user + * session, and boot-time start and Keychain credentials are mutually exclusive. + * + * The consequence users will meet is that Paddock starts **at login, not at + * boot** — after a restart nobody logs into, it is not running. Every surface + * that mentions this service says so, because otherwise it gets filed as a bug. + * + * A boot-time instance is a genuinely different feature: a LaunchDaemon plus + * `claude.credentials: own` and a token sitting in `EnvironmentVariables` — + * which is exactly the long-lived-credential-in-a-plist problem the agent shape + * avoids. Stated in the docs as the trade, not offered as a flag. + * + * ## What has actually been verified + * + * A turn completed under `launchctl kickstart` on a Mac whose Claude login + * existed only as the Keychain item, with `ps eww` confirming no credential in + * the process environment, and with no permission dialog. Start **at login** + * (`RunAtLoad` after a real logout/login) has NOT been tested; if a login-time + * agent can race keychain unlock, the fix belongs in this file's plist rather + * than in the design. Nothing here should be read as covering that case. + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { LAUNCHD_LABEL, type ServiceSpec } from "./spec.js"; +import type { Runner, ServiceBackend, ServiceState } from "./backend.js"; + +/** `~/Library/LaunchAgents/net.edspencer.paddock.plist`. */ +export function plistPath(homeDir: string = os.homedir()): string { + return path.join(homeDir, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`); +} + +function xmlEscape(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** + * Render the plist. + * + * `KeepAlive` is a dict rather than `` on purpose: `SuccessfulExit: + * false` means "restart it if it exits nonzero, leave it alone if it exits + * cleanly". A bare `` would fight a deliberate `launchctl bootout` and + * make stopping Paddock a wrestling match. + * + * `EnvironmentVariables` carries PATH and nothing else. It carries PATH because + * launchd hands a process a stub PATH that a version manager's `node`, and the + * `git` an agent will want, are both missing from. It carries nothing else + * because a plist is a world-readable file in the user's home, and the one + * category of value that would be tempting to put here — a credential — is + * precisely what the agent shape exists to avoid needing. + * + * No `PADDOCK_DATA_DIR`: see `buildSpec`. One `~/.paddock` instance, reachable + * from the service and from a terminal. + */ +export function renderPlist(spec: ServiceSpec): string { + const argv = [spec.nodePath, spec.scriptPath, ...spec.args]; + const strings = argv.map((a) => ` ${xmlEscape(a)}`).join("\n"); + return ` + + + + Label + ${xmlEscape(LAUNCHD_LABEL)} + ProgramArguments + +${strings} + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + ThrottleInterval + 10 + WorkingDirectory + ${xmlEscape(spec.workingDirectory)} + StandardOutPath + ${xmlEscape(spec.stdoutPath)} + StandardErrorPath + ${xmlEscape(spec.stderrPath)} + EnvironmentVariables + + PATH + ${xmlEscape(spec.pathEnv)} + + + +`; +} + +/** + * Pull `ProgramArguments` back out of a plist we wrote. + * + * `status` reports the port the service is ACTUALLY installed with, which is + * only knowable from the installed unit — recomputing it from today's flags + * would print a confident URL for a server listening somewhere else. Regex + * rather than a plist parser because the input is a file this module wrote, in + * a shape this module controls, and adding an XML dependency to read it back + * would be the larger risk. + */ +export function parsePlistArgv(xml: string): string[] { + const block = /ProgramArguments<\/key>\s*([\s\S]*?)<\/array>/.exec(xml); + if (block === null) return []; + return [...block[1].matchAll(/([\s\S]*?)<\/string>/g)].map((m) => + m[1] + .replace(/"/g, '"') + .replace(/>/g, ">") + .replace(/</g, "<") + .replace(/&/g, "&"), + ); +} + +/** + * Read liveness out of `launchctl print gui//