Skip to content

feat(daemon): one dispatcher for every transport, roles, ownership, admin credential - #94

Open
V3RON wants to merge 13 commits into
feat/0003-01-contract-modulefrom
feat/0003-02-dispatcher-roles
Open

feat(daemon): one dispatcher for every transport, roles, ownership, admin credential#94
V3RON wants to merge 13 commits into
feat/0003-01-contract-modulefrom
feat/0003-02-dispatcher-roles

Conversation

@V3RON

@V3RON V3RON commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Stacked on #93. PR 2 of 5 implementing ADR 0003 (§2, §3, §4, §5, §8, §9).

One dispatcher serves every transport (§2)

Request handling leaves DaemonServer. src/daemon/dispatcher.ts takes an operation name, a raw input and a session, and in order: parses the input, rejects a session below the operation's role with FORBIDDEN, runs the authorize hook, parks on startup readiness, calls the handler, parses the output. Handlers never see a role check or a raw payload.

DaemonServer keeps framing, connection lifecycle, held-lease tracking and pushes. The HTTP app calls the same Dispatcher instance in-process — nothing routes HTTP through the loopback socket; the parity comes from the shared dispatcher, not a shared wire.

Startup semantics are preserved exactly: hello and status.get answer during convergence, everything else parks, and a convergence failure still drains parked dispatches before closing sockets.

Roles, principal, requester, owner (§3, §4)

  • The principal is declared once at hello and fixed for the connection's life. A request cannot name a different principal than the connection it arrived on.
  • lease.request takes an optional requesterId defaulting to the principal, so one connection can hold many leases by passing one requester id per session. The core's one-lease-per-requester rule is unchanged.
  • A lease persists an ownerId from the session principal. lease.renew, lease.release, lease.cancel and lease.list compare it to the principal; admin bypasses. Records written before the field load with ownerId = requesterId — tested as a registry round-trip.

Socket identity is cooperative. Every socket peer is the same OS user, which is the real trust boundary; these checks protect against accidents such as releasing a guessed lease id, not against a hostile local process. Comments say so where the checks live.

Admin authority from a handshake credential (§5)

hello carries the protocol range, capabilities, the principal and an optional credential; the reply carries the negotiated version, the daemon's range, the daemon version and the resolved role. A missing or wrong credential fails the handshake with ADMIN_AUTHENTICATION_FAILED before any other request runs.

Two credentials, in order: an operator token from the token store, then the per-start admin secret. The secret is minted and hashed in AdminSecretManager's constructor — only the hash is ever held in memory — and written to admin.token via an atomic write whose owner-only mode is set at the temp file's own creation, never a later chmod. It is written only after the socket claim succeeds, so a daemon that loses the start race never touches the real file, and it is removed on graceful stop. hello verifies against memory, so a credential can be checked before the file lands.

The credential travels only inside the hello payload — never logged, never returned, never inferred from the socket path or a client-declared role. Filesystem, randomness and hashing all go through ports.

Falls out for free (§2)

HTTP's copies of status assembly, device/lease decoration, error mapping, requireAuth's role gate and requireOwnership are deleted. Two consequences the ADR predicted are now true and tested:

  • The download policy applies to HTTP. The socket path clamped allowDownload through config.downloads.policy while HTTP passed it through unclamped — that divergence is closed.
  • An HTTP request during startup waits instead of being refused.

