Skip to content

feat(cli): persistent runner daemon for gander --watch - #69

Merged
scott merged 15 commits into
mainfrom
feature/persistent-runner
Aug 27, 2026
Merged

feat(cli): persistent runner daemon for gander --watch#69
scott merged 15 commits into
mainfrom
feature/persistent-runner

Conversation

@scott

@scott scott commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Closes #68

Replaces the per---watch blocking CLI process with a long-lived
runner daemon. gander --watch <file> (and its share-mode sibling
gander watch <file>) now hand off to the daemon and exit; the
daemon owns the HTTP server, fsnotify loop, and ~/.gander/watches.json.
Watches survive the CLI's lifetime, reboots, and --upgrade.

Phases (one commit per phase)

# commit description
0 8c8096d extract serveWatchForever / serveShareWatcher
1 a09ff7d daemon core: runner.go, runner_ipc.go, runner_watch.go
2 2564b3f single-port token-gated HTTP, /w/?t=…
3 6a1fcb2 gander status, gander stop, gander logs
4 48c0e94 LaunchAgent (macOS) / systemd (Linux) auto-start
5 39c0a14 gander --upgrade coordination
6 7a7a75e docs (README, man page, AGENTS.md, plan)
7 df2436e unit + integration test coverage
4fd4127 wire --foreground on local --watch path (gap-fix)
4de043d don't fork-bomb tests from ensureRunner
931e4e3 runner owns share-mode watch loop; CLI hands off
bb18b4a extend isTestExecutable guard to auto-install paths
3a5d7a2 require HTTPS for share-mode watches
087bc69 exercise --foreground on share --watch tests

Acceptance

  • gander --watch plans/foo.md exits in milliseconds; prints
    Preview at: http://127.0.0.1:7821/w/<id>?t=<token>; browser
    still shows live reloads (the daemon owns the SSE).
  • gander status lists active watches with token-gated URLs.
  • gander stop <file|id> removes the watch; SSE channel closes.
  • Reboot → daemon (LaunchAgent / systemd user) restarts →
    watches re-spawned from watches.json.
  • gander --upgrade sends {"op":"shutdown"} over UDS, replaces
    the binary, supervisor respawns under new code; watches.json
    is reloaded so the upgrade is invisible to open viewers.
  • gander <file> --watch --foreground runs the old blocking
    watcher (4fd4127; closes the gap called out in feat(cli): persistent runner process for gander --watch #68 between the
    documented flag and the actual CLI surface).
  • gander watch <file> --foreground keeps the in-process
    share-mode push loop.

Security

  • UDS gated by ~/.gander/runner.sock mode 0600 inside ~/.gander
    mode 0700; SO_PEERCRED / LOCAL_PEERPID peer-UID check.
  • Per-watch + daemon-wide tokens (32 hex from crypto/rand),
    crypto/subtle.ConstantTimeCompare everywhere they're compared.
  • watches.json written via atomic temp-rename, chmod 0600;
    the daemon refuses to load if it sees a wider mode.
  • / index dropped (it leaked the path list to other local users);
    /healthz and /w/<id> both require ?t=.
  • Share-mode watches require HTTPS api_url; per-watch and
    persisted-watch checks; GANDER_ALLOW_INSECURE_API=1 overrides.
  • Detach uses portable SysProcAttr.Setpgid: true + pipe
    handshake (no setsid on macOS).
  • gander --upgrade cross-checks runner.pid before shutdown.

Verification

go vet ./...
go build ./...
go test -short -timeout 60s ./...

All clean. A live smoke test against the running daemon confirmed
gander --watch hands off in ~37 ms, gander status lists,
gander stop removes.

Plan / rollout plan

See plans/2026-08-26-persistent-runner-process-for-gander.md.

Encryption-at-rest for tokens is out of scope (0600 mode is the
mitigation). Log rotation is a follow-up issue.

scott added 15 commits August 26, 2026 09:58
Refactors the two watch modes (local SSE preview in watch.go, remote
push in share.go) to expose reusable context-driven helpers that
the persistent runner can drive later:

- serveWatchForever(ctx, state, port, debounceMs, onBound) — binds
  the HTTP server + SSE handler, runs the fsnotify loop, blocks
  until ctx is done. onBound fires synchronously after the listener
  binds so the caller can print the URL and open the browser in the
  original order.

- serveShareWatcher(ctx, pusher, absPath, debounceMs) — runs the
  fsnotify -> PUT /api/shares/<uuid> loop until ctx is done.

runWatch and runWatchAndPushCtx become thin wrappers that wire
SIGINT into the cancel context. The watchPusher.start / stop /
loop methods and its 'mu chan struct{}' stop signal are gone; the
type now just holds config and exposes push().

