feat(cli): persistent runner daemon for gander --watch - #69
Merged
Conversation
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
Collaborator
Author
|
/oc please review this PR and approve if you find it ready to merge |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #68
Replaces the per-
--watchblocking CLI process with a long-livedrunner daemon.
gander --watch <file>(and its share-mode siblinggander watch <file>) now hand off to the daemon and exit; thedaemon owns the HTTP server, fsnotify loop, and
~/.gander/watches.json.Watches survive the CLI's lifetime, reboots, and
--upgrade.Phases (one commit per phase)
Acceptance
gander --watch plans/foo.mdexits in milliseconds; printsPreview at: http://127.0.0.1:7821/w/<id>?t=<token>; browserstill shows live reloads (the daemon owns the SSE).
gander statuslists active watches with token-gated URLs.gander stop <file|id>removes the watch; SSE channel closes.watches re-spawned from
watches.json.gander --upgradesends{"op":"shutdown"}over UDS, replacesthe binary, supervisor respawns under new code;
watches.jsonis reloaded so the upgrade is invisible to open viewers.
gander <file> --watch --foregroundruns the old blockingwatcher (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> --foregroundkeeps the in-processshare-mode push loop.
Security
~/.gander/runner.sockmode 0600 inside~/.gandermode 0700;
SO_PEERCRED/LOCAL_PEERPIDpeer-UID check.crypto/rand),crypto/subtle.ConstantTimeCompareeverywhere they're compared.watches.jsonwritten 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);/healthzand/w/<id>both require?t=.api_url; per-watch andpersisted-watch checks;
GANDER_ALLOW_INSECURE_API=1overrides.SysProcAttr.Setpgid: true+ pipehandshake (no
setsidon macOS).gander --upgradecross-checksrunner.pidbefore shutdown.Verification
All clean. A live smoke test against the running daemon confirmed
gander --watchhands off in ~37 ms,gander statuslists,gander stopremoves.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.