LeaseRequestTracker and LeaseNoticeBuffer stay (until #72), but the notice buffer now consumes owner-routed facts rather than subscribing to the bus itself. The tracker's renew-immediately-after-grant hack is deleted — ttlMs on lease.request replaces it.

Operation additions (§9)

lease.cancel (cancel a pending request without closing the connection), lease.list (own leases; all for admin), and ttlMs on lease.request wired through LeaseRequestOptions to core. Supplying ttlMs for a held lease is BAD_REQUEST — held TTL is the backstop, not the caller's to shorten.

Owner-routed pushes (§8)

Lease-scoped pushes go to every live connection whose principal owns the lease, in either mode. Previously a detached holder learned of a crash only when a renew failed. heldLeaseIds remains, but only for release-on-close. A client is still never pushed lease-lost for a release it asked for itself.

Behaviour changes worth a reviewer's eye

  • GET /v1/leases/:id now returns 404 rather than 403 for another requester's lease — it reuses lease.list's own filter instead of a separate ownership check.
  • The HTTP gateway now starts concurrently with convergence, so a bind failure no longer reliably fails startDaemon(); it is logged and the daemon best-effort stopped. The narrow stop-during-start window is guarded by a flag but has no dedicated test.
  • ?since= duration parsing stays HTTP-local (the operation itself takes an absolute sinceTs).

typecheck, lint, format:check, typecheck:e2e and fallow clean; 1077 unit tests pass, including the ADR §12 isolated dispatcher suite driven against the fake driver.

V3RON added 12 commits September 3, 2026 10:42
… 0003 §4)

ownerId is the session principal a lease belongs to; requesterId stays pure
attribution (defaults to the principal, but a session may set it per-request).
A record written before ownerId existed loads with ownerId defaulted to
requesterId (Registry#parseLease). lease.request also threads an optional
ttlMs through to LeaseLifecycle#grant as the initial TTL for a detached
lease (ADR §9), and lease.expired/lease.released now carry ownerId on their
event payloads so a lease-scoped push can be routed by owner after the
lease itself has already left the registry.
…/release

ownsLease(getId) is the authorize hook the ADR sketches at §1: admin bypasses,
otherwise the resource's owner (looked up via AuthorizeContext.ownerId, an
unknown id treated as authorized so the handler's own not-found error
surfaces rather than a misleading FORBIDDEN). lease.renew and lease.release
now set it. leaseRecordSchema gains the ownerId field PR 1 deliberately left
out pending this PR's ownership work.
…atcher

ADR 0003 §2: Dispatcher#dispatch(operation, rawInput, session) parses input,
rejects a session below the operation's role with FORBIDDEN, runs the
authorize hook, parks on startup readiness, calls the handler, then parses
the output -- in that order, for every declared operation except hello
(answered before a session exists) and daemon.stop (the frozen exception).
DaemonServer keeps framing, connection lifecycle, held-lease tracking, and
pushes, exactly as the ADR describes; it now builds a DispatchSession per
call and wraps lease.request/release/release-all with the held-lease and
progress bookkeeping that has to live outside the dispatcher.

Session role resolution goes through the SessionRoleResolver seam
(session.ts): resolveAgentRole always answers "agent" today, and is the one
thing the next PR's credential handshake (ADR §5) replaces -- nothing else
in the dispatcher, DaemonServer, or the session shape needs to change for
that.

Lease-scoped pushes (lease-lost, device-unhealthy, device-recovered) are now
routed to every live connection whose principal owns the lease, in either
mode (ADR §8), not just the connection holding it -- fixing the bug where a
detached holder only learned of a crash when a renew failed. A connection
that explicitly released its own lease is still spared the redundant
self-push, tracked per-connection now that the fan-out is owner-wide rather
than a single heldLeaseIds lookup.