No behavior change: same prints, same URL timing, same Ctrl+C
semantics for the foreground paths. go vet, go build, go test all
clean.

Refs #68 — Phase 0 of the persistent-runner plan
(plans/2026-08-26-persistent-runner-process-for-gander.md).
gander --watch now spawns (or attaches to) a long-lived runner
process via 'gander _serve' rather than blocking the user's shell.
The CLI hands off the watch, the daemon owns the HTTP server and the
fsnotify loop, and the CLI exits.

Architecture (Phase 1 of plans/2026-08-26-persistent-runner-process-
for-gander.md):

- runner.go: daemon lifecycle. Acquires a flock on
  ~/.gander/runner.lock so only one daemon runs per profile; binds a
  Unix domain socket at ~/.gander/runner.sock; writes
  ~/.gander/runner.pid; honours SIGTERM/SIGINT for graceful shutdown.
- runner_ipc.go + runner_ipc_{linux,darwin}.go: line-delimited JSON
  protocol over UDS. Ops: ping, watch, stop (id|path|all), list,
  shutdown. Peer-credential check via SO_PEERCRED on Linux and
  LOCAL_PEERPID on macOS rejects connections from other UIDs even if
  the socket file mode were ever relaxed.
- runner_watch.go: watchManager keeps watches in memory and persists
  to ~/.gander/watches.json (temp + rename, 0600). On startup the
  daemon reloads the file and re-binds each watch; a 0600 mode check
  refuses to load a file with broader permissions.
- main.go: hidden 'gander _serve' dispatch; gander --watch hands off
  via IPC and exits.

Phase 1 covers local-mode watches only. Each local watch binds its
own port (per-watch HTTP server, reuses the serveWatchForever helper
from Phase 0). Sharing one port across watches with token-gated
/w/<id>?t=<token> URLs is Phase 2; share-mode pushes through the
runner are deferred to a later commit; the new top-level
status/stop/logs commands are Phase 3.

Refs #68.
Replaces the per-watch HTTP server from Phase 1 with one daemon-wide
HTTP server bound to 127.0.0.1:7821 (overridable; persisted at the
top level of ~/.gander/watches.json as 'port'). All watches live
under that server and are reachable at /w/<id>?t=<token>.

Tokens:
- daemon_token: 32 hex from crypto/rand, written to ~/.gander/
  watches.json top level on first startup. Required on /healthz.
- per-watch token: 32 hex, written into each persistedWatch. Required
  on /w/<id> and /w/<id>/events.
- Both are compared with crypto/subtle.ConstantTimeCompare.

Security review followups applied in this commit:
- /healthz refuses unauthenticated requests; returns only {ok, version,
  port, uptime_s, watch_count} -- no path list, no daemon PID.
- watches.json is the only state file persisted; runner.pid/lock are
  refreshed each startup. Mode check (0600 max) still refuses
  wider modes when loading.
- Peer-credential check on the UDS (Linux SO_PEERCRED, macOS
  LOCAL_PEERPID) is unchanged.
- Foreground watchState.handleIndex/handleEvents were hard-coded to
  reject any path != "/" -- appropriate for the foreground one-watch
  case, but breaks the runner where every path is /w/<id>. The runner
  wraps them with thin shims (serveWatchIndex, serveWatchEvents) that
  render the same payload without the path check.

URLs survive daemon restarts because the watch entry keeps its 4-byte
hex id and 32-hex token across persist/load.

Smoke-tested: /w/<id> with the correct token returns 200 + 10713
bytes of rendered HTML; /w/<id> without it returns 403; /w/bogus
returns 404; /healthz behaves the same way with the daemon token.
Editing the source file hot-swaps the rendered HTML within ~150ms
(debounce).

Refs #68 -- Phase 2 of the persistent-runner plan.
Three new top-level commands that talk to the runner daemon over the
existing UDS protocol.

- gander status: tabwriter-rendered runner version + uptime + active
  watches table (id, mode, canonical path, since-started, full URL
  including the per-watch token). Returns a friendly 'no active
  watches' message when the list is empty.

- gander stop [<file>|<id>] [--all]: routes by 8-character id regex
  (same shape as remove) or canonical path; --all removes everything.
  The runner replies with a list of removed ids; CLI prints one
  'stopped <id>' line per removal.

