feat(daemon): one dispatcher for every transport, roles, ownership, admin credential - #94
Open
V3RON wants to merge 13 commits into
Open
feat(daemon): one dispatcher for every transport, roles, ownership, admin credential#94V3RON wants to merge 13 commits into
V3RON wants to merge 13 commits into
Conversation
… 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.
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.
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.tstakes an operation name, a raw input and a session, and in order: parses the input, rejects a session below the operation's role withFORBIDDEN, runs theauthorizehook, parks on startup readiness, calls the handler, parses the output. Handlers never see a role check or a raw payload.DaemonServerkeeps framing, connection lifecycle, held-lease tracking and pushes. The HTTP app calls the sameDispatcherinstance 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:
helloandstatus.getanswer during convergence, everything else parks, and a convergence failure still drains parked dispatches before closing sockets.Roles, principal, requester, owner (§3, §4)
helloand fixed for the connection's life. A request cannot name a different principal than the connection it arrived on.lease.requesttakes an optionalrequesterIddefaulting 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.ownerIdfrom the session principal.lease.renew,lease.release,lease.cancelandlease.listcompare it to the principal; admin bypasses. Records written before the field load withownerId = 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)
hellocarries the protocol range, capabilities, the principal and an optionalcredential; 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 withADMIN_AUTHENTICATION_FAILEDbefore 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 toadmin.tokenvia an atomic write whose owner-only mode is set at the temp file's own creation, never a laterchmod. 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.helloverifies against memory, so a credential can be checked before the file lands.The credential travels only inside the
hellopayload — 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 andrequireOwnershipare deleted. Two consequences the ADR predicted are now true and tested:allowDownloadthroughconfig.downloads.policywhile HTTP passed it through unclamped — that divergence is closed.LeaseRequestTrackerandLeaseNoticeBufferstay (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 —ttlMsonlease.requestreplaces it.Operation additions (§9)
lease.cancel(cancel a pending request without closing the connection),lease.list(own leases; all for admin), andttlMsonlease.requestwired throughLeaseRequestOptionsto core. SupplyingttlMsfor a held lease isBAD_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.
heldLeaseIdsremains, but only for release-on-close. A client is still never pushedlease-lostfor a release it asked for itself.Behaviour changes worth a reviewer's eye
GET /v1/leases/:idnow returns 404 rather than 403 for another requester's lease — it reuseslease.list's own filter instead of a separate ownership check.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 absolutesinceTs).typecheck,lint,format:check,typecheck:e2eandfallowclean; 1077 unit tests pass, including the ADR §12 isolated dispatcher suite driven against the fake driver.