Companion to refresh_daemon_spec.md (the ratified design, 2026-08-20). All
design forks from the earlier draft of this file are resolved; the decision log
below records the rulings. Branch: feat/refresh-daemon, stacked on
feat/agent-lifetime (PR 1 = openpubkey/opkssh#611).
0. Predicate PR — storage layout (own design discussion; not yet designed)
XDG base-dir support ($XDG_CONFIG_HOME/opk, $XDG_STATE_HOME/opk, ~/.opk
fallback) + per-provider key-file naming replacing single id_ecdsa.
Correctness prerequisite for increment 2's multi-session disk sync (fixed path
clobbers). Scope to settle in its discussion: migration/back-compat for
existing key paths, configureSSH IdentityFile references, logout coverage
of both layouts.
1. Daemon PR, increment 1 — engine + foreground host. No dependencies;
can start immediately. Extract LoginWithRefresh's loop into daemon/
(engine.go, session.go, agent.go, discovery-free), with: injected clock; merged
tick with one-tick refresh lookahead; backoff ladder capped at backoff_max,
retries independent of exp (degraded state, post-exp recovery); exp from
FreshIDToken; per-session refreshability determination; exp-bound certs +
exp+5m agent lifetimes for refresh-capable sessions (--lifetime flag always
wins for agent lifetime); Add-before-Remove rotation with access-token
re-embed; login --auto-refresh (daemonless) hosts it in-process.
login's PR-1 agent add refactors onto daemon.DialAgent.
Fixes a live upstream bug en passant (flag in the PR): current
payloadFromCompactPkt reads the fresh ID token's protected header as the
payload (compact format is colon-joined + dot-appended fresh token), so the
tracked exp never updates after the first refresh and --auto-refresh
hot-loops refresh requests against the OP.
2. Daemon PR, increment 2 — daemon process. opkssh daemon +
flow.go (daemon-owned auth via OpenBrowser:false + SetLoginURIHook),
ipc server/client (ndjson, singleton socket), reconcile loop over registered
sessions + monitor-only discovery, opkssh status, logout drop integration,
daemon: config block. Multi-identity disk sync gated on deliverable 0.
3. Documented follow-ups (named in spec §12 and the PR body; not built):
persistence/keyring (carries the upstream GetRefreshToken accessor
dependency), Windows transports, desktop notifications, service manifests,
agent-backed key-binding signer.
| Decision | Ruling |
|---|---|
| Process model | Daemon standalone, user-started, never spawned by login |
| Auth ownership | Daemon owns flow + secrets; CLI handles all browser interaction (launch/print URL) |
| Login gating | Daemon manages every login when running; --auto-refresh = daemonless compat, not a gate; --no-daemon escape |
| Restart model | Lost daemon = lost refresh; monitor-only rehydration; persistence = follow-up |
| Refresh token placement | Never in cert/agent/socket (rejected with analysis, spec §10); disk store deferred to follow-up on predicate layout |
| Reconcile | One merged pass, agent_sync_frequency 60s; reconcile-authority over ssh-add -D; prune rule deleted (lifetimes self-clean) |
| Cert expiry | Refresh-managed: ValidBefore = exp (Ethan-ratified); daemonless one-shot keeps PR-1 semantics |
| IPC format | ndjson + versioned envelope (repo precedent: JSON only; no gRPC) |
| Config naming | agent_sync_frequency, sock_path, ssh_auth_sock (named for $SSH_AUTH_SOCK) |
| Package shape | Flat top-level daemon/; _unix/_windows suffix files; commands structs + main.go wiring; config in commands/config |
| Upstream openpubkey | Zero changes needed in core (verified: SetLoginURIHook exists); accessor moves to persistence follow-up |
All findings verified at primaries and folded into the spec:
- Refreshability carve-out (C1): determined per session at flow completion (Refreshable provider AND refresh token granted); non-refreshable sessions mint PR-1 style, no worker; CI/CD providers bypass the daemon by design (owner ruling).
- Retry terminus (I1): retries independent of exp (
oidc_refreshedaccepts post-exp refresh);degradedstate; terminal = invalid_grant only. - Margin vs tick (I2): one-tick lookahead on the due condition.
- Chooser + invariant scoping (I3):
start-loginhas an explicit chooser variant (daemon builds the list from its config); zero-credential invariant scoped with the CLI-supplied client_secret exception. - Singleton (I4): flock lockfile authority, probe-timeout = running, socket self-stat per reconcile pass (XDG cleanup survival).
--lifetimeprecedence (I5): explicit flag always wins for agent lifetime; configagent_lifetime= non-refresh sessions only.- Session key (I6): (issuer, client_id, sub); email/comments display-only.
- Agent-socket drift (I7): status prints the managed socket; CLI warns on
SSH_AUTH_SOCKmismatch. - Minor:
ssh_auth_sockrename, access-token re-embed on rotation, provider writer plumbing (SetDefaultWriters), TempDir fallback wording, observable-state wording, flow-collision UX.
- Publish both documents as public gists (owner-directed); share with Ethan on #606 (design changed since the gist he blessed — daemon-owned auth, exp-binding scope, follow-up roster) before or alongside implementation.
- File the upstream
payloadFromCompactPkthot-loop bug as an issue (verified live on main; increment 1 fixes it in opkssh). - Write the implementation plan for increment 1 (writing-plans), then build.
- Schedule the predicate-PR design discussion (deliverable 0) — required before increment 2 lands, not before increment 1 starts.