- gander logs [<id>] [--follow|--no-follow]: tails
  ~/.gander/runner.log. Stderr from the daemon is redirected to the
  same file (Go's default log package writes there), so reload and
  push events are visible. --follow polls every 500ms and prints
  anything new; --no-follow prints once and exits.

The route /w/<id>?t=<token> URLs printed by 'gander status' are the
same URLs the browser opens, so a user can copy-paste them straight
into a fresh shell. The help output and bash/zsh completions are
updated to advertise the new commands; status/stop/logs are listed
unconditionally (they don't require --auth).

Smoke-tested end-to-end against a single watch:
  - 'gander status' shows the row
  - 'gander logs --no-follow' shows register lines
  - editing the file twice + 'gander logs' shows two Reloaded lines
  - 'gander stop --all' clears the watch and the next 'gander status'
    prints the empty message

Refs #68 -- Phase 3 of the persistent-runner plan.
…inux)

The first 'gander --watch' invokes 'gander runner install' under the
hood, which writes a per-user supervisor unit and enables it. The
unit fires on login and runs 'gander _serve' in the background.

macOS: ~/Library/LaunchAgents/com.gandermd.gander.runner.plist with
RunAtLoad, KeepAlive (throttled 10s), and stderr redirected to the
same ~/.gander/runner.log the daemon writes to directly (so log
output goes through one channel).

Linux: ~/.config/systemd/user/gander.service with Type=simple,
Restart=always, WantedBy=default.target. Activated via
'systemctl --user daemon-reload && enable --now'.

Both paths:
- Resolve the binary via os.Executable + EvalSymlinks so Homebrew
  symlinks are followed.
- Refuse to install if the binary is non-executable or not owned by
  the current user (best-effort).
- Pre-create runner.log / runner.err with mode 0600 so the launchd
  umask doesn't end up creating them at 0644.
- Are idempotent: if the unit file already has the same content, the
  install is a no-op (still printed once).

The CLI surface is 'gander runner {install|uninstall}' for explicit
control. Auto-install is best-effort and never blocks the user:
failures are logged to runner.log and the user's --watch invocation
proceeds against the already-spawned daemon.

Stderr redirection is intentionally collapsed to runner.log for now
(today the daemon has nothing on stderr that isn't also log.Printf).
Splits into separate streams later if observability needs grow.

Refs #68 -- Phase 4 of the persistent-runner plan.
Three new helpers in upgrade.go:

- stopDaemonForUpgrade(exePath): reads the recorded runner.pid and
  verifies it's still a live same-UID process (signal 0 probe) before
  sending {op:shutdown} over the UDS. Polls the socket for up to 5s
  for it to disappear; if the runner is dead or the profile is fresh,
  the upgrade proceeds without forcing a shutdown.

- restartDaemonAfterUpgrade(exePath): detects whether the unit is
  supervised by launchd (com.gandermd.gander.runner, via
  'launchctl list') or by systemd user ('systemctl --user
  is-enabled gander.service'). If supervised, prints a one-liner --
  launchd/systemd picks the new binary up because the unit points at
  the same path and the daemon is restarted automatically. If not
  supervised, calls ensureRunner to spawn the upgraded binary
  directly. Either way the user's watches.json is reloaded by the
  new daemon so the upgrade is invisible to open viewers.

- runningPIDForOurUpgrade: returns the live runner PID or 0. Reads
  ~/.gander.<profile>/runner.pid, refuses to signal the current
  process, and uses os.FindProcess + signal 0 to verify liveness.
  sameProcess(pid) is the lower-level helper.

isRunnerSupervised shells out once per call; cheap enough for a
periodic command, and only ever runs at 'gander --upgrade' time.

Two unit tests pin the helpers' behavior on systems without a
supervised daemon: no runner.pid -> 0; non-numeric or stale PID -> 0.
The 'supervised' test is conditional skip() so it's a no-op when the
LaunchAgent is active on the dev's machine.

Refs #68 -- Phase 5 of the persistent-runner plan.
README.md: expand the --watch section to explain the daemon handoff,
gander status / stop / logs, the auto-installed LaunchAgent/systemd
unit, the per-watch token URL pattern, and --foreground for the old
blocking mode. Update the Subcommands list to include status, stop,
logs, and runner install|uninstall. Refresh 'How it works' with the
new architecture (daemon + supervisor + token-gated URL).

man/man1/gander.1: add SYNOPSIS lines for status, stop, logs, and
runner install|uninstall; rewrite --watch to describe the runner
handoff and the LaunchAgent/systemd unit; document --foreground;
add SS sections for Status, Stop, Logs, Runner.

AGENTS.md: extend the source-layout table with runner.go, runner_ipc*.go,
runner_watch.go, runner_http.go, runner_install.go, runner_cmd.go,
status.go, stop.go, logs.go, and the plans/ directory. Append a
'Runner architecture' paragraph that summarizes the daemon lifecycle,
security defaults, and the --upgrade coordination strategy.
Pins the security-review deltas as automated tests:

- TestWatchManagerLoadRefusesWideMode: writes watches.json with mode
  0644 and asserts newWatchManager.load() returns a refusal error that
  names the reason. Catches regressions where someone widens the
  default mode.

- TestWatchManagerLoadAcceptsStrictMode: same file at mode 0600
  loads cleanly and surfaces the persisted daemon_token + port.

- TestWatchManagerPersistProduces0600File: writes a fresh watch
  manager, calls persist(), stat()s the resulting watches.json and
  asserts the file is mode 0600.

- TestWatchManagerRegisterWritesTokenAndURL: register() returns
  info with a 32-hex token and a /w/<id>?t=<token> URL on
  127.0.0.1:7821.

- TestWatchManagerReRegisterReturnsExisting: second register() of
  the same canonical path returns the same id + token (idempotent).

- TestRunnerHTTPRejectsRequestWithoutToken: /healthz returns 403
  without a token, 200 with the right daemon_token, 404 for a bogus
  /w/<id> id (with or without a token).

- TestRunnerHTTPRejectsBadWatchToken: /w/<id> with the wrong token
  returns 403; with the right token returns 200 + rendered HTML.

- TestRunStopRejectsNoArgsWithoutAll + TestRunStopAllWithoutDaemon:
  smoke-test the new gander stop command shape -- one positive
  usage error and one that tolerates a missing daemon.

runner_http.go gains a newRunnerHTTPOnPort(mgr, port) helper so the
tests can bind to OS-assigned ports without colliding with the real
daemon on 7821.

Refs #68 -- Phase 7 of the persistent-runner plan.
The local preview path now honours --foreground as an in-process
escape hatch (CI, sandboxes, debugging the watcher). Without the
flag, the CLI hands off to the runner daemon as before. Closes the
gap called out in #68 between the documented --foreground flag and
the actual CLI surface.
When a test exercises ensureRunner, go test sets os.Executable to
gander.test. Without this guard, ensureRunner re-execs that test
binary as _serve, which re-runs the test suite — a fork bomb that
hangs CI. The same isTestExecutable check already guards
autoInstallIfNeeded and runRunnerInstall in runner_install.go;
this brings ensureRunner in line.
`gander share --watch` (and its `gander watch <file>` alias) no
longer block the terminal. After the initial upload to gandermd, the
CLI hands the watcher off to the runner daemon via IPC and exits;
the daemon owns the fsnotify loop and pushes content updates over
the watcher's lifetime.

The runner's runShare replaces its 'not yet supported' stub with a
real serveShareWatcher goroutine, wired to the same cancel/shutdown
plumbing as serveLocal. The CLI keeps --foreground as the
in-process escape hatch (CI, sandboxes, debugging).

HTTPS enforcement for share-mode endpoints ships in the next
commit; this commit only wires the hand-off.
runRunnerInstall (manual install) and autoInstallIfNeeded (called
from ensureRunner's first-spawn path) both invoke os.Executable()
paths that point at gander.test under 'go test'. Without this
guard, gander runner install under the test binary would write a
LaunchAgent / systemd unit pointing at gander.test — a much louder
fork bomb than the one in ensureRunner alone.

Also adds a TestMain that lets the test binary be invoked as
'serve' (matching the production CLI), plus unit tests for the
isTestExecutable predicate and the ensureRunner refusal path.
Share-mode watchers carry the user's gandermd API token and ship
markdown content over the wire on every save — both should be on TLS.
This adds two guards:

- runShare refuses to push to a cleartext api_url (per-watch, so a
  fresh register on a cleartext endpoint idles instead of leaking).
- enforceHTTPSForShareWatches refuses to start the runner with a
  persisted share-mode watch whose saved ShareURL isn't https://.
  This catches the case where a local-development api_url= was
  swapped to https:// between sessions but watches.json still
  holds the old cleartext URL.

Override both with GANDER_ALLOW_INSECURE_API=1 (the only path that
ships tokens in cleartext — for local gandermd development).
`gander share --watch` now hands off to the runner daemon (931e4e3);
the existing end-to-end tests want the in-process push loop to verify
the watcher side-effects, so pass --foreground to keep them on the
runWatchAndPush path. Without this, the tests would exit immediately
after the IPC round-trip instead of exercising the push loop.
- migrate a legacy ~/.gander.<name> file into a directory
  (config.json + runner.sock/watches.json)
- trust loopback http api_url so local gandermd share-watch works
- fall back when port 7821 is already taken
- gander status shows the gandermd short id + share URL
- gander stop accepts that short id
@scott

scott commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator Author

/oc please review this PR and approve if you find it ready to merge

@scott
scott merged commit a4c67dd into main Aug 27, 2026
1 check passed
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.

feat(cli): persistent runner process for gander --watch

1 participant