Adds the ADR §12 dispatcher coverage: role rejection, ownership
(ownsLease) on lease.renew/release, lease.list's own-vs-all-for-admin
filtering, ttlMs forwarding and its held-lease rejection, lease.cancel while
still queued, and the owner-routed push fan-out to a detached holder on a
second connection.
Compile-only fix to keep HTTP building against the core's new required
LeaseRecord.ownerId field: the tracker sets it from the same requesterId it
already has (HTTP has no session/principal concept yet -- that lands when
HTTP moves onto the dispatcher in the next PR). Test fixtures follow suit.
ADR 0003 §8's owner-routed pushes can now reach more than one connection
sharing a principal -- two CLI processes started without a distinct
--agent-id/SIMLOCK_AGENT_ID both fall back to the daemon's shared default
principal. The held-lease holder used to treat any lease-lost push as its
own (true when the daemon only ever pushed to the single holding
connection); it now ignores one for a lease id that is not the one it was
just granted.
Extends Filesystem#writeFileAtomic with an optional mode, applied at
the temp file's own creation (never a later chmod), and surfaces it
back through FileStat#mode. Needed by the daemon's per-start admin
secret (ADR 0003 §5), which must be owner-only from the moment it
exists on disk.
AdminSecretManager mints the daemon's per-start admin secret at
construction, keeping only its SHA-256 hash in memory; persist()
writes the plaintext to admin.token (owner-only, atomic) and is only
ever called after the socket claim succeeds. createCredentialRoleResolver
replaces the PR-2a placeholder resolver: no credential resolves
"agent"; a credential verified against either an operator token or
the admin secret resolves "admin"; anything else throws
AdminAuthenticationFailedError, generic enough to never echo the
credential itself.

Neither the dispatcher nor DaemonServer's connection state changes --
this only replaces what session.ts's resolver does, per the seam PR
2a left.
DaemonServer:
- Persists/removes the admin secret file around start()/stop(), only
  after the socket claim (a daemon that loses the start race never
  calls persist(), since start() throws before reaching that line).
- Adds a public dispatch() forwarding to the private Dispatcher, and
  an onSocketClaimed hook fired once #readyPromise is assigned -- the
  seam an in-process auxiliary frontend (HTTP, next commit) needs to
  call the exact same dispatcher and park on the exact same
  startup-readiness gate a socket request does.
- Maps AdminAuthenticationFailedError onto the ADMIN_AUTHENTICATION_FAILED
  contract code, same pattern as every other typed rejection.

Extracts OwnerRoutedFactBus: the lease-lost/device-unhealthy/
device-recovered translation DaemonServer's push routing already did
inline, now a small standalone bus so the HTTP gateway's
LeaseNoticeBuffer (next commit) can subscribe to the same facts
instead of the raw event bus a second time. DaemonServer's own
per-connection routing subscribes to it instead of the raw bus,
unchanged in external behaviour.
The ADR says lease.cancel "cancels this principal's pending request
by requester id" -- today it was unrestricted (any session could
cancel any pending request by passing its requesterId). Adds an
authorize hook matching the pattern lease.renew/lease.release use:
admin bypasses, an omitted requesterId defaults to the principal (and
so always passes), an explicit different requesterId requires admin.
createHttpApp now takes a dispatch function (the same Dispatcher
instance the socket path uses, shared in-process via DaemonServer's
new public dispatch()) and calls it for every route that maps onto a
daemon operation. This deletes HTTP's own copies of status assembly,
device/lease decoration, error->status mapping, requireAuth's role
gate, and requireOwnership -- all now the dispatcher's job, so HTTP
and the socket path get identical role/ownership/parsing behaviour
for the same operation.

Two consequences the ADR calls out become true:
- The download policy now applies to HTTP: the clamp lives inside the
  dispatcher's lease.request handler, which HTTP calls directly.
- An HTTP request during startup parks the same way a socket request
  does (dispatch() awaits startup readiness before every operation
  but status.get) -- main.ts now starts the HTTP gateway right after
  the socket claim (DaemonServer's new onSocketClaimed hook) instead
  of after full convergence, so there's something for it to park
  against.

LeaseRequestTracker calls dispatch("lease.request", ...) instead of
LeaseCommands.request directly, which also deletes the
renew-immediately-after-grant TTL hack (ttlMs now travels on the
request itself, ADR §9). LeaseNoticeBuffer and the tracker's own
lease-ended bookkeeping both consume the owner-routed fact bus
instead of subscribing to the raw event bus.

auth.ts's requireAuth drops its minRole parameter and requireOwnership
export -- role gating and ownership are the dispatcher's job now;
this middleware only extracts and verifies the bearer token identity
(the "bearer-token-to-session adapter" the ADR describes). errors.ts's
mapError gains a DispatchError branch, mapping through the contract's
own ERROR_TABLE httpStatus column rather than a second HTTP-only
guess.

main.ts wires this up: TokenStore and AdminSecretManager are
constructed once (shared between the socket hello handshake and HTTP
bearer-token verification), and the HTTP gateway's own startup is
driven by DaemonServer's onSocketClaimed hook with a small
best-effort race guard for the (now possible) case where daemon.stop()
runs while the gateway is still mid-start.
…her moves

- daemon/dispatcher.test.ts (ADR §12): drives Dispatcher directly
  against a real LeaseEngine/Registry/CleanupReaper backed by
  FakeDriver -- parsing, role rejection (including doctor.run's
  input-dependent role), ownership (lease.renew/release/list/cancel),
  error codes, the download-policy clamp applying regardless of
  caller, and startup-readiness parking.
- daemon/main.test.ts: an HTTP-level regression test mirroring the
  existing socket-level startup-readiness test -- an HTTP request
  parks (rather than being refused) while convergence is still in
  flight, proving the gateway is actually listening early and its
  request actually parks.
- http/test-fakes.ts: FakeDispatcher replaces FakeLeaseCommands/
  FakeQueueControl for HTTP-layer tests -- it does not reproduce the
  real Dispatcher's parsing/role/ownership logic (covered directly
  above); it exists to script an operation's answer for routing and
  serialization tests. FakeCapacityReader/FakeCatalogReader dropped
  (no longer needed once HTTP calls dispatch() for those operations).
- http/app.test.ts, http/tracker.test.ts, http/auth.test.ts: rebuilt
  against FakeDispatcher and the simplified requireAuth.
`#dispatchLine` intercepted `daemon.stop` before the handshake and any
role check, citing ADR 0003 §6's "frozen exception" -- but that
exception is scoped to the protocol-version gate only ("the daemon
accepts it at any protocol version it has ever spoken"), not to
authentication or role. §3's operation matrix assigns `daemon.stop`
role admin, and the ADR's Context section names this exact gap ("Any
local connection can release any lease, nuke, or stop the daemon") as
the defect being fixed. Any local process could stop the daemon
without a credential.

`daemon.stop` now still requires a completed, successfully
authenticated handshake and the resolved role being admin, but stays
reachable across a protocol-version mismatch and while the daemon is
already stopping. On a `hello` whose protocol negotiation fails, the
daemon still verifies the credential and resolves the role (credential
verification is independent of protocol version), replies
PROTOCOL_VERSION_UNSUPPORTED as before, but keeps the connection open
in a new `protocolMismatch` state: `daemon.stop` is accepted there,
gated on the resolved role, while every other operation keeps getting
refused with the same version error. A wrong credential still fails
the handshake outright and closes the connection, so nothing --
including `daemon.stop` -- runs on it.
…e CLI working

This branch starts enforcing roles: list.get, cleanup.run, nuke.run, config.get,
events.*, lease.release-all and daemon.stop are admin-only. But the CLI had no way
to present a credential until two branches later, so from here every admin command
answered FORBIDDEN -- including daemon stop, which meant a daemon could not be shut
down at all. The e2e suite failed wholesale, each test burning ~30s before teardown
reported a stray daemon that outlived its stop.

ADR 0003 §5 pairs enforcement with the CLI's credential resolution deliberately:
the CLI is the operator interface. Splitting them across PRs was the defect.

Resolution order here is SIMLOCK_ADMIN_TOKEN, then the local admin.token file;
--token arrives with the typed client. The credential is resolved *after* the
socket connects, never before, because the daemon writes admin.token just after
claiming its socket -- resolving eagerly would make the CLI an agent on every cold
start. An unreadable file degrades to an agent session with a stderr notice.

Also retargets http-api's 403 fixture at GET /v1/devices: it used GET /v1/leases,
which now dispatches lease.list (role: agent) and so is no longer a 403 case.
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.

1 participant