From f36184df60079f090db335171898105ddff778af Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 30 Aug 2026 17:37:42 -0400 Subject: [PATCH] feat(session): secure daemon upgrades --- .changeset/fair-brokers-rest.md | 2 + .../secure-session-broker-integration.md | 5 + .github/workflows/ci.yml | 8 + .github/workflows/pr-ci.yml | 3 + docs/agent-workflows.md | 10 +- docs/session-broker-sdk.md | 74 ++- packages/session-broker-bun/package.json | 2 +- packages/session-broker-bun/src/serve.test.ts | 184 ++++- packages/session-broker-bun/src/serve.ts | 52 +- packages/session-broker-core/package.json | 2 +- packages/session-broker-core/src/auth.test.ts | 4 +- packages/session-broker-core/src/auth.ts | 4 + .../src/brokerState.test.ts | 91 +++ .../session-broker-core/src/brokerState.ts | 38 +- .../session-broker-core/src/budgets.test.ts | 38 ++ packages/session-broker-core/src/budgets.ts | 61 ++ .../session-broker-core/src/limits.test.ts | 127 +++- packages/session-broker-core/src/limits.ts | 86 ++- packages/session-broker-node/package.json | 2 +- .../session-broker-node/src/serve.test.ts | 10 +- packages/session-broker-node/src/serve.ts | 57 +- packages/session-broker/package.json | 2 +- .../session-broker/src/authentication.test.ts | 104 ++- packages/session-broker/src/authentication.ts | 42 +- packages/session-broker/src/broker.ts | 15 +- .../src/clientAuthentication.test.ts | 433 ++++++++++++ .../src/clientAuthentication.ts | 629 ++++++++++++++++++ .../session-broker/src/connection.test.ts | 151 ++++- packages/session-broker/src/connection.ts | 177 ++++- packages/session-broker/src/daemon.test.ts | 300 ++++++++- packages/session-broker/src/daemon.ts | 427 +++++++++++- packages/session-broker/src/index.ts | 1 + packages/session-broker/src/types.ts | 2 + src/main.tsx | 2 +- src/session/agent/cliClient.test.ts | 13 +- src/session/agent/cliClient.ts | 98 ++- src/session/agent/commands.daemon.test.ts | 1 - src/session/agent/commands.test.ts | 294 ++------ src/session/agent/commands.ts | 105 +-- src/session/broker/appContract.ts | 15 + src/session/broker/brokerClient.test.ts | 424 ++++++++++-- src/session/broker/brokerClient.ts | 158 ++--- src/session/broker/brokerConfig.test.ts | 15 + src/session/broker/brokerLauncher.test.ts | 34 + src/session/broker/brokerLauncher.ts | 90 ++- .../broker/brokerServer.helpers.test.ts | 4 +- src/session/broker/brokerServer.test.ts | 223 +++++-- src/session/broker/brokerServer.ts | 161 ++++- src/session/broker/credentials.test.ts | 91 +++ src/session/broker/credentials.ts | 375 +++++++++++ src/session/broker/state.ts | 3 +- src/session/client/capabilities.ts | 9 +- test/cli/install-vm/README.md | 5 +- test/cli/install-vm/contract.test.ts | 40 ++ test/cli/install-vm/contract.ts | 94 +++ .../prepare-daemon-upgrade-fixtures.ts | 290 ++++++++ test/cli/install-vm/prepare-fixtures.test.ts | 283 +++++++- test/cli/install-vm/prepare-fixtures.ts | 246 ++++++- test/cli/install-vm/results.test.ts | 417 +++++++++++- test/cli/install-vm/results.ts | 392 ++++++++++- test/cli/install-vm/scenarios.json | 84 +++ .../scenarios/authenticated-daemon-upgrade.sh | 507 ++++++++++++++ .../cli/install-vm/validate-release-result.ts | 72 +- .../sessionBrokerAdapterConformance.json | 3 +- test/session-broker-node/adapter.test.mjs | 68 +- test/session/broker-e2e.test.ts | 25 +- test/session/cli.test.ts | 54 +- test/session/daemon.test.ts | 6 +- 68 files changed, 6988 insertions(+), 856 deletions(-) create mode 100644 .changeset/fair-brokers-rest.md create mode 100644 .changeset/secure-session-broker-integration.md create mode 100644 packages/session-broker/src/clientAuthentication.test.ts create mode 100644 packages/session-broker/src/clientAuthentication.ts create mode 100644 src/session/broker/appContract.ts create mode 100644 src/session/broker/credentials.test.ts create mode 100644 src/session/broker/credentials.ts create mode 100644 test/cli/install-vm/prepare-daemon-upgrade-fixtures.ts create mode 100755 test/cli/install-vm/scenarios/authenticated-daemon-upgrade.sh diff --git a/.changeset/fair-brokers-rest.md b/.changeset/fair-brokers-rest.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/fair-brokers-rest.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/secure-session-broker-integration.md b/.changeset/secure-session-broker-integration.md new file mode 100644 index 000000000..9e555da26 --- /dev/null +++ b/.changeset/secure-session-broker-integration.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Authenticate local session producers and CLI controls with automatically discovered owner-private credentials, signed responses, scoped reconnect replacement, and bounded handshakes. Expose only minimal public daemon health, refuse unsafe PID-based replacement, and let interactive Hunk windows reconnect automatically after an incompatible incumbent becomes idle. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 802573b0f..eabc5cf5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,11 @@ jobs: with: bun-version: 1.3.14 + - name: Set up Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + - name: Install Jujutsu uses: taiki-e/install-action@07b4745e0c39a41822af610387492e3e53aa222b # v2.83.4 with: @@ -81,6 +86,9 @@ jobs: - name: Test suite run: bun run test + - name: Real Node broker adapter tests + run: bun run test:session-broker-node + - name: PTY integration tests run: bun run test:integration diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 9b53ad6f2..86b125a71 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -187,6 +187,9 @@ jobs: - name: Test suite run: bun run test + - name: Real Node broker adapter tests + run: bun run test:session-broker-node + - name: PTY integration tests run: bun run test:integration diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index 390247dd1..09ce109f1 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -25,15 +25,7 @@ When a Hunk TUI starts, it registers with a local loopback daemon. `hunk session Most users only need `hunk session ...`. Use `hunk mcp serve` only for manual startup or debugging of the local daemon. -If `hunk session list` reports no sessions while Hunk is visibly running, the agent sandbox may be blocking loopback access. Probe the daemon directly: - -```bash -curl -s -X POST http://127.0.0.1:47657/session-api \ - -H 'content-type: application/json' \ - --data '{"action":"list"}' -``` - -If this shows sessions, rerun the command with the agent's network/sandbox escalation. If you run the daemon with a custom `HUNK_MCP_PORT`, use that port instead. +If `hunk session list` reports no sessions while Hunk is visibly running, the agent sandbox may be blocking loopback access. Rerun `hunk session list --json` with the agent's network/sandbox escalation. Do not probe `/session-api` with raw `curl`: session controls require an automatically discovered, owner-private caller credential and signed responses, and Hunk intentionally exposes no credential flags. ## The commands you will use most diff --git a/docs/session-broker-sdk.md b/docs/session-broker-sdk.md index 12a2c2fea..14d273013 100644 --- a/docs/session-broker-sdk.md +++ b/docs/session-broker-sdk.md @@ -207,8 +207,9 @@ Each request carries caller session, caller request ID, and a canonical uint64 d `(?:0|[1-9][0-9]{0,19})`, at most `18446744073709551615`, parsed with integer/BigInt rather than JSON number. The signature binds generation, caller session, grant/key ID, hello transcript hash, HTTP method, canonical path and sorted/encoded query, canonical body digest, request ID, and sequence. -Authorization precedes execution and cache lookup. Signed target command envelopes and signed -responses repeat the exact selected application revision/features; producers reject mismatches +Responses bind the same caller session, request ID, and sequence so signatures cannot cross caller +sessions. Authorization precedes execution and cache lookup. Signed target command envelopes and +signed responses repeat the exact selected application revision/features; producers reject mismatches before parsing input. Target incompatibility returns a structured error without mutating the caller session. @@ -286,9 +287,10 @@ input/result for the exact contract, and cross-process health/capabilities. App revision. Failures return stable codes without stacks/parser internals, commit no partial mutation or result, -close producers when stale assumptions would remain, and behave identically on Node/Bun. Reject -binary WebSocket frames with `1003`, oversized frames with `1009`, and malformed discriminants, -numbers, and versions rather than casting them. +and close producers when stale assumptions would remain. Reject binary WebSocket frames with `1003` +and malformed discriminants, numbers, and versions rather than casting them. Native per-message caps +reject oversized frames before application decoding: Node's `ws` reports `1009`, while Bun 1.3 may +surface its non-configurable abnormal `1006` because it does not invoke the application callback. ## Local security contract @@ -398,10 +400,10 @@ follow their descriptor capacities and per-group FIFO. | Resource | Initial default | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Sessions and commands | 256 sessions/daemon; 64 queued+in-flight/session; 1,024/daemon; 32 waiting for a missing bridge; one active/session by default | +| Sessions and commands | 256 sessions/daemon; 64 queued+in-flight/session; 1,024/daemon; 32 queued+executing through one producer bridge; one active/session by default | | Handshakes | Per daemon: 64 unauthenticated/challenged sockets, 128 incomplete records, 4 MiB incomplete bytes; 64 KiB/proposal | | Callers and HTTP | Per daemon: 256 caller sessions at 8 KiB each/2 MiB total, 32 concurrent controls, 64 MiB in-flight body bytes; 4 MiB maximum decoded request; 8 MiB maximum decoded/aggregated response | -| WebSocket | 8 MiB maximum inbound message; 64 MiB decoded/in-flight inbound/daemon; buffered outbound: 8 MiB/peer and 64 MiB/daemon | +| WebSocket | 8 MiB native inbound message cap; 64 MiB broker-owned delivered-message processing/daemon; 64 socket admissions; buffered outbound: 8 MiB/peer and 64 MiB/daemon | | Retained session state | 4 MiB metadata+snapshot/session; 256 MiB/daemon | | Command data/time | 1 MiB validated input/entry; 64 MiB queued-command bytes/daemon; 15 s default timeout; 5 min caller maximum | | Idempotency | 1,024 entries/session; 65,536 entries/daemon; 10 min TTL; 1 MiB result/entry; 64 MiB/daemon | @@ -410,10 +412,15 @@ Hosts may lower limits. Raising a public network/body/frame/buffer ceiling requi unsafe-limits configuration and is outside supported defaults. App validators add tighter collection, string, nesting, state, and command limits. -Overflow returns structured `busy`, `queue-full`, or `capacity-exceeded`. Reserve aggregate inbound -bytes before read/decode/parse and release in `finally`; unavailable WebSocket capacity or slow-peer -outbound overflow closes with retryable `1013`. Unwritten work is `not-delivered`; written work -follows the delivery matrix. Reserve outbound serialized bytes per peer/daemon until flush/close. HTTP readers reserve declared/incremental chunks and return `503` when unavailable. +Overflow returns structured `busy`, `queue-full`, or `capacity-exceeded`. Native WebSocket frame +assembly and runtime-owned queues are outside broker byte accounting; adapters bound them per message +with the native cap and bound peer count at socket admission. Once a message is delivered, reserve its +bytes before broker decode/parse/handling and release in `finally`. Unavailable socket-count admission +returns `503`; unavailable delivered-message capacity or slow-peer outbound overflow closes with +retryable `1013`. Unwritten work is `not-delivered`; written work follows the delivery matrix. Reserve +outbound serialized bytes per peer/daemon until flush/close. HTTP readers reserve the permitted source +maximum before the first pull, resize to actual bytes, and require aggregate capacity for the +source-plus-copy peak before retaining the merged body; unavailable capacity returns `503`. Unauthenticated overload allocates no handshake state. Response overflow fails before unbounded aggregation. New-session failure never evicts existing state. @@ -540,20 +547,41 @@ resources/cache/assembly, comments/highlights/navigation/reload/intents; HTTP ac discovery; and executable launch/upgrade copy. Generic state removes Hunk-shaped projections; Hunk builds them from read-only generic entries/events, and the package never imports Hunk types. -| Existing contract | Migration requirement | -| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `HUNK_MCP_HOST`, `HUNK_MCP_PORT`, `HUNK_MCP_DISABLE` | Preserve in Hunk adapter for at least one documented minor window. | -| `HUNK_MCP_UNSAFE_ALLOW_REMOTE` | Temporary unsupported Hunk-only escape hatch; never generic. | -| runtime directory `hunk-mcp` | Preserve or dual-read so old/new binaries cannot race separate namespaces. | -| default `127.0.0.1:47657` | Winning candidate reserves/retains it as guard before coordinator publication; explicit port reserves that endpoint in the same namespace. | -| `/session`, `/session-api` | Preserve CLI semantics but require new authentication; old clients get actionable upgrade refusal. | -| `/mcp` returns `410` | Retain until separately deprecated. | -| exact-version restart | Never signal from unverifiable PID; legacy daemon is an actionable manual stop/restart conflict; only authenticated generation may stop automatically. | -| missing `repoBoundary` | Preserve containment fallback; new clients may provide VCS-aware boundary. | +| Existing contract | Migration requirement | +| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `HUNK_MCP_HOST`, `HUNK_MCP_PORT`, `HUNK_MCP_DISABLE` | Preserve in Hunk adapter for at least one documented minor window. | +| `HUNK_MCP_UNSAFE_ALLOW_REMOTE` | Temporary unsupported Hunk-only escape hatch; never generic. | +| runtime directory `hunk-mcp` | Preserve or dual-read so old/new binaries cannot race separate namespaces. | +| default `127.0.0.1:47657` | Winning candidate reserves/retains it as guard before coordinator publication; explicit port reserves that endpoint in the same namespace. | +| `/session`, `/session-api` | Preserve CLI semantics but require new authentication; old clients get actionable upgrade refusal. | +| `/mcp` returns `410` | Retain until separately deprecated. | +| incompatible daemon | Never signal from unverifiable PID. Interactive Hunk waits and retries until the incumbent exits while idle; forced replacement requires an authenticated generation. | +| missing `repoBoundary` | Preserve containment fallback; new clients may provide VCS-aware boundary. | Security outranks wire compatibility: no migration accepts unauthenticated control/registration. -Preservation means paths, selectors, outputs, and automatic credential discovery for upgraded -clients—not interoperability with pre-authentication binaries. +An interactive Hunk window keeps reviewing locally while an incompatible or pre-authentication +incumbent owns the endpoint. After the first signed-handshake refusal it polls only minimal health, +so repeated WebSocket closes cannot postpone the incumbent's idle timeout; once health disappears, +the same bounded connection object reruns discovery, signed negotiation, and registration. One-shot +session commands fail promptly with instructions instead +of becoming a second restart owner. Daemons too old to retire while idle, or hung incumbents, still +require manual termination. Preservation means paths, selectors, outputs, and automatic credential +discovery for upgraded clients—not interoperability with pre-authentication binaries. + +Hunk's fixed-endpoint Phase-1 credential store uses a home-local `.hunk` parent when +`XDG_RUNTIME_DIR` is unavailable, rather than a predictable name in a shared temporary directory. +It inherits the current user's ACL when it creates the `hunk-mcp/security-v1` directory +on Windows and rejects symbolic-link redirection. Node does not +provide a portable owner/DACL or general reparse-point inspection API, so this integration cannot +detect a pre-existing custom permissive DACL or every non-symlink reparse point; completing native +Windows ACL validation remains a release-gate item before the reusable package is published. + +The fixed-endpoint integration authenticates bootstrap reconnects, distinguishes `register` from +`reconnect` scope, atomically retires the previous socket, and rejects its uncertain work. Retained +producer authority is rechecked before inbound mutations and before any queued command bytes leave +the daemon. It does +not yet claim the durable candidate-key `registered`/`registration-ack` rotation sequence above; +that sequence remains a publication gate rather than an unauthenticated compatibility fallback. Before publishing even `initializing`, a Hunk candidate binds and retains the legacy guard endpoint; only its holder may enter coordinator election. A contender unable to bind waits a bounded startup diff --git a/packages/session-broker-bun/package.json b/packages/session-broker-bun/package.json index 1873c83c4..569d65c1d 100644 --- a/packages/session-broker-bun/package.json +++ b/packages/session-broker-bun/package.json @@ -19,7 +19,7 @@ "@hunk/session-broker": "workspace:*" }, "engines": { - "bun": ">=1.0.0", + "bun": ">=1.3.14", "node": ">=22" } } diff --git a/packages/session-broker-bun/src/serve.test.ts b/packages/session-broker-bun/src/serve.test.ts index dc8ed76b4..20fede6a6 100644 --- a/packages/session-broker-bun/src/serve.test.ts +++ b/packages/session-broker-bun/src/serve.test.ts @@ -1,11 +1,13 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { createServer } from "node:net"; +import { connect, createServer } from "node:net"; import { + BrokerCapacityError, SESSION_BROKER_REGISTRATION_VERSION, brokerWireParsers, parseSessionRegistrationEnvelope, parseSessionSnapshotEnvelope, type SessionRegistration, + type SessionServerMessage, type SessionSnapshot, } from "@hunk/session-broker-core"; import { @@ -115,6 +117,36 @@ async function waitUntil( } } +/** Return the HTTP status from one raw WebSocket upgrade attempt. */ +async function rawWebSocketUpgradeStatus(port: number) { + return await new Promise((resolve, reject) => { + const socket = connect({ host: "127.0.0.1", port }, () => { + socket.write( + [ + "GET /session HTTP/1.1", + `Host: 127.0.0.1:${port}`, + "Connection: Upgrade", + "Upgrade: websocket", + "Sec-WebSocket-Version: 13", + "Sec-WebSocket-Key: dGVzdC1zZXNzaW9uLWtleQ==", + "", + "", + ].join("\r\n"), + ); + }); + let response = ""; + socket.on("data", (chunk) => { + response += chunk.toString("utf8"); + if (!response.includes("\r\n\r\n")) return; + const status = Number(response.match(/^HTTP\/1\.1 (\d{3})/)?.[1]); + socket.destroy(); + if (Number.isInteger(status)) resolve(status); + else reject(new Error("WebSocket upgrade returned an invalid HTTP response.")); + }); + socket.on("error", reject); + }); +} + async function openTestSocket(url: string) { const socket = new WebSocket(url); await new Promise((resolve, reject) => { @@ -196,18 +228,11 @@ afterEach(() => { }); describe("session broker bun adapter", () => { - test("uses the shared binary, oversize, and pressure close corpus", () => { - expect(SESSION_BROKER_ADAPTER_CONFORMANCE).toMatchObject({ - textOnly: { binaryCloseCode: 1003 }, - inbound: { oversizedCloseCode: 1009, pressureCloseCode: 1013 }, - }); - }); - - test("closes binary, oversized, and aggregate-pressure messages per the shared corpus", async () => { + test("closes binary and oversized messages per the shared corpus", async () => { const broker = new SessionBroker({ protocolParsers }); const daemon = createSessionBrokerDaemon({ broker, - limits: { maxWsMessageBytes: 8, maxInFlightWsBytes: 0 }, + limits: { maxWsMessageBytes: 8 }, }); const port = await reserveLoopbackPort(); const server = serveSessionBrokerDaemon({ daemon, hostname: "127.0.0.1", port }); @@ -220,16 +245,60 @@ describe("session broker bun adapter", () => { const oversized = await openTestSocket(`ws://127.0.0.1:${port}/session`); const oversizedClosed = testSocketCloseCode(oversized); oversized.send("123456789"); - expect(await oversizedClosed).toBe( - SESSION_BROKER_ADAPTER_CONFORMANCE.inbound.oversizedCloseCode, + expect(SESSION_BROKER_ADAPTER_CONFORMANCE.inbound.bunNativeOversizedCloseCodes).toContain( + await oversizedClosed, ); + } finally { + server.stop(true); + await server.stopped; + } + }); - const pressure = await openTestSocket(`ws://127.0.0.1:${port}/session`); - const pressureClosed = testSocketCloseCode(pressure); - pressure.send("{}"); - expect(await pressureClosed).toBe( - SESSION_BROKER_ADAPTER_CONFORMANCE.inbound.pressureCloseCode, + test("returns the shared HTTP status when socket admission is full and releases on close", async () => { + const broker = new SessionBroker({ protocolParsers }); + const daemon = createSessionBrokerDaemon({ + broker, + limits: { maxUnauthenticatedSockets: 1, maxHandshakeDurationMs: 1_000 }, + helloAuthenticator: {} as never, + producerEndpoint: "ws://127.0.0.1/session", + }); + const port = await reserveLoopbackPort(); + const server = serveSessionBrokerDaemon({ daemon, hostname: "127.0.0.1", port }); + try { + const first = await openTestSocket(`ws://127.0.0.1:${port}/session`); + expect(await rawWebSocketUpgradeStatus(port)).toBe( + SESSION_BROKER_ADAPTER_CONFORMANCE.inbound.admissionHttpStatus, ); + const closed = testSocketCloseCode(first); + first.close(); + await closed; + const afterRelease = await openTestSocket(`ws://127.0.0.1:${port}/session`); + afterRelease.close(); + } finally { + server.stop(true); + await server.stopped; + } + }); + + test("contains handler failures and closes the affected peer", async () => { + const broker = new SessionBroker({ protocolParsers }); + const daemon = createSessionBrokerDaemon({ broker }); + daemon.handleConnectionMessage = (_peer, message) => { + if (message === "capacity") throw new BrokerCapacityError("busy", "test"); + throw new Error("unexpected handler failure"); + }; + const port = await reserveLoopbackPort(); + const server = serveSessionBrokerDaemon({ daemon, hostname: "127.0.0.1", port }); + try { + const capacity = await openTestSocket(`ws://127.0.0.1:${port}/session`); + const capacityClosed = testSocketCloseCode(capacity); + capacity.send("capacity"); + expect(await capacityClosed).toBe(1013); + + const unexpected = await openTestSocket(`ws://127.0.0.1:${port}/session`); + const unexpectedClosed = testSocketCloseCode(unexpected); + unexpected.send("unexpected"); + expect(await unexpectedClosed).toBe(1011); } finally { server.stop(true); await server.stopped; @@ -262,6 +331,76 @@ describe("session broker bun adapter", () => { } }); + test("closes outbound aggregate pressure and releases socket capacity for reconnect", async () => { + type PressureMessage = SessionServerMessage<"annotate", { summary: string }>; + const pressureParsers = createSessionBrokerProtocolParsers< + TestSessionInfo, + TestSessionState, + PressureMessage, + { applied: true } + >({ + appRevision: 1, + features: [], + parseRegistration: (value) => parseSessionRegistrationEnvelope(value, parseInfo), + parseSnapshot: (value) => parseSessionSnapshotEnvelope(value, parseState), + commands: [ + { + command: "annotate", + version: 1, + parseInput: (value) => + typeof (value as { summary?: unknown })?.summary === "string" + ? { summary: (value as { summary: string }).summary } + : null, + parseResult: (value) => + (value as { applied?: unknown })?.applied === true ? { applied: true } : null, + }, + ], + }); + const broker = new SessionBroker({ protocolParsers: pressureParsers }); + const daemon = createSessionBrokerDaemon({ + broker, + limits: { maxOutboundBytesTotal: 8 }, + }); + const port = await reserveLoopbackPort(); + const server = serveSessionBrokerDaemon({ daemon, hostname: "127.0.0.1", port }); + try { + const socket = await openTestSocket(`ws://127.0.0.1:${port}/session`); + socket.send( + JSON.stringify({ + type: "register", + registration: createRegistration(), + snapshot: createSnapshot(), + }), + ); + await waitUntil("pressure registration", () => + broker.listSessions().length === 1 ? true : null, + ); + const closed = testSocketCloseCode(socket); + const dispatchResult = broker + .dispatchCommand({ + selector: { sessionId: "session-1" }, + command: "annotate", + input: { summary: "pressure" }, + timeoutMessage: "timeout", + }) + .then( + () => null, + (error: unknown) => error, + ); + expect(await closed).toBe(SESSION_BROKER_ADAPTER_CONFORMANCE.outbound.pressureCloseCode); + expect(await dispatchResult).toBeInstanceOf(Error); + await waitUntil("pressure disconnect cleanup", () => + broker.listSessions().length === 0 ? true : null, + ); + + const afterRelease = await openTestSocket(`ws://127.0.0.1:${port}/session`); + afterRelease.close(); + } finally { + server.stop(true); + await server.stopped; + } + }); + test("manual stop retires peer admission and rejects late message delivery", async () => { let snapshotCalls = 0; const countingParsers = createSessionBrokerProtocolParsers({ @@ -366,11 +505,13 @@ describe("session broker bun adapter", () => { expect(stoppedSettled).toBe(true); }); - test("admits exactly the configured number of active websocket peers", async () => { + test("admits exactly the configured number of unauthenticated websocket peers", async () => { const broker = new SessionBroker({ protocolParsers }); const daemon = createSessionBrokerDaemon({ broker, - limits: { maxUnauthenticatedSockets: 1 }, + limits: { maxUnauthenticatedSockets: 1, maxHandshakeDurationMs: 50 }, + helloAuthenticator: {} as never, + producerEndpoint: "ws://127.0.0.1/session", }); const port = await reserveLoopbackPort(); const server = serveSessionBrokerDaemon({ daemon, hostname: "127.0.0.1", port }); @@ -378,8 +519,7 @@ describe("session broker bun adapter", () => { const first = await openTestSocket(`ws://127.0.0.1:${port}/session`); await expect(openTestSocket(`ws://127.0.0.1:${port}/session`)).rejects.toThrow(); const closed = testSocketCloseCode(first); - first.close(); - await closed; + expect(await closed).toBe(1008); const afterRelease = await openTestSocket(`ws://127.0.0.1:${port}/session`); afterRelease.close(); } finally { @@ -413,7 +553,9 @@ describe("session broker bun adapter", () => { generation: "generation-1", brokerRevision: 1 as const, ...(appContract ? { appContract } : {}), + callerSessionId: "caller-session-1", requestId: "request-1", + sequence: "1", httpStatus, bodyDigest: "test-digest", daemonKeyId: "daemon-key-1", diff --git a/packages/session-broker-bun/src/serve.ts b/packages/session-broker-bun/src/serve.ts index 468c7d5d9..6ac39ee64 100644 --- a/packages/session-broker-bun/src/serve.ts +++ b/packages/session-broker-bun/src/serve.ts @@ -10,6 +10,7 @@ import type { SessionBrokerDaemon, SessionBrokerPeer } from "@hunk/session-broke interface BrokerWebSocketData { admission: BudgetReservation; + handshakeTimer?: ReturnType; } export interface ServeSessionBrokerDaemonOptions< @@ -158,6 +159,16 @@ export function serveSessionBrokerDaemon< } }, close: (code, reason) => socket.close(code, reason), + markAuthenticated() { + const data = (socket as typeof socket & { data?: BrokerWebSocketData }).data; + if (!data) return; + if (data.handshakeTimer) { + clearTimeout(data.handshakeTimer); + data.handshakeTimer = undefined; + } + activeAdmissions.delete(data.admission); + data.admission.release(); + }, }; peers.set(key, peer); return peer; @@ -212,15 +223,20 @@ export function serveSessionBrokerDaemon< const admission = unauthenticatedSocketBudget.tryReserve(); if (!admission) return new Response(null, { status: 503 }); activeAdmissions.add(admission); - if (bunServer.upgrade(request, { data: { admission } })) { - return undefined; + try { + if (bunServer.upgrade(request, { data: { admission } })) { + return undefined; + } + } catch (error) { + activeAdmissions.delete(admission); + admission.release(); + throw error; } activeAdmissions.delete(admission); admission.release(); // Bun signals failed upgrades by returning false from upgrade rather than by throwing, // so surface that as one explicit HTTP response here. - return new Response("Expected websocket upgrade.", { status: 426 }); } @@ -236,16 +252,20 @@ export function serveSessionBrokerDaemon< } }, websocket: { - // Bun cannot customize the close code of its native payload rejection. Keep the native cap - // at the fixed aggregate ceiling so decoded messages above the per-message limit reach the - // portable 1009 path while runtime buffering remains bounded. - maxPayloadLength: Math.min( - Number.MAX_SAFE_INTEGER, - Math.max( - options.daemon.limits.maxWsMessageBytes + 1, - options.daemon.limits.maxInFlightWsBytes, - ), - ), + open: (socket) => { + if (!options.daemon.requiresProducerAuthentication) { + activeAdmissions.delete(socket.data.admission); + socket.data.admission.release(); + return; + } + socket.data.handshakeTimer = setTimeout(() => { + socket.close(1008, "Session broker authentication timed out."); + }, options.daemon.limits.maxHandshakeDurationMs); + socket.data.handshakeTimer.unref?.(); + }, + // Bun bounds native frame assembly per message but exposes no accounting hook before it + // delivers the decoded string. The broker budget below covers only application processing. + maxPayloadLength: Math.max(1, options.daemon.limits.maxWsMessageBytes), message: (socket, message) => { const peer = peerFor(socket); if (typeof message !== "string") { @@ -265,6 +285,11 @@ export function serveSessionBrokerDaemon< } try { options.daemon.handleConnectionMessage(peer, message); + } catch (error) { + socket.close( + error instanceof BrokerCapacityError ? 1013 : 1011, + "Session broker message handling failed.", + ); } finally { reservation.release(); } @@ -283,6 +308,7 @@ export function serveSessionBrokerDaemon< }, close: (socket) => { const key = socket as object; + if (socket.data.handshakeTimer) clearTimeout(socket.data.handshakeTimer); bufferedReservations.get(key)?.release(); bufferedReservations.delete(key); activeAdmissions.delete(socket.data.admission); diff --git a/packages/session-broker-core/package.json b/packages/session-broker-core/package.json index 36e39e37e..afc6294aa 100644 --- a/packages/session-broker-core/package.json +++ b/packages/session-broker-core/package.json @@ -16,7 +16,7 @@ } }, "engines": { - "bun": ">=1.0.0", + "bun": ">=1.3.14", "node": ">=22" } } diff --git a/packages/session-broker-core/src/auth.test.ts b/packages/session-broker-core/src/auth.test.ts index 7e4fb3f70..ad1809937 100644 --- a/packages/session-broker-core/src/auth.test.ts +++ b/packages/session-broker-core/src/auth.test.ts @@ -75,13 +75,15 @@ describe("session broker authentication core", () => { generation: "generation-1", brokerRevision: 1, appContract: { appRevision: 7, features: [] }, + callerSessionId: "caller-session-1", requestId: "request-1", + sequence: "1", httpStatus: 200, bodyDigest: "body-hash", }), ), ).toBe( - '{"appContract":{"appRevision":7,"features":[]},"appId":"dev.example","bodyDigest":"body-hash","brokerRevision":1,"domain":"dev.hunk.session-broker.v1/caller-response","generation":"generation-1","httpStatus":200,"requestId":"request-1"}', + '{"appContract":{"appRevision":7,"features":[]},"appId":"dev.example","bodyDigest":"body-hash","brokerRevision":1,"callerSessionId":"caller-session-1","domain":"dev.hunk.session-broker.v1/caller-response","generation":"generation-1","httpStatus":200,"requestId":"request-1","sequence":"1"}', ); expect( new TextDecoder().decode( diff --git a/packages/session-broker-core/src/auth.ts b/packages/session-broker-core/src/auth.ts index e75f3f038..3199942f9 100644 --- a/packages/session-broker-core/src/auth.ts +++ b/packages/session-broker-core/src/auth.ts @@ -134,7 +134,9 @@ export interface BrokerResponseTranscriptInput { readonly appId: string; readonly generation: string; readonly brokerRevision: typeof SESSION_BROKER_PROTOCOL_REVISION; + readonly callerSessionId: string; readonly requestId: string; + readonly sequence: string; readonly httpStatus: number; readonly bodyDigest: string; readonly appContract?: BrokerAppContract; @@ -349,10 +351,12 @@ export function buildBrokerResponseTranscript(input: BrokerResponseTranscriptInp : {}), bodyDigest: input.bodyDigest, brokerRevision: input.brokerRevision, + callerSessionId: input.callerSessionId, domain: `${SESSION_BROKER_AUTH_DOMAIN}/caller-response`, generation: input.generation, httpStatus: input.httpStatus, requestId: input.requestId, + sequence: input.sequence, }); } diff --git a/packages/session-broker-core/src/brokerState.test.ts b/packages/session-broker-core/src/brokerState.test.ts index 4a91691d0..40a7a2ea2 100644 --- a/packages/session-broker-core/src/brokerState.test.ts +++ b/packages/session-broker-core/src/brokerState.test.ts @@ -199,6 +199,26 @@ function createListedSession(overrides: Partial = {}): TestLi } describe("session broker state", () => { + test("keeps shutdown terminal against registration and command re-admission", () => { + const state = createState(); + const socket = { send() {} }; + const shutdownError = new Error("terminal shutdown"); + state.shutdown(shutdownError); + state.shutdown(new Error("ignored second shutdown")); + + expect(state.registerSession(socket, createRegistration(), createSnapshot())).toBe("shutdown"); + expect(state.getSessionCount()).toBe(0); + expect(() => + state.dispatchCommand({ + selector: { sessionId: "session-1" }, + command: "annotate", + input: { filePath: "a.ts", summary: "late" }, + timeoutMessage: "timeout", + }), + ).toThrow(shutdownError); + expect(state.getPendingCommandCount()).toBe(0); + }); + test("resolves one target session by session id, session path, repo root, or sole-session fallback", () => { const one = [createListedSession()]; const two = [ @@ -527,6 +547,77 @@ describe("session broker state", () => { expect(state.listSessions()).toHaveLength(1); }); + test("atomically replaces a live owner without leaking the replacement socket's prior reservations", () => { + const registration = createRegistration(); + const snapshot = createSnapshot(); + const retainedBytes = + new TextEncoder().encode(JSON.stringify({ registration, snapshot })).byteLength + 256; + const expandedRegistration = createRegistration({ + info: { ...registration.info, title: "x".repeat(64) }, + }); + const expandedBytes = + new TextEncoder().encode( + JSON.stringify({ + registration: expandedRegistration, + snapshot: createSnapshot({ updatedAt: "2026-03-22T00:00:01.000Z" }), + }), + ).byteLength + 256; + const state = createState({ + limits: { + maxSessions: 2, + maxRetainedSessionBytes: expandedBytes, + maxRetainedBytes: retainedBytes * 2, + }, + }); + const originalSocket = { send() {} }; + const replacementSocket = { send() {} }; + state.registerSession(originalSocket, registration, snapshot); + state.registerSession( + replacementSocket, + createRegistration({ sessionId: "session-2" }), + snapshot, + ); + + expect( + state.registerSession( + replacementSocket, + expandedRegistration, + createSnapshot({ updatedAt: "2026-03-22T00:00:01.000Z" }), + { replaceOwner: true }, + ), + ).toBe("registered"); + expect(state.markSessionSeen(originalSocket, "session-1")).toBe("not-owner"); + expect(state.markSessionSeen(replacementSocket, "session-1")).toBe("seen"); + state.unregisterSocket(originalSocket); + expect(state.listSessions()).toHaveLength(1); + }); + + test("releases the replacement socket's prior session count reservation", () => { + const state = createState({ limits: { maxSessions: 2 } }); + const originalSocket = { send() {} }; + const replacementSocket = { send() {} }; + const thirdSocket = { send() {} }; + state.registerSession(originalSocket, createRegistration(), createSnapshot()); + state.registerSession( + replacementSocket, + createRegistration({ sessionId: "session-2" }), + createSnapshot(), + ); + expect( + state.registerSession(replacementSocket, createRegistration(), createSnapshot(), { + replaceOwner: true, + }), + ).toBe("registered"); + expect( + state.registerSession( + thirdSocket, + createRegistration({ sessionId: "session-3" }), + createSnapshot(), + ), + ).toBe("registered"); + expect(state.listSessions()).toHaveLength(2); + }); + test("rejects commands immediately when the live session socket cannot accept them", async () => { const state = createState(); const socket = { diff --git a/packages/session-broker-core/src/brokerState.ts b/packages/session-broker-core/src/brokerState.ts index 2f2f52bff..34abbd9fd 100644 --- a/packages/session-broker-core/src/brokerState.ts +++ b/packages/session-broker-core/src/brokerState.ts @@ -93,7 +93,8 @@ export type RegisterSessionResult = | "registered" | "invalid" | "already-connected" - | "capacity-exceeded"; + | "capacity-exceeded" + | "shutdown"; export type UpdateSnapshotResult = "updated" | "invalid" | "not-owner" | "capacity-exceeded"; export type MarkSessionSeenResult = "seen" | "not-owner"; export type HandleCommandResult = "handled" | "not-found" | "not-owner" | "invalid"; @@ -220,6 +221,7 @@ export class SessionBrokerState< private readonly queuedCommandByteBudget: ResourceBudget; private readonly retainedByteBudget: ResourceBudget; private lastPruneAt: number | null = null; + private shutdownError: Error | null = null; constructor( private view: SessionBrokerViewAdapter< @@ -287,7 +289,10 @@ export class SessionBrokerState< socket: DaemonSessionSocket, registrationInput: unknown, snapshotInput: unknown, + options: { replaceOwner?: boolean } = {}, ): RegisterSessionResult { + if (this.shutdownError) return "shutdown"; + let registration: SessionRegistration | null; let snapshot: SessionSnapshot | null; try { @@ -312,7 +317,7 @@ export class SessionBrokerState< if (retainedBytes > this.limits.maxRetainedSessionBytes) return "capacity-exceeded"; const existing = this.sessions.get(registration.sessionId); - if (existing && existing.socket !== socket) return "already-connected"; + if (existing && existing.socket !== socket && !options.replaceOwner) return "already-connected"; const previousSessionId = this.sessionIdsBySocket.get(socket); const transferSessionId = existing ? registration.sessionId : previousSessionId; const previousRetained = transferSessionId @@ -321,13 +326,27 @@ export class SessionBrokerState< const previousCount = transferSessionId ? this.sessionReservations.get(transferSessionId) : undefined; + const abandonedRetained = + existing && previousSessionId && previousSessionId !== registration.sessionId + ? this.retainedReservations.get(previousSessionId) + : undefined; + const abandonedCount = + existing && previousSessionId && previousSessionId !== registration.sessionId + ? this.sessionReservations.get(previousSessionId) + : undefined; let retainedReservation: BudgetReservation | null = null; let sessionReservation: BudgetReservation | null = null; try { try { retainedReservation = previousRetained - ? this.retainedByteBudget.resize(previousRetained, retainedBytes) + ? abandonedRetained + ? this.retainedByteBudget.resizeWithCredit( + previousRetained, + retainedBytes, + abandonedRetained, + ) + : this.retainedByteBudget.resize(previousRetained, retainedBytes) : this.retainedByteBudget.reserve(retainedBytes); sessionReservation = previousCount ?? this.sessionBudget.reserve(); } catch { @@ -335,11 +354,20 @@ export class SessionBrokerState< } const now = new Date().toISOString(); + if (existing && existing.socket !== socket) { + this.sessionIdsBySocket.delete(existing.socket); + this.rejectPendingCommandsForSession( + registration.sessionId, + new Error("The session owner reconnected."), + ); + } if (previousSessionId && previousSessionId !== registration.sessionId) { // Detach the old identity without releasing the reservations transferred to its replacement. this.sessions.delete(previousSessionId); this.retainedReservations.delete(previousSessionId); this.sessionReservations.delete(previousSessionId); + abandonedRetained?.release(); + abandonedCount?.release(); this.rejectPendingCommandsForSession( previousSessionId, new Error("The session registration was replaced."), @@ -495,6 +523,7 @@ export class SessionBrokerState< timeoutMessage: string; timeoutMs?: number; }) { + if (this.shutdownError) throw this.shutdownError; if (!isValidBrokerRevision(commandVersion)) { throw new TypeError("Command version must be a positive safe integer."); } @@ -625,6 +654,9 @@ export class SessionBrokerState< } shutdown(error = new Error("The session broker daemon shut down.")) { + if (this.shutdownError) return; + this.shutdownError = error; + for (const pending of this.pendingCommands.values()) { this.finishPending(pending, () => pending.reject(error), false); } diff --git a/packages/session-broker-core/src/budgets.test.ts b/packages/session-broker-core/src/budgets.test.ts index aeb0a5300..3d1e5d9fe 100644 --- a/packages/session-broker-core/src/budgets.test.ts +++ b/packages/session-broker-core/src/budgets.test.ts @@ -30,6 +30,7 @@ describe("session broker limits", () => { maxInFlightWsBytes: 64 * 1024 * 1024, challengeTtlMs: 15_000, callerSessionTtlMs: 5 * 60_000, + maxHandshakeDurationMs: 15_000, }); expect(Object.isFrozen(DEFAULT_SESSION_BROKER_LIMITS)).toBe(true); }); @@ -64,6 +65,31 @@ describe("session broker limits", () => { expect(() => resolveSessionBrokerLimits({ limits: { unknown: 1 } as never })).toThrow( "Unknown session broker limit", ); + expect(() => + resolveSessionBrokerLimits({ + limits: { maxWsMessageBytes: 8, maxInFlightWsBytes: 7 }, + }), + ).toThrow("WebSocket message bytes must not exceed in-flight bytes"); + expect( + resolveSessionBrokerLimits({ + limits: { maxHttpBodyBytes: 4, maxInFlightHttpBodyBytes: 8 }, + }).maxHttpBodyBytes, + ).toBe(4); + expect(() => + resolveSessionBrokerLimits({ + limits: { maxHttpBodyBytes: 4, maxInFlightHttpBodyBytes: 7 }, + }), + ).toThrow("source-plus-copy peak"); + expect( + resolveSessionBrokerLimits({ + limits: { maxHttpResponseBytes: 4, maxInFlightHttpResponseBytes: 8 }, + }).maxHttpResponseBytes, + ).toBe(4); + expect(() => + resolveSessionBrokerLimits({ + limits: { maxHttpResponseBytes: 4, maxInFlightHttpResponseBytes: 7 }, + }), + ).toThrow("source-plus-copy peak"); }); }); @@ -78,6 +104,18 @@ describe("resource reservations", () => { expect(budget.used).toBe(0); }); + test("combines a replacement and retired reservation without transient over-admission", () => { + const budget = new ResourceBudget(10, "bytes"); + const target = budget.reserve(6); + const credit = budget.reserve(4); + const replacement = budget.resizeWithCredit(target, 9, credit); + expect(budget.used).toBe(9); + expect(target.released).toBe(true); + expect(credit.released).toBe(true); + replacement.release(); + expect(budget.used).toBe(0); + }); + test("resizes retained records by their delta and transfers release ownership", () => { const budget = new ResourceBudget(4, "bytes"); const original = budget.reserve(4); diff --git a/packages/session-broker-core/src/budgets.ts b/packages/session-broker-core/src/budgets.ts index f2dc20c68..12cd6ef62 100644 --- a/packages/session-broker-core/src/budgets.ts +++ b/packages/session-broker-core/src/budgets.ts @@ -5,6 +5,7 @@ export interface SessionBrokerLimits { readonly maxSessions: number; readonly maxCommandsPerSession: number; readonly maxCommandsTotal: number; + /** Bound producer commands retained while queued or executing through one bridge. */ readonly maxPreBridgeCommands: number; readonly maxCommandInputBytes: number; readonly maxCommandResultBytes: number; @@ -19,10 +20,12 @@ export interface SessionBrokerLimits { readonly maxHttpResponseBytes: number; readonly maxInFlightHttpResponseBytes: number; readonly maxWsMessageBytes: number; + /** Bound broker-owned processing after native WebSocket delivery, excluding runtime queues. */ readonly maxInFlightWsBytes: number; readonly maxOutboundBytesPerPeer: number; readonly maxOutboundBytesTotal: number; readonly maxUnauthenticatedSockets: number; + readonly maxHandshakeDurationMs: number; readonly maxIncompleteHandshakes: number; readonly maxIncompleteHandshakeBytes: number; readonly maxHandshakeProposalBytes: number; @@ -56,6 +59,7 @@ export const DEFAULT_SESSION_BROKER_LIMITS: Readonly = Obje maxOutboundBytesPerPeer: 8 * 1024 * 1024, maxOutboundBytesTotal: 64 * 1024 * 1024, maxUnauthenticatedSockets: 64, + maxHandshakeDurationMs: 15_000, maxIncompleteHandshakes: 128, maxIncompleteHandshakeBytes: 4 * 1024 * 1024, maxHandshakeProposalBytes: 64 * 1024, @@ -139,6 +143,19 @@ export function mergeSessionBrokerLimits( if (resolved.maxCallerSessionBytes > resolved.maxCallerSessionsBytes) { throw new TypeError("Session broker per-caller retained bytes must not exceed daemon bytes."); } + if (resolved.maxHttpBodyBytes > Math.floor(resolved.maxInFlightHttpBodyBytes / 2)) { + throw new TypeError( + "Session broker in-flight HTTP body bytes must cover the source-plus-copy peak.", + ); + } + if (resolved.maxHttpResponseBytes > Math.floor(resolved.maxInFlightHttpResponseBytes / 2)) { + throw new TypeError( + "Session broker in-flight HTTP response bytes must cover the source-plus-copy peak.", + ); + } + if (resolved.maxWsMessageBytes > resolved.maxInFlightWsBytes) { + throw new TypeError("Session broker WebSocket message bytes must not exceed in-flight bytes."); + } return Object.freeze(resolved); } @@ -233,6 +250,50 @@ export class ResourceBudget { this.reservationStates.set(replacement, replacementState); return replacement; } + + /** Atomically resize one reservation while retiring a second reservation from this budget. */ + resizeWithCredit( + previous: BudgetReservation, + amount: number, + credit: BudgetReservation, + ): BudgetReservation { + assertLimit(amount, this.resource); + const previousState = this.reservationStates.get(previous); + const creditState = this.reservationStates.get(credit); + if ( + previous === credit || + !previousState || + previousState.released || + !creditState || + creditState.released + ) { + throw new TypeError(`Cannot combine inactive ${this.resource} reservations.`); + } + const delta = amount - previousState.amount - creditState.amount; + if (delta > this.capacity - this.reserved) { + throw new BrokerCapacityError(this.code, this.resource); + } + this.reserved += delta; + const replacementState = { amount, released: false }; + const replacement: BudgetReservation = { + amount, + get released() { + return replacementState.released; + }, + release: () => { + if (replacementState.released) return; + replacementState.released = true; + this.reservationStates.delete(replacement); + this.reserved -= replacementState.amount; + }, + }; + previousState.released = true; + creditState.released = true; + this.reservationStates.delete(previous); + this.reservationStates.delete(credit); + this.reservationStates.set(replacement, replacementState); + return replacement; + } } /** Own several incremental reservations and release all of them idempotently. */ diff --git a/packages/session-broker-core/src/limits.test.ts b/packages/session-broker-core/src/limits.test.ts index e8e65f448..b6ea78026 100644 --- a/packages/session-broker-core/src/limits.test.ts +++ b/packages/session-broker-core/src/limits.test.ts @@ -36,16 +36,32 @@ function streamingRequest(byteLength: number, chunkSize = 64 * 1024) { } describe("readRequestTextWithLimit", () => { - test("rejects an oversized declared Content-Length before reading the body", async () => { + test("cancels an oversized declared body without pulling it", async () => { + let pulls = 0; + let cancelled = false; + const body = new ReadableStream( + { + pull(controller) { + pulls += 1; + controller.enqueue(new Uint8Array([1])); + }, + cancel() { + cancelled = true; + }, + }, + { highWaterMark: 0 }, + ); const request = new Request("http://broker.test/api", { method: "POST", - headers: { "content-type": "application/json", "content-length": String(10 * 1024 * 1024) }, - body: "ignored", - }); + headers: { "content-length": String(10 * 1024 * 1024) }, + body, + duplex: "half", + } as RequestInit); await expect(readRequestTextWithLimit(request, 1024)).rejects.toBeInstanceOf( PayloadTooLargeError, ); + expect({ pulls, cancelled }).toEqual({ pulls: 0, cancelled: true }); }); test("aborts the stream when a missing Content-Length hides an oversized body", async () => { @@ -56,15 +72,80 @@ describe("readRequestTextWithLimit", () => { ); }); - test("rejects malformed Content-Length instead of treating it as undeclared", async () => { + test("cancels a malformed declared body without pulling it", async () => { + let pulls = 0; + let cancelled = false; + const body = new ReadableStream( + { + pull(controller) { + pulls += 1; + controller.enqueue(new Uint8Array([1])); + }, + cancel() { + cancelled = true; + }, + }, + { highWaterMark: 0 }, + ); const request = new Request("http://broker.test/api", { method: "POST", headers: { "content-length": "01" }, - body: "x", - }); + body, + duplex: "half", + } as RequestInit); await expect(readRequestBytesWithLimit(request, 1024)).rejects.toBeInstanceOf( InvalidContentLengthError, ); + expect({ pulls, cancelled }).toEqual({ pulls: 0, cancelled: true }); + }); + + test("rejects a full aggregate budget before pulling and cancels the request body", async () => { + let pulls = 0; + let cancelled = false; + const body = new ReadableStream( + { + pull(controller) { + pulls += 1; + controller.enqueue(new Uint8Array([1])); + controller.close(); + }, + cancel() { + cancelled = true; + }, + }, + { highWaterMark: 0 }, + ); + const request = new Request("http://broker.test/api", { + method: "POST", + body, + duplex: "half", + } as RequestInit); + const budget = new ResourceBudget(4, "http"); + const occupied = budget.reserve(4); + + await expect(readRequestBytesWithReservation(request, 4, budget)).rejects.toBeInstanceOf( + BrokerCapacityError, + ); + expect({ pulls, cancelled, used: budget.used }).toEqual({ + pulls: 0, + cancelled: true, + used: 4, + }); + occupied.release(); + }); + + test("charges the maximum before reading a body whose declared length is dishonest", async () => { + const budget = new ResourceBudget(8, "http"); + const request = new Request("http://broker.test/api", { + method: "POST", + headers: { "content-length": "1" }, + body: "1234", + }); + const read = await readRequestBytesWithReservation(request, 4, budget); + expect(new TextDecoder().decode(read.bytes)).toBe("1234"); + expect(budget.used).toBe(4); + read.reservation.release(); + expect(budget.used).toBe(0); }); test("accounts source-plus-merged peak and transfers retained body capacity", async () => { @@ -119,6 +200,34 @@ describe("readRequestTextWithLimit", () => { }); describe("boundHttpResponse", () => { + test("rejects a full aggregate budget before pulling and cancels the response body", async () => { + let pulls = 0; + let cancelled = false; + const body = new ReadableStream( + { + pull(controller) { + pulls += 1; + controller.enqueue(new Uint8Array([1])); + controller.close(); + }, + cancel() { + cancelled = true; + }, + }, + { highWaterMark: 0 }, + ); + const budget = new ResourceBudget(4, "response"); + const occupied = budget.reserve(4); + const response = await boundHttpResponse(new Response(body), 4, budget); + expect(response.status).toBe(503); + expect({ pulls, cancelled, used: budget.used }).toEqual({ + pulls: 0, + cancelled: true, + used: 4, + }); + occupied.release(); + }); + test("accepts an exact-ceiling response and charges it until pull", async () => { const budget = new ResourceBudget(8, "response"); const response = await boundHttpResponse(new Response("1234"), 4, budget); @@ -162,9 +271,11 @@ describe("boundHttpResponse", () => { }); describe("utf8ByteLength", () => { - test("counts multi-byte characters by their encoded size", () => { + test("counts multi-byte characters and replacement sequences without allocating", () => { expect(utf8ByteLength("abc")).toBe(3); expect(utf8ByteLength("é")).toBe(2); expect(utf8ByteLength("😀")).toBe(4); + expect(utf8ByteLength("\ud800")).toBe(3); + expect(utf8ByteLength("\udc00")).toBe(3); }); }); diff --git a/packages/session-broker-core/src/limits.ts b/packages/session-broker-core/src/limits.ts index 7e12f9eb5..c379621c9 100644 --- a/packages/session-broker-core/src/limits.ts +++ b/packages/session-broker-core/src/limits.ts @@ -52,13 +52,32 @@ export class InvalidContentLengthError extends Error { } } -// Reused across every websocket message, HTTP body, and patch check to avoid a per-call alloc. -const sharedTextEncoder = new TextEncoder(); const fatalTextDecoder = new TextDecoder("utf-8", { fatal: true }); -/** UTF-8 byte length of a string without allocating a Buffer in non-Node runtimes. */ +/** Count UTF-8 bytes without allocating an encoded copy before resource admission. */ export function utf8ByteLength(value: string): number { - return sharedTextEncoder.encode(value).length; + let bytes = 0; + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit <= 0x7f) { + bytes += 1; + } else if (codeUnit <= 0x7ff) { + bytes += 2; + } else if ( + codeUnit >= 0xd800 && + codeUnit <= 0xdbff && + index + 1 < value.length && + value.charCodeAt(index + 1) >= 0xdc00 && + value.charCodeAt(index + 1) <= 0xdfff + ) { + bytes += 4; + index += 1; + } else { + // TextEncoder replaces every unpaired surrogate with the three-byte U+FFFD sequence. + bytes += 3; + } + } + return bytes; } /** @@ -73,24 +92,26 @@ export async function readRequestBytesWithReservation( maxBytes: number, aggregateBudget?: ResourceBudget, ): Promise<{ bytes: Uint8Array; reservation: BudgetReservation }> { + const body = request.body; const declaredHeader = request.headers.get("content-length"); if (declaredHeader !== null && !/^(?:0|[1-9][0-9]*)$/.test(declaredHeader)) { + await body?.cancel().catch(() => {}); throw new InvalidContentLengthError(); } const declared = declaredHeader === null ? null : Number(declaredHeader); if (declared !== null && (!Number.isSafeInteger(declared) || declared > maxBytes)) { + await body?.cancel().catch(() => {}); throw new PayloadTooLargeError(maxBytes); } - const sourceReservations = new ReservationGroup(); const retainedReservation = new ReservationGroup(); - try { - if (aggregateBudget && declared !== null) { - sourceReservations.add(aggregateBudget.reserve(declared)); - } - const body = request.body; - if (!body) return { bytes: new Uint8Array(), reservation: sourceReservations }; + if (!body) return { bytes: new Uint8Array(), reservation: retainedReservation }; + let sourceReservation: BudgetReservation | null = null; + try { + // Stream APIs expose bytes only after pulling them. Reserve the full permitted source before + // the first pull so an unknown or dishonest length cannot create an uncharged transient chunk. + if (aggregateBudget) sourceReservation = aggregateBudget.reserve(maxBytes); const reader = body.getReader(); const chunks: Uint8Array[] = []; let total = 0; @@ -105,11 +126,6 @@ export async function readRequestBytesWithReservation( await reader.cancel().catch(() => {}); throw new PayloadTooLargeError(maxBytes); } - if (aggregateBudget && nextTotal > (declared ?? 0)) { - sourceReservations.add( - aggregateBudget.reserve(nextTotal - Math.max(total, declared ?? 0)), - ); - } total = nextTotal; chunks.push(value); } @@ -117,20 +133,23 @@ export async function readRequestBytesWithReservation( reader.releaseLock(); } - // Retain capacity for the merged copy before allocating it; source chunks stay charged until - // copying completes so aggregate accounting covers the real peak. - if (aggregateBudget) retainedReservation.add(aggregateBudget.reserve(total)); + if (aggregateBudget && sourceReservation) { + sourceReservation = aggregateBudget.resize(sourceReservation, total); + retainedReservation.add(aggregateBudget.reserve(total)); + } const merged = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { merged.set(chunk, offset); offset += chunk.byteLength; } - sourceReservations.release(); + sourceReservation?.release(); + sourceReservation = null; return { bytes: merged, reservation: retainedReservation }; } catch (error) { - sourceReservations.release(); + sourceReservation?.release(); retainedReservation.release(); + if (!body.locked) await body.cancel().catch(() => {}); throw error; } } @@ -157,13 +176,16 @@ export async function boundHttpResponse( } if (!response.body) return response; - const reader = response.body.getReader(); - const sourceReservations = new ReservationGroup(); + let sourceReservation: BudgetReservation | null = null; const retainedReservation = new ReservationGroup(); + let reader: ReadableStreamDefaultReader | null = null; const chunks: Uint8Array[] = []; let total = 0; let transferred = false; try { + // Reserve before the first pull because WHATWG streams do not expose chunk size beforehand. + if (aggregateBudget) sourceReservation = aggregateBudget.reserve(maxBytes); + reader = response.body.getReader(); for (;;) { const { done, value } = await reader.read(); if (done) break; @@ -173,19 +195,20 @@ export async function boundHttpResponse( await reader.cancel().catch(() => {}); return new Response(null, { status: 503 }); } - if (aggregateBudget) sourceReservations.add(aggregateBudget.reserve(value.byteLength)); chunks.push(value); } - // Charge both the source chunks and their merged replacement during the copy peak, then retain - // only the replacement body until the downstream transport first pulls or cancels it. - if (aggregateBudget) retainedReservation.add(aggregateBudget.reserve(total)); + if (aggregateBudget && sourceReservation) { + sourceReservation = aggregateBudget.resize(sourceReservation, total); + retainedReservation.add(aggregateBudget.reserve(total)); + } const body = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; } - sourceReservations.release(); + sourceReservation?.release(); + sourceReservation = null; const headers = new Headers(response.headers); headers.set("content-length", String(total)); let delivered = false; @@ -213,14 +236,15 @@ export async function boundHttpResponse( }); } catch (error) { if (error instanceof BrokerCapacityError) { - await reader.cancel().catch(() => {}); + if (reader) await reader.cancel().catch(() => {}); + else await response.body.cancel().catch(() => {}); return new Response(null, { status: 503 }); } throw error; } finally { - sourceReservations.release(); + sourceReservation?.release(); if (!transferred) retainedReservation.release(); - reader.releaseLock(); + reader?.releaseLock(); } } diff --git a/packages/session-broker-node/package.json b/packages/session-broker-node/package.json index c417693e6..aa7631355 100644 --- a/packages/session-broker-node/package.json +++ b/packages/session-broker-node/package.json @@ -20,7 +20,7 @@ "ws": "^8.18.3" }, "engines": { - "bun": ">=1.0.0", + "bun": ">=1.3.14", "node": ">=22" } } diff --git a/packages/session-broker-node/src/serve.test.ts b/packages/session-broker-node/src/serve.test.ts index 876c568dd..29c099523 100644 --- a/packages/session-broker-node/src/serve.test.ts +++ b/packages/session-broker-node/src/serve.test.ts @@ -13,7 +13,6 @@ import { createSessionBrokerDaemon, createSessionBrokerProtocolParsers, } from "@hunk/session-broker"; -import SESSION_BROKER_ADAPTER_CONFORMANCE from "../../../test/fixtures/sessionBrokerAdapterConformance.json" with { type: "json" }; import { serveSessionBrokerDaemon } from "./serve"; interface TestSessionInfo { @@ -116,13 +115,6 @@ async function waitUntil( } describe("session broker node adapter", () => { - test("uses the shared binary, oversize, and pressure close corpus", () => { - expect(SESSION_BROKER_ADAPTER_CONFORMANCE).toMatchObject({ - textOnly: { binaryCloseCode: 1003 }, - inbound: { oversizedCloseCode: 1009, pressureCloseCode: 1013 }, - }); - }); - test("serves the generic daemon API and websocket path through Node", async () => { const broker = new SessionBroker({ protocolParsers }); const daemon = createSessionBrokerDaemon({ @@ -148,7 +140,9 @@ describe("session broker node adapter", () => { generation: "generation-1", brokerRevision: 1 as const, ...(appContract ? { appContract } : {}), + callerSessionId: "caller-session-1", requestId: "request-1", + sequence: "1", httpStatus, bodyDigest: "test-digest", daemonKeyId: "daemon-key-1", diff --git a/packages/session-broker-node/src/serve.ts b/packages/session-broker-node/src/serve.ts index f3c680bd9..1fe75fa79 100644 --- a/packages/session-broker-node/src/serve.ts +++ b/packages/session-broker-node/src/serve.ts @@ -49,6 +49,7 @@ function toNodeConnection( socket: WebSocket, outboundBudget: ResourceBudget, maxPeerBytes: number, + markAuthenticated: () => void, ): SessionBrokerPeer { return { send(data: string) { @@ -77,6 +78,7 @@ function toNodeConnection( close(code?: number, reason?: string) { socket.close(code, reason); }, + markAuthenticated, }; } @@ -231,6 +233,7 @@ export async function serveSessionBrokerDaemon< // connection object that registration and message handling used earlier. const peerBySocket = new WeakMap(); const admissionBySocket = new WeakMap(); + const handshakeTimers = new WeakMap>(); const activeWebSockets = new Set(); const activeSockets = new Set(); server.on("connection", (socket) => { @@ -254,11 +257,28 @@ export async function serveSessionBrokerDaemon< webSocketServer.on("connection", (socket: WebSocket) => { activeWebSockets.add(socket); + const markAuthenticated = () => { + admissionBySocket.get(socket)?.release(); + admissionBySocket.delete(socket); + const timer = handshakeTimers.get(socket); + if (timer) clearTimeout(timer); + handshakeTimers.delete(socket); + }; const peer = toNodeConnection( socket, outboundBudget, options.daemon.limits.maxOutboundBytesPerPeer, + markAuthenticated, ); + if (options.daemon.requiresProducerAuthentication) { + const timer = setTimeout(() => { + socket.close(1008, "Session broker authentication timed out."); + }, options.daemon.limits.maxHandshakeDurationMs); + timer.unref?.(); + handshakeTimers.set(socket, timer); + } else { + markAuthenticated(); + } peerBySocket.set(socket, peer); socket.on("message", (message: Buffer | ArrayBuffer | Buffer[], isBinary: boolean) => { if (stopping) { @@ -269,21 +289,26 @@ export async function serveSessionBrokerDaemon< socket.close(1003, "Session broker accepts text messages only."); return; } - const bytes = Array.isArray(message) - ? Buffer.concat(message) - : message instanceof ArrayBuffer - ? Buffer.from(new Uint8Array(message)) - : Buffer.from(message); - if (bytes.byteLength > options.daemon.limits.maxWsMessageBytes) { + const byteLength = Array.isArray(message) + ? message.reduce((total, chunk) => total + chunk.byteLength, 0) + : message.byteLength; + if (byteLength > options.daemon.limits.maxWsMessageBytes) { socket.close(1009, "Message exceeds the session broker size limit."); return; } - const reservation = inboundBudget.tryReserve(bytes.byteLength); + const reservation = inboundBudget.tryReserve(byteLength); if (!reservation) { socket.close(1013, "Session broker inbound pressure exceeded."); return; } try { + // ws usually supplies one Buffer. Concatenate only fragmented array variants, and create a + // zero-copy Buffer view for ArrayBuffer so broker accounting covers any required copy. + const bytes = Array.isArray(message) + ? Buffer.concat(message, byteLength) + : message instanceof ArrayBuffer + ? Buffer.from(message) + : message; let text: string; try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); @@ -308,7 +333,11 @@ export async function serveSessionBrokerDaemon< socket.on("error", () => {}); socket.on("close", (code: number, reason: Buffer) => { activeWebSockets.delete(socket); + const timer = handshakeTimers.get(socket); + if (timer) clearTimeout(timer); + handshakeTimers.delete(socket); admissionBySocket.get(socket)?.release(); + admissionBySocket.delete(socket); options.daemon.handleConnectionClose(peerBySocket.get(socket) ?? peer); // The runtime-neutral daemon only cares that the transport closed; Node-specific close data // stays ignored here instead of leaking into the shared broker API. @@ -323,8 +352,18 @@ export async function serveSessionBrokerDaemon< socket.destroy(); return; } - const pathname = new URL(`http://${options.hostname}:${options.port}${request.url ?? "/"}`) - .pathname; + let pathname: string; + try { + const target = request.url; + if (!target?.startsWith("/") || target.startsWith("//")) { + throw new TypeError("Expected an origin-form WebSocket request target."); + } + pathname = new URL(target, `http://${options.hostname}:${options.port}`).pathname; + } catch { + socket.write("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n"); + socket.destroy(); + return; + } if (!options.daemon.matchesSocketPath(pathname)) { socket.destroy(); return; diff --git a/packages/session-broker/package.json b/packages/session-broker/package.json index 27dcc8bbc..693d321ee 100644 --- a/packages/session-broker/package.json +++ b/packages/session-broker/package.json @@ -19,7 +19,7 @@ "@hunk/session-broker-core": "workspace:*" }, "engines": { - "bun": ">=1.0.0", + "bun": ">=1.3.14", "node": ">=22" } } diff --git a/packages/session-broker/src/authentication.test.ts b/packages/session-broker/src/authentication.test.ts index 40b5b79dd..ab5d52fec 100644 --- a/packages/session-broker/src/authentication.test.ts +++ b/packages/session-broker/src/authentication.test.ts @@ -269,10 +269,89 @@ describe("session broker signed authentication", () => { "connection-1", ), ).resolves.toMatchObject({ + ack: { + connectionId: "connection-1", + daemonKeyId: "daemon-key-1", + principal: { kind: "producer", scopes: ["register"] }, + }, + assertActive: expect.any(Function), + }); + }); + + test("producer authority rechecks revocation and credential-clear epochs", async () => { + let revoked = false; + const values = await setup({ revoked: () => revoked }); + const proof = await signedHelloProof(values, "producer"); + const authority = await values.authenticator.completeProducerHello( + { challengeId: proof.challengeId, signature: proof.signature }, + "connection-1", + ); + expect(() => authority.assertActive()).not.toThrow(); + expect(JSON.parse(JSON.stringify(authority.ack))).toMatchObject({ connectionId: "connection-1", - daemonKeyId: "daemon-key-1", - principal: { kind: "producer", scopes: ["register"] }, + principal: { kind: "producer" }, }); + expect("assertActive" in authority.ack).toBe(false); + revoked = true; + expect(() => authority.assertActive()).toThrow( + expect.objectContaining({ code: "credential-revoked" }), + ); + + const cleared = await setup(); + const clearedProof = await signedHelloProof(cleared, "producer"); + const clearedAuthority = await cleared.authenticator.completeProducerHello( + { challengeId: clearedProof.challengeId, signature: clearedProof.signature }, + "connection-2", + ); + cleared.authenticator.clear(); + expect(() => clearedAuthority.assertActive()).toThrow( + expect.objectContaining({ code: "invalid-credential" }), + ); + }); + + test("rejects producer acknowledgement when revocation races asynchronous signing", async () => { + let revoked = false; + let revokeAfterSign = false; + const cryptoWithRevocation: SessionBrokerCrypto = { + ...webSessionBrokerCrypto, + async sign(privateKey, value) { + const signature = await webSessionBrokerCrypto.sign(privateKey, value); + if (revokeAfterSign) revoked = true; + return signature; + }, + }; + const values = await setup({ revoked: () => revoked, crypto: cryptoWithRevocation }); + const proof = await signedHelloProof(values, "producer"); + revokeAfterSign = true; + await expect( + values.authenticator.completeProducerHello( + { challengeId: proof.challengeId, signature: proof.signature }, + "connection-1", + ), + ).rejects.toMatchObject({ code: "credential-revoked" }); + }); + + test("rechecks producer expiry and revocation after hello completion", async () => { + let revoked = false; + const values = await setup({ revoked: () => revoked }); + const request = challengeRequest("producer"); + const challenge = await values.authenticator.issueChallenge(request, request.endpoint); + const transcript = challengeTranscriptForClient(request, challenge, "generation-1"); + const signature = encodeBase64Url( + await webSessionBrokerCrypto.sign(values.producer.privateKey, transcript), + ); + const hello = await values.authenticator.completeProducerHello( + { challengeId: challenge.challengeId, signature }, + "connection-1", + ); + + expect(hello.ack.principal.kind).toBe("producer"); + expect(hello.assertActive).not.toThrow(); + revoked = true; + expect(hello.assertActive).toThrow(SessionBrokerAuthenticationError); + revoked = false; + values.setNow(10_001); + expect(hello.assertActive).toThrow(SessionBrokerAuthenticationError); }); test("rejects missing, wrong, expired, revoked, and reused credentials with redacted errors", async () => { @@ -443,7 +522,7 @@ describe("session broker signed authentication", () => { { challengeId: next.challengeId, signature: next.signature }, "connection-2", ), - ).resolves.toMatchObject({ connectionId: "connection-2" }); + ).resolves.toMatchObject({ ack: { connectionId: "connection-2" } }); }); test("clear invalidates response signing across deferred crypto", async () => { @@ -535,7 +614,9 @@ describe("session broker signed authentication", () => { generation: response.generation, brokerRevision: 1 as const, appContract: { appRevision: 1, features: [] }, + callerSessionId: response.callerSessionId, requestId: response.requestId, + sequence: response.sequence, httpStatus: response.httpStatus, bodyDigest: response.bodyDigest, }; @@ -567,6 +648,23 @@ describe("session broker signed authentication", () => { buildBrokerResponseTranscript({ ...transcriptInput, bodyDigest: "tampered" }), ), ).toBe(false); + expect( + await webSessionBrokerCrypto.verify( + values.daemon.publicKey, + signature, + buildBrokerResponseTranscript({ + ...transcriptInput, + callerSessionId: "caller-session-2", + }), + ), + ).toBe(false); + expect( + await webSessionBrokerCrypto.verify( + values.daemon.publicKey, + signature, + buildBrokerResponseTranscript({ ...transcriptInput, sequence: "2" }), + ), + ).toBe(false); }); test("uses bounded collision-safe IDs with a deterministic custom random source", async () => { diff --git a/packages/session-broker/src/authentication.ts b/packages/session-broker/src/authentication.ts index afcceb984..b4dd46f2e 100644 --- a/packages/session-broker/src/authentication.ts +++ b/packages/session-broker/src/authentication.ts @@ -115,6 +115,7 @@ export interface SessionBrokerHelloChallengeRequest { export interface SessionBrokerHelloChallenge { readonly challengeId: string; + readonly generation: string; readonly responderNonce: string; readonly expiresAt: number; readonly daemonKeyId: string; @@ -139,7 +140,7 @@ export interface AuthenticatedCallerSession { readonly daemonSignature: string; } -export interface AuthenticatedProducerHello { +export interface SessionBrokerProducerHelloAck { readonly principal: ProducerPrincipal; readonly connectionId: string; readonly brokerRevision: typeof SESSION_BROKER_PROTOCOL_REVISION; @@ -150,6 +151,13 @@ export interface AuthenticatedProducerHello { readonly daemonSignature: string; } +/** Keep signed wire data separate from server-only authority retained for the live peer. */ +export interface AuthenticatedProducerHello { + readonly ack: SessionBrokerProducerHelloAck; + /** Reject producer work after credential revocation, expiry, or a clear epoch. */ + assertActive(): void; +} + export interface CallerRequestAuthenticationInput { readonly request: Request; readonly body: Uint8Array; @@ -159,7 +167,9 @@ export interface SessionBrokerResponseAuthentication { readonly generation: string; readonly brokerRevision: typeof SESSION_BROKER_PROTOCOL_REVISION; readonly appContract?: BrokerAppContract; + readonly callerSessionId: string; readonly requestId: string; + readonly sequence: string; readonly httpStatus: number; readonly bodyDigest: string; readonly daemonKeyId: string; @@ -541,8 +551,19 @@ export function canonicalHttpTarget(url: URL): string { return query ? `${path}?${query}` : path; } +export interface SessionBrokerHelloAuthenticator { + issueChallenge(request: unknown, listenerEndpoint: string): Promise; + completeCallerHello(proofInput: unknown): Promise; + completeProducerHello( + proofInput: unknown, + connectionId: unknown, + ): Promise; +} + /** Authenticate bounded producer hellos and generation-bound signed caller request sessions. */ -export class SessionBrokerAuthenticator implements CallerRequestAuthenticator { +export class SessionBrokerAuthenticator + implements CallerRequestAuthenticator, SessionBrokerHelloAuthenticator +{ private readonly crypto: SessionBrokerCrypto; private readonly config: AuthenticatorSnapshot; private readonly credentials: Map; @@ -635,6 +656,7 @@ export class SessionBrokerAuthenticator implements CallerRequestAuthenticator { committed = true; return Object.freeze({ challengeId, + generation: this.config.generation, responderNonce, expiresAt, daemonKeyId: this.config.daemonIdentity.keyId, @@ -774,7 +796,12 @@ export class SessionBrokerAuthenticator implements CallerRequestAuthenticator { ), ); this.assertClearEpoch(epoch); - return Object.freeze({ + this.requireActiveGrant(grant); + const assertActive = () => { + this.assertClearEpoch(epoch); + this.requireActiveGrant(grant); + }; + const ack: SessionBrokerProducerHelloAck = Object.freeze({ principal: principalFromGrant(grant), connectionId, brokerRevision: SESSION_BROKER_PROTOCOL_REVISION, @@ -784,6 +811,7 @@ export class SessionBrokerAuthenticator implements CallerRequestAuthenticator { daemonKeyId: this.config.daemonIdentity.keyId, daemonSignature, }); + return Object.freeze({ ack, assertActive }); } finally { pending.reservation.release(); } @@ -868,7 +896,7 @@ export class SessionBrokerAuthenticator implements CallerRequestAuthenticator { requestId, assertActive, signResponse: (input: CallerResponseSigningInput) => - this.signResponse(requestId, input, assertActive), + this.signResponse(callerSessionId, requestId, sequence, input, assertActive), }); } @@ -910,7 +938,9 @@ export class SessionBrokerAuthenticator implements CallerRequestAuthenticator { } private async signResponse( + callerSessionId: string, requestId: string, + sequence: string, input: CallerResponseSigningInput, assertActive: () => void, ): Promise { @@ -935,7 +965,9 @@ export class SessionBrokerAuthenticator implements CallerRequestAuthenticator { appId: this.config.appId, generation: this.config.generation, brokerRevision: SESSION_BROKER_PROTOCOL_REVISION, + callerSessionId, requestId, + sequence, httpStatus: input.httpStatus, bodyDigest, ...(appContract ? { appContract } : {}), @@ -948,7 +980,9 @@ export class SessionBrokerAuthenticator implements CallerRequestAuthenticator { generation: this.config.generation, brokerRevision: SESSION_BROKER_PROTOCOL_REVISION, ...(appContract ? { appContract } : {}), + callerSessionId, requestId, + sequence, httpStatus: input.httpStatus, bodyDigest, daemonKeyId: this.config.daemonIdentity.keyId, diff --git a/packages/session-broker/src/broker.ts b/packages/session-broker/src/broker.ts index 44ff0752a..8a20b52bf 100644 --- a/packages/session-broker/src/broker.ts +++ b/packages/session-broker/src/broker.ts @@ -19,6 +19,7 @@ import type { SessionBrokerProtocolParsers } from "./protocolParsers"; export interface SessionBrokerPeer { send(data: string): unknown; close?(code?: number, reason?: string): unknown; + markAuthenticated?(): void; } /** One raw live session record with the original registration and snapshot payloads intact. */ @@ -64,12 +65,15 @@ export interface SessionBrokerController< readonly limits?: Readonly; listSessions(): SessionView[]; getSession(selector: SessionTargetSelector): SessionView; + resolveSessionId(selector: SessionTargetSelector): string; + getSessionIds(): string[]; getSessionCount(): number; getPendingCommandCount(): number; registerSession( connection: SessionBrokerPeer, registrationInput: unknown, snapshotInput: unknown, + options?: { replaceOwner?: boolean }, ): RegisterSessionResult; updateSnapshot( connection: SessionBrokerPeer, @@ -191,6 +195,14 @@ export class SessionBroker< return this.state.getSession(selector); } + resolveSessionId(selector: SessionTargetSelector) { + return this.state.getSession(selector).sessionId; + } + + getSessionIds() { + return this.state.listSessions().map((session) => session.sessionId); + } + getSessionCount() { return this.state.getSessionCount(); } @@ -203,8 +215,9 @@ export class SessionBroker< connection: SessionBrokerPeer, registrationInput: unknown, snapshotInput: unknown, + options?: { replaceOwner?: boolean }, ) { - return this.state.registerSession(connection, registrationInput, snapshotInput); + return this.state.registerSession(connection, registrationInput, snapshotInput, options); } updateSnapshot( diff --git a/packages/session-broker/src/clientAuthentication.test.ts b/packages/session-broker/src/clientAuthentication.test.ts new file mode 100644 index 000000000..ac2e29078 --- /dev/null +++ b/packages/session-broker/src/clientAuthentication.test.ts @@ -0,0 +1,433 @@ +import { describe, expect, test } from "bun:test"; +import { SESSION_BROKER_SIGNATURE_ALGORITHM, type CallerGrant } from "@hunk/session-broker-core"; +import { SessionBrokerAuthenticator } from "./authentication"; +import { + SessionBrokerCallerClient, + SessionBrokerClientAuthenticationError, +} from "./clientAuthentication"; + +async function keyPair() { + const generated = (await crypto.subtle.generateKey("Ed25519", true, [ + "sign", + "verify", + ])) as CryptoKeyPair; + const privateBytes = await crypto.subtle.exportKey("pkcs8", generated.privateKey); + return { + publicKey: generated.publicKey, + privateKey: await crypto.subtle.importKey("pkcs8", privateBytes, "Ed25519", false, ["sign"]), + }; +} + +async function setup() { + const daemon = await keyPair(); + const caller = await keyPair(); + const grant: CallerGrant = { + kind: "caller", + appId: "dev.example", + principalId: "caller-1", + keyId: "caller-key-1", + grantId: "caller-grant-1", + algorithm: SESSION_BROKER_SIGNATURE_ALGORITHM, + issuedAt: Date.now() - 1_000, + expiresAt: Date.now() + 60_000, + revocationId: "caller-revocation-1", + mayDelegate: false, + operations: ["list"], + commands: [], + }; + const authenticator = new SessionBrokerAuthenticator({ + appId: "dev.example", + appRevision: 7, + generation: "generation-1", + daemonIdentity: { keyId: "daemon-key-1", privateKey: daemon.privateKey }, + credentials: [{ grant, publicKey: caller.publicKey }], + }); + return { daemon, caller, grant, authenticator }; +} + +/** Build an in-memory HTTP adapter exercising the exact generic challenge/proof/request bytes. */ +function createFetch( + authenticator: SessionBrokerAuthenticator, + proofCount: { value: number }, + targetSpecific = false, +) { + return (async (input: string | URL | Request, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + const url = new URL(request.url); + if (url.pathname === "/session-auth/challenge") { + return Response.json(await authenticator.issueChallenge(await request.json(), request.url)); + } + if (url.pathname === "/session-auth/proof") { + proofCount.value += 1; + return Response.json(await authenticator.completeCallerHello(await request.json())); + } + const body = new Uint8Array(await request.arrayBuffer()); + try { + const authenticated = await authenticator.authenticate({ request, body }); + const responseBody = { sessions: [] }; + return Response.json({ + body: responseBody, + authentication: await authenticated.signResponse({ + httpStatus: 200, + body: responseBody, + ...(targetSpecific ? { appContract: { appRevision: 7, features: [] } } : {}), + }), + }); + } catch { + return Response.json({ error: "authentication-required" }, { status: 401 }); + } + }) as typeof fetch; +} + +describe("session broker caller client", () => { + test("negotiates once, allocates monotonic signed sequences, and verifies signed responses", async () => { + const values = await setup(); + const proofCount = { value: 0 }; + const client = new SessionBrokerCallerClient({ + appId: "dev.example", + appRevision: 7, + origin: "http://broker.test", + credential: { grant: values.grant, privateKey: values.caller.privateKey }, + daemon: { keyId: "daemon-key-1", publicKey: values.daemon.publicKey }, + fetch: createFetch(values.authenticator, proofCount), + }); + + await expect( + client + .request("/control", { method: "POST", body: "{}" }) + .then((response) => response.json()), + ).resolves.toEqual({ sessions: [] }); + await expect( + client + .request("/control", { method: "POST", body: "{}" }) + .then((response) => response.json()), + ).resolves.toEqual({ sessions: [] }); + expect(proofCount.value).toBe(1); + }); + + test("rejects responses replayed across caller sessions or request sequences", async () => { + for (const [field, replacement] of [ + ["callerSessionId", "caller-session-replayed"], + ["sequence", "2"], + ] as const) { + const values = await setup(); + const proofCount = { value: 0 }; + const authenticatedFetch = createFetch(values.authenticator, proofCount); + const tamperingFetch = (async (input: string | URL | Request, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + const response = await authenticatedFetch(request); + if (new URL(request.url).pathname !== "/control") return response; + const envelope = (await response.json()) as { + body: unknown; + authentication: Record; + }; + envelope.authentication[field] = replacement; + return Response.json(envelope, { status: response.status }); + }) as typeof fetch; + const client = new SessionBrokerCallerClient({ + appId: "dev.example", + appRevision: 7, + origin: "http://broker.test", + credential: { + grant: values.grant, + privateKey: values.caller.privateKey, + }, + daemon: { keyId: "daemon-key-1", publicKey: values.daemon.publicKey }, + fetch: tamperingFetch, + }); + + await expect( + client.request("/control", { method: "POST", body: "{}" }), + ).rejects.toBeInstanceOf(SessionBrokerClientAuthenticationError); + } + }); + + test("requires the exact Hunk-style application contract on target-specific responses", async () => { + const values = await setup(); + const proofCount = { value: 0 }; + const client = new SessionBrokerCallerClient({ + appId: "dev.example", + appRevision: 7, + origin: "http://broker.test", + credential: { grant: values.grant, privateKey: values.caller.privateKey }, + daemon: { keyId: "daemon-key-1", publicKey: values.daemon.publicKey }, + fetch: createFetch(values.authenticator, proofCount, true), + }); + + await expect( + client + .request("/control", { method: "POST", body: "{}" }, { targetSpecific: true }) + .then((response) => response.json()), + ).resolves.toEqual({ sessions: [] }); + }); + + test("rejects an unsigned second 401 after one fresh-session retry", async () => { + const values = await setup(); + const proofCount = { value: 0 }; + const authenticatedFetch = createFetch(values.authenticator, proofCount); + const fetchWithForgedControls = (async (input: string | URL | Request, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + return new URL(request.url).pathname === "/control" + ? Response.json({ error: "forged" }, { status: 401 }) + : authenticatedFetch(request); + }) as typeof fetch; + const client = new SessionBrokerCallerClient({ + appId: "dev.example", + appRevision: 7, + origin: "http://broker.test", + credential: { grant: values.grant, privateKey: values.caller.privateKey }, + daemon: { keyId: "daemon-key-1", publicKey: values.daemon.publicKey }, + fetch: fetchWithForgedControls, + }); + + await expect(client.request("/control", { method: "POST", body: "{}" })).rejects.toThrow( + "daemon identity could not be verified", + ); + expect(proofCount.value).toBe(2); + }); + + test("delayed stale 401s do not clear an overlapping shared recovery negotiation", async () => { + const values = await setup(); + const proofCount = { value: 0 }; + const authenticatedFetch = createFetch(values.authenticator, proofCount); + let staleControls = 0; + let staleMode = false; + let releaseFirst401!: () => void; + let releaseSecond401!: () => void; + let releaseRecovery!: () => void; + let signalBothStale!: () => void; + let signalRecovery!: () => void; + const first401 = new Promise((resolve) => (releaseFirst401 = resolve)); + const second401 = new Promise((resolve) => (releaseSecond401 = resolve)); + const recoveryGate = new Promise((resolve) => (releaseRecovery = resolve)); + const bothStale = new Promise((resolve) => (signalBothStale = resolve)); + const recoveryStarted = new Promise((resolve) => (signalRecovery = resolve)); + const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + const pathname = new URL(request.url).pathname; + if (staleMode && pathname === "/session-auth/challenge" && proofCount.value === 1) { + signalRecovery(); + await recoveryGate; + } + if (staleMode && pathname === "/control" && proofCount.value === 1 && staleControls < 2) { + staleControls += 1; + if (staleControls === 2) signalBothStale(); + await (staleControls === 1 ? first401 : second401); + return Response.json({ error: "stale-session" }, { status: 401 }); + } + return authenticatedFetch(request); + }) as typeof fetch; + const client = new SessionBrokerCallerClient({ + appId: "dev.example", + appRevision: 7, + origin: "http://broker.test", + credential: { grant: values.grant, privateKey: values.caller.privateKey }, + daemon: { keyId: "daemon-key-1", publicKey: values.daemon.publicKey }, + fetch: fetchImpl, + }); + + await expect(client.request("/control")).resolves.toBeInstanceOf(Response); + staleMode = true; + const first = client.request("/control"); + const second = client.request("/control"); + await bothStale; + releaseFirst401(); + await recoveryStarted; + releaseSecond401(); + releaseRecovery(); + + await expect(Promise.all([first, second])).resolves.toHaveLength(2); + expect(proofCount.value).toBe(2); + }); + + test("coalesces concurrent negotiations while retaining unique request sequences", async () => { + const values = await setup(); + const proofCount = { value: 0 }; + const client = new SessionBrokerCallerClient({ + appId: "dev.example", + appRevision: 7, + origin: "http://broker.test", + credential: { grant: values.grant, privateKey: values.caller.privateKey }, + daemon: { keyId: "daemon-key-1", publicKey: values.daemon.publicKey }, + fetch: createFetch(values.authenticator, proofCount), + }); + + const responses = await Promise.all( + Array.from({ length: 32 }, () => client.request("/control", { method: "POST", body: "{}" })), + ); + expect(responses).toHaveLength(32); + expect(proofCount.value).toBe(1); + }); + + test("aborting one negotiation waiter does not cancel another", async () => { + const values = await setup(); + const proofCount = { value: 0 }; + const authenticatedFetch = createFetch(values.authenticator, proofCount); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const gatedFetch = (async (input: string | URL | Request, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + if (new URL(request.url).pathname === "/session-auth/challenge") await gate; + return authenticatedFetch(request); + }) as typeof fetch; + const client = new SessionBrokerCallerClient({ + appId: "dev.example", + appRevision: 7, + origin: "http://broker.test", + credential: { grant: values.grant, privateKey: values.caller.privateKey }, + daemon: { keyId: "daemon-key-1", publicKey: values.daemon.publicKey }, + fetch: gatedFetch, + }); + const controller = new AbortController(); + const aborted = client.request("/control", { signal: controller.signal }); + const surviving = client.request("/control"); + controller.abort(new Error("caller stopped")); + release(); + + await expect(aborted).rejects.toThrow("caller stopped"); + await expect(surviving).resolves.toBeInstanceOf(Response); + expect(proofCount.value).toBe(1); + }); + + test("clear invalidates stale negotiation installation and failures allow retry", async () => { + const values = await setup(); + const authenticatedFetch = createFetch(values.authenticator, { value: 0 }); + let challengeCount = 0; + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + if (new URL(request.url).pathname === "/session-auth/challenge") { + challengeCount += 1; + if (challengeCount === 1) await firstGate; + if (challengeCount === 2) return new Response("no", { status: 503 }); + } + return authenticatedFetch(request); + }) as typeof fetch; + const client = new SessionBrokerCallerClient({ + appId: "dev.example", + appRevision: 7, + origin: "http://broker.test", + credential: { grant: values.grant, privateKey: values.caller.privateKey }, + daemon: { keyId: "daemon-key-1", publicKey: values.daemon.publicKey }, + fetch: fetchImpl, + }); + + const stale = client.request("/control"); + client.clear(); + await expect(client.request("/control")).rejects.toBeInstanceOf( + SessionBrokerClientAuthenticationError, + ); + releaseFirst(); + await expect(stale).rejects.toBeInstanceOf(SessionBrokerClientAuthenticationError); + await expect(client.request("/control")).resolves.toBeInstanceOf(Response); + expect(challengeCount).toBe(3); + }); + + test("rejects oversized unauthenticated challenge responses before parsing", async () => { + const values = await setup(); + const client = new SessionBrokerCallerClient({ + appId: "dev.example", + appRevision: 7, + origin: "http://broker.test", + credential: { grant: values.grant, privateKey: values.caller.privateKey }, + daemon: { keyId: "daemon-key-1", publicKey: values.daemon.publicKey }, + maxResponseBytes: 32, + fetch: (async () => Response.json({ padding: "x".repeat(128) })) as unknown as typeof fetch, + }); + + await expect(client.request("/control")).rejects.toThrow( + "daemon identity could not be verified", + ); + }); + + test("cancels malformed or oversized declared response bodies before rejecting", async () => { + for (const declared of ["invalid", "33"]) { + const values = await setup(); + let cancelled = false; + const client = new SessionBrokerCallerClient({ + appId: "dev.example", + appRevision: 7, + origin: "http://broker.test", + credential: { + grant: values.grant, + privateKey: values.caller.privateKey, + }, + daemon: { keyId: "daemon-key-1", publicKey: values.daemon.publicKey }, + maxResponseBytes: 32, + fetch: (async () => + new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + { headers: { "content-length": declared } }, + )) as unknown as typeof fetch, + }); + + await expect(client.request("/control")).rejects.toThrow( + "daemon identity could not be verified", + ); + expect(cancelled).toBe(true); + } + }); + + test("rejects challenge records with unknown or dangerous own keys", async () => { + for (const extra of ["extra", "__proto__"]) { + const values = await setup(); + const authenticatedFetch = createFetch(values.authenticator, { + value: 0, + }); + const fetchWithExtra = (async (input: string | URL | Request, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + const response = await authenticatedFetch(request); + if (new URL(request.url).pathname !== "/session-auth/challenge") return response; + const challenge = (await response.json()) as Record; + Object.defineProperty(challenge, extra, { + value: true, + enumerable: true, + }); + return Response.json(challenge); + }) as typeof fetch; + const client = new SessionBrokerCallerClient({ + appId: "dev.example", + appRevision: 7, + origin: "http://broker.test", + credential: { + grant: values.grant, + privateKey: values.caller.privateKey, + }, + daemon: { keyId: "daemon-key-1", publicKey: values.daemon.publicKey }, + fetch: fetchWithExtra, + }); + + await expect(client.request("/control")).rejects.toThrow( + "daemon identity could not be verified", + ); + } + }); + + test("verifies the daemon challenge before presenting caller proof", async () => { + const values = await setup(); + const wrongDaemon = await keyPair(); + const proofCount = { value: 0 }; + const client = new SessionBrokerCallerClient({ + appId: "dev.example", + appRevision: 7, + origin: "http://broker.test", + credential: { grant: values.grant, privateKey: values.caller.privateKey }, + daemon: { keyId: "daemon-key-1", publicKey: wrongDaemon.publicKey }, + fetch: createFetch(values.authenticator, proofCount), + }); + + await expect(client.request("/control", { method: "POST", body: "{}" })).rejects.toThrow( + "daemon identity could not be verified", + ); + expect(proofCount.value).toBe(0); + }); +}); diff --git a/packages/session-broker/src/clientAuthentication.ts b/packages/session-broker/src/clientAuthentication.ts new file mode 100644 index 000000000..304d93718 --- /dev/null +++ b/packages/session-broker/src/clientAuthentication.ts @@ -0,0 +1,629 @@ +import { + CallerSequenceAllocator, + DEFAULT_SESSION_BROKER_LIMITS, + SESSION_BROKER_PROTOCOL_REVISION, + buildBrokerHelloAckTranscript, + buildBrokerResponseTranscript, + buildCallerRequestTranscript, + canonicalJsonBytes, + isValidBrokerIdentifier, + type BrokerGrant, + type BrokerHelloProposal, + type CallerGrant, + type CanonicalJsonValue, + type ProducerGrant, +} from "@hunk/session-broker-core"; +import { + canonicalHttpTarget, + challengeTranscriptForClient, + type AuthenticatedCallerSession, + type SessionBrokerProducerHelloAck, + type SessionBrokerHelloChallenge, + type SessionBrokerHelloChallengeRequest, +} from "./authentication"; +import { + decodeBase64Url, + encodeBase64Url, + webSessionBrokerCrypto, + type SessionBrokerCrypto, +} from "./crypto"; +import type { SessionBrokerAuthenticatedResponse } from "./types"; + +export interface SessionBrokerClientCredential { + readonly grant: Grant; + readonly privateKey: CryptoKey; +} + +export interface SessionBrokerDaemonVerifier { + readonly keyId: string; + readonly publicKey: CryptoKey; +} + +export interface SessionBrokerHelloClientOptions { + readonly appId: string; + readonly appRevision: number; + readonly endpoint: string; + readonly credential: SessionBrokerClientCredential; + readonly daemon: SessionBrokerDaemonVerifier; + readonly crypto?: SessionBrokerCrypto; +} + +export interface PendingSessionBrokerHello { + readonly request: SessionBrokerHelloChallengeRequest; + readonly transcript: Uint8Array; + readonly transcriptHash: string; + readonly proof: { readonly challengeId: string; readonly signature: string }; + readonly challenge: SessionBrokerHelloChallenge; + readonly options: SessionBrokerHelloClientOptions; +} + +export class SessionBrokerClientAuthenticationError extends Error { + constructor() { + super("Session broker authentication failed or the daemon identity could not be verified."); + this.name = "SessionBrokerClientAuthenticationError"; + } +} + +function clientAuthError(): never { + throw new SessionBrokerClientAuthenticationError(); +} + +function exactRecord(value: unknown, keys: readonly string[]): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) clientAuthError(); + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) clientAuthError(); + const record = value as Record; + const ownKeys = Object.keys(record); + if ( + ownKeys.some((key) => ["__proto__", "prototype", "constructor"].includes(key)) || + ownKeys.length !== keys.length || + keys.some((key) => !Object.hasOwn(record, key)) || + ownKeys.some((key) => !keys.includes(key)) + ) + clientAuthError(); + return record; +} + +function parseChallenge(value: unknown): SessionBrokerHelloChallenge { + const record = exactRecord(value, [ + "challengeId", + "generation", + "responderNonce", + "expiresAt", + "daemonKeyId", + "daemonSignature", + ]); + if ( + !isValidBrokerIdentifier(record.challengeId) || + !isValidBrokerIdentifier(record.generation) || + !isValidBrokerIdentifier(record.responderNonce) || + !Number.isFinite(record.expiresAt) || + typeof record.daemonKeyId !== "string" || + typeof record.daemonSignature !== "string" + ) + clientAuthError(); + return record as unknown as SessionBrokerHelloChallenge; +} + +function randomId(cryptoImpl: SessionBrokerCrypto) { + return `b_${encodeBase64Url(cryptoImpl.randomBytes(24))}_0`; +} + +function fixedProposal(appRevision: number): BrokerHelloProposal { + return { + brokerRevision: SESSION_BROKER_PROTOCOL_REVISION, + appRevision, + features: [], + }; +} + +/** Create the credential-free hello proposal that starts either producer or caller authentication. */ +export function createSessionBrokerHelloRequest( + options: SessionBrokerHelloClientOptions, +): SessionBrokerHelloChallengeRequest { + const cryptoImpl = options.crypto ?? webSessionBrokerCrypto; + return Object.freeze({ + role: options.credential.grant.kind, + appId: options.appId, + endpoint: options.endpoint, + keyId: options.credential.grant.keyId, + grantId: options.credential.grant.grantId, + initiatorNonce: randomId(cryptoImpl), + proposal: fixedProposal(options.appRevision), + }); +} + +/** Verify the daemon challenge before signing the same generation-bound transcript. */ +export async function answerSessionBrokerHelloChallenge( + options: SessionBrokerHelloClientOptions, + request: SessionBrokerHelloChallengeRequest, + challenge: SessionBrokerHelloChallenge, +): Promise> { + const cryptoImpl = options.crypto ?? webSessionBrokerCrypto; + if ( + challenge.daemonKeyId !== options.daemon.keyId || + !isValidBrokerIdentifier(challenge.challengeId) || + !isValidBrokerIdentifier(challenge.generation) || + !isValidBrokerIdentifier(challenge.responderNonce) || + !Number.isFinite(challenge.expiresAt) || + Date.now() >= challenge.expiresAt + ) + clientAuthError(); + const transcript = challengeTranscriptForClient(request, challenge, challenge.generation); + const daemonSignature = decodeBase64Url(challenge.daemonSignature); + if ( + !daemonSignature || + !(await cryptoImpl.verify(options.daemon.publicKey, daemonSignature, transcript)) + ) { + clientAuthError(); + } + const signature = encodeBase64Url( + await cryptoImpl.sign(options.credential.privateKey, transcript), + ); + return Object.freeze({ + request, + transcript, + transcriptHash: encodeBase64Url(await cryptoImpl.sha256(transcript)), + proof: Object.freeze({ challengeId: challenge.challengeId, signature }), + challenge, + options, + }); +} + +/** Verify a signed producer acknowledgement against the authenticated hello transcript. */ +export async function verifyProducerHelloAck( + pending: PendingSessionBrokerHello, + ack: SessionBrokerProducerHelloAck, +): Promise { + exactRecord(ack, [ + "principal", + "connectionId", + "brokerRevision", + "appRevision", + "features", + "helloTranscriptHash", + "daemonKeyId", + "daemonSignature", + ]); + const grant = pending.options.credential.grant; + const principal = exactRecord(ack.principal, [ + "kind", + "appId", + "principalId", + "keyId", + "grantId", + "scopes", + ...(grant.sessionId ? ["sessionId"] : []), + ]); + const cryptoImpl = pending.options.crypto ?? webSessionBrokerCrypto; + if ( + principal.kind !== "producer" || + principal.appId !== grant.appId || + principal.principalId !== grant.principalId || + principal.keyId !== grant.keyId || + principal.grantId !== grant.grantId || + principal.sessionId !== grant.sessionId || + JSON.stringify(principal.scopes) !== JSON.stringify(grant.operations) || + ack.daemonKeyId !== pending.options.daemon.keyId || + ack.helloTranscriptHash !== pending.transcriptHash || + ack.brokerRevision !== SESSION_BROKER_PROTOCOL_REVISION || + ack.appRevision !== pending.options.appRevision || + !Array.isArray(ack.features) || + ack.features.length !== 0 || + !isValidBrokerIdentifier(ack.connectionId) + ) + clientAuthError(); + const signature = decodeBase64Url(ack.daemonSignature); + if ( + !signature || + !(await cryptoImpl.verify( + pending.options.daemon.publicKey, + signature, + buildBrokerHelloAckTranscript({ + role: "producer", + appId: pending.options.appId, + generation: pending.challenge.generation, + keyId: pending.options.credential.grant.keyId, + grantId: pending.options.credential.grant.grantId, + helloTranscriptHash: pending.transcriptHash, + selection: fixedProposal(pending.options.appRevision), + connectionId: ack.connectionId, + }), + )) + ) + clientAuthError(); +} + +export type SessionBrokerSignedRequestInit = Omit & { + readonly body?: string | null; +}; + +export interface SessionBrokerCallerClientOptions { + readonly appId: string; + readonly appRevision: number; + readonly origin: string; + readonly credential: SessionBrokerClientCredential; + readonly daemon: SessionBrokerDaemonVerifier; + readonly fetch?: typeof fetch; + readonly crypto?: SessionBrokerCrypto; + readonly challengePath?: string; + readonly proofPath?: string; + readonly maxResponseBytes?: number; +} + +/** Read one untrusted response through a strict byte ceiling before JSON decoding. */ +async function readBoundedResponseJson(response: Response, maxBytes: number): Promise { + const declared = response.headers.get("content-length"); + if (declared && (!/^(?:0|[1-9][0-9]*)$/.test(declared) || Number(declared) > maxBytes)) { + await response.body?.cancel().catch(() => undefined); + clientAuthError(); + } + if (!response.body) clientAuthError(); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + clientAuthError(); + } + chunks.push(value); + } + } catch { + clientAuthError(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + } catch { + clientAuthError(); + } +} + +/** Negotiates short-lived caller sessions and signs/verifies every exact HTTP control payload. */ +export class SessionBrokerCallerClient { + private session: AuthenticatedCallerSession | null = null; + private sequence: CallerSequenceAllocator | null = null; + private pending: PendingSessionBrokerHello | null = null; + private negotiation: { epoch: number; promise: Promise } | null = null; + private authenticationEpoch = 0; + private readonly fetchImpl: typeof fetch; + private readonly cryptoImpl: SessionBrokerCrypto; + + constructor(private readonly options: SessionBrokerCallerClientOptions) { + this.fetchImpl = options.fetch ?? fetch; + this.cryptoImpl = options.crypto ?? webSessionBrokerCrypto; + } + + /** Issue one signed request, renegotiating once after restart, expiry, or replay rejection. */ + async request( + path: string, + init: SessionBrokerSignedRequestInit = {}, + options: { readonly targetSpecific?: boolean } = {}, + ): Promise { + for (let attempt = 0; attempt < 2; attempt += 1) { + if (!this.session || Date.now() >= this.session.expiresAt) { + await this.ensureNegotiated(init.signal); + } + const attemptedSession = this.session; + const attemptedEpoch = this.authenticationEpoch; + const response = await this.signedRequest(path, init, options.targetSpecific ?? false); + if (response === null) { + if (attempt === 0) { + // A delayed 401 from an older session must not invalidate recovery another request already + // completed. Only the request that still owns the current authentication epoch clears it. + if (this.session === attemptedSession && this.authenticationEpoch === attemptedEpoch) { + this.clear(); + } + continue; + } + clientAuthError(); + } + return response; + } + clientAuthError(); + } + + clear() { + this.authenticationEpoch += 1; + this.session = null; + this.sequence = null; + this.pending = null; + this.negotiation = null; + } + + /** Share one negotiation while allowing each waiting request to abort independently. */ + private async ensureNegotiated(signal?: AbortSignal | null) { + if (!this.negotiation) { + const epoch = this.authenticationEpoch; + const promise = this.negotiate(epoch).finally(() => { + if (this.negotiation?.promise === promise) this.negotiation = null; + }); + this.negotiation = { epoch, promise }; + } + const promise = this.negotiation.promise; + if (!signal) return promise; + if (signal.aborted) throw signal.reason ?? new DOMException("Aborted", "AbortError"); + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason ?? new DOMException("Aborted", "AbortError")); + signal.addEventListener("abort", onAbort, { once: true }); + void promise + .then(resolve, reject) + .finally(() => signal.removeEventListener("abort", onAbort)); + }); + } + + private async negotiate(epoch: number) { + const challengePath = this.options.challengePath ?? "/session-auth/challenge"; + const proofPath = this.options.proofPath ?? "/session-auth/proof"; + const endpoint = `${this.options.origin}${challengePath}`; + const helloOptions: SessionBrokerHelloClientOptions = { + appId: this.options.appId, + appRevision: this.options.appRevision, + endpoint, + credential: this.options.credential, + daemon: this.options.daemon, + crypto: this.cryptoImpl, + }; + const request = createSessionBrokerHelloRequest(helloOptions); + const challengeResponse = await this.fetchImpl(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }); + if (!challengeResponse.ok) clientAuthError(); + const challenge = parseChallenge( + await readBoundedResponseJson( + challengeResponse, + this.options.maxResponseBytes ?? DEFAULT_SESSION_BROKER_LIMITS.maxHttpResponseBytes, + ), + ); + const pending = await answerSessionBrokerHelloChallenge(helloOptions, request, challenge); + const proofResponse = await this.fetchImpl(`${this.options.origin}${proofPath}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(pending.proof), + }); + if (!proofResponse.ok) clientAuthError(); + const sessionValue = await readBoundedResponseJson( + proofResponse, + this.options.maxResponseBytes ?? DEFAULT_SESSION_BROKER_LIMITS.maxHttpResponseBytes, + ); + const sessionRecord = exactRecord(sessionValue, [ + "callerSessionId", + "principal", + "expiresAt", + "initialSequence", + "brokerRevision", + "appRevision", + "features", + "helloTranscriptHash", + "daemonKeyId", + "daemonSignature", + ]); + const session = sessionRecord as unknown as AuthenticatedCallerSession; + await this.verifyCallerAck(pending, session); + if (epoch !== this.authenticationEpoch) clientAuthError(); + this.pending = pending; + this.session = session; + this.sequence = new CallerSequenceAllocator(BigInt(session.initialSequence)); + } + + private async verifyCallerAck( + pending: PendingSessionBrokerHello, + session: AuthenticatedCallerSession, + ) { + const grant = this.options.credential.grant; + const principal = exactRecord(session.principal, [ + "kind", + "appId", + "principalId", + "keyId", + "grantId", + "operations", + "commands", + ...(grant.sessionId ? ["sessionId"] : []), + ]); + if ( + principal.kind !== "caller" || + principal.appId !== grant.appId || + principal.principalId !== grant.principalId || + principal.keyId !== grant.keyId || + principal.grantId !== grant.grantId || + principal.sessionId !== grant.sessionId || + JSON.stringify(principal.operations) !== JSON.stringify(grant.operations) || + JSON.stringify(principal.commands) !== JSON.stringify(grant.commands) || + session.daemonKeyId !== this.options.daemon.keyId || + session.helloTranscriptHash !== pending.transcriptHash || + session.brokerRevision !== SESSION_BROKER_PROTOCOL_REVISION || + session.appRevision !== this.options.appRevision || + !Array.isArray(session.features) || + session.features.length !== 0 || + !Number.isFinite(session.expiresAt) || + session.initialSequence !== "1" || + !isValidBrokerIdentifier(session.callerSessionId) + ) + clientAuthError(); + const signature = decodeBase64Url(session.daemonSignature); + if ( + !signature || + !(await this.cryptoImpl.verify( + this.options.daemon.publicKey, + signature, + buildBrokerHelloAckTranscript({ + role: "caller", + appId: this.options.appId, + generation: pending.challenge.generation, + keyId: this.options.credential.grant.keyId, + grantId: this.options.credential.grant.grantId, + helloTranscriptHash: pending.transcriptHash, + selection: fixedProposal(this.options.appRevision), + callerSessionId: session.callerSessionId, + initialSequence: session.initialSequence, + }), + )) + ) + clientAuthError(); + } + + private async signedRequest( + path: string, + init: SessionBrokerSignedRequestInit, + targetSpecific: boolean, + ) { + const session = this.session!; + const pending = this.pending!; + const sequence = this.sequence!.allocate(); + if (!sequence) clientAuthError(); + const method = (init.method ?? "GET").toUpperCase(); + const bodyBytes = + typeof init.body === "string" + ? new TextEncoder().encode(init.body) + : init.body == null + ? new Uint8Array() + : clientAuthError(); + const url = new URL(path, this.options.origin); + if ( + url.origin !== new URL(this.options.origin).origin || + url.username || + url.password || + url.hash + ) { + clientAuthError(); + } + const requestId = randomId(this.cryptoImpl); + const bodyDigest = encodeBase64Url(await this.cryptoImpl.sha256(bodyBytes)); + const signature = encodeBase64Url( + await this.cryptoImpl.sign( + this.options.credential.privateKey, + buildCallerRequestTranscript({ + appId: this.options.appId, + generation: pending.challenge.generation, + callerSessionId: session.callerSessionId, + keyId: this.options.credential.grant.keyId, + grantId: this.options.credential.grant.grantId, + helloTranscriptHash: pending.transcriptHash, + method, + target: canonicalHttpTarget(url), + bodyDigest, + requestId, + sequence, + }), + ), + ); + const headers = new Headers(init.headers); + headers.set("x-session-broker-caller-session", session.callerSessionId); + headers.set("x-session-broker-request-id", requestId); + headers.set("x-session-broker-sequence", sequence); + headers.set("x-session-broker-signature", signature); + const response = await this.fetchImpl(url, { ...init, method, headers }); + let envelope: SessionBrokerAuthenticatedResponse; + try { + envelope = (await readBoundedResponseJson( + response, + this.options.maxResponseBytes ?? DEFAULT_SESSION_BROKER_LIMITS.maxHttpResponseBytes, + )) as SessionBrokerAuthenticatedResponse; + await this.verifyResponse( + envelope, + response.status, + session.callerSessionId, + requestId, + sequence, + pending.challenge.generation, + targetSpecific, + ); + } catch { + if (response.status === 401) return null; + clientAuthError(); + } + return new Response(JSON.stringify(envelope.body), { + status: response.status, + headers: { "content-type": "application/json" }, + }); + } + + private async verifyResponse( + envelope: SessionBrokerAuthenticatedResponse, + status: number, + callerSessionId: string, + requestId: string, + sequence: string, + generation: string, + targetSpecific: boolean, + ) { + const envelopeRecord = exactRecord(envelope, ["body", "authentication"]); + const authenticationKeys = [ + "generation", + "brokerRevision", + "callerSessionId", + "requestId", + "sequence", + "httpStatus", + "bodyDigest", + "daemonKeyId", + "daemonSignature", + ...(targetSpecific ? ["appContract"] : []), + ]; + const auth = exactRecord( + envelopeRecord.authentication, + authenticationKeys, + ) as unknown as SessionBrokerAuthenticatedResponse["authentication"]; + const appContract = auth.appContract + ? exactRecord(auth.appContract, ["appRevision", "features"]) + : undefined; + if ( + !auth || + typeof auth.bodyDigest !== "string" || + typeof auth.daemonSignature !== "string" || + auth.generation !== generation || + auth.callerSessionId !== callerSessionId || + auth.requestId !== requestId || + auth.sequence !== sequence || + auth.httpStatus !== status || + auth.brokerRevision !== SESSION_BROKER_PROTOCOL_REVISION || + auth.daemonKeyId !== this.options.daemon.keyId || + (targetSpecific ? !auth.appContract : auth.appContract !== undefined) + ) + clientAuthError(); + if ( + appContract && + (appContract.appRevision !== this.options.appRevision || + !Array.isArray(appContract.features) || + appContract.features.length !== 0) + ) + clientAuthError(); + const bodyDigest = encodeBase64Url( + await this.cryptoImpl.sha256(canonicalJsonBytes(envelopeRecord.body as CanonicalJsonValue)), + ); + if (bodyDigest !== auth.bodyDigest) clientAuthError(); + const signature = decodeBase64Url(auth.daemonSignature); + if ( + !signature || + !(await this.cryptoImpl.verify( + this.options.daemon.publicKey, + signature, + buildBrokerResponseTranscript({ + appId: this.options.appId, + generation, + brokerRevision: SESSION_BROKER_PROTOCOL_REVISION, + callerSessionId, + requestId, + sequence, + httpStatus: status, + bodyDigest, + ...(auth.appContract ? { appContract: auth.appContract } : {}), + }), + )) + ) + clientAuthError(); + } +} diff --git a/packages/session-broker/src/connection.test.ts b/packages/session-broker/src/connection.test.ts index df9765ecc..edf9eb219 100644 --- a/packages/session-broker/src/connection.test.ts +++ b/packages/session-broker/src/connection.test.ts @@ -1,10 +1,14 @@ import { describe, expect, test } from "bun:test"; import type { + ProducerGrant, SessionRegistration, SessionServerMessage, SessionSnapshot, } from "@hunk/session-broker-core"; -import { SESSION_BROKER_REGISTRATION_VERSION } from "@hunk/session-broker-core"; +import { + SESSION_BROKER_REGISTRATION_VERSION, + SESSION_BROKER_SIGNATURE_ALGORITHM, +} from "@hunk/session-broker-core"; import { createSessionBrokerConnection } from "./connection"; import { createSessionBrokerProtocolParsers } from "./protocolParsers"; import type { SessionBrokerSocketLike } from "./types"; @@ -149,6 +153,58 @@ describe("session broker connection", () => { }); }); + test("withholds registration and replacement updates until producer authentication completes", async () => { + const socket = new TestSocket(); + const pair = (await crypto.subtle.generateKey("Ed25519", false, [ + "sign", + "verify", + ])) as CryptoKeyPair; + const grant: ProducerGrant = { + kind: "producer", + appId: "dev.example", + principalId: "producer-1", + keyId: "producer-key-1", + grantId: "producer-grant-1", + algorithm: SESSION_BROKER_SIGNATURE_ALGORITHM, + issuedAt: Date.now() - 1_000, + expiresAt: Date.now() + 60_000, + revocationId: "producer-revocation-1", + mayDelegate: false, + operations: ["register", "reconnect"], + }; + const connection = createSessionBrokerConnection< + TestSessionInfo, + TestSessionState, + TestSocket, + TestServerMessage, + { ok: true } + >({ + url: "ws://broker.test/session", + createSocket: () => socket, + registration: createRegistration(), + snapshot: createSnapshot(), + protocolParsers, + producerAuthentication: { + appId: "dev.example", + appRevision: 1, + credential: { grant, privateKey: pair.privateKey }, + daemon: { keyId: "daemon-key-1", publicKey: pair.publicKey }, + }, + }); + + connection.start(); + socket.emitOpen(); + connection.updateSnapshot({ + ...createSnapshot(), + state: { selectedIndex: 2 }, + }); + connection.replaceSession(createRegistration(), createSnapshot()); + + expect(socket.sent).toHaveLength(1); + expect(JSON.parse(socket.sent[0]!)).toMatchObject({ type: "hello-init" }); + connection.stop(); + }); + test("keeps the previous registration when replacement send throws", () => { const socket = new TestSocket(); const registration = createRegistration(); @@ -853,6 +909,99 @@ describe("session broker connection", () => { connection.stop(); }); + test("rejects producer hello wrappers with unknown or dangerous keys", async () => { + const socket = new TestSocket(); + const pair = (await crypto.subtle.generateKey("Ed25519", false, [ + "sign", + "verify", + ])) as CryptoKeyPair; + const grant: ProducerGrant = { + kind: "producer", + appId: "dev.example", + principalId: "producer-1", + keyId: "producer-key-1", + grantId: "producer-grant-1", + algorithm: SESSION_BROKER_SIGNATURE_ALGORITHM, + issuedAt: Date.now() - 1_000, + expiresAt: Date.now() + 60_000, + revocationId: "producer-revocation-1", + mayDelegate: false, + operations: ["register"], + }; + const connection = createSessionBrokerConnection({ + url: "ws://broker.test/session", + createSocket: () => socket, + registration: createRegistration(), + snapshot: createSnapshot(), + protocolParsers, + producerAuthentication: { + appId: "dev.example", + appRevision: 1, + credential: { grant, privateKey: pair.privateKey }, + daemon: { keyId: "daemon-key-1", publicKey: pair.publicKey }, + }, + reconnectDelayMs: 10_000, + }); + connection.start(); + socket.emitOpen(); + + for (const message of [ + '{"type":"hello-challenge","challenge":{},"extra":true}', + '{"type":"hello-challenge","challenge":{},"__proto__":{}}', + ]) { + socket.readyState = 1; + socket.emitMessage(message); + expect(socket.lastClose).toEqual({ + code: 1008, + reason: "Session broker authentication failed.", + }); + } + connection.stop(); + }); + + test("prepares reconnect once per attempt and stops after awaited preparation", async () => { + const sockets: TestSocket[] = []; + const warnings: string[] = []; + let attempts = 0; + let prepare = async () => { + attempts += 1; + if (attempts === 1) throw new Error("incumbent still alive"); + }; + const connection = createSessionBrokerConnection({ + url: "ws://broker.test/session", + createSocket: () => { + const socket = new TestSocket(); + sockets.push(socket); + return socket; + }, + registration: createRegistration(), + snapshot: createSnapshot(), + protocolParsers, + reconnectDelayMs: 1, + prepareReconnect: () => prepare(), + onWarning: (message) => warnings.push(message), + }); + connection.start(); + sockets[0]!.emitOpen(); + sockets[0]!.emitClose(); + await Bun.sleep(8); + + expect(attempts).toBe(2); + expect(warnings).toEqual(["incumbent still alive"]); + expect(sockets).toHaveLength(2); + + let release!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + sockets[1]!.emitOpen(); + prepare = () => gate; + sockets[1]!.emitClose(); + await Bun.sleep(2); + connection.stop(); + release(); + await Bun.sleep(2); + expect(sockets).toHaveLength(2); + }); + test("reconnects after socket close unless a close directive disables it", async () => { const sockets: TestSocket[] = []; const warnings: string[] = []; diff --git a/packages/session-broker/src/connection.ts b/packages/session-broker/src/connection.ts index dd2406c72..6d17d331b 100644 --- a/packages/session-broker/src/connection.ts +++ b/packages/session-broker/src/connection.ts @@ -3,6 +3,7 @@ import { BrokerProtocolError, ReservationGroup, ResourceBudget, + parseExactBrokerRecord, resolveSessionBrokerLimits, utf8ByteLength, type BudgetReservation, @@ -15,6 +16,20 @@ import { } from "@hunk/session-broker-core"; import type { SessionBrokerProtocolParsers } from "./protocolParsers"; import { parseSessionBrokerJsonText } from "./protocolParsers"; +import { + answerSessionBrokerHelloChallenge, + createSessionBrokerHelloRequest, + verifyProducerHelloAck, + type PendingSessionBrokerHello, + type SessionBrokerClientCredential, + type SessionBrokerDaemonVerifier, +} from "./clientAuthentication"; +import type { + SessionBrokerProducerHelloAck, + SessionBrokerHelloChallenge, + SessionBrokerHelloChallengeRequest, +} from "./authentication"; +import type { ProducerGrant } from "@hunk/session-broker-core"; import type { SessionBrokerConnectionCloseDirective, SessionBrokerSocketCloseEvent, @@ -39,6 +54,13 @@ function commandValueBytes(value: unknown): number { return utf8ByteLength(serialized); } +/** Parse one exact handshake wrapper before its payload reaches the authentication parser. */ +function exactHelloEnvelope(value: unknown, type: string, payloadKey: "challenge" | "ack") { + const record = parseExactBrokerRecord(value, ["type", payloadKey] as const, [] as const); + if (record.type !== type) throw new BrokerProtocolError("invalid-discriminant"); + return record; +} + export interface SessionBrokerConnectionBridge< ServerMessage extends SessionServerMessage = SessionServerMessage, Result = unknown, @@ -59,10 +81,19 @@ export interface SessionBrokerConnectionOptions< snapshot: SessionSnapshot; bridge?: SessionBrokerConnectionBridge | null; protocolParsers: SessionBrokerProtocolParsers; + producerAuthentication?: { + readonly appId: string; + readonly appRevision: number; + readonly credential: SessionBrokerClientCredential; + readonly daemon: SessionBrokerDaemonVerifier; + }; heartbeatIntervalMs?: number; reconnectDelayMs?: number; openState?: number; resolveClose?: (event: SessionBrokerSocketCloseEvent) => SessionBrokerConnectionCloseDirective; + /** Prepare application-owned discovery before one reconnect attempt opens a new socket. */ + prepareReconnect?: () => Promise; + onConnected?: () => void; onWarning?: (message: string) => void; limits?: SessionBrokerLimitOptions["limits"]; unsafeLimits?: SessionBrokerLimitOptions["unsafeLimits"]; @@ -80,6 +111,7 @@ export class SessionBrokerConnection< Result = unknown, > { private socket: Socket | null = null; + private activeSocket: Socket | null = null; private bridge: SessionBrokerConnectionBridge | null; readonly limits: Readonly; @@ -93,6 +125,14 @@ export class SessionBrokerConnection< private stopped = false; private registration: SessionRegistration; private snapshot: SessionSnapshot; + private readonly handshakeTimers = new WeakMap>(); + private readonly producerHellos = new WeakMap< + Socket, + { + request: SessionBrokerHelloChallengeRequest; + pending?: PendingSessionBrokerHello | null; + } + >(); constructor( private readonly options: SessionBrokerConnectionOptions< @@ -141,8 +181,14 @@ export class SessionBrokerConnection< } this.stopHeartbeat(); - this.socket?.close(); + if (this.socket) { + const handshakeTimer = this.handshakeTimers.get(this.socket); + if (handshakeTimer) clearTimeout(handshakeTimer); + this.handshakeTimers.delete(this.socket); + this.socket.close(); + } this.socket = null; + this.activeSocket = null; } getRegistration() { @@ -155,6 +201,12 @@ export class SessionBrokerConnection< } replaceSession(registration: SessionRegistration, snapshot: SessionSnapshot) { + if ( + this.options.producerAuthentication && + registration.sessionId !== this.registration.sessionId + ) { + throw new BrokerProtocolError("invalid-app-payload"); + } // Re-register instead of sending only a snapshot because selectors like cwd, repoRoot, and the // session id itself live in the registration envelope. Send before committing local state so // a throwing socket keeps the previous registration and snapshot coherent. @@ -183,16 +235,26 @@ export class SessionBrokerConnection< const socket = this.options.createSocket(this.options.url); this.socket = socket; + if (this.options.producerAuthentication) { + const timer = setTimeout(() => { + socket.close(1008, "Session broker authentication timed out."); + }, this.limits.maxHandshakeDurationMs); + timer.unref?.(); + this.handshakeTimers.set(socket, timer); + } socket.onopen = () => { - this.startHeartbeat(); - // Register on every fresh socket after the prior close retired its broker-side ownership. - this.sendToSocket(socket, { - type: "register", - registration: this.registration, - snapshot: this.snapshot, - }); - void this.flushQueuedMessages(socket); + if (this.options.producerAuthentication) { + const authentication = this.options.producerAuthentication; + const request = createSessionBrokerHelloRequest({ + ...authentication, + endpoint: this.options.url, + }); + this.producerHellos.set(socket, { request }); + socket.send(JSON.stringify({ type: "hello-init", hello: request })); + return; + } + this.activateSocket(socket); }; socket.onmessage = (event) => { @@ -205,7 +267,10 @@ export class SessionBrokerConnection< socket.close(1009, "Message exceeds the session broker size limit."); return; } - + if (this.options.producerAuthentication && this.activeSocket !== socket) { + void this.handleProducerHello(socket, event.data); + return; + } let parsed: ServerMessage; try { const raw = parseSessionBrokerJsonText(event.data) as { input?: unknown }; @@ -227,8 +292,13 @@ export class SessionBrokerConnection< }; socket.onclose = (event) => { + const wasAuthenticated = this.activeSocket === socket; + const handshakeTimer = this.handshakeTimers.get(socket); + if (handshakeTimer) clearTimeout(handshakeTimer); + this.handshakeTimers.delete(socket); if (this.socket === socket) { this.socket = null; + this.activeSocket = null; this.stopHeartbeat(); } @@ -243,7 +313,11 @@ export class SessionBrokerConnection< return; } - const directive = this.options.resolveClose?.(event) ?? { reconnect: true }; + const directive = this.options.resolveClose?.({ + code: event.code, + reason: event.reason, + authenticated: wasAuthenticated, + }) ?? { reconnect: true }; if (directive.warning) { this.options.onWarning?.(directive.warning); } @@ -260,19 +334,85 @@ export class SessionBrokerConnection< }; } - private scheduleReconnect(delayMs = this.options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS) { - if (this.reconnectTimer || this.stopped) { - return; + private activateSocket(socket: Socket) { + if (this.socket !== socket || this.activeSocket === socket) return; + this.activeSocket = socket; + const handshakeTimer = this.handshakeTimers.get(socket); + if (handshakeTimer) clearTimeout(handshakeTimer); + this.handshakeTimers.delete(socket); + this.startHeartbeat(); + this.options.onConnected?.(); + this.sendToSocket(socket, { + type: "register", + registration: this.registration, + snapshot: this.snapshot, + }); + void this.flushQueuedMessages(socket); + } + + /** Verify the daemon challenge and acknowledgement before registration leaves this process. */ + private async handleProducerHello(socket: Socket, message: unknown) { + try { + if (typeof message === "string" && utf8ByteLength(message) > this.limits.maxWsMessageBytes) { + socket.close(1009, "Session broker authentication message exceeded its limit."); + return; + } + const value = parseSessionBrokerJsonText(message); + const authentication = this.options.producerAuthentication!; + const hello = this.producerHellos.get(socket); + if (!hello) throw new Error(); + if (hello.pending === undefined) { + const envelope = exactHelloEnvelope(value, "hello-challenge", "challenge"); + hello.pending = null; + const pending = await answerSessionBrokerHelloChallenge( + { ...authentication, endpoint: this.options.url }, + hello.request, + envelope.challenge as SessionBrokerHelloChallenge, + ); + if ( + this.socket !== socket || + socket.readyState !== (this.options.openState ?? DEFAULT_SOCKET_OPEN_STATE) + ) { + return; + } + hello.pending = pending; + socket.send(JSON.stringify({ type: "hello-proof", proof: pending.proof })); + return; + } + if (!hello.pending) throw new Error(); + const envelope = exactHelloEnvelope(value, "hello-ack", "ack"); + await verifyProducerHelloAck(hello.pending, envelope.ack as SessionBrokerProducerHelloAck); + this.activateSocket(socket); + } catch { + socket.close(1008, "Session broker authentication failed."); } + } + + private scheduleReconnect(delayMs = this.options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS) { + if (this.reconnectTimer || this.stopped) return; this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; - this.connect(); + void this.prepareAndReconnect(); }, delayMs); - this.reconnectTimer.unref?.(); } + /** Run app discovery once per retry while retaining this connection's aggregate budgets. */ + private async prepareAndReconnect() { + try { + await this.options.prepareReconnect?.(); + } catch (error) { + if (this.stopped) return; + this.options.onWarning?.( + error instanceof Error ? error.message : "Session broker reconnect preparation failed.", + ); + this.scheduleReconnect(); + return; + } + if (!this.stopped) this.connect(); + } + private startHeartbeat() { if (this.heartbeatTimer) { return; @@ -298,17 +438,18 @@ export class SessionBrokerConnection< } private send(message: SessionClientMessage) { - if (!this.socket) { + if (!this.activeSocket) { return; } - this.sendToSocket(this.socket, message); + this.sendToSocket(this.activeSocket, message); } /** Send a response only through the still-active socket that received its command. */ private sendToSocket(socket: Socket, message: SessionClientMessage) { if ( this.socket !== socket || + this.activeSocket !== socket || socket.readyState !== (this.options.openState ?? DEFAULT_SOCKET_OPEN_STATE) ) { return; diff --git a/packages/session-broker/src/daemon.test.ts b/packages/session-broker/src/daemon.test.ts index 4371990f8..ba5afde7d 100644 --- a/packages/session-broker/src/daemon.test.ts +++ b/packages/session-broker/src/daemon.test.ts @@ -5,6 +5,7 @@ import { parseSessionRegistrationEnvelope, parseSessionSnapshotEnvelope, type CallerPrincipal, + type ProducerOperation, type SessionRegistration, type SessionServerMessage, type SessionSnapshot, @@ -12,7 +13,11 @@ import { import { SessionBroker } from "./broker"; import { createSessionBrokerDaemon } from "./daemon"; import { createSessionBrokerProtocolParsers } from "./protocolParsers"; -import type { AuthenticatedCallerRequest } from "./authentication"; +import type { + AuthenticatedCallerRequest, + AuthenticatedProducerHello, + SessionBrokerHelloChallenge, +} from "./authentication"; interface TestSessionInfo { title: string; @@ -131,7 +136,9 @@ function authenticatedRequest(principal: CallerPrincipal): AuthenticatedCallerRe generation: "generation-1", brokerRevision: 1, ...(input.appContract ? { appContract: input.appContract } : {}), + callerSessionId: "caller-session-1", requestId: "request-1", + sequence: "1", httpStatus: input.httpStatus, bodyDigest: "test-body-digest", daemonKeyId: "daemon-key-1", @@ -170,9 +177,13 @@ async function authenticatedBody(response: Response | null) { function createConnection() { const sent: string[] = []; let closed: { code?: number; reason?: string } | null = null; + let authenticated = false; return { sent, + get authenticated() { + return authenticated; + }, get closed() { return closed; }, @@ -183,11 +194,84 @@ function createConnection() { close(code?: number, reason?: string) { closed = { code, reason }; }, + markAuthenticated() { + authenticated = true; + }, }, }; } describe("session broker daemon", () => { + test("closes late producer messages without parsing or recreating state after shutdown", () => { + let registrationParses = 0; + const parsers = createSessionBrokerProtocolParsers({ + appRevision: 1, + features: [], + parseRegistration: (value) => { + registrationParses += 1; + return parseSessionRegistrationEnvelope(value, parseInfo); + }, + parseSnapshot: (value) => parseSessionSnapshotEnvelope(value, parseState), + commands: [], + }); + const broker = new SessionBroker({ protocolParsers: parsers }); + const daemon = createSessionBrokerDaemon({ broker }); + const peer = createConnection(); + daemon.shutdown(); + daemon.handleConnectionMessage( + peer.connection, + JSON.stringify({ + type: "register", + registration: createRegistration(), + snapshot: createSnapshot(), + }), + ); + + expect(registrationParses).toBe(0); + expect(broker.listSessions()).toHaveLength(0); + expect(peer.closed).toEqual({ + code: 1001, + reason: "Session broker shutting down.", + }); + }); + + test("does not send a deferred producer challenge after shutdown", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const daemon = createSessionBrokerDaemon({ + broker: createBroker(), + appId: "dev.example", + appRevision: 1, + producerEndpoint: "ws://broker.test/session", + helloAuthenticator: { + async issueChallenge() { + await gate; + return { challengeId: "challenge-1" } as SessionBrokerHelloChallenge; + }, + async completeCallerHello() { + throw new Error("not used"); + }, + async completeProducerHello() { + throw new Error("not used"); + }, + }, + }); + const peer = createConnection(); + daemon.handleConnectionMessage( + peer.connection, + JSON.stringify({ type: "hello-init", hello: {} }), + ); + await Bun.sleep(0); + daemon.shutdown(); + release(); + await Bun.sleep(0); + + expect(peer.sent).toEqual([]); + expect(daemon.listSessions()).toEqual([]); + }); + test("serves health and raw list/get requests when the HTTP API is enabled", async () => { const daemon = createSessionBrokerDaemon({ broker: createBroker(), @@ -315,7 +399,10 @@ describe("session broker daemon", () => { ["HEAD", new Headers({ "content-length": "1" })], ] as const) { const response = await daemon.handleRequest( - new Request("http://broker.test/broker/capabilities", { method, headers }), + new Request("http://broker.test/broker/capabilities", { + method, + headers, + }), ); expect(response?.status).toBe(400); await expect(response?.json()).resolves.toEqual({ @@ -679,6 +766,198 @@ describe("session broker daemon", () => { daemon.shutdown(); }); + test("rejects producer hello wrappers with unknown or dangerous keys", async () => { + let challengeCalls = 0; + const daemon = createSessionBrokerDaemon({ + broker: createBroker(), + appId: "dev.example", + appRevision: 1, + producerEndpoint: "ws://broker.test/session", + helloAuthenticator: { + async issueChallenge() { + challengeCalls += 1; + return { challengeId: "challenge-1" } as SessionBrokerHelloChallenge; + }, + async completeCallerHello() { + throw new Error("not used"); + }, + async completeProducerHello() { + throw new Error("not used"); + }, + }, + }); + + for (const message of [ + '{"type":"hello-init","hello":{},"extra":true}', + '{"type":"hello-init","hello":{},"__proto__":{}}', + ]) { + const peer = createConnection(); + daemon.handleConnectionMessage(peer.connection, message); + await Bun.sleep(0); + expect(peer.closed?.reason).toContain("authentication required"); + } + expect(challengeCalls).toBe(0); + daemon.shutdown(); + }); + + test("pre-registration authentication failures do not postpone idle shutdown", async () => { + const daemon = createSessionBrokerDaemon({ + broker: createBroker(), + appId: "dev.example", + appRevision: 1, + producerEndpoint: "ws://broker.test/session", + idleTimeoutMs: 50, + helloAuthenticator: { + async issueChallenge() { + throw new Error("incompatible"); + }, + async completeCallerHello() { + throw new Error("not used"); + }, + async completeProducerHello() { + throw new Error("not used"); + }, + }, + }); + const activityBeforeRefusal = (daemon as any).lastActivityAt; + await Bun.sleep(10); + const peer = createConnection(); + daemon.handleConnectionMessage( + peer.connection, + JSON.stringify({ type: "hello-init", hello: {} }), + ); + await Bun.sleep(0); + daemon.handleConnectionClose(peer.connection); + expect((daemon as any).lastActivityAt).toBe(activityBeforeRefusal); + + const outcome = await Promise.race([ + daemon.stopped.then(() => "stopped"), + Bun.sleep(80).then(() => "timed-out"), + ]); + expect(outcome).toBe("stopped"); + }); + + test("requires reconnect scope and rechecks retained producer authority", async () => { + let operations: readonly ProducerOperation[] = ["register"]; + let active = true; + let activeChecks = 0; + const principal = () => ({ + kind: "producer" as const, + appId: "dev.example", + principalId: "producer-1", + keyId: "producer-key-1", + grantId: "producer-grant-1", + scopes: operations, + }); + const broker = createBroker(); + const daemon = createSessionBrokerDaemon({ + broker, + appId: "dev.example", + appRevision: 1, + producerEndpoint: "ws://broker.test/session", + helloAuthenticator: { + async issueChallenge() { + return { challengeId: "challenge-1" } as SessionBrokerHelloChallenge; + }, + async completeCallerHello() { + throw new Error("not used"); + }, + async completeProducerHello(_proof, connectionId) { + return { + ack: { + principal: principal(), + connectionId: String(connectionId), + brokerRevision: 1, + appRevision: 1, + features: [], + helloTranscriptHash: "transcript-1", + daemonKeyId: "daemon-key-1", + daemonSignature: "signature-1", + }, + assertActive() { + activeChecks += 1; + if (!active) throw new Error("revoked"); + }, + } satisfies AuthenticatedProducerHello; + }, + }, + }); + const first = createConnection(); + const denied = createConnection(); + const replacement = createConnection(); + const authenticate = async (connection: ReturnType["connection"]) => { + daemon.handleConnectionMessage(connection, JSON.stringify({ type: "hello-init", hello: {} })); + await Bun.sleep(0); + daemon.handleConnectionMessage( + connection, + JSON.stringify({ type: "hello-proof", proof: {} }), + ); + await Bun.sleep(0); + }; + const register = (connection: ReturnType["connection"]) => + daemon.handleConnectionMessage( + connection, + JSON.stringify({ + type: "register", + registration: createRegistration(), + snapshot: createSnapshot(), + }), + ); + + await authenticate(first.connection); + expect(first.authenticated).toBe(false); + register(first.connection); + expect(first.authenticated).toBe(true); + await authenticate(denied.connection); + register(denied.connection); + expect(denied.closed?.reason).toContain("scope rejected"); + expect(first.closed).toBeNull(); + + operations = ["reconnect"]; + await authenticate(replacement.connection); + register(replacement.connection); + expect(first.closed?.reason).toContain("owner reconnected"); + expect(daemon.listSessions()).toHaveLength(1); + + // Deliver work that was already queued on the displaced transport after replacement. Its + // retired authentication state must not let it reclaim the session. + register(first.connection); + expect(first.closed?.reason).toContain("authentication required"); + expect(daemon.listSessions()).toHaveLength(1); + + const checksBeforeRevocation = activeChecks; + const sentBeforeRevocation = replacement.sent.length; + active = false; + await expect( + broker.dispatchCommand({ + selector: { sessionId: "session-1" }, + command: "annotate", + input: { summary: "must stay private" }, + timeoutMessage: "timed out", + }), + ).rejects.toThrow("revoked"); + expect(replacement.sent).toHaveLength(sentBeforeRevocation); + expect(replacement.closed?.reason).toContain("authority expired"); + + daemon.handleConnectionMessage( + replacement.connection, + JSON.stringify({ type: "heartbeat", sessionId: "session-1" }), + ); + daemon.handleConnectionMessage( + replacement.connection, + JSON.stringify({ + type: "snapshot", + sessionId: "session-1", + snapshot: createSnapshot({ selectedIndex: 1 }), + }), + ); + expect(activeChecks).toBe(checksBeforeRevocation + 3); + expect(replacement.closed?.reason).toContain("authority expired"); + expect(daemon.getSession({ sessionId: "session-1" })).toMatchObject({ + snapshot: { state: { selectedIndex: 0 } }, + }); + }); + test("rejects duplicate live registration without retiring the owner", () => { const daemon = createSessionBrokerDaemon({ broker: createBroker(), @@ -919,6 +1198,15 @@ describe("session broker daemon", () => { return true; }, }); + const owner = createConnection(); + daemon.handleConnectionMessage( + owner.connection, + JSON.stringify({ + type: "register", + registration: createRegistration(), + snapshot: createSnapshot(), + }), + ); const post = (body: unknown) => daemon.handleRequest( new Request("http://broker.test/broker", { @@ -928,13 +1216,13 @@ describe("session broker daemon", () => { }), ); - expect((await post({ action: "get", selector: { sessionId: "missing" } }))?.status).toBe(403); + expect((await post({ action: "get", selector: { sessionId: "session-1" } }))?.status).toBe(403); expect(appAuthorizerCalls).toBe(0); expect( ( await post({ action: "dispatch", - selector: { sessionId: "missing" }, + selector: { sessionId: "session-1" }, command: "forbidden", input: {}, }) @@ -945,7 +1233,7 @@ describe("session broker daemon", () => { ( await post({ action: "dispatch", - selector: { sessionId: "missing" }, + selector: { sessionId: "session-1" }, command: "allowed", commandVersion: 0, input: {}, @@ -1042,7 +1330,7 @@ describe("session broker daemon", () => { test("supports a lower route-specific body ceiling and releases its reservation", async () => { const daemon = createSessionBrokerDaemon({ broker: createBroker(), - limits: { maxHttpBodyBytes: 8, maxInFlightHttpBodyBytes: 8 }, + limits: { maxHttpBodyBytes: 4, maxInFlightHttpBodyBytes: 8 }, }); let handled = 0; const invoke = (body: string) => diff --git a/packages/session-broker/src/daemon.ts b/packages/session-broker/src/daemon.ts index 90719ddbe..880085e9c 100644 --- a/packages/session-broker/src/daemon.ts +++ b/packages/session-broker/src/daemon.ts @@ -8,15 +8,18 @@ import { mergeSessionBrokerLimits, DEFAULT_SESSION_BROKER_LIMITS, callerPrincipalAllows, + producerPrincipalAllows, canonicalizeJson, isValidBrokerAppId, isValidBrokerIdentifier, + parseExactBrokerRecord, isValidBrokerRevision, utf8ByteLength, type BudgetReservation, type CallerOperation, type CallerPrincipal, type CanonicalJsonValue, + type ProducerPrincipal, type SessionBrokerLimitOptions, type SessionBrokerLimits, type SessionServerMessage, @@ -27,6 +30,7 @@ import { SessionBrokerAuthenticationError, type AuthenticatedCallerRequest, type CallerRequestAuthenticator, + type SessionBrokerHelloAuthenticator, } from "./authentication"; import { parseSessionBrokerJsonBytes, @@ -65,6 +69,19 @@ const BROKER_STATE_LIMITS = [ "maxCommandTimeoutMs", ] as const satisfies readonly (keyof SessionBrokerLimits)[]; +export interface SessionBrokerAuthenticatedControlFacts { + readonly operation: CallerOperation; + readonly sessionId?: string; + readonly command?: string; + readonly commandVersion?: number; + readonly targetSpecific?: boolean; +} + +export interface SessionBrokerAuthenticatedControlResult { + readonly body: CanonicalJsonValue; + readonly status?: number; +} + export interface SessionBrokerDaemonOptions< SessionView = unknown, ServerMessage extends SessionServerMessage = SessionServerMessage, @@ -75,6 +92,10 @@ export interface SessionBrokerDaemonOptions< paths?: Partial; exposeHttpApi?: boolean; callerAuthenticator?: CallerRequestAuthenticator; + helloAuthenticator?: SessionBrokerHelloAuthenticator; + /** @deprecated Use helloAuthenticator. */ + producerAuthenticator?: SessionBrokerHelloAuthenticator; + producerEndpoint?: string; authorizer?: SessionBrokerAuthorizer; audit?: SessionBrokerAuditHook; appId?: string; @@ -129,6 +150,38 @@ function defaultTimeoutMessage(command: string) { return `Timed out waiting for the session to handle ${command}.`; } +interface ProducerAuthenticationState { + state: "challenged" | "authenticated"; + principal?: ProducerPrincipal; + sessionId?: string; + assertActive?: () => void; + brokerPeer?: SessionBrokerPeer; +} + +interface ProducerOwner { + connection: SessionBrokerPeer; + brokerPeer: SessionBrokerPeer; + principal: ProducerPrincipal; +} + +/** Parse one exact producer handshake wrapper before forwarding its opaque payload. */ +function exactProducerHelloEnvelope(value: unknown, type: string, payloadKey: "hello" | "proof") { + const record = parseExactBrokerRecord(value, ["type", payloadKey] as const, [] as const); + if (record.type !== type) throw new BrokerProtocolError("invalid-discriminant"); + return record; +} + +/** Match the immutable producer identity that is allowed to reclaim one session. */ +function sameProducerBinding(left: ProducerPrincipal, right: ProducerPrincipal) { + return ( + left.appId === right.appId && + left.principalId === right.principalId && + left.keyId === right.keyId && + left.grantId === right.grantId && + left.sessionId === right.sessionId + ); +} + /** * Runtime-neutral daemon engine that owns broker lifecycle, health, stale pruning, and raw HTTP * plus websocket message handling without choosing Bun, Node, or any other server implementation. @@ -157,7 +210,18 @@ export class SessionBrokerDaemon< private readonly appId: string; private readonly appRevision?: number; private readonly callerAuthenticator?: CallerRequestAuthenticator; + private readonly helloAuthenticator?: SessionBrokerHelloAuthenticator; + private readonly producerEndpoint?: string; private readonly authorizer?: SessionBrokerAuthorizer; + private readonly producerAuthentication = new WeakMap< + SessionBrokerPeer, + ProducerAuthenticationState + >(); + private readonly producerOwners = new Map(); + private readonly producerReconnects = new Map< + string, + { principal: ProducerPrincipal; disconnectedAt: number } + >(); private readonly audit?: SessionBrokerAuditHook; private readonly httpControlBudget: ResourceBudget; private readonly httpBodyBudget: ResourceBudget; @@ -217,6 +281,14 @@ export class SessionBrokerDaemon< this.appId = options.appId ?? "session-broker"; this.appRevision = this.protocolParsers.appRevision; this.callerAuthenticator = options.callerAuthenticator; + this.helloAuthenticator = options.helloAuthenticator ?? options.producerAuthenticator; + this.producerEndpoint = options.producerEndpoint; + if (options.producerAuthenticator && !this.producerEndpoint) { + throw new TypeError("Authenticated producer transport requires its listener endpoint."); + } + if (this.producerEndpoint && !this.helloAuthenticator) { + throw new TypeError("Authenticated producer transport requires a hello authenticator."); + } this.authorizer = options.authorizer; this.audit = options.audit; this.idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; @@ -255,6 +327,10 @@ export class SessionBrokerDaemon< return pathname === this.paths.socket; } + get requiresProducerAuthentication() { + return this.producerEndpoint !== undefined; + } + /** Run one app-specific finite HTTP control through the daemon's shared count/body budgets. */ async handleBoundedControl( request: Request, @@ -304,6 +380,27 @@ export class SessionBrokerDaemon< async handleRequest(request: Request) { const url = new URL(request.url); + if (url.pathname === "/session-auth/challenge" || url.pathname === "/session-auth/proof") { + if (request.method !== "POST" || !hasJsonContentType(request) || !this.helloAuthenticator) { + return jsonError("Session broker authentication requires an upgraded client.", 401); + } + return this.handleBoundedControl(request, async (body) => { + try { + const input = parseSessionBrokerJsonBytes(body); + const result = url.pathname.endsWith("/challenge") + ? await this.helloAuthenticator!.issueChallenge(input, request.url) + : await this.helloAuthenticator!.completeCallerHello(input); + return Response.json(result); + } catch (error) { + const code = + error instanceof SessionBrokerAuthenticationError + ? error.code + : "authentication-required"; + return Response.json({ error: code }, { status: 401 }); + } + }); + } + if (url.pathname === this.paths.health) { // Treat health checks as a cheap maintenance pulse so stale sessions disappear even when the // daemon is mostly idle and no websocket traffic is flowing. @@ -313,6 +410,7 @@ export class SessionBrokerDaemon< if (removed > 0) { this.noteActivity(); } + this.reconcileProducerOwners(); // Public health is deliberately liveness-only. Apps may expose authenticated diagnostics on // a separate route, but broker identity, paths, counts, and process facts stay private. @@ -332,6 +430,101 @@ export class SessionBrokerDaemon< } handleConnectionMessage(connection: SessionBrokerPeer, message: unknown) { + if (this.shuttingDown) { + connection.close?.(1001, "Session broker shutting down."); + return; + } + if (typeof message === "string" && utf8ByteLength(message) > this.limits.maxWsMessageBytes) { + connection.close?.(1009, "Session broker message exceeded its limit."); + return; + } + if (this.producerEndpoint && this.helloAuthenticator) { + const authentication = this.producerAuthentication.get(connection); + if (authentication?.state !== "authenticated") { + void this.handleProducerHelloMessage(connection, message, authentication); + return; + } + try { + authentication.assertActive?.(); + } catch { + connection.close?.(INCOMPATIBLE_PAYLOAD_CLOSE_CODE, "Session producer authority expired."); + return; + } + } + this.handleAuthenticatedConnectionMessage(connection, message); + } + + /** Complete the producer hello before allowing any registration-shaped message to reach state. */ + private async handleProducerHelloMessage( + connection: SessionBrokerPeer, + message: unknown, + current?: ProducerAuthenticationState, + ) { + try { + const value = parseSessionBrokerJsonText(message); + if (!current) { + const envelope = exactProducerHelloEnvelope(value, "hello-init", "hello"); + const challenged = { state: "challenged" as const }; + this.producerAuthentication.set(connection, challenged); + const challenge = await this.helloAuthenticator!.issueChallenge( + envelope.hello, + this.producerEndpoint!, + ); + if (this.shuttingDown || this.producerAuthentication.get(connection) !== challenged) { + return; + } + connection.send(JSON.stringify({ type: "hello-challenge", challenge })); + return; + } + if (current.state !== "challenged") throw new Error(); + const envelope = exactProducerHelloEnvelope(value, "hello-proof", "proof"); + const connectionId = `b_${crypto.randomUUID().replaceAll("-", "")}_0`; + const authority = await this.helloAuthenticator!.completeProducerHello( + envelope.proof, + connectionId, + ); + if (this.shuttingDown || this.producerAuthentication.get(connection) !== current) { + return; + } + authority.assertActive(); + const brokerPeer: SessionBrokerPeer = { + send: (data) => { + try { + authority.assertActive(); + } catch (error) { + connection.close?.( + INCOMPATIBLE_PAYLOAD_CLOSE_CODE, + "Session producer authority expired.", + ); + throw error; + } + return connection.send(data); + }, + close: (code, reason) => connection.close?.(code, reason), + markAuthenticated: () => connection.markAuthenticated?.(), + }; + this.producerAuthentication.set(connection, { + state: "authenticated", + principal: authority.ack.principal, + assertActive: authority.assertActive, + brokerPeer, + }); + connection.send(JSON.stringify({ type: "hello-ack", ack: authority.ack })); + } catch { + this.producerAuthentication.delete(connection); + connection.close?.( + INCOMPATIBLE_PAYLOAD_CLOSE_CODE, + "Session broker authentication required; upgrade Hunk.", + ); + } + } + + private handleAuthenticatedConnectionMessage(connection: SessionBrokerPeer, message: unknown) { + if (this.shuttingDown) { + connection.close?.(1001, "Session broker shutting down."); + return; + } + let parsed; try { parsed = this.protocolParsers.parseClientMessage(parseSessionBrokerJsonText(message)); @@ -340,12 +533,39 @@ export class SessionBrokerDaemon< return; } + const producerAuthentication = this.producerAuthentication.get(connection); + const brokerPeer = producerAuthentication?.brokerPeer ?? connection; switch (parsed.type) { case "register": { + const sessionId = (parsed.registration as { sessionId: string }).sessionId; + this.pruneProducerReconnects(); + const owner = this.producerOwners.get(sessionId); + const reconnect = + owner && owner.connection !== connection ? owner : this.producerReconnects.get(sessionId); + const operation = reconnect ? "reconnect" : "register"; + if ( + this.producerEndpoint && + this.helloAuthenticator && + (!producerAuthentication?.principal || + (producerAuthentication.sessionId !== undefined && + producerAuthentication.sessionId !== sessionId) || + (reconnect && + !sameProducerBinding(producerAuthentication.principal, reconnect.principal)) || + !producerPrincipalAllows(producerAuthentication.principal, { + appId: this.appId, + operation, + sessionId, + })) + ) { + connection.close?.(INCOMPATIBLE_PAYLOAD_CLOSE_CODE, "Session producer scope rejected."); + return; + } + const replacedConnection = owner?.connection !== connection ? owner?.connection : undefined; const registrationResult = this.broker.registerSession( - connection, + brokerPeer, parsed.registration, parsed.snapshot, + { replaceOwner: replacedConnection !== undefined }, ); if (registrationResult === "invalid") { // Close immediately when the registration payload is incompatible so the session does not @@ -362,15 +582,37 @@ export class SessionBrokerDaemon< connection.close?.(1013, "Session broker capacity exceeded."); return; } + if (registrationResult === "shutdown") { + connection.close?.(1001, "Session broker shutting down."); + return; + } + if (producerAuthentication?.principal) { + // Retire the displaced transport before publishing the new owner. A queued message from + // the old socket must re-enter as unauthenticated and can never reclaim the session. + if (replacedConnection) this.producerAuthentication.delete(replacedConnection); + producerAuthentication.sessionId = sessionId; + this.producerOwners.set(sessionId, { + connection, + brokerPeer, + principal: producerAuthentication.principal, + }); + this.producerReconnects.delete(sessionId); + } + connection.markAuthenticated?.(); + replacedConnection?.close?.(1000, "Session owner reconnected."); this.noteActivity(); break; } case "snapshot": { + if (this.producerEndpoint && producerAuthentication?.sessionId !== parsed.sessionId) { + connection.close?.(INCOMPATIBLE_PAYLOAD_CLOSE_CODE, "Session producer scope rejected."); + return; + } // Snapshot updates are only valid after registration. Closing missing or invalid sessions // keeps the broker state single-sourced instead of guessing how to recover. const updateResult = this.broker.updateSnapshot( - connection, + brokerPeer, parsed.sessionId, parsed.snapshot, ); @@ -392,7 +634,11 @@ export class SessionBrokerDaemon< break; } case "heartbeat": { - const seenResult = this.broker.markSessionSeen(connection, parsed.sessionId); + if (this.producerEndpoint && producerAuthentication?.sessionId !== parsed.sessionId) { + connection.close?.(INCOMPATIBLE_PAYLOAD_CLOSE_CODE, "Session producer scope rejected."); + return; + } + const seenResult = this.broker.markSessionSeen(brokerPeer, parsed.sessionId); if (seenResult === "not-owner") { connection.close?.(INCOMPATIBLE_PAYLOAD_CLOSE_CODE, "Session ownership rejected."); return; @@ -402,7 +648,7 @@ export class SessionBrokerDaemon< break; } case "command-result": { - const result = this.broker.handleCommandResult(connection, parsed); + const result = this.broker.handleCommandResult(brokerPeer, parsed); if (result === "not-owner") { connection.close?.(INCOMPATIBLE_PAYLOAD_CLOSE_CODE, "Command ownership rejected."); return; @@ -422,8 +668,61 @@ export class SessionBrokerDaemon< } handleConnectionClose(connection: SessionBrokerPeer) { - this.broker.unregisterConnection(connection); - this.noteActivity(); + const authentication = this.producerAuthentication.get(connection); + this.producerAuthentication.delete(connection); + const sessionId = authentication?.sessionId; + if (sessionId && authentication.principal) { + const owner = this.producerOwners.get(sessionId); + if (owner?.connection === connection) { + this.producerOwners.delete(sessionId); + this.pruneProducerReconnects(); + if (this.producerReconnects.size >= this.limits.maxSessions) { + const oldest = this.producerReconnects.keys().next().value as string | undefined; + if (oldest) this.producerReconnects.delete(oldest); + } + this.producerReconnects.set(sessionId, { + principal: authentication.principal, + disconnectedAt: Date.now(), + }); + } + } + this.broker.unregisterConnection(authentication?.brokerPeer ?? connection); + // Pre-registration authentication failures must not postpone quiescent shutdown. This is also + // what lets a newer client wait out an incompatible incumbent without keeping it alive. + if (!this.producerEndpoint || sessionId !== undefined) this.noteActivity(); + } + + /** Retire producer sockets whose session vanished or whose configured grant is no longer active. */ + private reconcileProducerOwners() { + const live = new Set(this.broker.getSessionIds()); + for (const [sessionId, owner] of this.producerOwners) { + let active = live.has(sessionId); + if (active && this.helloAuthenticator) { + try { + const authentication = this.producerAuthentication.get(owner.connection); + if (authentication?.state !== "authenticated" || !authentication.assertActive) { + active = false; + } else { + authentication.assertActive(); + } + } catch { + active = false; + } + } + if (active) continue; + this.producerOwners.delete(sessionId); + this.broker.unregisterConnection(owner.brokerPeer); + owner.connection.close?.(1000, "Session producer authority retired."); + } + } + + /** Expire bounded reconnect authority on the same horizon as disconnected session state. */ + private pruneProducerReconnects(now = Date.now()) { + for (const [sessionId, reconnect] of this.producerReconnects) { + if (now - reconnect.disconnectedAt >= this.staleSessionTtlMs) { + this.producerReconnects.delete(sessionId); + } + } } shutdown(error = new Error("The session broker daemon shut down.")) { @@ -443,6 +742,8 @@ export class SessionBrokerDaemon< } this.broker.shutdown(error); + this.producerOwners.clear(); + this.producerReconnects.clear(); this.callerAuthenticator?.clear?.(); this.resolveStopped?.(); this.resolveStopped = null; @@ -456,6 +757,7 @@ export class SessionBrokerDaemon< if (removed > 0) { this.noteActivity(); } + this.reconcileProducerOwners(); }, this.staleSessionSweepIntervalMs); this.sweepTimer.unref?.(); @@ -504,6 +806,69 @@ export class SessionBrokerDaemon< }, remainingMs); } + /** Authenticate, authorize, execute, and sign one app-owned finite JSON control. */ + async handleAuthenticatedControl( + request: Request, + options: { + resolve: (body: Uint8Array) => SessionBrokerAuthenticatedControlFacts; + resolveFailureTargetSpecific?: (body: Uint8Array) => boolean; + handle: ( + body: Uint8Array, + facts: SessionBrokerAuthenticatedControlFacts, + ) => + | SessionBrokerAuthenticatedControlResult + | Promise; + }, + ): Promise { + return this.handleBoundedControl(request, async (body) => { + const authenticated = await this.authenticateRequest(request, body, "list"); + if (authenticated instanceof Response) return authenticated; + let facts: SessionBrokerAuthenticatedControlFacts; + try { + facts = options.resolve(body); + } catch { + let targetSpecific = false; + try { + targetSpecific = options.resolveFailureTargetSpecific?.(body) ?? false; + } catch { + // Malformed bodies have no trustworthy target contract. + } + return this.authenticatedResponse( + authenticated, + { error: "protocol-validation-failed" }, + 400, + targetSpecific, + ); + } + if (!(await this.authorize(request, authenticated, facts))) { + return this.authenticatedResponse( + authenticated, + { error: "authorization-denied" }, + 403, + facts.targetSpecific ?? facts.operation !== "list", + ); + } + const inactive = this.rejectInactiveRequest(authenticated); + if (inactive) return inactive; + try { + const result = await options.handle(body, facts); + return this.authenticatedResponse( + authenticated, + result.body, + result.status ?? 200, + facts.targetSpecific ?? facts.operation !== "list", + ); + } catch { + return this.authenticatedResponse( + authenticated, + { error: "session-control-failed" }, + 400, + facts.targetSpecific ?? facts.operation !== "list", + ); + } + }); + } + private async authenticateRequest( request: Request, body: Uint8Array, @@ -612,10 +977,18 @@ export class SessionBrokerDaemon< let responseStatus = status; const targetContract = targetSpecific && this.appRevision !== undefined - ? { appContract: { appRevision: this.appRevision, features: [] as const } } + ? { + appContract: { + appRevision: this.appRevision, + features: [] as const, + }, + } : {}; if (utf8ByteLength(canonicalizeJson(structuredBody)) > this.limits.maxHttpResponseBytes) { - structuredBody = { error: "capacity-exceeded", resource: "maxHttpResponseBytes" }; + structuredBody = { + error: "capacity-exceeded", + resource: "maxHttpResponseBytes", + }; responseStatus = 503; } const authentication = await authenticated.signResponse({ @@ -629,7 +1002,10 @@ export class SessionBrokerDaemon< }; let serializedEnvelope = canonicalizeJson(envelope as unknown as CanonicalJsonValue); if (utf8ByteLength(serializedEnvelope) > this.limits.maxHttpResponseBytes) { - structuredBody = { error: "capacity-exceeded", resource: "maxHttpResponseBytes" }; + structuredBody = { + error: "capacity-exceeded", + resource: "maxHttpResponseBytes", + }; responseStatus = 503; envelope = { body: structuredBody, @@ -698,7 +1074,11 @@ export class SessionBrokerDaemon< } const authenticated = await this.authenticateRequest(request, body, "diagnostics"); if (authenticated instanceof Response) return authenticated; - if (!(await this.authorize(request, authenticated, { operation: "diagnostics" }))) { + if ( + !(await this.authorize(request, authenticated, { + operation: "diagnostics", + })) + ) { return this.authenticatedResponse(authenticated, { error: "authorization-denied" }, 403); } const inactive = this.rejectInactiveRequest(authenticated); @@ -761,7 +1141,22 @@ export class SessionBrokerDaemon< const operation = input.action as CallerOperation; const selector = "selector" in input ? input.selector : undefined; - const sessionId = selector?.sessionId; + const targetSpecific = input.action !== "list"; + let sessionId: string | undefined; + if (selector) { + try { + sessionId = this.broker.resolveSessionId(selector); + } catch (error) { + return this.authenticatedResponse( + authenticated, + { + error: error instanceof Error ? error.message : "Session target resolution failed.", + }, + 400, + true, + ); + } + } const command = input.action === "dispatch" ? input.command : undefined; const commandVersion = input.action === "dispatch" ? (input.commandVersion ?? 1) : undefined; const facts = { @@ -769,7 +1164,6 @@ export class SessionBrokerDaemon< ...(sessionId !== undefined ? { sessionId } : {}), ...(command !== undefined ? { command, commandVersion } : {}), }; - const targetSpecific = input.action !== "list"; if (!(await this.authorize(request, authenticated, facts))) { return this.authenticatedResponse( authenticated, @@ -788,15 +1182,14 @@ export class SessionBrokerDaemon< response = { sessions: this.broker.listSessions() }; break; case "get": - response = { session: this.broker.getSession(input.selector) }; + response = { + session: this.broker.getSession({ sessionId: sessionId! }), + }; break; case "dispatch": { - // Resolve the target before invoking app-owned parsing so the exact target contract is - // selected first. This read-only lookup happens only after authentication/authorization. - this.broker.getSession(input.selector); response = { result: await this.broker.dispatchCommand({ - selector: input.selector, + selector: { sessionId: sessionId! }, command: input.command, commandVersion: input.commandVersion ?? 1, input: input.input, diff --git a/packages/session-broker/src/index.ts b/packages/session-broker/src/index.ts index 7e0be3777..dddd0821f 100644 --- a/packages/session-broker/src/index.ts +++ b/packages/session-broker/src/index.ts @@ -5,4 +5,5 @@ export * from "./daemon"; export * from "./connection"; export * from "./crypto"; export * from "./authentication"; +export * from "./clientAuthentication"; export * from "./protocolParsers"; diff --git a/packages/session-broker/src/types.ts b/packages/session-broker/src/types.ts index 0c35c5fa2..0cb702f47 100644 --- a/packages/session-broker/src/types.ts +++ b/packages/session-broker/src/types.ts @@ -83,6 +83,8 @@ export interface SessionBrokerHealth { export interface SessionBrokerSocketCloseEvent { code: number; reason: string; + /** Whether this socket completed authentication and became the active producer transport. */ + authenticated?: boolean; } export interface SessionBrokerSocketMessageEvent { diff --git a/src/main.tsx b/src/main.tsx index cf0992d60..848f1f905 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -21,7 +21,7 @@ async function main() { } if (startupPlan.kind === "daemon-serve") { - const server = serveSessionBrokerDaemon(); + const server = await serveSessionBrokerDaemon(); await server.stopped; return; } diff --git a/src/session/agent/cliClient.test.ts b/src/session/agent/cliClient.test.ts index ab4738856..b58b0a9f9 100644 --- a/src/session/agent/cliClient.test.ts +++ b/src/session/agent/cliClient.test.ts @@ -34,6 +34,9 @@ import { const selector = { sessionId: "session-1" } satisfies SessionSelectorInput; const originalFetch = globalThis.fetch; +const injectedCaller = { + request: (path: string, init?: RequestInit) => globalThis.fetch(path, init), +}; afterEach(() => { globalThis.fetch = originalFetch; @@ -134,7 +137,7 @@ describe("HTTP Hunk session CLI client", () => { return Response.json(responses[request.action as keyof typeof responses]); }) as typeof fetch; - const client = createHttpHunkSessionCliClient(); + const client = createHttpHunkSessionCliClient({ caller: injectedCaller }); expect(await client.getCapabilities()).toMatchObject({ version: HUNK_SESSION_API_VERSION }); expect(await client.listSessions()).toEqual([session]); @@ -327,7 +330,7 @@ describe("HTTP Hunk session CLI client", () => { }); }) as typeof fetch; - const client = createHttpHunkSessionCliClient({ timeoutMs: 10 }); + const client = createHttpHunkSessionCliClient({ timeoutMs: 10, caller: injectedCaller }); await expect(client.listSessions()).rejects.toThrow( "Timed out waiting for the Hunk session daemon to complete session list.", @@ -340,7 +343,7 @@ describe("HTTP Hunk session CLI client", () => { sessions: [{ sessionId: "partial", unknown: true }], })) as unknown as typeof fetch; - const client = createHttpHunkSessionCliClient(); + const client = createHttpHunkSessionCliClient({ caller: injectedCaller }); await expect(client.listSessions()).rejects.toThrow( "Invalid Hunk session daemon response for list.", ); @@ -356,7 +359,7 @@ describe("HTTP Hunk session CLI client", () => { globalThis.fetch = (async () => Response.json({ sessions: [session] })) as unknown as typeof fetch; - const client = createHttpHunkSessionCliClient(); + const client = createHttpHunkSessionCliClient({ caller: injectedCaller }); const result = await client.listSessions(); expect(result).toEqual([session]); expect(result[0]).not.toBe(session); @@ -369,7 +372,7 @@ describe("HTTP Hunk session CLI client", () => { { status: 404, statusText: "Not Found" }, )) as unknown as typeof fetch; - const client = createHttpHunkSessionCliClient(); + const client = createHttpHunkSessionCliClient({ caller: injectedCaller }); await expect(client.listSessions()).rejects.toThrow("No matching session."); globalThis.fetch = (async () => diff --git a/src/session/agent/cliClient.ts b/src/session/agent/cliClient.ts index ca22c0f56..b6ab01072 100644 --- a/src/session/agent/cliClient.ts +++ b/src/session/agent/cliClient.ts @@ -1,19 +1,28 @@ import { sanitizeTerminalText } from "../../lib/terminalText"; import { resolveSessionBrokerConfig } from "../broker/brokerConfig"; +import { + SessionBrokerCallerClient, + type SessionBrokerSignedRequestInit, +} from "@hunk/session-broker"; import type { SessionTerminalLocation, SessionTerminalMetadata } from "@hunk/session-broker-core"; -import { readHunkSessionDaemonCapabilities } from "../client/capabilities"; import { HUNK_SESSION_DAEMON_HTTP_TIMEOUT_MS, - requestSessionDaemonHttp, + withSessionDaemonHttpTimeout, } from "../client/daemonHttp"; +import { loadOrCreateHunkSessionBrokerCredentials } from "../broker/credentials"; +import { + HUNK_SESSION_BROKER_APP_ID, + HUNK_SESSION_BROKER_APP_REVISION, +} from "../broker/appContract"; import { HUNK_SESSION_API_PATH, + HUNK_SESSION_CAPABILITIES_PATH, type SessionDaemonAction, type SessionDaemonCapabilities, type SessionDaemonRequest, type SessionDaemonResponses, } from "../protocol"; -import { parseSessionDaemonResponse } from "../protocolSchemas"; +import { parseSessionDaemonCapabilities, parseSessionDaemonResponse } from "../protocolSchemas"; import type { AppliedCommentBatchResult, AppliedCommentResult, @@ -76,31 +85,60 @@ async function extractResponseError(response: Response) { return response.statusText || "Unknown Hunk session daemon error."; } +interface HunkCallerTransport { + request( + path: string, + init?: SessionBrokerSignedRequestInit, + options?: { readonly targetSpecific?: boolean }, + ): Promise; +} + class HttpHunkSessionCliClient implements HunkSessionCliClient { private readonly config = resolveSessionBrokerConfig(); - - constructor(private readonly timeoutMs = HUNK_SESSION_DAEMON_HTTP_TIMEOUT_MS) {} + private callerPromise: Promise | null = null; + + constructor( + private readonly timeoutMs = HUNK_SESSION_DAEMON_HTTP_TIMEOUT_MS, + private readonly injectedCaller?: HunkCallerTransport, + ) {} + + private caller() { + if (this.injectedCaller) return Promise.resolve(this.injectedCaller); + this.callerPromise ??= loadOrCreateHunkSessionBrokerCredentials().then( + (credentials) => + new SessionBrokerCallerClient({ + appId: HUNK_SESSION_BROKER_APP_ID, + appRevision: HUNK_SESSION_BROKER_APP_REVISION, + origin: this.config.httpOrigin, + credential: credentials.caller, + daemon: { + keyId: credentials.daemonIdentity.keyId, + publicKey: credentials.daemonPublicKey, + }, + }), + ); + return this.callerPromise; + } private async request( input: Extract, ): Promise { - return requestSessionDaemonHttp({ - config: this.config, - path: HUNK_SESSION_API_PATH, + return withSessionDaemonHttpTimeout({ operation: `complete session ${input.action}`, timeoutMs: this.timeoutMs, - init: { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify(input), - }, - parse: async (response) => { - if (!response.ok) { - throw new Error(await extractResponseError(response)); - } - + task: async (signal) => { + const caller = await this.caller(); + const response = await caller.request( + HUNK_SESSION_API_PATH, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + signal, + }, + { targetSpecific: input.action !== "list" }, + ); + if (!response.ok) throw new Error(await extractResponseError(response)); let value: unknown; try { value = await response.json(); @@ -113,7 +151,20 @@ class HttpHunkSessionCliClient implements HunkSessionCliClient { } async getCapabilities() { - return readHunkSessionDaemonCapabilities(this.config, this.timeoutMs); + return withSessionDaemonHttpTimeout({ + operation: "report capabilities", + timeoutMs: this.timeoutMs, + task: async (signal) => { + const response = await ( + await this.caller() + ).request(HUNK_SESSION_CAPABILITIES_PATH, { + method: "GET", + signal, + }); + if (!response.ok) return null; + return parseSessionDaemonCapabilities(await response.json()); + }, + }); } async listSessions() { @@ -255,8 +306,9 @@ class HttpHunkSessionCliClient implements HunkSessionCliClient { /** Create the concrete Hunk session CLI client that speaks to the broker-backed HTTP API. */ export function createHttpHunkSessionCliClient({ timeoutMs, -}: { timeoutMs?: number } = {}): HunkSessionCliClient { - return new HttpHunkSessionCliClient(timeoutMs); + caller, +}: { timeoutMs?: number; caller?: HunkCallerTransport } = {}): HunkSessionCliClient { + return new HttpHunkSessionCliClient(timeoutMs, caller); } export function stringifyJson(value: unknown) { diff --git a/src/session/agent/commands.daemon.test.ts b/src/session/agent/commands.daemon.test.ts index f5c240a4c..dbb4c7f62 100644 --- a/src/session/agent/commands.daemon.test.ts +++ b/src/session/agent/commands.daemon.test.ts @@ -141,7 +141,6 @@ describe("text output formatting", () => { test("renders reload, comment-add, and comment-clear as non-empty text", async () => { setSessionCommandTestHooks({ resolveDaemonAvailability: async () => true, - restartDaemonForMissingAction: async () => {}, createClient: () => createFakeClient(), }); diff --git a/src/session/agent/commands.test.ts b/src/session/agent/commands.test.ts index 6eda84832..15c6c01ad 100644 --- a/src/session/agent/commands.test.ts +++ b/src/session/agent/commands.test.ts @@ -13,8 +13,8 @@ import { setSessionCommandTestHooks, type HunkDaemonCliClient, } from "./commands"; -import { HUNK_DAEMON_UPGRADE_RESTART_NOTICE } from "../client/capabilities"; import { HUNK_SESSION_API_VERSION, HUNK_SESSION_DAEMON_VERSION } from "../protocol"; +import { SessionBrokerClientAuthenticationError } from "@hunk/session-broker"; function createTestListedSession(sessionId: string) { return buildTestListedSession({ @@ -147,233 +147,87 @@ afterEach(() => { }); describe("session command compatibility checks", () => { - test("refreshes an older daemon without the session API before running context", async () => { - const selector: SessionSelectorInput = { sessionId: "session-1" }; - const restartCalls: Array<{ action: string; selector?: SessionSelectorInput }> = []; - const createdClients: string[] = []; - const notices: string[] = []; - const originalConsoleError = console.error; - console.error = (...args: unknown[]) => { - notices.push(args.map((value) => String(value)).join(" ")); - }; - - const clients = [ - createClient({ - getCapabilities: async () => { - createdClients.push("stale-capabilities"); - return null; - }, - }), - createClient({ - getSelectedContext: async (receivedSelector) => { - createdClients.push("fresh-context"); - expect(receivedSelector).toEqual(selector); - return createTestSelectedSessionContext(); - }, - }), - ]; - - try { - setSessionCommandTestHooks({ - createClient: () => { - const client = clients.shift(); - if (!client) { - throw new Error("No fake session client remaining."); - } - - return client; - }, - resolveDaemonAvailability: async () => true, - restartDaemonForMissingAction: async (action, receivedSelector) => { - restartCalls.push({ action, selector: receivedSelector }); - }, - }); + test("fails promptly without executing an action against an incompatible daemon", async () => { + let contextCalls = 0; + setSessionCommandTestHooks({ + createClient: () => + createClient({ + getCapabilities: async () => null, + getSelectedContext: async () => { + contextCalls += 1; + return createTestSelectedSessionContext(); + }, + }), + resolveDaemonAvailability: async () => true, + }); - const output = await runSessionCommand({ + await expect( + runSessionCommand({ kind: "session", action: "context", - selector, + selector: { sessionId: "session-1" }, output: "json", - } satisfies SessionCommandInput); - - expect(JSON.parse(output)).toMatchObject({ - context: { - sessionId: "session-1", - selectedFile: { - path: "README.md", - }, - selectedHunk: { - index: 0, - }, - }, - }); - expect(restartCalls).toEqual([ - { - action: "context", - selector, - }, - ]); - expect(createdClients).toEqual(["stale-capabilities", "fresh-context"]); - expect(notices).toContain(HUNK_DAEMON_UPGRADE_RESTART_NOTICE); - } finally { - console.error = originalConsoleError; - } + } satisfies SessionCommandInput), + ).rejects.toThrow( + "Close older Hunk windows, wait for the daemon to become idle, then retry this command.", + ); + expect(contextCalls).toBe(0); }); - test("refreshes an incompatible daemon version before running list", async () => { - const restartCalls: Array<{ action: string; selector?: SessionSelectorInput }> = []; - const createdClients: string[] = []; - const notices: string[] = []; - const originalConsoleError = console.error; - console.error = (...args: unknown[]) => { - notices.push(args.map((value) => String(value)).join(" ")); - }; - - const clients = [ - createClient({ - getCapabilities: async () => { - createdClients.push("stale-capabilities"); - return { - version: HUNK_SESSION_API_VERSION - 1, - daemonVersion: HUNK_SESSION_DAEMON_VERSION, - actions: ["list"], - }; - }, - }), - createClient({ - listSessions: async () => { - createdClients.push("fresh-list"); - return [createTestListedSession("session-1")]; - }, - }), - ]; - - try { - setSessionCommandTestHooks({ - createClient: () => { - const client = clients.shift(); - if (!client) { - throw new Error("No fake session client remaining."); - } - - return client; - }, - resolveDaemonAvailability: async () => true, - restartDaemonForMissingAction: async (action, receivedSelector) => { - restartCalls.push({ action, selector: receivedSelector }); - }, - }); - - const output = await runSessionCommand({ - kind: "session", - action: "list", - output: "json", - } satisfies SessionCommandInput); - - expect(JSON.parse(output)).toMatchObject({ - sessions: [ - { - sessionId: "session-1", + test("maps signed negotiation failure to quiescent upgrade guidance", async () => { + setSessionCommandTestHooks({ + createClient: () => + createClient({ + getCapabilities: async () => { + throw new SessionBrokerClientAuthenticationError(); }, - ], - }); - expect(restartCalls).toEqual([ - { - action: "list", - selector: undefined, - }, - ]); - expect(createdClients).toEqual(["stale-capabilities", "fresh-list"]); - expect(notices).toContain(HUNK_DAEMON_UPGRADE_RESTART_NOTICE); - } finally { - console.error = originalConsoleError; - } + }), + resolveDaemonAvailability: async () => true, + }); + + await expect( + runSessionCommand({ kind: "session", action: "list", output: "json" }), + ).rejects.toThrow("Close older Hunk windows"); }); - test("refreshes a stale daemon before running comment-add", async () => { - const selector: SessionSelectorInput = { sessionId: "session-1" }; - const restartCalls: Array<{ action: string; selector?: SessionSelectorInput }> = []; - const createdClients: string[] = []; - const notices: string[] = []; - const originalConsoleError = console.error; - console.error = (...args: unknown[]) => { - notices.push(args.map((value) => String(value)).join(" ")); - }; + test("preserves local credential-store failures", async () => { + setSessionCommandTestHooks({ + createClient: () => + createClient({ + getCapabilities: async () => { + throw new Error("owner-private credential store is unsafe"); + }, + }), + resolveDaemonAvailability: async () => true, + }); - const clients = [ - createClient({ - getCapabilities: async () => { - createdClients.push("stale-capabilities"); - return null; - }, - }), - createClient({ - addComment: async (input) => { - createdClients.push("fresh-comment-add"); - expect(input.selector).toEqual(selector); - expect(input.filePath).toBe("README.md"); - expect(input.side).toBe("new"); - expect(input.line).toBe(2); - expect(input.summary).toBe("Review note"); - return { - commentId: "comment-1", - fileId: "file-1", - filePath: "README.md", - hunkIndex: 0, - side: "new", - line: 2, - }; - }, - }), - ]; - - try { - setSessionCommandTestHooks({ - createClient: () => { - const client = clients.shift(); - if (!client) { - throw new Error("No fake session client remaining."); - } - - return client; - }, - resolveDaemonAvailability: async () => true, - restartDaemonForMissingAction: async (action, receivedSelector) => { - restartCalls.push({ action, selector: receivedSelector }); - }, - }); + await expect( + runSessionCommand({ kind: "session", action: "list", output: "json" }), + ).rejects.toThrow("owner-private credential store is unsafe"); + }); - const output = await runSessionCommand({ - kind: "session", - action: "comment-add", - selector, - filePath: "README.md", - side: "new", - line: 2, - summary: "Review note", - reveal: false, - output: "json", - } satisfies SessionCommandInput); + test("fails promptly when compatible capabilities omit the required action", async () => { + let listCalls = 0; + setSessionCommandTestHooks({ + createClient: () => + createClient({ + getCapabilities: async () => ({ + version: HUNK_SESSION_API_VERSION, + daemonVersion: HUNK_SESSION_DAEMON_VERSION, + actions: ["get"], + }), + listSessions: async () => { + listCalls += 1; + return []; + }, + }), + resolveDaemonAvailability: async () => true, + }); - expect(JSON.parse(output)).toMatchObject({ - result: { - commentId: "comment-1", - filePath: "README.md", - side: "new", - line: 2, - }, - }); - expect(restartCalls).toEqual([ - { - action: "comment-add", - selector, - }, - ]); - expect(createdClients).toEqual(["stale-capabilities", "fresh-comment-add"]); - expect(notices).toContain(HUNK_DAEMON_UPGRADE_RESTART_NOTICE); - } finally { - console.error = originalConsoleError; - } + await expect( + runSessionCommand({ kind: "session", action: "list", output: "json" }), + ).rejects.toThrow("missing required support for list"); + expect(listCalls).toBe(0); }); test("runs review commands through the daemon without raw patch text by default", async () => { @@ -865,9 +719,7 @@ describe("session command compatibility checks", () => { ); }); - test("does not restart when the daemon already exposes the needed session action", async () => { - const restartCalls: string[] = []; - + test("runs when the daemon already exposes the needed session action", async () => { setSessionCommandTestHooks({ createClient: () => createClient({ @@ -890,9 +742,6 @@ describe("session command compatibility checks", () => { }), }), resolveDaemonAvailability: async () => true, - restartDaemonForMissingAction: async (action) => { - restartCalls.push(action); - }, }); const output = await runSessionCommand({ @@ -903,7 +752,6 @@ describe("session command compatibility checks", () => { } satisfies SessionCommandInput); expect(JSON.parse(output)).toEqual({ comments: [] }); - expect(restartCalls).toEqual([]); }); test("normalizes session-path selectors for reload commands before calling the daemon client", async () => { diff --git a/src/session/agent/commands.ts b/src/session/agent/commands.ts index e5cfca35f..714c6df02 100644 --- a/src/session/agent/commands.ts +++ b/src/session/agent/commands.ts @@ -1,19 +1,10 @@ -import type { - SessionCommandInput, - SessionCommandOutput, - SessionSelectorInput, -} from "../../core/run/commandInputs"; +import type { SessionCommandInput, SessionCommandOutput } from "../../core/run/commandInputs"; import type { SessionLiveCommentSummary, SessionReviewNoteSummary } from "../types"; import { NO_ACTIVE_SESSIONS_MESSAGE } from "./errors"; -import { - ensureSessionBrokerAvailable, - isSessionBrokerHealthy, - isLoopbackPortReachable, - readSessionBrokerHealth, - waitForSessionBrokerShutdown, -} from "../broker/brokerLauncher"; +import { isSessionBrokerHealthy, isLoopbackPortReachable } from "../broker/brokerLauncher"; import { resolveSessionBrokerConfig } from "../broker/brokerConfig"; -import { matchesSessionSelector, normalizeSessionSelector } from "@hunk/session-broker-core"; +import { normalizeSessionSelector } from "@hunk/session-broker-core"; +import { SessionBrokerClientAuthenticationError } from "@hunk/session-broker"; import { createHttpHunkSessionCliClient, formatClearCommentsOutput, @@ -33,7 +24,6 @@ import { stringifyJson, type HunkSessionCliClient, } from "./cliClient"; -import { reportHunkDaemonUpgradeRestart } from "../client/capabilities"; import { HUNK_SESSION_API_VERSION, type SessionDaemonAction } from "../protocol"; const REQUIRED_ACTION_BY_COMMAND: Record = { @@ -57,10 +47,6 @@ export type HunkDaemonCliClient = HunkSessionCliClient; interface SessionCommandTestHooks { createClient?: () => HunkSessionCliClient; resolveDaemonAvailability?: (action: SessionCommandInput["action"]) => Promise; - restartDaemonForMissingAction?: ( - action: SessionDaemonAction, - selector?: SessionSelectorInput, - ) => Promise; } let sessionCommandTestHooks: SessionCommandTestHooks | null = null; @@ -73,80 +59,22 @@ function createDaemonCliClient() { return sessionCommandTestHooks?.createClient?.() ?? createHttpHunkSessionCliClient(); } -async function waitForSessionRegistration(selector?: SessionSelectorInput, timeoutMs = 8_000) { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - const client = createDaemonCliClient(); - - try { - const sessions = await client.listSessions(); - if (sessions.some((session) => matchesSessionSelector(session, selector))) { - return true; - } - } catch { - // Keep polling while the fresh daemon/session reconnects. - } - - await Bun.sleep(200); - } - - return false; -} - -async function restartDaemonForMissingAction( - action: SessionDaemonAction, - selector?: SessionSelectorInput, -) { - const health = await readSessionBrokerHealth(); - const pid = health?.pid; - const hadSessions = (health?.sessions ?? 0) > 0; - if (!pid || pid === process.pid) { - throw new Error( - `The running Hunk session daemon is missing required support for ${action}. ` + - `Restart Hunk so it can launch a fresh daemon from the current source tree.`, - ); - } - - process.kill(pid, "SIGTERM"); - - const shutDown = await waitForSessionBrokerShutdown(); - if (!shutDown) { - throw new Error( - `Stopped waiting for the old Hunk session daemon to exit after it was found missing ${action}.`, - ); - } - - const config = resolveSessionBrokerConfig(); - await ensureSessionBrokerAvailable({ - config, - timeoutMs: 3_000, - timeoutMessage: "Timed out waiting for the refreshed Hunk session daemon to start.", - }); - - // `hunk session list` can recover from a stale daemon even when the old process belonged to a - // sibling worktree that reports sessions which will never reconnect to this fresh daemon. - if (selector || (hadSessions && action !== "list")) { - const registered = await waitForSessionRegistration(selector); - if (!registered) { - throw new Error( - "Timed out waiting for the live Hunk session to reconnect after refreshing the session daemon. " + - "Restart that Hunk window if it was launched from an older build.", - ); - } +async function ensureRequiredAction(action: SessionDaemonAction, client = createDaemonCliClient()) { + let capabilities; + try { + capabilities = await client.getCapabilities(); + } catch (error) { + if (!(error instanceof SessionBrokerClientAuthenticationError)) throw error; + capabilities = null; } -} - -async function ensureRequiredAction(action: SessionDaemonAction, selector?: SessionSelectorInput) { - const client = createDaemonCliClient(); - const capabilities = await client.getCapabilities(); if (capabilities?.version === HUNK_SESSION_API_VERSION && capabilities.actions.includes(action)) { return; } - reportHunkDaemonUpgradeRestart(); - await (sessionCommandTestHooks?.restartDaemonForMissingAction?.(action, selector) ?? - restartDaemonForMissingAction(action, selector)); + throw new Error( + `The running Hunk session daemon is incompatible or missing required support for ${action}. ` + + "Close older Hunk windows, wait for the daemon to become idle, then retry this command.", + ); } async function resolveDaemonAvailability(action: SessionCommandInput["action"]) { @@ -185,9 +113,8 @@ export async function runSessionCommand(input: SessionCommandInput) { const normalizedSelector = "selector" in input ? normalizeSessionSelector(input.selector) : null; const requiredAction = REQUIRED_ACTION_BY_COMMAND[input.action]; - await ensureRequiredAction(requiredAction, normalizedSelector ?? undefined); - const client = createDaemonCliClient(); + await ensureRequiredAction(requiredAction, client); switch (input.action) { case "list": { diff --git a/src/session/broker/appContract.ts b/src/session/broker/appContract.ts new file mode 100644 index 000000000..fbc2612ae --- /dev/null +++ b/src/session/broker/appContract.ts @@ -0,0 +1,15 @@ +import { + SESSION_BROKER_PROTOCOL_REVISION, + type BrokerAppContract, +} from "@hunk/session-broker-core"; +import { HUNK_SESSION_DAEMON_VERSION } from "../protocol"; + +/** Defines Hunk's immutable Phase-1 broker and application wire contract. */ +export const HUNK_SESSION_BROKER_APP_ID = "dev.hunk" as const; +export const HUNK_SESSION_BROKER_REVISION = SESSION_BROKER_PROTOCOL_REVISION; +export const HUNK_SESSION_BROKER_APP_REVISION = HUNK_SESSION_DAEMON_VERSION; +export const HUNK_SESSION_BROKER_FEATURES = Object.freeze([]) as readonly []; +export const HUNK_SESSION_BROKER_APP_CONTRACT: Readonly = Object.freeze({ + appRevision: HUNK_SESSION_BROKER_APP_REVISION, + features: HUNK_SESSION_BROKER_FEATURES, +}); diff --git a/src/session/broker/brokerClient.test.ts b/src/session/broker/brokerClient.test.ts index 00c6ebc5c..8194f7d76 100644 --- a/src/session/broker/brokerClient.test.ts +++ b/src/session/broker/brokerClient.test.ts @@ -1,17 +1,28 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createServer } from "node:http"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { createTestSessionRegistration, createTestSessionReviewFile, createTestSessionSnapshot, } from "../../../test/helpers/session-daemon-fixtures"; import { HUNK_SESSION_API_VERSION, HUNK_SESSION_DAEMON_VERSION } from "../protocol"; -import { SessionBrokerClient } from "./brokerClient"; +import { SessionBroker, createSessionBrokerDaemon } from "@hunk/session-broker"; +import { serveSessionBrokerDaemon as serveBunSessionBrokerDaemon } from "@hunk/session-broker-bun"; +import { hunkSessionProtocolParsers } from "./protocolParsers"; +import { isQuiescentUpgradeRefusal, SessionBrokerClient } from "./brokerClient"; +import { loadOrCreateHunkSessionBrokerCredentials } from "./credentials"; +import { serveSessionBrokerDaemon as serveHunkSessionBrokerDaemon } from "./brokerServer"; +import { createHttpHunkSessionCliClient } from "../agent/cliClient"; +import { resolveSessionBrokerRuntimePaths } from "./brokerLauncher"; const originalHost = process.env.HUNK_MCP_HOST; const originalPort = process.env.HUNK_MCP_PORT; const originalDisable = process.env.HUNK_MCP_DISABLE; const originalUnsafeRemote = process.env.HUNK_MCP_UNSAFE_ALLOW_REMOTE; +const originalRuntimeDir = process.env.XDG_RUNTIME_DIR; const originalConsoleError = console.error; function createRegistration() { @@ -33,11 +44,16 @@ function createSnapshot() { }); } -async function waitUntil(label: string, fn: () => boolean, timeoutMs = 5_000, intervalMs = 50) { +async function waitUntil( + label: string, + fn: () => boolean | Promise, + timeoutMs = 5_000, + intervalMs = 50, +) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - if (fn()) { + if (await fn()) { return; } @@ -72,10 +88,37 @@ afterEach(() => { process.env.HUNK_MCP_UNSAFE_ALLOW_REMOTE = originalUnsafeRemote; } + if (originalRuntimeDir === undefined) { + delete process.env.XDG_RUNTIME_DIR; + } else { + process.env.XDG_RUNTIME_DIR = originalRuntimeDir; + } + console.error = originalConsoleError; }); describe("Hunk session daemon client", () => { + test("only treats exact pre-authentication compatibility closes as quiescent refusals", () => { + const reason = "Session broker authentication required; upgrade Hunk."; + expect(isQuiescentUpgradeRefusal({ code: 1008, reason, authenticated: false })).toBe(true); + expect( + isQuiescentUpgradeRefusal({ + code: 1008, + reason: "Malformed session broker protocol.", + authenticated: false, + }), + ).toBe(true); + expect(isQuiescentUpgradeRefusal({ code: 1008, reason, authenticated: true })).toBe(false); + expect(isQuiescentUpgradeRefusal({ code: 1006, reason, authenticated: false })).toBe(false); + expect( + isQuiescentUpgradeRefusal({ + code: 1008, + reason: "Session broker authentication failed.", + authenticated: false, + }), + ).toBe(false); + }); + test("keeps its previous registration when the live connection rejects replacement", () => { const registration = createRegistration(); const client = new SessionBrokerClient(registration, createSnapshot()); @@ -120,39 +163,23 @@ describe("Hunk session daemon client", () => { } }, 10_000); - test("restartIncompatibleDaemon lets startup recover when the stale daemon already exited", async () => { - const server = createServer((_request, response) => { - response.writeHead(404, { "content-type": "text/plain" }); - response.end("gone"); - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve()); - }); - - const address = server.address(); - const port = typeof address === "object" && address ? address.port : 0; - const config = { - host: "127.0.0.1", - port, - httpOrigin: `http://127.0.0.1:${port}`, - wsOrigin: `ws://127.0.0.1:${port}`, - }; - + test("does not retain the legacy PID-based incompatible-daemon replacement path", () => { const client = new SessionBrokerClient(createRegistration(), createSnapshot()); - - try { - await expect((client as any).restartIncompatibleDaemon(config)).resolves.toBeUndefined(); - } finally { - client.stop(); - await new Promise((resolve) => server.close(() => resolve())); - } + expect((client as any).restartIncompatibleDaemon).toBeUndefined(); + client.stop(); }); test("logs one actionable warning when a refreshed daemon rejects an older Hunk window", async () => { const listener = createServer((_request, response) => { response.writeHead(200, { "content-type": "application/json" }); - response.end(JSON.stringify({ ok: true, pid: process.pid, sessions: 0, pendingCommands: 0 })); + response.end( + JSON.stringify({ + ok: true, + pid: process.pid, + sessions: 0, + pendingCommands: 0, + }), + ); }); await new Promise((resolve, reject) => { listener.once("error", reject); @@ -170,7 +197,12 @@ describe("Hunk session daemon client", () => { fetch(request, bunServer) { const url = new URL(request.url); if (url.pathname === "/health") { - return Response.json({ ok: true, pid: process.pid, sessions: 0, pendingCommands: 0 }); + return Response.json({ + ok: true, + pid: process.pid, + sessions: 0, + pendingCommands: 0, + }); } if (url.pathname === "/session-api/capabilities") { @@ -194,21 +226,27 @@ describe("Hunk session daemon client", () => { websocket: { open(socket) { websocketOpens += 1; - socket.close(1008, "Incompatible session registration."); + setTimeout( + () => socket.close(1008, "Session broker authentication required; upgrade Hunk."), + 20, + ); }, message() {}, }, }); const messages: string[] = []; - const client = new SessionBrokerClient(createRegistration(), createSnapshot()); - let reconnectScheduled = false; - (client as any).scheduleReconnect = () => { - reconnectScheduled = true; - }; - (client as any).warnUnavailable = (error: unknown) => { - messages.push(error instanceof Error ? error.message : String(error)); + console.error = (...args: unknown[]) => { + messages.push(args.map((value) => String(value)).join(" ")); }; + const client = new SessionBrokerClient(createRegistration(), createSnapshot(), { + reconnectDelayMs: 10, + }); + const skewedRegistration = createRegistration(); + skewedRegistration.sessionId = "session-skewed"; + const skewedClient = new SessionBrokerClient(skewedRegistration, createSnapshot(), { + reconnectDelayMs: 17, + }); try { for (let attempt = 0; attempt < 20; attempt += 1) { @@ -224,30 +262,314 @@ describe("Hunk session daemon client", () => { await Bun.sleep(25); } - await (client as any).connect({ + const credentials = await loadOrCreateHunkSessionBrokerCredentials(); + const config = { host: "127.0.0.1", port, httpOrigin: `http://127.0.0.1:${port}`, wsOrigin: `ws://127.0.0.1:${port}`, - }); - await waitUntil("incompatible session warning", () => - messages.some((message) => - message.includes("too old for the refreshed session broker daemon"), - ), + }; + (client as any).credentials = credentials; + (client as any).connect(config); + await Bun.sleep(7); + (skewedClient as any).credentials = credentials; + (skewedClient as any).connect(config); + await waitUntil("both incompatible session warnings", () => messages.length === 2); + expect(messages.every((message) => message.includes("Close older Hunk windows"))).toBe(true); + await Bun.sleep(60); + expect(websocketOpens).toBe(2); + expect((client as any).waitingForIncumbentExit).toBe(true); + expect((skewedClient as any).waitingForIncumbentExit).toBe(true); + } finally { + client.stop(); + skewedClient.stop(); + server.stop(true); + } + }, 10_000); + + test("authenticates after a successor becomes healthy before the waiter observes absence", async () => { + const runtimeDir = mkdtempSync(join(tmpdir(), "hunk-missed-daemon-absence-")); + const listener = createServer(); + await new Promise((resolve, reject) => { + listener.once("error", reject); + listener.listen(0, "127.0.0.1", resolve); + }); + const address = listener.address(); + const port = typeof address === "object" && address ? address.port : 0; + await new Promise((resolve) => listener.close(() => resolve())); + process.env.XDG_RUNTIME_DIR = runtimeDir; + process.env.HUNK_MCP_HOST = "127.0.0.1"; + process.env.HUNK_MCP_PORT = String(port); + + let helloAttempts = 0; + const incumbentDaemon = createSessionBrokerDaemon({ + broker: new SessionBroker({ + protocolParsers: hunkSessionProtocolParsers, + }), + appId: "dev.hunk", + appRevision: HUNK_SESSION_DAEMON_VERSION, + producerEndpoint: `ws://127.0.0.1:${port}/session`, + idleTimeoutMs: 0, + helloAuthenticator: { + async issueChallenge() { + helloAttempts += 1; + throw new Error("incompatible application revision"); + }, + async completeCallerHello() { + throw new Error("not used"); + }, + async completeProducerHello() { + throw new Error("not used"); + }, + }, + }); + const incumbent = serveBunSessionBrokerDaemon({ + daemon: incumbentDaemon, + hostname: "127.0.0.1", + port, + }); + void incumbentDaemon.stopped.then(() => incumbent.stop(true)); + let successor: Awaited> | null = null; + const client = new SessionBrokerClient(createRegistration(), createSnapshot(), { + reconnectDelayMs: 25, + }); + const metadataPath = resolveSessionBrokerRuntimePaths({ + host: "127.0.0.1", + port, + }).metadataPath; + mkdirSync(join(metadataPath, ".."), { recursive: true }); + const writeMetadata = (pid: number) => + writeFileSync( + metadataPath, + JSON.stringify({ + pid, + host: "127.0.0.1", + port, + command: "/fixture/hunk", + args: ["daemon", "serve"], + launchedAt: new Date(pid).toISOString(), + launchedByPid: pid, + launchCwd: "/fixture", + }), ); + writeMetadata(100); - expect(messages[0]).toContain( - "This window is too old for the refreshed session broker daemon.", + try { + await client.start(); + const retainedConnection = (client as any).connection; + await waitUntil("incompatible signed hello", () => helloAttempts === 1); + + incumbentDaemon.shutdown(); + await incumbentDaemon.stopped; + successor = await serveHunkSessionBrokerDaemon({ idleTimeoutMs: 0 }); + writeMetadata(200); + + await waitUntil( + "registration after missed endpoint absence", + async () => { + try { + return ( + ( + await createHttpHunkSessionCliClient({ + timeoutMs: 250, + }).listSessions() + ).length === 1 + ); + } catch { + return false; + } + }, + 5_000, + 25, ); - expect(messages[0]).toContain("Restart the window to reconnect."); - expect(reconnectScheduled).toBe(false); - expect(websocketOpens).toBe(1); + expect(helloAttempts).toBe(1); + expect((client as any).connection).toBe(retainedConnection); } finally { client.stop(); - server.stop(true); + incumbentDaemon.shutdown(); + incumbent.stop(true); + successor?.stop(true); + if (successor) await successor.stopped; + rmSync(runtimeDir, { recursive: true, force: true }); } }, 10_000); + test("waits out an incompatible incumbent and registers on its successor with one connection", async () => { + const runtimeDir = mkdtempSync(join(tmpdir(), "hunk-quiescent-upgrade-")); + const listener = createServer(); + await new Promise((resolve, reject) => { + listener.once("error", reject); + listener.listen(0, "127.0.0.1", resolve); + }); + const address = listener.address(); + const port = typeof address === "object" && address ? address.port : 0; + await new Promise((resolve) => listener.close(() => resolve())); + process.env.XDG_RUNTIME_DIR = runtimeDir; + process.env.HUNK_MCP_HOST = "127.0.0.1"; + process.env.HUNK_MCP_PORT = String(port); + + let helloAttempts = 0; + const incumbentDaemon = createSessionBrokerDaemon({ + broker: new SessionBroker({ + protocolParsers: hunkSessionProtocolParsers, + }), + appId: "dev.hunk", + appRevision: HUNK_SESSION_DAEMON_VERSION, + producerEndpoint: `ws://127.0.0.1:${port}/session`, + idleTimeoutMs: 150, + helloAuthenticator: { + async issueChallenge() { + helloAttempts += 1; + throw new Error("incompatible application revision"); + }, + async completeCallerHello() { + throw new Error("not used"); + }, + async completeProducerHello() { + throw new Error("not used"); + }, + }, + }); + const incumbent = serveBunSessionBrokerDaemon({ + daemon: incumbentDaemon, + hostname: "127.0.0.1", + port, + }); + void incumbentDaemon.stopped.then(() => incumbent.stop(true)); + let successor: Awaited> | null = null; + const client = new SessionBrokerClient(createRegistration(), createSnapshot(), { + reconnectDelayMs: 10, + }); + (client as any).ensureDaemonAvailable = async () => { + try { + if ((await fetch(`http://127.0.0.1:${port}/health`)).ok) return; + } catch { + // Launch the successor after the incumbent's short test-only quiescent lifetime. + } + successor ??= await serveHunkSessionBrokerDaemon({ idleTimeoutMs: 0 }); + }; + + try { + await client.start(); + const retainedConnection = (client as any).connection; + await waitUntil("first incompatible websocket", () => helloAttempts === 1); + await Bun.sleep(35); + expect(helloAttempts).toBe(1); + + await waitUntil( + "successor session registration", + async () => { + try { + return ( + ( + await createHttpHunkSessionCliClient({ + timeoutMs: 250, + }).listSessions() + ).length === 1 + ); + } catch { + return false; + } + }, + 5_000, + 50, + ); + expect((client as any).connection).toBe(retainedConnection); + + const firstSuccessor = successor as unknown as Awaited< + ReturnType + >; + firstSuccessor.stop(true); + await firstSuccessor.stopped; + successor = null; + await waitUntil( + "registration after a second daemon generation", + async () => { + try { + return ( + ( + await createHttpHunkSessionCliClient({ + timeoutMs: 250, + }).listSessions() + ).length === 1 + ); + } catch { + return false; + } + }, + 5_000, + 50, + ); + expect((client as any).connection).toBe(retainedConnection); + } finally { + client.stop(); + incumbent.stop(true); + const runningSuccessor = successor as Awaited< + ReturnType + > | null; + runningSuccessor?.stop(true); + if (runningSuccessor) await runningSuccessor.stopped; + rmSync(runtimeDir, { recursive: true, force: true }); + } + }, 10_000); + + test("retries the complete startup cycle and recovers without restarting the client", async () => { + const messages: string[] = []; + console.error = (...args: unknown[]) => { + messages.push(args.map((value) => String(value)).join(" ")); + }; + const client = new SessionBrokerClient(createRegistration(), createSnapshot(), { + reconnectDelayMs: 10, + }); + let attempts = 0; + (client as any).ensureDaemonAndConnect = async () => { + attempts += 1; + if (attempts === 1) throw new Error("incumbent incompatible"); + }; + + try { + await client.start(); + await waitUntil("second complete startup attempt", () => attempts === 2); + expect(messages).toEqual(["[session:broker] incumbent incompatible"]); + } finally { + client.stop(); + } + }); + + test("does no daemon work when start follows terminal stop", async () => { + const client = new SessionBrokerClient(createRegistration(), createSnapshot()); + let attempts = 0; + (client as any).ensureDaemonAndConnect = async () => { + attempts += 1; + }; + + client.stop(); + await client.start(); + expect(attempts).toBe(0); + }); + + test("does not schedule recovery after stop wins a startup race", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let reconnectScheduled = false; + const client = new SessionBrokerClient(createRegistration(), createSnapshot()); + (client as any).ensureDaemonAndConnect = async () => { + await gate; + throw new Error("late startup failure"); + }; + (client as any).scheduleReconnect = () => { + reconnectScheduled = true; + }; + + const startup = client.start(); + client.stop(); + release(); + await startup; + expect(reconnectScheduled).toBe(false); + }); + test("logs one actionable warning when a non-Hunk listener owns the session daemon port", async () => { const conflictingListener = createServer((_request, response) => { response.writeHead(404, { "content-type": "text/plain" }); diff --git a/src/session/broker/brokerClient.ts b/src/session/broker/brokerClient.ts index 53ab54330..3c68f385f 100644 --- a/src/session/broker/brokerClient.ts +++ b/src/session/broker/brokerClient.ts @@ -12,14 +12,16 @@ import { } from "./brokerConfig"; import { ensureSessionBrokerAvailable, - readSessionBrokerHealth, - waitForSessionBrokerShutdown, + isSessionBrokerHealthy, + readSessionBrokerLaunchFingerprint, } from "./brokerLauncher"; import { hunkSessionProtocolParsers } from "./protocolParsers"; import { - readHunkSessionDaemonCapabilities, - reportHunkDaemonUpgradeRestart, -} from "../client/capabilities"; + loadOrCreateHunkSessionBrokerCredentials, + type HunkSessionBrokerCredentials, +} from "./credentials"; +import { HUNK_SESSION_BROKER_APP_ID, HUNK_SESSION_BROKER_APP_REVISION } from "./appContract"; +import { HUNK_DAEMON_UPGRADE_WAIT_MESSAGE } from "../client/capabilities"; import type { HunkSessionCommandResult, HunkSessionInfo, @@ -31,9 +33,10 @@ const DAEMON_STARTUP_TIMEOUT_MS = 3_000; const RECONNECT_DELAY_MS = 3_000; const HEARTBEAT_INTERVAL_MS = 10_000; const INCOMPATIBLE_SESSION_CLOSE_CODE = 1008; -const INCOMPATIBLE_SESSION_CLOSE_REASON_PREFIX = "Incompatible session "; -const INCOMPATIBLE_SESSION_CLOSE_MESSAGE = - "This window is too old for the refreshed session broker daemon. Restart the window to reconnect."; +const QUIESCENT_REFUSAL_REASONS = new Set([ + "Session broker authentication required; upgrade Hunk.", + "Malformed session broker protocol.", +]); type SessionAppBridge = SessionBrokerConnectionBridge< HunkSessionServerMessage, @@ -45,6 +48,19 @@ interface SessionBrokerClientTiming { reconnectDelayMs?: number; } +/** Identify only known compatibility refusals before producer activation. */ +export function isQuiescentUpgradeRefusal(event: { + code: number; + reason: string; + authenticated?: boolean; +}) { + return ( + event.authenticated === false && + event.code === INCOMPATIBLE_SESSION_CLOSE_CODE && + QUIESCENT_REFUSAL_REASONS.has(event.reason) + ); +} + /** The concrete broker client bound to Hunk's session contracts. */ export type HunkSessionBrokerClient = SessionBrokerClient; @@ -62,6 +78,9 @@ export class SessionBrokerClient { private stopped = false; private startupPromise: Promise | null = null; private lastConnectionWarning: string | null = null; + private credentials: HunkSessionBrokerCredentials | null = null; + private waitingForIncumbentExit = false; + private incumbentLaunchFingerprint: string | null = null; constructor( private registration: SessionRegistration, @@ -70,7 +89,7 @@ export class SessionBrokerClient { ) {} start() { - if (process.env.HUNK_MCP_DISABLE === "1") { + if (this.stopped || process.env.HUNK_MCP_DISABLE === "1") { return; } @@ -127,6 +146,7 @@ export class SessionBrokerClient { private async ensureDaemonAndConnect() { const config = this.resolveConfig(); await this.ensureDaemonAvailable(config); + this.credentials ??= await loadOrCreateHunkSessionBrokerCredentials(); this.connect(config); } @@ -136,59 +156,8 @@ export class SessionBrokerClient { timeoutMs: this.timing.daemonStartupTimeoutMs ?? DAEMON_STARTUP_TIMEOUT_MS, }); - const capabilities = await readHunkSessionDaemonCapabilities(config); - if (!capabilities) { - await this.restartIncompatibleDaemon(config); - await ensureSessionBrokerAvailable({ - config, - timeoutMs: this.timing.daemonStartupTimeoutMs ?? DAEMON_STARTUP_TIMEOUT_MS, - }); - - if (!(await readHunkSessionDaemonCapabilities(config))) { - throw new Error( - "The running session broker daemon is incompatible with this build. " + - "Restart the app so it can launch a fresh daemon from the current source tree.", - ); - } - } - - this.lastConnectionWarning = null; - } - - private async restartIncompatibleDaemon(config: ResolvedSessionBrokerConfig) { - reportHunkDaemonUpgradeRestart(); - const health = await readSessionBrokerHealth(config); - const pid = health?.pid; - if (pid === process.pid) { - throw new Error( - "The running session broker daemon is incompatible with this build. " + - "Restart the app so it can launch a fresh daemon from the current source tree.", - ); - } - - // If the stale daemon already disappeared on its own, let the normal startup path launch a - // fresh one instead of turning that race into a manual restart error. - if (!pid) { - return; - } - - try { - process.kill(pid, "SIGTERM"); - } catch (error) { - if (!(error instanceof Error) || !("code" in error) || error.code !== "ESRCH") { - throw error; - } - } - - const shutDown = await waitForSessionBrokerShutdown({ - config, - timeoutMs: DAEMON_STARTUP_TIMEOUT_MS, - }); - if (!shutDown) { - throw new Error( - "Stopped waiting for the old session broker daemon to exit after it was found incompatible.", - ); - } + // Minimal health proves only liveness. Compatibility and identity are established by the + // signed websocket hello; an unverifiable incumbent is never signalled or replaced by PID. } setBridge(bridge: SessionAppBridge | null) { @@ -206,7 +175,8 @@ export class SessionBrokerClient { return; } - this.connection = createSessionBrokerConnection< + if (!this.credentials) return; + const connection = createSessionBrokerConnection< HunkSessionInfo, HunkSessionState, SessionBrokerSocketLike, @@ -219,16 +189,54 @@ export class SessionBrokerClient { snapshot: this.snapshot, bridge: this.bridge, protocolParsers: hunkSessionProtocolParsers, + producerAuthentication: { + appId: HUNK_SESSION_BROKER_APP_ID, + appRevision: HUNK_SESSION_BROKER_APP_REVISION, + credential: this.credentials.producer, + daemon: { + keyId: this.credentials.daemonIdentity.keyId, + publicKey: this.credentials.daemonPublicKey, + }, + }, heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS, reconnectDelayMs: this.timing.reconnectDelayMs ?? RECONNECT_DELAY_MS, - resolveClose: (event) => - this.isIncompatibleSessionClose(event) - ? { reconnect: false, warning: INCOMPATIBLE_SESSION_CLOSE_MESSAGE } - : { reconnect: true }, + prepareReconnect: async () => { + if (this.waitingForIncumbentExit) { + const healthy = await isSessionBrokerHealthy(config); + if (healthy) { + const currentFingerprint = readSessionBrokerLaunchFingerprint(config); + // Owner-private metadata is only a generation-change hint. The signed hello remains the + // sole compatibility and identity authority, and unchanged/malformed metadata causes + // health-only polling so skewed waiters cannot keep the incumbent active. + if (currentFingerprint === this.incumbentLaunchFingerprint) { + throw new Error(HUNK_DAEMON_UPGRADE_WAIT_MESSAGE); + } + } + this.waitingForIncumbentExit = false; + } + await this.ensureDaemonAvailable(config); + }, + resolveClose: (event) => { + const preAuthenticationRefusal = isQuiescentUpgradeRefusal(event); + if (preAuthenticationRefusal) { + this.waitingForIncumbentExit = true; + this.incumbentLaunchFingerprint = readSessionBrokerLaunchFingerprint(config); + } + return { + reconnect: true, + ...(preAuthenticationRefusal ? { warning: HUNK_DAEMON_UPGRADE_WAIT_MESSAGE } : {}), + }; + }, + onConnected: () => { + this.waitingForIncumbentExit = false; + this.incumbentLaunchFingerprint = null; + this.lastConnectionWarning = null; + }, onWarning: (message) => this.warnUnavailable(message), }); - this.connection.start(); + this.connection = connection; + connection.start(); } private scheduleReconnect(delayMs = this.timing.reconnectDelayMs ?? RECONNECT_DELAY_MS) { @@ -243,17 +251,13 @@ export class SessionBrokerClient { this.reconnectTimer.unref?.(); } - /** Return whether the daemon explicitly rejected this session as incompatible after an upgrade. */ - private isIncompatibleSessionClose(event: { code: number; reason: string }) { - return ( - event.code === INCOMPATIBLE_SESSION_CLOSE_CODE && - event.reason.startsWith(INCOMPATIBLE_SESSION_CLOSE_REASON_PREFIX) - ); - } - private warnUnavailable(error: unknown) { const message = - error instanceof Error ? error.message : "Unknown session broker connection error."; + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "Unknown session broker connection error."; if (message === this.lastConnectionWarning) { return; } diff --git a/src/session/broker/brokerConfig.test.ts b/src/session/broker/brokerConfig.test.ts index e782f75f1..7eb925a4b 100644 --- a/src/session/broker/brokerConfig.test.ts +++ b/src/session/broker/brokerConfig.test.ts @@ -1,4 +1,11 @@ import { describe, expect, test } from "bun:test"; +import { HUNK_SESSION_DAEMON_VERSION } from "../protocol"; +import { + HUNK_SESSION_BROKER_APP_ID, + HUNK_SESSION_BROKER_APP_REVISION, + HUNK_SESSION_BROKER_FEATURES, + HUNK_SESSION_BROKER_REVISION, +} from "./appContract"; import { DEFAULT_SESSION_BROKER_HOST, DEFAULT_SESSION_BROKER_PORT, @@ -11,6 +18,14 @@ import { } from "./brokerConfig"; describe("Hunk session daemon config", () => { + test("exports one fixed Phase-1 Hunk contract", () => { + expect(HUNK_SESSION_BROKER_APP_ID).toBe("dev.hunk"); + expect(HUNK_SESSION_BROKER_REVISION).toBe(1); + expect(HUNK_SESSION_BROKER_APP_REVISION).toBe(HUNK_SESSION_DAEMON_VERSION); + expect(HUNK_SESSION_BROKER_FEATURES).toEqual([]); + expect(Object.isFrozen(HUNK_SESSION_BROKER_FEATURES)).toBe(true); + }); + test("resolves exported host and port metadata as runtime defaults", () => { expect(resolveSessionBrokerConfig({})).toMatchObject({ host: DEFAULT_SESSION_BROKER_HOST, diff --git a/src/session/broker/brokerLauncher.test.ts b/src/session/broker/brokerLauncher.test.ts index e3b9d6097..c544d2689 100644 --- a/src/session/broker/brokerLauncher.test.ts +++ b/src/session/broker/brokerLauncher.test.ts @@ -7,6 +7,7 @@ import { ensureSessionBrokerAvailable, isLoopbackPortReachable, parseSessionBrokerHealth, + readSessionBrokerLaunchFingerprint, resolveDaemonLaunchCommand, resolveSessionBrokerRuntimePaths, } from "./brokerLauncher"; @@ -35,6 +36,39 @@ afterEach(() => { }); describe("session daemon launcher", () => { + test("reads only bounded exact launch metadata as a generation hint", () => { + const runtime = createRuntimeDir(); + const env = { ...process.env, XDG_RUNTIME_DIR: runtime }; + const paths = resolveSessionBrokerRuntimePaths(testConfig, env); + mkdirSync(paths.runtimeDir, { recursive: true }); + const metadata = { + pid: 123, + host: testConfig.host, + port: testConfig.port, + command: "/fixture/hunk", + args: ["daemon", "serve"], + launchedAt: "2026-01-01T00:00:00.000Z", + launchedByPid: 122, + launchCwd: "/fixture", + }; + writeFileSync(paths.metadataPath, JSON.stringify(metadata)); + const first = readSessionBrokerLaunchFingerprint(testConfig, env); + expect(first).toBe(JSON.stringify(metadata)); + writeFileSync(paths.metadataPath, JSON.stringify({ ...metadata, pid: 124 })); + expect(readSessionBrokerLaunchFingerprint(testConfig, env)).not.toBe(first); + for (const malformed of [[], { ...metadata, extra: true }, { ...metadata, args: {} }]) { + writeFileSync(paths.metadataPath, JSON.stringify(malformed)); + expect(readSessionBrokerLaunchFingerprint(testConfig, env)).toBeNull(); + } + writeFileSync( + paths.metadataPath, + JSON.stringify(metadata).replace('{"pid"', '{"__proto__":true,"pid"'), + ); + expect(readSessionBrokerLaunchFingerprint(testConfig, env)).toBeNull(); + writeFileSync(paths.metadataPath, "x".repeat(16 * 1024 + 1)); + expect(readSessionBrokerLaunchFingerprint(testConfig, env)).toBeNull(); + }); + test("strictly parses minimal and legacy health responses", () => { expect(parseSessionBrokerHealth({ ok: true })).toEqual({ ok: true }); expect( diff --git a/src/session/broker/brokerLauncher.ts b/src/session/broker/brokerLauncher.ts index 3976484df..5149ea228 100644 --- a/src/session/broker/brokerLauncher.ts +++ b/src/session/broker/brokerLauncher.ts @@ -2,7 +2,7 @@ import { spawn } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { connect } from "node:net"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { parseBrokerSafeInteger, @@ -15,6 +15,7 @@ const SCRIPT_ENTRYPOINT_PATTERN = /[\\/]|\.(?:[cm]?js|tsx?)$/; const DEFAULT_DAEMON_LOCK_STALE_MS = 15_000; const DEFAULT_DAEMON_STARTUP_TIMEOUT_MS = 3_000; const DEFAULT_DAEMON_HEALTH_POLL_INTERVAL_MS = 100; +const MAX_DAEMON_LAUNCH_METADATA_BYTES = 16 * 1024; export interface DaemonLaunchCommand { command: string; @@ -93,7 +94,11 @@ function safeRuntimeToken(value: string) { } function resolveRuntimeBaseDir(env: NodeJS.ProcessEnv = process.env) { - return env.XDG_RUNTIME_DIR?.trim() || tmpdir(); + const configured = env.XDG_RUNTIME_DIR?.trim(); + if (configured) return configured; + // Unix temporary directories are commonly shared across users. Keep the fallback beneath the + // current home directory instead of a predictable shared-/tmp name another account can pre-own. + return typeof process.getuid === "function" ? join(homedir(), ".hunk") : tmpdir(); } function isRunningPid(pid: number) { @@ -117,6 +122,41 @@ function readJsonFile(path: string) { } } +/** Parse exact launch metadata used only as a change-detection hint across daemon generations. */ +function parseSessionBrokerLaunchMetadata(value: unknown): SessionBrokerLaunchMetadata | null { + try { + const record = parseExactBrokerRecord(value, [ + "pid", + "host", + "port", + "command", + "args", + "launchedAt", + "launchedByPid", + "launchCwd", + ] as const); + if (!Array.isArray(record.args)) return null; + const args = record.args.map((argument) => parseBrokerString(argument)); + return { + pid: parseBrokerSafeInteger(record.pid, { minimum: 1 }), + host: parseBrokerString(record.host), + port: parseBrokerSafeInteger(record.port, { + minimum: 1, + maximum: 65_535, + }), + command: parseBrokerString(record.command), + args, + launchedAt: parseBrokerString(record.launchedAt), + launchedByPid: parseBrokerSafeInteger(record.launchedByPid, { + minimum: 1, + }), + launchCwd: parseBrokerString(record.launchCwd), + }; + } catch { + return null; + } +} + function removeFileIfPresent(path: string) { try { rmSync(path, { force: true }); @@ -146,7 +186,7 @@ function tryAcquireDaemonLaunchLock({ staleAfterMs: number; }): SessionBrokerLaunchLock | null { const paths = resolveSessionBrokerRuntimePaths(config, env); - mkdirSync(paths.runtimeDir, { recursive: true }); + mkdirSync(paths.runtimeDir, { recursive: true, mode: 0o700 }); const payload: SessionBrokerLaunchLockFile = { ownerPid: process.pid, @@ -376,6 +416,27 @@ export function parseSessionBrokerHealth(value: unknown): SessionBrokerHealth | } } +/** Read a bounded exact metadata fingerprint as a reconnect hint, never process authority. */ +export function readSessionBrokerLaunchFingerprint( + config: Pick = resolveSessionBrokerConfig(), + env: NodeJS.ProcessEnv = process.env, +) { + const { metadataPath } = resolveSessionBrokerRuntimePaths(config, env); + try { + const stat = statSync(metadataPath); + if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_DAEMON_LAUNCH_METADATA_BYTES) + return null; + const bytes = readFileSync(metadataPath); + if (bytes.byteLength !== stat.size || bytes.byteLength > MAX_DAEMON_LAUNCH_METADATA_BYTES) { + return null; + } + const metadata = parseSessionBrokerLaunchMetadata(JSON.parse(bytes.toString("utf8"))); + return metadata ? JSON.stringify(metadata) : null; + } catch { + return null; + } +} + /** Read the daemon's health payload when one is reachable on the configured loopback port. */ export async function readSessionBrokerHealth( config: ResolvedSessionBrokerConfig = resolveSessionBrokerConfig(), @@ -439,29 +500,6 @@ export function isLoopbackPortReachable( }); } -/** Wait for the running daemon to stop responding on its health endpoint. */ -export async function waitForSessionBrokerShutdown({ - config = resolveSessionBrokerConfig(), - timeoutMs = 3_000, - intervalMs = 100, -}: { - config?: ResolvedSessionBrokerConfig; - timeoutMs?: number; - intervalMs?: number; -} = {}) { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - if (!(await isSessionBrokerHealthy(config))) { - return true; - } - - await Bun.sleep(intervalMs); - } - - return false; -} - /** Launch the broker daemon in the background without tying it to the current TTY session. */ export function launchSessionBrokerDaemon({ cwd = process.cwd(), diff --git a/src/session/broker/brokerServer.helpers.test.ts b/src/session/broker/brokerServer.helpers.test.ts index d43f3f70e..9a5d3f7f1 100644 --- a/src/session/broker/brokerServer.helpers.test.ts +++ b/src/session/broker/brokerServer.helpers.test.ts @@ -78,8 +78,8 @@ describe("parseHostAndPort", () => { expect(parseHostAndPort("[::1]:0")).toBeNull(); }); - test("tolerates an unbracketed IPv6 literal by dropping the port", () => { - expect(parseHostAndPort("::1")).toEqual({ host: "::1", port: undefined }); + test("rejects ambiguous unbracketed IPv6 authorities", () => { + expect(parseHostAndPort("::1")).toBeNull(); }); }); diff --git a/src/session/broker/brokerServer.test.ts b/src/session/broker/brokerServer.test.ts index 7bdd791af..b042ec437 100644 --- a/src/session/broker/brokerServer.test.ts +++ b/src/session/broker/brokerServer.test.ts @@ -7,8 +7,19 @@ import { createTestSessionSnapshot, } from "../../../test/helpers/session-daemon-fixtures"; import { SessionBrokerState } from "@hunk/session-broker-core"; +import { + SessionBrokerCallerClient, + answerSessionBrokerHelloChallenge, + createSessionBrokerHelloRequest, + verifyProducerHelloAck, + type SessionBrokerHelloChallenge, + type SessionBrokerProducerHelloAck, + type SessionBrokerSignedRequestInit, +} from "@hunk/session-broker"; import { HUNK_SESSION_API_VERSION, HUNK_SESSION_DAEMON_VERSION } from "../protocol"; import { serveSessionBrokerDaemon } from "./brokerServer"; +import { loadOrCreateHunkSessionBrokerCredentials } from "./credentials"; +import { HUNK_SESSION_BROKER_APP_ID, HUNK_SESSION_BROKER_APP_REVISION } from "./appContract"; const originalHost = process.env.HUNK_MCP_HOST; const originalPort = process.env.HUNK_MCP_PORT; @@ -16,9 +27,9 @@ const originalUnsafeRemote = process.env.HUNK_MCP_UNSAFE_ALLOW_REMOTE; interface HealthResponse { ok: boolean; - pid: number; - sessions: number; - pendingCommands: number; + pid?: number; + sessions?: number; + pendingCommands?: number; paths?: Record; sessionApi?: string; sessionCapabilities?: string; @@ -85,10 +96,41 @@ async function waitForShutdown(port: number, timeoutMs = 1_500) { ); } +async function authenticatedFetch( + port: number, + path: string, + init: SessionBrokerSignedRequestInit = {}, +) { + const credentials = await loadOrCreateHunkSessionBrokerCredentials(); + const caller = new SessionBrokerCallerClient({ + appId: HUNK_SESSION_BROKER_APP_ID, + appRevision: HUNK_SESSION_BROKER_APP_REVISION, + origin: `http://127.0.0.1:${port}`, + credential: credentials.caller, + daemon: { keyId: credentials.daemonIdentity.keyId, publicKey: credentials.daemonPublicKey }, + }); + const action = + typeof init.body === "string" + ? ((JSON.parse(init.body) as { action?: string }).action ?? "") + : ""; + return caller.request(path, init, { + targetSpecific: path === "/session-api" && action !== "list", + }); +} + async function waitForSessionCount(port: number, count: number) { await waitUntil("session registration", async () => { - const health = await readHealth(port); - return health?.sessions === count ? health : null; + try { + const response = await authenticatedFetch(port, "/session-api", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "list" }), + }); + const body = (await response.json()) as { sessions?: unknown[] }; + return body.sessions?.length === count ? body : null; + } catch { + return null; + } }); } @@ -169,6 +211,49 @@ async function openRegisteredSession( snapshotOverrides: Parameters[0] = {}, ) { const socket = await openSessionSocket(port); + const credentials = await loadOrCreateHunkSessionBrokerCredentials(); + const options = { + appId: HUNK_SESSION_BROKER_APP_ID, + appRevision: HUNK_SESSION_BROKER_APP_REVISION, + endpoint: `ws://127.0.0.1:${port}/session`, + credential: credentials.producer, + daemon: { keyId: credentials.daemonIdentity.keyId, publicKey: credentials.daemonPublicKey }, + }; + const hello = createSessionBrokerHelloRequest(options); + socket.send(JSON.stringify({ type: "hello-init", hello })); + const challenge = await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Timed out waiting for producer challenge.")), + 1_000, + ); + socket.addEventListener( + "message", + (event) => { + clearTimeout(timeout); + resolve( + (JSON.parse(String(event.data)) as { challenge: SessionBrokerHelloChallenge }).challenge, + ); + }, + { once: true }, + ); + }); + const pending = await answerSessionBrokerHelloChallenge(options, hello, challenge); + socket.send(JSON.stringify({ type: "hello-proof", proof: pending.proof })); + const ack = await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Timed out waiting for producer acknowledgement.")), + 1_000, + ); + socket.addEventListener( + "message", + (event) => { + clearTimeout(timeout); + resolve((JSON.parse(String(event.data)) as { ack: SessionBrokerProducerHelloAck }).ack); + }, + { once: true }, + ); + }); + await verifyProducerHelloAck(pending, ack); socket.send( JSON.stringify({ @@ -228,12 +313,12 @@ afterEach(() => { }); describe("Hunk session daemon server", () => { - test("refuses non-loopback binding unless explicitly allowed", () => { + test("refuses non-loopback binding unless explicitly allowed", async () => { process.env.HUNK_MCP_HOST = "0.0.0.0"; process.env.HUNK_MCP_PORT = "47657"; delete process.env.HUNK_MCP_UNSAFE_ALLOW_REMOTE; - expect(() => serveSessionBrokerDaemon()).toThrow("local-only by default"); + await expect(serveSessionBrokerDaemon()).rejects.toThrow("local-only by default"); }); test("reports a clear error when the daemon port is already in use", async () => { @@ -249,7 +334,7 @@ describe("Hunk session daemon server", () => { process.env.HUNK_MCP_PORT = String(port); try { - expect(() => serveSessionBrokerDaemon()).toThrow("port is already in use"); + await expect(serveSessionBrokerDaemon()).rejects.toThrow("port is already in use"); } finally { await new Promise((resolve) => listener.close(() => resolve())); } @@ -260,21 +345,13 @@ describe("Hunk session daemon server", () => { process.env.HUNK_MCP_HOST = "127.0.0.1"; process.env.HUNK_MCP_PORT = String(port); - const server = serveSessionBrokerDaemon(); + const server = await serveSessionBrokerDaemon(); try { const health = await fetch(`http://127.0.0.1:${port}/health`); expect(health.status).toBe(200); const healthPayload = (await health.json()) as HealthResponse; - expect(healthPayload.paths).toEqual({ - health: "/health", - socket: "/session", - }); - expect(healthPayload).toMatchObject({ - sessionApi: `http://127.0.0.1:${port}/session-api`, - sessionCapabilities: `http://127.0.0.1:${port}/session-api/capabilities`, - sessionSocket: `ws://127.0.0.1:${port}/session`, - }); + expect(healthPayload).toEqual({ ok: true }); const genericCapabilities = await fetch(`http://127.0.0.1:${port}/broker/capabilities`); expect(genericCapabilities.status).toBe(404); @@ -288,7 +365,7 @@ describe("Hunk session daemon server", () => { }); expect(genericBroker.status).toBe(404); - const capabilities = await fetch(`http://127.0.0.1:${port}/session-api/capabilities`); + const capabilities = await authenticatedFetch(port, "/session-api/capabilities"); expect(capabilities.status).toBe(200); await expect(capabilities.json()).resolves.toMatchObject({ version: HUNK_SESSION_API_VERSION, @@ -326,12 +403,41 @@ describe("Hunk session daemon server", () => { } }); + test("keeps generic caller and browser-review authority independent", async () => { + const port = await reserveLoopbackPort(); + process.env.HUNK_MCP_HOST = "127.0.0.1"; + process.env.HUNK_MCP_PORT = String(port); + const server = await serveSessionBrokerDaemon(); + try { + await expect(authenticatedFetch(port, "/review-api/missing/publication")).rejects.toThrow( + "daemon identity could not be verified", + ); + const genericHeadersWithoutReviewCapability = await fetch( + `http://127.0.0.1:${port}/review-api/missing/publication`, + { headers: { "x-session-broker-caller-session": "generic-only" } }, + ); + expect(genericHeadersWithoutReviewCapability.status).toBe(401); + + const reviewCapabilityOnSession = await fetch(`http://127.0.0.1:${port}/session-api`, { + method: "POST", + headers: { + "content-type": "application/json", + "hunk-review-capability": "review-only-capability", + }, + body: JSON.stringify({ action: "list" }), + }); + expect(reviewCapabilityOnSession.status).toBe(401); + } finally { + server.stop(true); + } + }); + test("rejects HTTP requests with non-loopback or wrong-port Host headers", async () => { const port = await reserveLoopbackPort(); process.env.HUNK_MCP_HOST = "127.0.0.1"; process.env.HUNK_MCP_PORT = String(port); - const server = serveSessionBrokerDaemon(); + const server = await serveSessionBrokerDaemon(); try { const attackerHostResponse = await fetch(`http://127.0.0.1:${port}/health`, { @@ -361,7 +467,7 @@ describe("Hunk session daemon server", () => { process.env.HUNK_MCP_HOST = "127.0.0.1"; process.env.HUNK_MCP_PORT = String(port); - const server = serveSessionBrokerDaemon(); + const server = await serveSessionBrokerDaemon(); try { const response = await fetch(`http://127.0.0.1:${port}/session-api/capabilities`, { @@ -379,15 +485,35 @@ describe("Hunk session daemon server", () => { } }); + test("requires GET with an empty body for authenticated Hunk capabilities", async () => { + const port = await reserveLoopbackPort(); + process.env.HUNK_MCP_HOST = "127.0.0.1"; + process.env.HUNK_MCP_PORT = String(port); + const server = await serveSessionBrokerDaemon(); + try { + const wrongMethod = await authenticatedFetch(port, "/session-api/capabilities", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + expect(wrongMethod.status).toBe(405); + await expect(wrongMethod.json()).resolves.toEqual({ + error: "Capabilities require GET with an empty body.", + }); + } finally { + server.stop(true); + } + }); + test("requires JSON content type for session API posts", async () => { const port = await reserveLoopbackPort(); process.env.HUNK_MCP_HOST = "127.0.0.1"; process.env.HUNK_MCP_PORT = String(port); - const server = serveSessionBrokerDaemon(); + const server = await serveSessionBrokerDaemon(); try { - const response = await fetch(`http://127.0.0.1:${port}/session-api`, { + const response = await authenticatedFetch(port, "/session-api", { method: "POST", headers: { "content-type": "text/plain" }, body: JSON.stringify({ action: "list" }), @@ -407,18 +533,22 @@ describe("Hunk session daemon server", () => { process.env.HUNK_MCP_HOST = "127.0.0.1"; process.env.HUNK_MCP_PORT = String(port); - const server = serveSessionBrokerDaemon(); + const server = await serveSessionBrokerDaemon(); try { const response = await fetch(`http://127.0.0.1:${port}/session-api`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + "x-session-broker-caller-session": "oversized-test-session", + }, body: JSON.stringify({ action: "list", filler: "x".repeat(5 * 1024 * 1024) }), }); expect(response.status).toBe(413); await expect(response.json()).resolves.toMatchObject({ - error: expect.stringContaining("session broker limit"), + error: "capacity-exceeded", + resource: "maxHttpBodyBytes", }); } finally { server.stop(true); @@ -436,7 +566,7 @@ describe("Hunk session daemon server", () => { process.env.HUNK_MCP_HOST = "127.0.0.1"; process.env.HUNK_MCP_PORT = String(port); - const server = serveSessionBrokerDaemon({ + const server = await serveSessionBrokerDaemon({ idleTimeoutMs: 250, staleSessionTtlMs: 500, staleSessionSweepIntervalMs: 25, @@ -455,7 +585,7 @@ describe("Hunk session daemon server", () => { await expect(closed).resolves.toEqual({ code: 1008, - reason: "Session ownership rejected.", + reason: "Session broker authentication required; upgrade Hunk.", }); } finally { socket.close(); @@ -468,7 +598,7 @@ describe("Hunk session daemon server", () => { process.env.HUNK_MCP_HOST = "127.0.0.1"; process.env.HUNK_MCP_PORT = String(port); - const server = serveSessionBrokerDaemon({ + const server = await serveSessionBrokerDaemon({ idleTimeoutMs: 250, staleSessionTtlMs: 500, staleSessionSweepIntervalMs: 25, @@ -498,7 +628,7 @@ describe("Hunk session daemon server", () => { 1_000, ); - const emptyList = await fetch(`http://127.0.0.1:${port}/session-api`, { + const emptyList = await authenticatedFetch(port, "/session-api", { method: "POST", headers: { "content-type": "application/json", @@ -510,7 +640,7 @@ describe("Hunk session daemon server", () => { const goodSocket = await openRegisteredSession(port, "session-good"); try { - const response = await fetch(`http://127.0.0.1:${port}/session-api`, { + const response = await authenticatedFetch(port, "/session-api", { method: "POST", headers: { "content-type": "application/json", @@ -536,7 +666,7 @@ describe("Hunk session daemon server", () => { process.env.HUNK_MCP_HOST = "127.0.0.1"; process.env.HUNK_MCP_PORT = String(port); - const server = serveSessionBrokerDaemon({ + const server = await serveSessionBrokerDaemon({ idleTimeoutMs: 60, staleSessionTtlMs: 500, staleSessionSweepIntervalMs: 25, @@ -545,10 +675,7 @@ describe("Hunk session daemon server", () => { try { await Bun.sleep(150); - await expect(waitForHealth(port)).resolves.toMatchObject({ - ok: true, - sessions: 1, - }); + await expect(waitForHealth(port)).resolves.toEqual({ ok: true }); } finally { socket.close(); server.stop(true); @@ -560,7 +687,7 @@ describe("Hunk session daemon server", () => { process.env.HUNK_MCP_HOST = "127.0.0.1"; process.env.HUNK_MCP_PORT = String(port); - const server = serveSessionBrokerDaemon({ + const server = await serveSessionBrokerDaemon({ idleTimeoutMs: 75, staleSessionTtlMs: 500, staleSessionSweepIntervalMs: 25, @@ -582,7 +709,7 @@ describe("Hunk session daemon server", () => { process.env.HUNK_MCP_HOST = "127.0.0.1"; process.env.HUNK_MCP_PORT = String(port); - const server = serveSessionBrokerDaemon({ + const server = await serveSessionBrokerDaemon({ idleTimeoutMs: 75, staleSessionTtlMs: 80, staleSessionSweepIntervalMs: 20, @@ -658,10 +785,10 @@ describe("Hunk session daemon server", () => { }; }; - const server = serveSessionBrokerDaemon(); + const server = await serveSessionBrokerDaemon(); try { - const response = await fetch(`http://127.0.0.1:${port}/session-api`, { + const response = await authenticatedFetch(port, "/session-api", { method: "POST", headers: { "content-type": "application/json", @@ -700,7 +827,7 @@ describe("Hunk session daemon server", () => { SessionBrokerState.prototype.dispatchCommand = (({ command, input }: any) => { expect(command).toBe("reload_session"); expect(input).toMatchObject({ - sessionPath: "/tmp/live-session", + sessionId: "session-1", sourcePath: "/tmp/source-repo", nextInput: { kind: "vcs", @@ -719,17 +846,17 @@ describe("Hunk session daemon server", () => { }); }) as SessionBrokerState["dispatchCommand"]; - const server = serveSessionBrokerDaemon(); + const server = await serveSessionBrokerDaemon(); try { - const response = await fetch(`http://127.0.0.1:${port}/session-api`, { + const response = await authenticatedFetch(port, "/session-api", { method: "POST", headers: { "content-type": "application/json", }, body: JSON.stringify({ action: "reload", - selector: { sessionPath: "/tmp/live-session" }, + selector: { sessionId: "session-1" }, sourcePath: "/tmp/source-repo", nextInput: { kind: "vcs", @@ -758,7 +885,7 @@ describe("Hunk session daemon server", () => { process.env.HUNK_MCP_HOST = "127.0.0.1"; process.env.HUNK_MCP_PORT = String(port); - const server = serveSessionBrokerDaemon(); + const server = await serveSessionBrokerDaemon(); const socket = await openRegisteredSession(port, "session-1", { reviewNoteCount: 2, reviewNotes: [ @@ -783,7 +910,7 @@ describe("Hunk session daemon server", () => { }); try { - const listResponse = await fetch(`http://127.0.0.1:${port}/session-api`, { + const listResponse = await authenticatedFetch(port, "/session-api", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ @@ -853,10 +980,10 @@ describe("Hunk session daemon server", () => { }); }) as SessionBrokerState["dispatchCommand"]; - const server = serveSessionBrokerDaemon(); + const server = await serveSessionBrokerDaemon(); try { - const response = await fetch(`http://127.0.0.1:${port}/session-api`, { + const response = await authenticatedFetch(port, "/session-api", { method: "POST", headers: { "content-type": "application/json", diff --git a/src/session/broker/brokerServer.ts b/src/session/broker/brokerServer.ts index 5be7a5ac8..2fa609193 100644 --- a/src/session/broker/brokerServer.ts +++ b/src/session/broker/brokerServer.ts @@ -1,4 +1,9 @@ -import { createSessionBrokerDaemon, type SessionBrokerController } from "@hunk/session-broker"; +import { + SessionBrokerAuthenticator, + createSessionBrokerDaemon, + type SessionBrokerAuthenticatedControlFacts, + type SessionBrokerController, +} from "@hunk/session-broker"; import { serveSessionBrokerDaemon as serveSessionBrokerDaemonWithBun, type RunningSessionBrokerDaemon as RunningBunSessionBrokerDaemon, @@ -45,6 +50,8 @@ import { import { MAX_HUNK_REVIEW_ENVELOPE_BYTES } from "../reviewProtocol"; import { parseSessionDaemonRequest } from "../protocolSchemas"; import { hunkSessionProtocolParsers } from "./protocolParsers"; +import { loadOrCreateHunkSessionBrokerCredentials } from "./credentials"; +import { HUNK_SESSION_BROKER_APP_ID, HUNK_SESSION_BROKER_APP_REVISION } from "./appContract"; const DEFAULT_STALE_SESSION_TTL_MS = 45_000; const DEFAULT_STALE_SESSION_SWEEP_INTERVAL_MS = 15_000; @@ -119,7 +126,7 @@ function hasJsonContentType(request: Request) { /** Parse a Host-style value into hostname and optional port pieces. */ export function parseHostAndPort(value: string) { const trimmed = value.trim(); - if (!trimmed) { + if (!trimmed || trimmed.includes(",")) { return null; } @@ -139,8 +146,10 @@ export function parseHostAndPort(value: string) { return null; } - const port = Number.parseInt(rest.slice(1), 10); - return Number.isInteger(port) && port > 0 ? { host, port } : null; + const rawPort = rest.slice(1); + if (!/^[0-9]+$/.test(rawPort)) return null; + const port = Number(rawPort); + return Number.isInteger(port) && port > 0 && port <= 65_535 ? { host, port } : null; } const colonCount = [...trimmed].filter((character) => character === ":").length; @@ -150,13 +159,14 @@ export function parseHostAndPort(value: string) { if (colonCount === 1) { const [host, rawPort] = trimmed.split(":"); - const port = Number.parseInt(rawPort ?? "", 10); - return host && Number.isInteger(port) && port > 0 ? { host, port } : null; + if (!host || !/^[0-9]+$/.test(rawPort ?? "")) return null; + const port = Number(rawPort); + return Number.isInteger(port) && port > 0 && port <= 65_535 ? { host, port } : null; } - // Unbracketed IPv6 literals are invalid in Host headers, but accepting the address without a - // port keeps validation strict enough for DNS-rebinding while tolerating unusual native clients. - return { host: trimmed, port: undefined }; + // URL authorities require brackets around IPv6 literals; accepting another spelling would make + // listener-derived authority comparison ambiguous. + return null; } /** Return whether a parsed authority targets an accepted broker host and port. */ @@ -192,6 +202,9 @@ export function validateOriginHeader(request: Request, expectedPort: number, all if (!origin) { return null; } + if (origin === "null" || origin.includes(",")) { + return jsonError("Origin is not allowed for the local session broker.", 403); + } let url: URL; try { @@ -200,7 +213,15 @@ export function validateOriginHeader(request: Request, expectedPort: number, all return jsonError("Origin is not allowed for the local session broker.", 403); } - if (url.protocol !== "http:" && url.protocol !== "https:") { + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash || + url.origin !== origin + ) { return jsonError("Origin is not allowed for the local session broker.", 403); } @@ -281,10 +302,43 @@ function resolveNavigateCommandInput( }; } +/** Map each Hunk action to the generic operation and exact producer command scope it requires. */ +function sessionApiAuthorizationFacts( + state: HunkSessionBrokerState, + bytes: Uint8Array, +): SessionBrokerAuthenticatedControlFacts { + const input = parseJsonRequestBytes(bytes); + if (input.action === "list") return { operation: "list", targetSpecific: false }; + const sessionId = input.selector.sessionId ?? state.getSession(input.selector).sessionId; + if (["get", "context", "review", "comment-list"].includes(input.action)) { + return { operation: "get", sessionId, targetSpecific: true }; + } + const commandByAction = { + navigate: "navigate_to_hunk", + reload: "reload_session", + "comment-add": "comment", + "comment-apply": "comment_batch", + "comment-rm": "remove_comment", + "comment-clear": "clear_comments", + "highlight-add": "highlight", + "highlight-clear": "clear_highlights", + } as const; + const command = commandByAction[input.action as keyof typeof commandByAction]; + if (!command) throw new Error("Unknown session API action."); + return { + operation: "dispatch", + sessionId, + command, + commandVersion: 1, + targetSpecific: true, + }; +} + export async function handleSessionApiRequest( state: HunkSessionBrokerState, request: Request, bodyBytes?: Uint8Array, + resolvedSessionId?: string, ) { if (request.method !== "POST") { return jsonError("Session API requests must use POST.", 405); @@ -295,9 +349,13 @@ export async function handleSessionApiRequest( } try { - const input = parseJsonRequestBytes( + const parsedInput = parseJsonRequestBytes( bodyBytes ?? (await readRequestBytesWithLimit(request, MAX_HTTP_BODY_BYTES)), ); + const input: SessionDaemonRequest = + resolvedSessionId && parsedInput.action !== "list" + ? { ...parsedInput, selector: { sessionId: resolvedSessionId } } + : parsedInput; let response: SessionDaemonResponse; switch (input.action) { @@ -495,10 +553,12 @@ function createHunkBrokerController( limits: state.limits, listSessions: () => state.listSessions(), getSession: (selector) => state.getSession(selector), + resolveSessionId: (selector) => state.getSession(selector).sessionId, + getSessionIds: () => state.listSessions().map((session) => session.sessionId), getSessionCount: () => state.getSessionCount(), getPendingCommandCount: () => state.getPendingCommandCount(), - registerSession: (connection, registrationInput, snapshotInput) => - state.registerSession(connection, registrationInput, snapshotInput), + registerSession: (connection, registrationInput, snapshotInput, options) => + state.registerSession(connection, registrationInput, snapshotInput, options), updateSnapshot: (connection, sessionId, snapshotInput) => state.updateSnapshot(connection, sessionId, snapshotInput), markSessionSeen: (connection, sessionId) => state.markSessionSeen(connection, sessionId), @@ -514,9 +574,9 @@ function createHunkBrokerController( } /** Serve the local session broker daemon and websocket broker transport. */ -export function serveSessionBrokerDaemon( +export async function serveSessionBrokerDaemon( options: ServeSessionBrokerDaemonOptions = {}, -): RunningSessionBrokerDaemon { +): Promise { const config = resolveSessionBrokerConfig(); const allowRemote = allowsUnsafeRemoteSessionBroker(); const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; @@ -524,6 +584,18 @@ export function serveSessionBrokerDaemon( const staleSessionSweepIntervalMs = options.staleSessionSweepIntervalMs ?? DEFAULT_STALE_SESSION_SWEEP_INTERVAL_MS; const state = createHunkSessionBrokerState(); + const credentials = await loadOrCreateHunkSessionBrokerCredentials(); + const generation = `h_${crypto.randomUUID().replaceAll("-", "")}_0`; + const authenticator = new SessionBrokerAuthenticator({ + appId: HUNK_SESSION_BROKER_APP_ID, + appRevision: HUNK_SESSION_BROKER_APP_REVISION, + generation, + daemonIdentity: credentials.daemonIdentity, + credentials: [credentials.producer, credentials.caller], + // A CLI process normally performs capabilities plus one action, then exits. Retire its caller + // session quickly so repeated short-lived commands cannot fill the generic retained-session cap. + callerSessionTtlMs: 30_000, + }); const daemon = createSessionBrokerDaemon({ broker: createHunkBrokerController(state), capabilities: { @@ -534,6 +606,15 @@ export function serveSessionBrokerDaemon( idleTimeoutMs, staleSessionTtlMs, staleSessionSweepIntervalMs, + appId: HUNK_SESSION_BROKER_APP_ID, + appRevision: HUNK_SESSION_BROKER_APP_REVISION, + callerAuthenticator: authenticator, + helloAuthenticator: authenticator, + producerEndpoint: `${config.wsOrigin}${SESSION_BROKER_SOCKET_PATH}`, + authorizer: () => true, + // Hunk currently keeps audit decisions in-process; the generic hook guarantees only redacted + // principal/operation metadata can be wired to a future diagnostic sink. + audit: () => undefined, paths: { socket: SESSION_BROKER_SOCKET_PATH, }, @@ -566,29 +647,45 @@ export function serveSessionBrokerDaemon( const url = new URL(request.url); - if (url.pathname === "/health") { - // Extend the generic health payload with the Hunk-specific companion endpoints that older - // CLI clients and debugging workflows still expect to discover from one place. - return Response.json({ - ...daemon.getHealth(), - sessionApi: `${config.httpOrigin}${HUNK_SESSION_API_PATH}`, - sessionCapabilities: `${config.httpOrigin}${HUNK_SESSION_CAPABILITIES_PATH}`, - sessionSocket: `${config.wsOrigin}${SESSION_BROKER_SOCKET_PATH}`, - }); + if ( + (url.pathname === HUNK_SESSION_CAPABILITIES_PATH || + url.pathname === HUNK_SESSION_API_PATH) && + !request.headers.has("x-session-broker-caller-session") + ) { + return Response.json( + { + error: "authentication-required", + message: + "This Hunk session client must be upgraded to use automatic signed authentication.", + }, + { status: 401 }, + ); } if (url.pathname === HUNK_SESSION_CAPABILITIES_PATH) { - return Response.json(sessionCapabilities()); + return daemon.handleAuthenticatedControl(request, { + resolve: () => ({ operation: "diagnostics", targetSpecific: false }), + handle: (body) => + request.method === "GET" && body.byteLength === 0 + ? { body: sessionCapabilities() as never } + : { + body: { error: "Capabilities require GET with an empty body." }, + status: request.method === "GET" ? 400 : 405, + }, + }); } - // Keep the richer Hunk session API here rather than in the shared package so commands like - // review, reload, and comment flows stay app-specific. + // Keep Hunk action parsing and lowering app-owned while the generic hook authenticates, + // authorizes, budgets, and signs the exact transport body and response. if (url.pathname === HUNK_SESSION_API_PATH) { - return daemon.handleBoundedControl( - request, - (body) => handleSessionApiRequest(state, request, body), - { payloadTooLarge: (error) => jsonError(error.message, 413) }, - ); + return daemon.handleAuthenticatedControl(request, { + resolve: (body) => sessionApiAuthorizationFacts(state, body), + resolveFailureTargetSpecific: (body) => parseJsonRequestBytes(body).action !== "list", + handle: async (body, facts) => { + const response = await handleSessionApiRequest(state, request, body, facts.sessionId); + return { body: (await response.json()) as never, status: response.status }; + }, + }); } // The review surface authorizes every one of its own routes with a per-session diff --git a/src/session/broker/credentials.test.ts b/src/session/broker/credentials.test.ts new file mode 100644 index 000000000..62b48cfb9 --- /dev/null +++ b/src/session/broker/credentials.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + chmodSync, + lstatSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadOrCreateHunkSessionBrokerCredentials } from "./credentials"; + +const roots: string[] = []; + +function isolatedEnv() { + const root = mkdtempSync(join(tmpdir(), "hunk-credentials-test-")); + roots.push(root); + return { ...process.env, XDG_RUNTIME_DIR: root }; +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("Hunk session broker credential store", () => { + test("creates stable independent Ed25519 material with owner-private Unix permissions", async () => { + const env = isolatedEnv(); + const first = await loadOrCreateHunkSessionBrokerCredentials({ env }); + const second = await loadOrCreateHunkSessionBrokerCredentials({ env }); + + expect(second.daemonIdentity.keyId).toBe(first.daemonIdentity.keyId); + expect(second.producer.grant.keyId).toBe(first.producer.grant.keyId); + expect(second.caller.grant.keyId).toBe(first.caller.grant.keyId); + expect(first.producer.grant.keyId).not.toBe(first.caller.grant.keyId); + + const securityDir = join(env.XDG_RUNTIME_DIR!, "hunk-mcp", "security-v1"); + if (process.platform !== "win32") { + expect(lstatSync(securityDir).mode & 0o777).toBe(0o700); + for (const name of ["daemon.json", "producer.json", "caller.json"]) { + expect(lstatSync(join(securityDir, name)).mode & 0o777).toBe(0o600); + } + } + const callerFile = readFileSync(join(securityDir, "caller.json"), "utf8"); + expect(callerFile).not.toContain("hunk-review-capability"); + }); + + test("adopts one complete winner under concurrent first use", async () => { + const env = isolatedEnv(); + const results = await Promise.all( + Array.from({ length: 12 }, () => loadOrCreateHunkSessionBrokerCredentials({ env })), + ); + expect(new Set(results.map((value) => value.daemonIdentity.keyId)).size).toBe(1); + expect(new Set(results.map((value) => value.producer.grant.keyId)).size).toBe(1); + expect(new Set(results.map((value) => value.caller.grant.keyId)).size).toBe(1); + }); + + test("rejects malformed and overly permissive credential files without leaking private bytes", async () => { + const env = isolatedEnv(); + await loadOrCreateHunkSessionBrokerCredentials({ env }); + const callerPath = join(env.XDG_RUNTIME_DIR!, "hunk-mcp", "security-v1", "caller.json"); + const secret = "private-secret-sentinel"; + writeFileSync(callerPath, `{"privateKey":"${secret}"}`); + if (process.platform !== "win32") chmodSync(callerPath, 0o644); + + let message = ""; + try { + await loadOrCreateHunkSessionBrokerCredentials({ env }); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + expect(message).toContain("unsafe or malformed"); + expect(message).not.toContain(secret); + }); + + test("rejects a symlinked security directory", async () => { + if (process.platform === "win32") return; + const env = isolatedEnv(); + const runtimeDir = join(env.XDG_RUNTIME_DIR!, "hunk-mcp"); + const target = join(env.XDG_RUNTIME_DIR!, "redirect"); + const { mkdirSync } = await import("node:fs"); + mkdirSync(runtimeDir, { mode: 0o700 }); + mkdirSync(target, { mode: 0o700 }); + symlinkSync(target, join(runtimeDir, "security-v1"), "dir"); + + await expect(loadOrCreateHunkSessionBrokerCredentials({ env })).rejects.toThrow( + "unsafe or malformed", + ); + }); +}); diff --git a/src/session/broker/credentials.ts b/src/session/broker/credentials.ts new file mode 100644 index 000000000..c3d715d73 --- /dev/null +++ b/src/session/broker/credentials.ts @@ -0,0 +1,375 @@ +import { + closeSync, + constants, + fsyncSync, + fstatSync, + lstatSync, + linkSync, + mkdirSync, + openSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { + SESSION_BROKER_SIGNATURE_ALGORITHM, + type CallerGrant, + type ProducerGrant, +} from "@hunk/session-broker-core"; +import { + importEd25519PrivateKey, + importEd25519PublicKey, + type SessionBrokerCredential, + type SessionBrokerDaemonIdentity, +} from "@hunk/session-broker"; +import { resolveSessionBrokerRuntimePaths } from "./brokerLauncher"; +import { HUNK_SESSION_BROKER_APP_ID } from "./appContract"; + +const CREDENTIAL_VERSION = 1; +const CREDENTIAL_LIFETIME_MS = 10 * 365 * 24 * 60 * 60 * 1_000; +const PRIVATE_MODE = 0o600; +const DIRECTORY_MODE = 0o700; + +const HUNK_COMMAND_SCOPES = [ + "navigate_to_hunk", + "reload_session", + "comment", + "comment_batch", + "remove_comment", + "clear_comments", + "highlight", + "clear_highlights", +].map((name) => ({ name, version: 1 })) as readonly { name: string; version: number }[]; + +interface StoredCredentialFile { + version: 1; + role: "daemon" | "producer" | "caller"; + keyId: string; + publicKey: string; + privateKey: string; + grant?: ProducerGrant | CallerGrant; +} + +export interface HunkSessionBrokerCredentials { + readonly daemonIdentity: SessionBrokerDaemonIdentity; + readonly daemonPublicKey: CryptoKey; + readonly producer: SessionBrokerCredential & { readonly privateKey: CryptoKey }; + readonly caller: SessionBrokerCredential & { readonly privateKey: CryptoKey }; +} + +export interface HunkCredentialStoreOptions { + readonly env?: NodeJS.ProcessEnv; + readonly now?: () => number; + readonly randomBytes?: (length: number) => Uint8Array; +} + +function securityError(): never { + throw new Error( + "Hunk session credentials are unavailable because their owner-private runtime state is unsafe or malformed.", + ); +} + +function encode(bytes: ArrayBuffer | Uint8Array) { + return Buffer.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)).toString( + "base64url", + ); +} + +function decode(value: unknown): Uint8Array { + if (typeof value !== "string" || !/^[A-Za-z0-9_-]+$/.test(value)) securityError(); + const bytes = Buffer.from(value, "base64url"); + if (bytes.length === 0 || bytes.toString("base64url") !== value) securityError(); + return bytes; +} + +function randomId(randomBytes: (length: number) => Uint8Array) { + return `h_${Buffer.from(randomBytes(18)).toString("base64url")}_0`; +} + +/** Reject credential directories and files that can redirect reads or expose owner material. */ +function validateOwnerPrivatePath(path: string, kind: "directory" | "file") { + let stat; + try { + stat = lstatSync(path); + } catch { + securityError(); + } + if (stat.isSymbolicLink() || (kind === "directory" ? !stat.isDirectory() : !stat.isFile())) { + securityError(); + } + if (process.platform !== "win32") { + if (typeof process.getuid === "function" && stat.uid !== process.getuid()) securityError(); + const unsafeBits = kind === "directory" ? stat.mode & 0o077 : stat.mode & 0o177; + if (unsafeBits !== 0) securityError(); + } +} + +/** Validate the legacy namespace parent while allowing its historical read/execute mode. */ +function ensureRuntimeNamespace(path: string) { + mkdirSync(path, { recursive: true, mode: DIRECTORY_MODE }); + const stat = lstatSync(path); + if (stat.isSymbolicLink() || !stat.isDirectory()) securityError(); + if (process.platform !== "win32") { + if (typeof process.getuid === "function" && stat.uid !== process.getuid()) securityError(); + if ((stat.mode & 0o022) !== 0) securityError(); + } +} + +/** Create and validate the stable hunk-mcp owner-private security directory. */ +function ensureSecurityDirectory(path: string) { + mkdirSync(path, { recursive: true, mode: DIRECTORY_MODE }); + if (process.platform !== "win32") { + // mkdir honors umask by making permissions narrower, which is safe; never broaden an existing dir. + validateOwnerPrivatePath(path, "directory"); + } else { + validateOwnerPrivatePath(path, "directory"); + } +} + +/** Read a regular owner-private file through a no-follow descriptor where the runtime supports it. */ +function readPrivateFile(path: string): unknown { + validateOwnerPrivatePath(path, "file"); + let descriptor: number | null = null; + try { + const noFollow = (constants as typeof constants & { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0; + descriptor = openSync(path, constants.O_RDONLY | noFollow); + const stat = fstatSync(descriptor); + if (!stat.isFile() || stat.size <= 0 || stat.size > 64 * 1024) securityError(); + if (process.platform !== "win32") { + if (typeof process.getuid === "function" && stat.uid !== process.getuid()) securityError(); + if ((stat.mode & 0o177) !== 0) securityError(); + } + return JSON.parse(readFileSync(descriptor, "utf8")); + } catch { + securityError(); + } finally { + if (descriptor !== null) closeSync(descriptor); + } +} + +function parseStored(value: unknown, role: StoredCredentialFile["role"]): StoredCredentialFile { + if (!value || typeof value !== "object" || Array.isArray(value)) securityError(); + const record = value as Record; + const expected = new Set([ + "version", + "role", + "keyId", + "publicKey", + "privateKey", + ...(role === "daemon" ? [] : ["grant"]), + ]); + if ( + Object.keys(record).some((key) => !expected.has(key)) || + Object.keys(record).length !== expected.size + ) + securityError(); + if (record.version !== CREDENTIAL_VERSION || record.role !== role) securityError(); + if (typeof record.keyId !== "string" || !/^h_[A-Za-z0-9_-]+_0$/.test(record.keyId)) + securityError(); + decode(record.publicKey); + decode(record.privateKey); + if (role !== "daemon") { + const grant = record.grant as Record | undefined; + const grantKeys = new Set([ + "kind", + "appId", + "principalId", + "keyId", + "grantId", + "algorithm", + "issuedAt", + "expiresAt", + "revocationId", + "mayDelegate", + "operations", + ...(role === "caller" ? ["commands"] : []), + ]); + const expectedOperations = + role === "producer" ? ["register", "reconnect"] : ["list", "get", "dispatch", "diagnostics"]; + if ( + !grant || + Object.keys(grant).length !== grantKeys.size || + Object.keys(grant).some((key) => !grantKeys.has(key)) || + grant.kind !== role || + grant.appId !== HUNK_SESSION_BROKER_APP_ID || + grant.principalId !== `hunk-${role}` || + grant.keyId !== record.keyId || + grant.grantId !== `hunk-${role}-bootstrap-v1` || + grant.algorithm !== SESSION_BROKER_SIGNATURE_ALGORITHM || + !Number.isFinite(grant.issuedAt) || + !Number.isFinite(grant.expiresAt) || + (grant.issuedAt as number) >= (grant.expiresAt as number) || + grant.revocationId !== `hunk-${role}-bootstrap-v1` || + grant.mayDelegate !== false || + JSON.stringify(grant.operations) !== JSON.stringify(expectedOperations) || + (role === "caller" && JSON.stringify(grant.commands) !== JSON.stringify(HUNK_COMMAND_SCOPES)) + ) + securityError(); + } + return record as unknown as StoredCredentialFile; +} + +/** Atomically adopts a complete credential file without ever replacing a live winner. */ +function adoptPrivateFile( + path: string, + contents: string, + randomBytes: (length: number) => Uint8Array, +) { + const temp = `${path}.tmp-${process.pid}-${Buffer.from(randomBytes(9)).toString("hex")}`; + let descriptor: number | null = null; + try { + descriptor = openSync( + temp, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, + PRIVATE_MODE, + ); + writeFileSync(descriptor, contents, "utf8"); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = null; + try { + // A hard link publishes the already-complete inode and fails rather than replacing a winner. + requireLink(temp, path); + if (process.platform !== "win32") { + const directory = openSync(dirname(path), constants.O_RDONLY); + try { + fsyncSync(directory); + } catch (error) { + if (!["EINVAL", "ENOTSUP"].includes((error as NodeJS.ErrnoException).code ?? "")) { + throw error; + } + } finally { + closeSync(directory); + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + } finally { + if (descriptor !== null) closeSync(descriptor); + rmSync(temp, { force: true }); + } +} + +function requireLink(source: string, destination: string) { + linkSync(source, destination); +} + +async function createStored( + role: StoredCredentialFile["role"], + now: number, + randomBytes: (length: number) => Uint8Array, +): Promise { + const pair = (await crypto.subtle.generateKey("Ed25519", true, [ + "sign", + "verify", + ])) as CryptoKeyPair; + const keyId = randomId(randomBytes); + const base = { + version: CREDENTIAL_VERSION, + role, + keyId, + publicKey: encode(await crypto.subtle.exportKey("spki", pair.publicKey)), + privateKey: encode(await crypto.subtle.exportKey("pkcs8", pair.privateKey)), + } as const; + if (role === "daemon") return base; + const common = { + kind: role, + appId: HUNK_SESSION_BROKER_APP_ID, + principalId: `hunk-${role}`, + keyId, + grantId: `hunk-${role}-bootstrap-v1`, + algorithm: SESSION_BROKER_SIGNATURE_ALGORITHM, + issuedAt: now, + expiresAt: now + CREDENTIAL_LIFETIME_MS, + revocationId: `hunk-${role}-bootstrap-v1`, + mayDelegate: false, + } as const; + const grant = + role === "producer" + ? ({ + ...common, + kind: "producer", + operations: ["register", "reconnect"], + } satisfies ProducerGrant) + : ({ + ...common, + kind: "caller", + operations: ["list", "get", "dispatch", "diagnostics"], + commands: HUNK_COMMAND_SCOPES, + } satisfies CallerGrant); + return { ...base, grant }; +} + +async function loadOrCreate( + path: string, + role: StoredCredentialFile["role"], + now: number, + randomBytes: (length: number) => Uint8Array, +) { + try { + return parseStored(readPrivateFile(path), role); + } catch (error) { + const code = (() => { + try { + lstatSync(path); + return "exists"; + } catch (cause) { + return (cause as NodeJS.ErrnoException).code; + } + })(); + if (code !== "ENOENT") throw error; + } + const generated = await createStored(role, now, randomBytes); + adoptPrivateFile(path, `${JSON.stringify(generated)}\n`, randomBytes); + return parseStored(readPrivateFile(path), role); +} + +/** Load or safely create Hunk's daemon, producer, and caller Ed25519 bootstrap material. */ +export async function loadOrCreateHunkSessionBrokerCredentials( + options: HunkCredentialStoreOptions = {}, +): Promise { + const env = options.env ?? process.env; + const randomBytes = + options.randomBytes ?? ((length) => crypto.getRandomValues(new Uint8Array(length))); + const runtimeDir = resolveSessionBrokerRuntimePaths(undefined, env).runtimeDir; + const securityDir = join(runtimeDir, "security-v1"); + ensureRuntimeNamespace(runtimeDir); + ensureSecurityDirectory(securityDir); + const now = (options.now ?? Date.now)(); + const [daemon, producer, caller] = await Promise.all([ + loadOrCreate(join(securityDir, "daemon.json"), "daemon", now, randomBytes), + loadOrCreate(join(securityDir, "producer.json"), "producer", now, randomBytes), + loadOrCreate(join(securityDir, "caller.json"), "caller", now, randomBytes), + ]); + const [ + daemonPublicKey, + daemonPrivateKey, + producerPublicKey, + producerPrivateKey, + callerPublicKey, + callerPrivateKey, + ] = await Promise.all([ + importEd25519PublicKey(decode(daemon.publicKey)), + importEd25519PrivateKey(decode(daemon.privateKey)), + importEd25519PublicKey(decode(producer.publicKey)), + importEd25519PrivateKey(decode(producer.privateKey)), + importEd25519PublicKey(decode(caller.publicKey)), + importEd25519PrivateKey(decode(caller.privateKey)), + ]); + return Object.freeze({ + daemonIdentity: Object.freeze({ keyId: daemon.keyId, privateKey: daemonPrivateKey }), + daemonPublicKey, + producer: Object.freeze({ + grant: producer.grant as ProducerGrant, + publicKey: producerPublicKey, + privateKey: producerPrivateKey, + }), + caller: Object.freeze({ + grant: caller.grant as CallerGrant, + publicKey: callerPublicKey, + privateKey: callerPrivateKey, + }), + }); +} diff --git a/src/session/broker/state.ts b/src/session/broker/state.ts index 48c8321c6..0a28fb64a 100644 --- a/src/session/broker/state.ts +++ b/src/session/broker/state.ts @@ -206,8 +206,9 @@ export class HunkSessionBrokerState extends SessionBrokerState< socket: HunkBrokerConnection, registrationInput: unknown, snapshotInput: unknown, + options?: { replaceOwner?: boolean }, ) { - const registered = super.registerSession(socket, registrationInput, snapshotInput); + const registered = super.registerSession(socket, registrationInput, snapshotInput, options); this.reconcileMirroredSessions(); if (registered !== "registered") { return registered; diff --git a/src/session/client/capabilities.ts b/src/session/client/capabilities.ts index 2892b5e98..2931d9353 100644 --- a/src/session/client/capabilities.ts +++ b/src/session/client/capabilities.ts @@ -6,13 +6,8 @@ import { HUNK_SESSION_CAPABILITIES_PATH, type SessionDaemonCapabilities } from " import { parseSessionDaemonCapabilities } from "../protocolSchemas"; import { HUNK_SESSION_DAEMON_HTTP_TIMEOUT_MS, requestSessionDaemonHttp } from "./daemonHttp"; -export const HUNK_DAEMON_UPGRADE_RESTART_NOTICE = - "[hunk:session] Restarting stale session daemon after upgrade."; - -/** Tell the user that Hunk is refreshing an old daemon left running across an upgrade. */ -export function reportHunkDaemonUpgradeRestart(log: (message: string) => void = console.error) { - log(HUNK_DAEMON_UPGRADE_RESTART_NOTICE); -} +export const HUNK_DAEMON_UPGRADE_WAIT_MESSAGE = + "An older or incompatible Hunk session daemon is running. Close older Hunk windows; this window will reconnect automatically."; /** * Read the live daemon's advertised compatibility, returning null when the daemon is too old for diff --git a/test/cli/install-vm/README.md b/test/cli/install-vm/README.md index 91e8ea319..cb935824e 100644 --- a/test/cli/install-vm/README.md +++ b/test/cli/install-vm/README.md @@ -1,6 +1,6 @@ # Optional Firecracker install compatibility suite -This suite tests Hunk's Linux x64 npm and pnpm installs/upgrades, legacy Bun fallback, offline execution, and curl install/upgrade behavior in fresh Firecracker microVMs. It is completely opt-in: `bun install`, normal tests, typechecking, builds, and packaging do not check for Docker/KVM or download VM assets. +This suite tests Hunk's Linux x64 npm and pnpm installs/upgrades, authenticated daemon upgrades, legacy Bun fallback, offline execution, and curl install/upgrade behavior in fresh Firecracker microVMs. It is completely opt-in: `bun install`, normal tests, typechecking, builds, and packaging do not check for Docker/KVM or download VM assets. ## Run @@ -13,6 +13,7 @@ Requirements: ```sh bun run test:install-vm -- --list bun run test:install-vm -- --scenario pnpm-global-upgrade +bun run test:install-vm -- --scenario authenticated-daemon-upgrade bun run test:install-vm ``` @@ -28,7 +29,7 @@ The runner deliberately does not reclaim a stale `tmp/install-vm/.lock`, because owned by a racing process is unsafe. After an interrupted host dies, confirm no suite is running and remove that lock directory manually before retrying. -Every scenario gets a sparse/reflink clone of the verified immutable base image, an ephemeral run-only SSH public key injected into that clone, and isolated HOME, PATH, npm prefix, pnpm global directory, and pnpm store. Hunk's generated fixture packages are checksum-pinned and published to the local registry. Verdaccio currently proxies uncached transitive dependencies, so first-run package installation still depends on npm availability; the historical corruption oracle also deliberately uses the live npm registry while consuming the validated exact Hunk, Bun, and pnpm pins from `pins.json`. Results include `result.json`, `junit.xml`, structured commands and observations, guest command logs, assertions, Firecracker console output, and the fixture source identity. Writable disks, SSH keys, sockets, cache identities, locks, and registry credentials are excluded from result artifacts. Release evidence can be checked against the current checkout and complete scenario manifest with `bun run ./test/cli/install-vm/validate-release-result.ts `. +Every scenario gets a sparse/reflink clone of the verified immutable base image, an ephemeral run-only SSH public key injected into that clone, and isolated HOME, PATH, npm prefix, pnpm global directory, and pnpm store. Hunk's generated fixture packages are checksum-pinned and published to the local registry. Fixture preparation also compiles two full Hunk binaries from isolated copies of the exact checkout and reflink-capable dependency snapshots: the current authenticated daemon revision and the immediately preceding incompatible revision. The checkout `sourceIdentity` remains source-only; a separate `daemonUpgradeBuildInputIdentity` frames and hashes the ignored `node_modules` snapshot bytes and contained symlink targets plus the Bun executable bytes/version used by the builder. External dependency symlinks are rejected, and an isolated PATH entry forces nested build commands to use that attested Bun executable. The fixture manifest binds both compiled binary SHA-256 digests, which the guest compares with the live A/B daemon executables. Temporary source, dependency, and package staging trees are removed before fixtures become visible. The daemon-upgrade scenario therefore adds two compilation passes and about one production idle timeout to a targeted run. Verdaccio currently proxies uncached transitive dependencies, so first-run package installation still depends on npm availability; the historical corruption oracle also deliberately uses the live npm registry while consuming the validated exact Hunk, Bun, and pnpm pins from `pins.json`. Results include `result.json`, `junit.xml`, structured commands and observations, guest command logs, assertions, Firecracker console output, and the fixture source identity. Scenario-specific required-evidence contracts bind required command IDs to exact expectation semantics and make missing daemon lifecycle proof fail aggregation and release validation. The daemon scenario stops one exact test-owned upgraded client across incumbent retirement, waits for the other original client to register on the successor, then resumes and verifies the delayed PID/start-token registers without restart; it never signals a daemon. Release validation rejects symlinked or escaping evidence and reads the referenced health, metadata, executable, fixture-manifest, warning, command-log, and session-list artifacts from the run directory instead of trusting result labels alone. It also requires the locally reusable fixture set to pass checkout, build-input, and package checksum verification, then extracts and hashes the actual A/B package binaries as an independent digest trust input; missing or stale local fixtures fail validation. Writable disks, SSH keys, sockets, cache identities, locks, and registry credentials are excluded from result artifacts. Release evidence can be checked against the current checkout and complete scenario manifest with `bun run ./test/cli/install-vm/validate-release-result.ts `. A targeted development run can be checked with `--scenario `; that explicit subset check is not complete release evidence. ## Security boundary diff --git a/test/cli/install-vm/contract.test.ts b/test/cli/install-vm/contract.test.ts index 93d21690b..73efc7f52 100644 --- a/test/cli/install-vm/contract.test.ts +++ b/test/cli/install-vm/contract.test.ts @@ -117,6 +117,46 @@ describe("install VM contract", () => { scenarios: [{ ...manifest.scenarios[0], script: "../escape.sh" }], }), ).toThrow("unsafe script path"); + expect( + validateScenarioManifest({ + schemaVersion: 1, + scenarios: [ + { + ...manifest.scenarios[0], + requiredEvidence: { + commands: ["run-upgrade"], + commandExpectations: { "run-upgrade": "exit 0" }, + assertions: ["daemon-preserved"], + observations: ["oldDaemonPid", "transcriptPath"], + }, + }, + ], + }).scenarios[0]?.requiredEvidence, + ).toEqual({ + commands: ["run-upgrade"], + commandExpectations: { "run-upgrade": "exit 0" }, + assertions: ["daemon-preserved"], + observations: ["oldDaemonPid", "transcriptPath"], + }); + for (const requiredEvidence of [ + { commands: ["duplicate", "duplicate"] }, + { commands: ["run-upgrade"], commandExpectations: {} }, + { commands: ["run-upgrade"], commandExpectations: { other: "exit 0" } }, + { + commands: ["run-upgrade"], + commandExpectations: { "run-upgrade": "anything passed" }, + }, + { assertions: ["Uppercase"] }, + { observations: ["not-kebab-case"] }, + { unknown: ["value"] }, + ]) { + expect(() => + validateScenarioManifest({ + schemaVersion: 1, + scenarios: [{ ...manifest.scenarios[0], requiredEvidence }], + }), + ).toThrow(); + } }); test("treats expected nonzero commands as passes only when diagnostics match", () => { diff --git a/test/cli/install-vm/contract.ts b/test/cli/install-vm/contract.ts index 282d74184..e3b0c0e7e 100644 --- a/test/cli/install-vm/contract.ts +++ b/test/cli/install-vm/contract.ts @@ -4,12 +4,20 @@ import path from "node:path"; export type InstallVmProfile = "minimal" | "node"; export type InstallVmNetwork = "local" | "live"; +export interface InstallVmScenarioRequiredEvidence { + commands?: string[]; + commandExpectations?: Record; + assertions?: string[]; + observations?: string[]; +} + export interface InstallVmScenario { id: string; description: string; profile: InstallVmProfile; script: string; network: InstallVmNetwork; + requiredEvidence?: InstallVmScenarioRequiredEvidence; } export interface InstallVmScenarioManifest { @@ -97,6 +105,36 @@ export interface InstallVmPins { const SCENARIO_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const SHA256_PATTERN = /^[a-f0-9]{64}$/; const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+$/; +const ZERO_EXIT_COMMAND_EXPECTATIONS = new Set([ + "background PTY remains live", + "SIGSTOP exact owned B client", + "SIGCONT exact owned B client", +]); + +/** Validate one expectation against the closed install-VM command grammar. */ +export function validateInstallVmCommandExpectation(expectation: string, exitCode?: number) { + const exitMatch = /^exit (-?(?:0|[1-9][0-9]*))$/.exec(expectation); + if (exitMatch) { + if (exitCode !== undefined && exitCode !== Number(exitMatch[1])) { + throw new Error(`Install VM command has impossible exit expectation: ${expectation}.`); + } + return; + } + if (expectation === "nonzero exit") { + if (exitCode === 0) { + throw new Error("Install VM command has impossible nonzero exit expectation."); + } + return; + } + if (expectation === "observed exit") return; + if (ZERO_EXIT_COMMAND_EXPECTATIONS.has(expectation)) { + if (exitCode !== undefined && exitCode !== 0) { + throw new Error(`Install VM command has impossible zero-exit expectation: ${expectation}.`); + } + return; + } + throw new Error(`Install VM command has unsupported expectation: ${expectation}.`); +} /** Validate checksum-attested VM inputs and exact versions used by compatibility scenarios. */ export function validateInstallVmPins(value: unknown) { @@ -218,6 +256,62 @@ export function validateScenarioManifest(value: unknown): InstallVmScenarioManif if (path.basename(scenario.script) !== scenario.script || !scenario.script.endsWith(".sh")) { throw new Error(`Scenario ${scenario.id} has an unsafe script path.`); } + if (scenario.requiredEvidence !== undefined) { + if (!scenario.requiredEvidence || typeof scenario.requiredEvidence !== "object") { + throw new Error(`Scenario ${scenario.id} has malformed required evidence.`); + } + const requiredEvidence = scenario.requiredEvidence as Record; + const expectedKeys = ["commands", "commandExpectations", "assertions", "observations"]; + if (Object.keys(requiredEvidence).some((key) => !expectedKeys.includes(key))) { + throw new Error(`Scenario ${scenario.id} has unknown required evidence.`); + } + for (const key of ["commands", "assertions", "observations"] as const) { + const entries = requiredEvidence[key]; + if (entries === undefined) continue; + if ( + !Array.isArray(entries) || + entries.length === 0 || + entries.some( + (entry) => + typeof entry !== "string" || + (key === "observations" + ? !/^[A-Za-z][A-Za-z0-9]*$/.test(entry) + : !SCENARIO_ID_PATTERN.test(entry)), + ) || + new Set(entries).size !== entries.length + ) { + throw new Error(`Scenario ${scenario.id} has malformed required ${key}.`); + } + } + const commandExpectations = requiredEvidence.commandExpectations; + if (commandExpectations !== undefined) { + if ( + !commandExpectations || + typeof commandExpectations !== "object" || + Array.isArray(commandExpectations) || + (Object.getPrototypeOf(commandExpectations) !== Object.prototype && + Object.getPrototypeOf(commandExpectations) !== null) + ) { + throw new Error(`Scenario ${scenario.id} has malformed command expectations.`); + } + const commands = requiredEvidence.commands; + const expectationRecord = commandExpectations as Record; + if ( + !Array.isArray(commands) || + Object.keys(expectationRecord).sort().join("\0") !== [...commands].sort().join("\0") + ) { + throw new Error( + `Scenario ${scenario.id} command expectations must exactly match required commands.`, + ); + } + for (const [commandId, expectation] of Object.entries(expectationRecord)) { + if (!SCENARIO_ID_PATTERN.test(commandId) || typeof expectation !== "string") { + throw new Error(`Scenario ${scenario.id} has malformed command expectations.`); + } + validateInstallVmCommandExpectation(expectation); + } + } + } } return manifest as InstallVmScenarioManifest; diff --git a/test/cli/install-vm/prepare-daemon-upgrade-fixtures.ts b/test/cli/install-vm/prepare-daemon-upgrade-fixtures.ts new file mode 100644 index 000000000..977d70fb3 --- /dev/null +++ b/test/cli/install-vm/prepare-daemon-upgrade-fixtures.ts @@ -0,0 +1,290 @@ +import { + chmodSync, + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + readlinkSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; +import { delimiter } from "node:path"; +import { createHash } from "node:crypto"; + +export const DAEMON_UPGRADE_VERSION_A = "899.0.0"; +export const DAEMON_UPGRADE_VERSION_B = "899.0.1"; + +const DAEMON_REVISION_PATTERN = /export const HUNK_SESSION_DAEMON_VERSION = ([1-9][0-9]*);/g; + +/** Read the one numeric Hunk daemon revision declaration used by authenticated app negotiation. */ +export function readDaemonRevision(source: string) { + const matches = [...source.matchAll(DAEMON_REVISION_PATTERN)]; + if (matches.length !== 1) { + throw new Error("Daemon upgrade fixtures require exactly one HUNK_SESSION_DAEMON_VERSION."); + } + const revision = Number(matches[0]![1]); + if (!Number.isSafeInteger(revision) || revision < 2) { + throw new Error("Daemon upgrade fixtures require a daemon revision of at least 2."); + } + return revision; +} + +/** Replace exactly one daemon revision declaration in an isolated fixture checkout. */ +export function replaceDaemonRevision(source: string, revision: number) { + readDaemonRevision(source); + if (!Number.isSafeInteger(revision) || revision < 1) { + throw new Error("Daemon fixture revision must be a positive safe integer."); + } + return source.replace( + DAEMON_REVISION_PATTERN, + `export const HUNK_SESSION_DAEMON_VERSION = ${revision};`, + ); +} + +/** Enumerate tracked and non-ignored checkout files without consulting committed-only bytes. */ +function checkoutFiles(repoRoot: string) { + const listed = Bun.spawnSync( + ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], + { cwd: repoRoot, stdout: "pipe", stderr: "pipe" }, + ); + if (listed.exitCode !== 0) { + throw new Error( + `Unable to enumerate daemon fixture checkout: ${new TextDecoder().decode(listed.stderr).trim()}`, + ); + } + return new TextDecoder().decode(listed.stdout).split("\0").filter(Boolean); +} + +/** Frame one build-input value so paths and contents cannot concatenate ambiguously. */ +function updateFramed(hash: ReturnType, value: string | Uint8Array) { + const bytes = typeof value === "string" ? Buffer.from(value) : Buffer.from(value); + const length = Buffer.alloc(8); + length.writeBigUInt64BE(BigInt(bytes.byteLength)); + hash.update(length); + hash.update(bytes); +} + +/** Attest ignored dependency bytes/symlink targets plus the exact Bun runtime used to build. */ +export function computeDaemonUpgradeBuildInputIdentity( + repoRoot: string, + options: { + dependenciesRoot?: string; + bunExecutable?: string; + bunVersion?: string; + } = {}, +) { + const realRepoRoot = realpathSync(repoRoot); + const dependenciesRoot = options.dependenciesRoot ?? path.join(repoRoot, "node_modules"); + const bunExecutable = options.bunExecutable ?? process.execPath; + const bunVersion = options.bunVersion ?? Bun.version; + if (!existsSync(dependenciesRoot) || !existsSync(bunExecutable)) { + throw new Error("Daemon upgrade build-input attestation requires dependencies and Bun."); + } + const hash = createHash("sha256"); + updateFramed(hash, "hunk-daemon-upgrade-build-input-v1"); + updateFramed(hash, bunVersion); + updateFramed(hash, readFileSync(bunExecutable)); + const walk = (directory: string, relativeDirectory: string) => { + for (const name of readdirSync(directory).sort()) { + const absolute = path.join(directory, name); + const relative = path.posix.join(relativeDirectory, name); + const stat = lstatSync(absolute); + if (stat.isDirectory()) { + updateFramed(hash, `d:${relative}`); + walk(absolute, relative); + } else if (stat.isSymbolicLink()) { + const resolvedTarget = realpathSync(absolute); + const targetRelative = path.relative(realRepoRoot, resolvedTarget); + if ( + targetRelative === ".." || + targetRelative.startsWith(`..${path.sep}`) || + path.isAbsolute(targetRelative) + ) { + throw new Error(`Daemon build input symlink escapes the checkout: ${relative}`); + } + updateFramed(hash, `l:${relative}`); + updateFramed(hash, readlinkSync(absolute)); + } else if (stat.isFile()) { + updateFramed(hash, `f:${relative}`); + updateFramed(hash, readFileSync(absolute)); + } else { + throw new Error(`Unsupported daemon build input: ${relative}`); + } + } + }; + walk(dependenciesRoot, "node_modules"); + return hash.digest("hex"); +} + +/** Build an environment whose `bun` command resolves to the exact attested executable. */ +export function createDaemonUpgradeCompilerEnvironment( + destination: string, + options: { env?: NodeJS.ProcessEnv; bunExecutable?: string } = {}, +) { + const compilerBin = path.join(destination, ".hunk-fixture-compiler-bin"); + const bunExecutable = realpathSync(options.bunExecutable ?? process.execPath); + rmSync(compilerBin, { recursive: true, force: true }); + mkdirSync(compilerBin, { recursive: true }); + const bunLink = path.join(compilerBin, "bun"); + symlinkSync(bunExecutable, bunLink); + const env = { + ...(options.env ?? process.env), + PATH: `${compilerBin}${delimiter}${options.env?.PATH ?? process.env.PATH ?? ""}`, + }; + const resolved = Bun.spawnSync(["sh", "-c", "command -v bun"], { + env, + stdout: "pipe", + stderr: "pipe", + }); + const resolvedPath = new TextDecoder().decode(resolved.stdout).trim(); + if ( + resolved.exitCode !== 0 || + resolvedPath !== bunLink || + realpathSync(resolvedPath) !== bunExecutable + ) { + rmSync(compilerBin, { recursive: true, force: true }); + throw new Error("Daemon fixture compiler did not resolve to the attested Bun executable."); + } + return { + env, + resolvedBun: resolvedPath, + cleanup: () => rmSync(compilerBin, { recursive: true, force: true }), + }; +} + +/** Snapshot installed dependencies into one Linux fixture build without sharing mutable paths. */ +export function snapshotDaemonUpgradeDependencies(repoRoot: string, destination: string) { + if (process.platform !== "linux") { + throw new Error("Daemon upgrade fixture dependency snapshots require Linux cp(1)."); + } + const dependencies = path.join(repoRoot, "node_modules"); + if (!existsSync(dependencies)) { + throw new Error("Daemon upgrade fixture builds require the checkout node_modules directory."); + } + mkdirSync(destination, { recursive: true }); + const copied = Bun.spawnSync( + ["cp", "-a", "--reflink=auto", `${dependencies}${path.sep}.`, destination], + { stdout: "pipe", stderr: "pipe" }, + ); + if (copied.exitCode !== 0) { + throw new Error( + `Unable to snapshot daemon fixture dependencies with cp --reflink=auto: ${new TextDecoder().decode(copied.stderr).trim()}`, + ); + } +} + +/** Copy the exact checkout snapshot into an isolated build tree while preserving file modes. */ +function copyCheckout(repoRoot: string, destination: string) { + mkdirSync(destination, { recursive: true }); + for (const relativePath of checkoutFiles(repoRoot)) { + const source = path.join(repoRoot, ...relativePath.split("/")); + const target = path.join(destination, ...relativePath.split("/")); + const stat = lstatSync(source); + mkdirSync(path.dirname(target), { recursive: true }); + if (stat.isSymbolicLink()) { + symlinkSync(readlinkSync(source), target); + continue; + } + if (!stat.isFile()) { + throw new Error(`Unsupported daemon fixture checkout entry: ${relativePath}`); + } + copyFileSync(source, target); + chmodSync(target, stat.mode & 0o777); + } + snapshotDaemonUpgradeDependencies(repoRoot, path.join(destination, "node_modules")); +} + +/** Build one fully functional fixture binary with only version/revision bytes changed. */ +async function buildVariant( + repoRoot: string, + destination: string, + packageVersion: string, + daemonRevision: number, + buildInputIdentity: string, +) { + copyCheckout(repoRoot, destination); + const snapshotIdentity = computeDaemonUpgradeBuildInputIdentity(destination); + if (snapshotIdentity !== buildInputIdentity) { + throw new Error("Daemon upgrade dependency snapshot changed while it was copied."); + } + const packagePath = path.join(destination, "package.json"); + const packageManifest = JSON.parse(readFileSync(packagePath, "utf8")) as Record; + packageManifest.version = packageVersion; + writeFileSync(packagePath, `${JSON.stringify(packageManifest, null, 2)}\n`); + + const protocolPath = path.join(destination, "src", "session", "protocol.ts"); + writeFileSync( + protocolPath, + replaceDaemonRevision(readFileSync(protocolPath, "utf8"), daemonRevision), + ); + const compiler = createDaemonUpgradeCompilerEnvironment(destination); + try { + const proc = Bun.spawn([process.execPath, "run", "./scripts/build-bin.ts"], { + cwd: destination, + env: compiler.env, + stdin: "ignore", + stdout: "inherit", + stderr: "inherit", + }); + if ((await proc.exited) !== 0) { + throw new Error(`Failed to build daemon upgrade fixture ${packageVersion}.`); + } + } finally { + compiler.cleanup(); + } + const binary = path.join(destination, "dist", "hunk"); + if (!existsSync(binary)) throw new Error(`Missing daemon upgrade binary for ${packageVersion}.`); + return binary; +} + +export interface DaemonUpgradeFixtureBuild { + daemonUpgradeBuildInputIdentity: string; + versionA: string; + versionB: string; + revisionA: number; + revisionB: number; + binaryA: string; + binaryB: string; + binarySha256A: string; + binarySha256B: string; +} + +/** Build incompatible authenticated Hunk binaries from isolated copies of the exact checkout. */ +export async function prepareDaemonUpgradeBinaries(repoRoot: string, buildRoot: string) { + rmSync(buildRoot, { recursive: true, force: true }); + mkdirSync(buildRoot, { recursive: true }); + const daemonUpgradeBuildInputIdentity = computeDaemonUpgradeBuildInputIdentity(repoRoot); + const protocolSource = readFileSync(path.join(repoRoot, "src", "session", "protocol.ts"), "utf8"); + const revisionB = readDaemonRevision(protocolSource); + const revisionA = revisionB - 1; + const binaryA = await buildVariant( + repoRoot, + path.join(buildRoot, "revision-a"), + DAEMON_UPGRADE_VERSION_A, + revisionA, + daemonUpgradeBuildInputIdentity, + ); + const binaryB = await buildVariant( + repoRoot, + path.join(buildRoot, "revision-b"), + DAEMON_UPGRADE_VERSION_B, + revisionB, + daemonUpgradeBuildInputIdentity, + ); + return { + daemonUpgradeBuildInputIdentity, + versionA: DAEMON_UPGRADE_VERSION_A, + versionB: DAEMON_UPGRADE_VERSION_B, + revisionA, + revisionB, + binaryA, + binaryB, + binarySha256A: createHash("sha256").update(readFileSync(binaryA)).digest("hex"), + binarySha256B: createHash("sha256").update(readFileSync(binaryB)).digest("hex"), + } satisfies DaemonUpgradeFixtureBuild; +} diff --git a/test/cli/install-vm/prepare-fixtures.test.ts b/test/cli/install-vm/prepare-fixtures.test.ts index 7086c59db..01183f13a 100644 --- a/test/cli/install-vm/prepare-fixtures.test.ts +++ b/test/cli/install-vm/prepare-fixtures.test.ts @@ -5,6 +5,8 @@ import { mkdirSync, mkdtempSync, readFileSync, + realpathSync, + renameSync, rmSync, symlinkSync, writeFileSync, @@ -17,15 +19,28 @@ import { CURL_BAD_CHECKSUM_VERSION, CURL_TRUNCATED_VERSION, CURL_UNAVAILABLE_VERSION, + deriveVerifiedDaemonUpgradeBinaryDigests, FIXTURE_VERSION_A, FIXTURE_VERSION_B, verifyInstallVmFixtures, type InstallVmFixtureManifest, } from "./prepare-fixtures"; +import { + DAEMON_UPGRADE_VERSION_A, + DAEMON_UPGRADE_VERSION_B, + computeDaemonUpgradeBuildInputIdentity, + createDaemonUpgradeCompilerEnvironment, + readDaemonRevision, + replaceDaemonRevision, + snapshotDaemonUpgradeDependencies, +} from "./prepare-daemon-upgrade-fixtures"; /** Initialize the minimal Git checkout required by source-identity discovery. */ function initializeTestGitRepo(repo: string) { - const result = Bun.spawnSync(["git", "init", "--quiet"], { cwd: repo, stderr: "pipe" }); + const result = Bun.spawnSync(["git", "init", "--quiet"], { + cwd: repo, + stderr: "pipe", + }); if (result.exitCode !== 0) throw new Error("Unable to initialize test Git repository."); } @@ -40,7 +55,20 @@ function writeTestFixtures(repo: string, fixtures: string) { const httpRoot = path.join(fixtures, "http"); mkdirSync(packageRoot, { recursive: true }); mkdirSync(httpRoot, { recursive: true }); - const versions = ["1.0.0", FIXTURE_VERSION_A, FIXTURE_VERSION_B]; + mkdirSync(path.join(repo, "src", "session"), { recursive: true }); + mkdirSync(path.join(repo, "node_modules"), { recursive: true }); + writeFileSync(path.join(repo, "node_modules", "fixture-dependency"), "dependency\n"); + writeFileSync( + path.join(repo, "src", "session", "protocol.ts"), + "export const HUNK_SESSION_DAEMON_VERSION = 11;\n", + ); + const versions = [ + "1.0.0", + DAEMON_UPGRADE_VERSION_A, + DAEMON_UPGRADE_VERSION_B, + FIXTURE_VERSION_A, + FIXTURE_VERSION_B, + ]; const packages = versions.flatMap((version) => ["hunkdiff-linux-x64", "hunkdiff"].map((name) => { const tarball = `${name}-${version}.tgz`; @@ -50,11 +78,20 @@ function writeTestFixtures(repo: string, fixtures: string) { }), ); const manifest: InstallVmFixtureManifest = { - schemaVersion: 1, + schemaVersion: 2, sourceIdentity: computeInstallVmFixtureSourceIdentity(repo), + daemonUpgradeBuildInputIdentity: computeDaemonUpgradeBuildInputIdentity(repo), currentVersion: "1.0.0", versionA: FIXTURE_VERSION_A, versionB: FIXTURE_VERSION_B, + daemonUpgrade: { + versionA: DAEMON_UPGRADE_VERSION_A, + versionB: DAEMON_UPGRADE_VERSION_B, + revisionA: 10, + revisionB: 11, + binarySha256A: "a".repeat(64), + binarySha256B: "b".repeat(64), + }, packages, }; const manifestBytes = `${JSON.stringify(manifest, null, 2)}\n`; @@ -95,6 +132,193 @@ function writeTestFixtures(repo: string, fixtures: string) { } describe("install VM package fixtures", () => { + test("derives trusted daemon binary digests from the actual platform tarballs", async () => { + if (process.platform !== "linux") return; + const root = mkdtempSync(path.join(tmpdir(), "hunk-daemon-tarball-digests-")); + try { + const packages = path.join(root, "packages"); + mkdirSync(packages, { recursive: true }); + const fixtures = [ + [DAEMON_UPGRADE_VERSION_A, "daemon-a\n"], + [DAEMON_UPGRADE_VERSION_B, "daemon-b\n"], + ] as const; + const entries = []; + const digests: string[] = []; + for (const [version, contents] of fixtures) { + const stage = path.join(root, `stage-${version}`, "package", "bin"); + mkdirSync(stage, { recursive: true }); + const binary = path.join(stage, "hunk"); + writeFileSync(binary, contents); + const tarball = `hunkdiff-linux-x64-${version}.tgz`; + const packed = Bun.spawnSync( + [ + "tar", + "-czf", + path.join(packages, tarball), + "-C", + path.dirname(path.dirname(stage)), + "package/bin/hunk", + ], + { stderr: "pipe" }, + ); + expect(packed.exitCode).toBe(0); + entries.push({ name: "hunkdiff-linux-x64", version, tarball, sha256: "0".repeat(64) }); + digests.push(sha256(binary)); + } + const manifest = { + schemaVersion: 2, + sourceIdentity: "0".repeat(64), + daemonUpgradeBuildInputIdentity: "1".repeat(64), + currentVersion: "1.0.0", + versionA: FIXTURE_VERSION_A, + versionB: FIXTURE_VERSION_B, + daemonUpgrade: { + versionA: DAEMON_UPGRADE_VERSION_A, + versionB: DAEMON_UPGRADE_VERSION_B, + revisionA: 10, + revisionB: 11, + binarySha256A: digests[0]!, + binarySha256B: digests[1]!, + }, + packages: entries, + } satisfies InstallVmFixtureManifest; + + await expect(deriveVerifiedDaemonUpgradeBinaryDigests(root, manifest)).resolves.toEqual({ + binarySha256A: digests[0]!, + binarySha256B: digests[1]!, + }); + manifest.daemonUpgrade.binarySha256A = "f".repeat(64); + await expect(deriveVerifiedDaemonUpgradeBinaryDigests(root, manifest)).rejects.toThrow( + "do not match their manifest", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("attests dependency bytes, symlink targets, and Bun build inputs", () => { + const root = mkdtempSync(path.join(tmpdir(), "hunk-daemon-inputs-")); + try { + const dependencies = path.join(root, "node_modules"); + const bun = path.join(root, "bun"); + mkdirSync(dependencies, { recursive: true }); + writeFileSync(path.join(dependencies, "dependency"), "one"); + writeFileSync(path.join(dependencies, "other"), "other"); + symlinkSync("dependency", path.join(dependencies, "link")); + writeFileSync(bun, "bun-one"); + const options = { + dependenciesRoot: dependencies, + bunExecutable: bun, + bunVersion: "1.2.3", + }; + const first = computeDaemonUpgradeBuildInputIdentity(root, options); + expect(first).toMatch(/^[0-9a-f]{64}$/); + writeFileSync(path.join(dependencies, "dependency"), "two"); + expect(computeDaemonUpgradeBuildInputIdentity(root, options)).not.toBe(first); + writeFileSync(path.join(dependencies, "dependency"), "one"); + rmSync(path.join(dependencies, "link")); + symlinkSync("other", path.join(dependencies, "link")); + expect(computeDaemonUpgradeBuildInputIdentity(root, options)).not.toBe(first); + + const external = path.join(root, "..", `${path.basename(root)}-external`); + writeFileSync(external, "outside-one"); + rmSync(path.join(dependencies, "link")); + symlinkSync(external, path.join(dependencies, "link")); + expect(() => computeDaemonUpgradeBuildInputIdentity(root, options)).toThrow( + "escapes the checkout", + ); + writeFileSync(external, "outside-two"); + expect(() => computeDaemonUpgradeBuildInputIdentity(root, options)).toThrow( + "escapes the checkout", + ); + rmSync(external, { force: true }); + } finally { + rmSync(path.join(root, "..", `${path.basename(root)}-external`), { + force: true, + }); + rmSync(root, { recursive: true, force: true }); + } + }); + + test("forces fixture compiler resolution ahead of a hostile PATH", () => { + if (process.platform !== "linux") return; + const root = mkdtempSync(path.join(tmpdir(), "hunk-daemon-compiler-")); + try { + const hostileBin = path.join(root, "hostile"); + const attestedBun = path.join(root, "attested-bun"); + mkdirSync(hostileBin); + writeFileSync(path.join(hostileBin, "bun"), "#!/bin/sh\nexit 99\n", { + mode: 0o755, + }); + writeFileSync(attestedBun, "attested compiler bytes\n", { mode: 0o755 }); + const compiler = createDaemonUpgradeCompilerEnvironment(path.join(root, "build"), { + env: { + ...process.env, + PATH: `${hostileBin}${path.delimiter}${process.env.PATH ?? ""}`, + }, + bunExecutable: attestedBun, + }); + try { + expect(realpathSync(compiler.resolvedBun)).toBe(realpathSync(attestedBun)); + expect(compiler.resolvedBun.startsWith(path.join(root, "build"))).toBe(true); + } finally { + compiler.cleanup(); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("snapshots dependencies while preserving workspace links inside the isolated tree", () => { + if (process.platform !== "linux") return; + const root = mkdtempSync(path.join(tmpdir(), "hunk-daemon-deps-")); + const isolated = path.join(root, "isolated"); + try { + mkdirSync(path.join(root, "repo", "node_modules", "@hunk"), { + recursive: true, + }); + mkdirSync(path.join(root, "repo", "packages", "session-broker"), { + recursive: true, + }); + writeFileSync(path.join(root, "repo", "node_modules", "dependency.txt"), "snapshot\n"); + symlinkSync( + "../../packages/session-broker", + path.join(root, "repo", "node_modules", "@hunk", "session-broker"), + ); + mkdirSync(path.join(isolated, "packages", "session-broker"), { + recursive: true, + }); + writeFileSync(path.join(isolated, "packages", "session-broker", "marker"), "isolated\n"); + + snapshotDaemonUpgradeDependencies( + path.join(root, "repo"), + path.join(isolated, "node_modules"), + ); + + expect(readFileSync(path.join(isolated, "node_modules", "dependency.txt"), "utf8")).toBe( + "snapshot\n", + ); + expect(realpathSync(path.join(isolated, "node_modules", "@hunk", "session-broker"))).toBe( + realpathSync(path.join(isolated, "packages", "session-broker")), + ); + writeFileSync(path.join(root, "repo", "node_modules", "dependency.txt"), "mutated\n"); + expect(readFileSync(path.join(isolated, "node_modules", "dependency.txt"), "utf8")).toBe( + "snapshot\n", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("rewrites exactly one positive daemon revision for isolated full-binary fixtures", () => { + const source = "export const HUNK_SESSION_DAEMON_VERSION = 11;\n"; + expect(readDaemonRevision(source)).toBe(11); + expect(replaceDaemonRevision(source, 10)).toContain("VERSION = 10;"); + expect(() => readDaemonRevision(`${source}${source}`)).toThrow("exactly one"); + expect(() => readDaemonRevision("export const unrelated = 1;\n")).toThrow("exactly one"); + expect(() => replaceDaemonRevision(source, 0)).toThrow("positive safe integer"); + }); + test("builds two distinct Linux x64 package topologies from staged engine metadata", () => { const stagedEngines = { node: ">=99" }; const fixtureA = buildSyntheticPackageManifests(FIXTURE_VERSION_A, stagedEngines); @@ -174,7 +398,9 @@ describe("install VM package fixtures", () => { const fixtures = mkdtempSync(path.join(tmpdir(), "hunk-install-vm-fixtures-")); try { initializeTestGitRepo(repo); - mkdirSync(path.join(repo, "test", "cli", "install-vm"), { recursive: true }); + mkdirSync(path.join(repo, "test", "cli", "install-vm"), { + recursive: true, + }); writeFileSync(path.join(repo, "package.json"), '{"name":"fixture","version":"1.0.0"}\n'); writeFileSync(path.join(repo, "test", "cli", "install-vm", "source.txt"), "source\n"); const manifest = writeTestFixtures(repo, fixtures); @@ -204,13 +430,60 @@ describe("install VM package fixtures", () => { const drifted = { ...manifest, packages: manifest.packages.slice(0, -1) }; writeFileSync(path.join(fixtures, "fixture-manifest.json"), `${JSON.stringify(drifted)}\n`); writeFileSync(httpManifest, `${JSON.stringify(drifted)}\n`); - expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow("exactly six"); + expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow("exactly ten"); + + const staleContract = { + ...manifest, + daemonUpgrade: { ...manifest.daemonUpgrade, revisionA: 9 }, + }; + writeFileSync( + path.join(fixtures, "fixture-manifest.json"), + `${JSON.stringify(staleContract)}\n`, + ); + writeFileSync(httpManifest, `${JSON.stringify(staleContract)}\n`); + expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow("malformed or stale"); + + const unboundBinary = { + ...manifest, + daemonUpgrade: { + ...manifest.daemonUpgrade, + binarySha256A: "b".repeat(64), + }, + }; + writeFileSync( + path.join(fixtures, "fixture-manifest.json"), + `${JSON.stringify(unboundBinary)}\n`, + ); + writeFileSync(httpManifest, `${JSON.stringify(unboundBinary)}\n`); + expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow("malformed or stale"); + const unknownContractKey = { + ...manifest, + daemonUpgrade: { ...manifest.daemonUpgrade, extra: true }, + }; + writeFileSync( + path.join(fixtures, "fixture-manifest.json"), + `${JSON.stringify(unknownContractKey)}\n`, + ); + writeFileSync(httpManifest, `${JSON.stringify(unknownContractKey)}\n`); + expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow("malformed or stale"); writeFileSync(path.join(fixtures, "fixture-manifest.json"), manifestBytes); writeFileSync(httpManifest, manifestBytes); const tarball = path.join(fixtures, "packages", manifest.packages[0]!.tarball); writeFileSync(tarball, "tampered\n"); expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow("checksum mismatch"); + rmSync(tarball); + symlinkSync(path.join(repo, "package.json"), tarball); + expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow("checksum mismatch"); + rmSync(tarball); + symlinkSync(path.join("..", manifest.packages[1]!.tarball), tarball); + expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow("checksum mismatch"); + rmSync(tarball); + const packages = path.join(fixtures, "packages"); + const realPackages = path.join(fixtures, "packages-real"); + renameSync(packages, realPackages); + symlinkSync(realPackages, packages); + expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow("checksum mismatch"); } finally { rmSync(repo, { recursive: true, force: true }); rmSync(fixtures, { recursive: true, force: true }); diff --git a/test/cli/install-vm/prepare-fixtures.ts b/test/cli/install-vm/prepare-fixtures.ts index ff65d3d6b..5f08d99f9 100644 --- a/test/cli/install-vm/prepare-fixtures.ts +++ b/test/cli/install-vm/prepare-fixtures.ts @@ -5,6 +5,7 @@ import { existsSync, lstatSync, mkdirSync, + mkdtempSync, readFileSync, readlinkSync, renameSync, @@ -13,6 +14,7 @@ import { writeFileSync, } from "node:fs"; import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; import path from "node:path"; import { assertNoMandatoryBunDependency, @@ -23,6 +25,13 @@ import { } from "../../../scripts/prebuilt-package-helpers"; import { stagePrebuiltArtifact } from "../../../scripts/build-prebuilt-artifact"; import { npmCommand } from "../../../scripts/script-helpers"; +import { + DAEMON_UPGRADE_VERSION_A, + DAEMON_UPGRADE_VERSION_B, + computeDaemonUpgradeBuildInputIdentity, + prepareDaemonUpgradeBinaries, + readDaemonRevision, +} from "./prepare-daemon-upgrade-fixtures"; export const FIXTURE_VERSION_A = "900.0.0"; export const FIXTURE_VERSION_B = "900.0.1"; @@ -38,11 +47,20 @@ export interface FixturePackage { } export interface InstallVmFixtureManifest { - schemaVersion: 1; + schemaVersion: 2; sourceIdentity: string; + daemonUpgradeBuildInputIdentity: string; currentVersion: string; versionA: string; versionB: string; + daemonUpgrade: { + versionA: string; + versionB: string; + revisionA: number; + revisionB: number; + binarySha256A: string; + binarySha256B: string; + }; packages: FixturePackage[]; } @@ -156,12 +174,29 @@ export function computeInstallVmFixtureSourceIdentity(repoRoot: string) { return hash.digest("hex"); } +/** Resolve one fixture file without allowing symlink components or output-root escape. */ +function containedFixtureFile(outputRoot: string, relativePath: string) { + const root = path.resolve(outputRoot); + let current = root; + for (const segment of relativePath.split("/")) { + current = path.join(current, segment); + const stat = lstatSync(current); + if (stat.isSymbolicLink()) + throw new Error(`Fixture path may not be a symlink: ${relativePath}`); + } + const resolved = path.resolve(current); + if (!resolved.startsWith(`${root}${path.sep}`) || !lstatSync(resolved).isFile()) { + throw new Error(`Fixture path is not a contained regular file: ${relativePath}`); + } + return resolved; +} + /** Verify reusable fixtures still match this checkout and every declared tarball digest. */ export function verifyInstallVmFixtures(repoRoot: string, outputRoot: string) { const manifestPath = path.join(outputRoot, "fixture-manifest.json"); const manifestBytes = readFileSync(manifestPath, "utf8"); const manifest = JSON.parse(manifestBytes) as InstallVmFixtureManifest; - if (manifest.schemaVersion !== 1) throw new Error("Fixture manifest must use schemaVersion 1."); + if (manifest.schemaVersion !== 2) throw new Error("Fixture manifest must use schemaVersion 2."); const expectedIdentity = computeInstallVmFixtureSourceIdentity(repoRoot); if (manifest.sourceIdentity !== expectedIdentity) { throw new Error("Install VM fixtures do not match the current checkout identity."); @@ -178,14 +213,45 @@ export function verifyInstallVmFixtures(repoRoot: string, outputRoot: string) { if (manifest.versionA !== FIXTURE_VERSION_A || manifest.versionB !== FIXTURE_VERSION_B) { throw new Error("Fixture upgrade versions do not match the harness contract."); } + if ( + manifest.daemonUpgradeBuildInputIdentity !== computeDaemonUpgradeBuildInputIdentity(repoRoot) + ) { + throw new Error( + "Fixture daemon upgrade build inputs do not match current dependencies and Bun.", + ); + } + const expectedRevisionB = readDaemonRevision( + readFileSync(path.join(repoRoot, "src", "session", "protocol.ts"), "utf8"), + ); + const daemonUpgrade = manifest.daemonUpgrade; + if ( + !daemonUpgrade || + typeof daemonUpgrade !== "object" || + Object.keys(daemonUpgrade).sort().join("\0") !== + ["binarySha256A", "binarySha256B", "revisionA", "revisionB", "versionA", "versionB"].join( + "\0", + ) || + daemonUpgrade.versionA !== DAEMON_UPGRADE_VERSION_A || + daemonUpgrade.versionB !== DAEMON_UPGRADE_VERSION_B || + daemonUpgrade.revisionB !== expectedRevisionB || + daemonUpgrade.revisionA !== expectedRevisionB - 1 || + !/^[0-9a-f]{64}$/.test(daemonUpgrade.binarySha256A) || + !/^[0-9a-f]{64}$/.test(daemonUpgrade.binarySha256B) || + daemonUpgrade.binarySha256A === daemonUpgrade.binarySha256B + ) { + throw new Error("Fixture daemon upgrade contract is malformed or stale."); + } const expectedIdentities = new Set( - [manifest.currentVersion, manifest.versionA, manifest.versionB].flatMap((version) => [ - `hunkdiff-linux-x64@${version}`, - `hunkdiff@${version}`, - ]), + [ + manifest.currentVersion, + daemonUpgrade.versionA, + daemonUpgrade.versionB, + manifest.versionA, + manifest.versionB, + ].flatMap((version) => [`hunkdiff-linux-x64@${version}`, `hunkdiff@${version}`]), ); if (!Array.isArray(manifest.packages) || manifest.packages.length !== expectedIdentities.size) { - throw new Error("Fixture manifest must contain exactly six coupled packages."); + throw new Error("Fixture manifest must contain exactly ten coupled packages."); } const identities = new Set(); @@ -202,8 +268,16 @@ export function verifyInstallVmFixtures(repoRoot: string, outputRoot: string) { if (!expectedIdentities.has(identity)) throw new Error(`Unexpected fixture package: ${identity}`); identities.add(identity); - const tarballPath = path.join(outputRoot, "packages", fixturePackage.tarball); - if (!existsSync(tarballPath) || sha256(tarballPath) !== fixturePackage.sha256) { + let tarballPath: string; + try { + tarballPath = containedFixtureFile( + outputRoot, + path.posix.join("packages", fixturePackage.tarball), + ); + } catch { + throw new Error(`Fixture tarball checksum mismatch: ${fixturePackage.tarball}`); + } + if (sha256(tarballPath) !== fixturePackage.sha256) { throw new Error(`Fixture tarball checksum mismatch: ${fixturePackage.tarball}`); } } @@ -259,6 +333,71 @@ export function verifyInstallVmFixtures(repoRoot: string, outputRoot: string) { return manifest; } +const MAX_DAEMON_FIXTURE_BINARY_BYTES = 512 * 1024 * 1024; + +/** Hash the actual daemon binaries stored in a checksum-verified local fixture set. */ +export async function deriveVerifiedDaemonUpgradeBinaryDigests( + outputRoot: string, + manifest: InstallVmFixtureManifest, +) { + const digestForVersion = async (version: string) => { + const fixturePackage = manifest.packages.find( + (entry) => entry.name === "hunkdiff-linux-x64" && entry.version === version, + ); + if (!fixturePackage) { + throw new Error(`Trusted fixture set is missing the Linux x64 package for ${version}.`); + } + const tarball = containedFixtureFile( + outputRoot, + path.posix.join("packages", fixturePackage.tarball), + ); + const extractionRoot = mkdtempSync(path.join(tmpdir(), "hunk-daemon-fixture-binary-")); + try { + const extraction = Bun.spawn( + [ + "tar", + "-xzf", + tarball, + "--no-same-owner", + "--no-same-permissions", + "-C", + extractionRoot, + "package/bin/hunk", + ], + { stdin: "ignore", stdout: "ignore", stderr: "pipe" }, + ); + const timeout = setTimeout(() => extraction.kill(), 30_000); + timeout.unref?.(); + const exitCode = await extraction.exited; + clearTimeout(timeout); + const stderr = await new Response(extraction.stderr).text(); + if (exitCode !== 0) { + throw new Error( + `Unable to extract trusted daemon fixture ${version}: ${stderr.trim() || `tar exited ${exitCode}`}`, + ); + } + const binary = containedFixtureFile(extractionRoot, "package/bin/hunk"); + const stat = lstatSync(binary); + if (stat.size <= 0 || stat.size > MAX_DAEMON_FIXTURE_BINARY_BYTES) { + throw new Error(`Trusted daemon fixture ${version} has an invalid binary size.`); + } + return sha256(binary); + } finally { + rmSync(extractionRoot, { recursive: true, force: true }); + } + }; + + const binarySha256A = await digestForVersion(manifest.daemonUpgrade.versionA); + const binarySha256B = await digestForVersion(manifest.daemonUpgrade.versionB); + if ( + binarySha256A !== manifest.daemonUpgrade.binarySha256A || + binarySha256B !== manifest.daemonUpgrade.binarySha256B + ) { + throw new Error("Trusted daemon fixture package binaries do not match their manifest digests."); + } + return { binarySha256A, binarySha256B }; +} + /** Write stable indented JSON with a trailing newline. */ function writeJson(filePath: string, value: unknown) { writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); @@ -354,6 +493,52 @@ async function stageSyntheticPackage( ]; } +/** Stage a full compiled Hunk binary behind the same coupled npm package topology. */ +async function stageDaemonUpgradePackage( + repoRoot: string, + stageRoot: string, + version: string, + binary: string, + engines: Readonly>, + packageOutput: string, +) { + const { meta, platform } = buildSyntheticPackageManifests(version, engines); + const platformDir = path.join(stageRoot, `${platform.name}-daemon-${version}`); + mkdirSync(path.join(platformDir, "bin"), { recursive: true }); + writeJson(path.join(platformDir, "package.json"), platform); + copyFileSync(binary, path.join(platformDir, "bin", "hunk")); + chmodSync(path.join(platformDir, "bin", "hunk"), 0o755); + + const metaDir = path.join(stageRoot, `hunkdiff-daemon-${version}`); + mkdirSync(path.join(metaDir, "bin"), { recursive: true }); + mkdirSync(path.join(metaDir, "dist", "npm"), { recursive: true }); + copyFileSync(path.join(repoRoot, "bin", "hunk.cjs"), path.join(metaDir, "bin", "hunk.cjs")); + chmodSync(path.join(metaDir, "bin", "hunk.cjs"), 0o755); + copyFixtureSkills(repoRoot, metaDir); + writeFileSync( + path.join(metaDir, "dist", "npm", "main.js"), + `console.error('Full daemon upgrade fixture ${version} requires its Linux x64 platform package.');\nprocess.exitCode = 1;\n`, + ); + writeJson(path.join(metaDir, "package.json"), meta); + + const platformTarball = await packPackage(platformDir, packageOutput); + const metaTarball = await packPackage(metaDir, packageOutput); + return [ + { + name: platform.name, + version, + tarball: platformTarball, + sha256: sha256(path.join(packageOutput, platformTarball)), + }, + { + name: meta.name, + version, + tarball: metaTarball, + sha256: sha256(path.join(packageOutput, metaTarball)), + }, + ]; +} + /** Stage one synthetic standalone archive and matching checksum manifest. */ async function stageSyntheticCurlArchive( repoRoot: string, @@ -407,9 +592,11 @@ export async function prepareInstallVmFixtures(repoRoot: string, outputRoot: str try { const packageOutput = path.join(temporaryRoot, "packages"); const stageRoot = path.join(temporaryRoot, "stage"); + const daemonBuildRoot = path.join(temporaryRoot, "daemon-builds"); mkdirSync(packageOutput, { recursive: true }); mkdirSync(stageRoot, { recursive: true }); + const daemonUpgrade = await prepareDaemonUpgradeBinaries(repoRoot, daemonBuildRoot); const currentPlatform = path.join(releaseRoot, "hunkdiff-linux-x64"); if (!existsSync(currentPlatform)) { throw new Error("Install VM fixtures require a Linux x64 prebuilt package."); @@ -427,6 +614,28 @@ export async function prepareInstallVmFixtures(repoRoot: string, outputRoot: str sha256: sha256(path.join(packageOutput, tarball)), }); } + packages.push( + ...(await stageDaemonUpgradePackage( + repoRoot, + stageRoot, + daemonUpgrade.versionA, + daemonUpgrade.binaryA, + currentManifest.engines, + packageOutput, + )), + ); + packages.push( + ...(await stageDaemonUpgradePackage( + repoRoot, + stageRoot, + daemonUpgrade.versionB, + daemonUpgrade.binaryB, + currentManifest.engines, + packageOutput, + )), + ); + // Remove isolated source/build trees before fixtures become atomically visible. + rmSync(daemonBuildRoot, { recursive: true, force: true }); packages.push( ...(await stageSyntheticPackage( repoRoot, @@ -454,7 +663,10 @@ export async function prepareInstallVmFixtures(repoRoot: string, outputRoot: str `${JSON.stringify({ tag_name: `v${currentVersion}` })}\n`, ); const artifactRoot = path.join(stageRoot, "artifacts"); - const artifactDir = stagePrebuiltArtifact({ repoRoot, outputRoot: artifactRoot }); + const artifactDir = stagePrebuiltArtifact({ + repoRoot, + outputRoot: artifactRoot, + }); const archiveName = "hunkdiff-linux-x64.tar.gz"; const goodDownloadDir = path.join(downloads, `v${currentVersion}`); mkdirSync(goodDownloadDir, { recursive: true }); @@ -494,11 +706,20 @@ export async function prepareInstallVmFixtures(repoRoot: string, outputRoot: str writeFileSync(path.join(httpRoot, "install.sh"), installer); const fixtureManifest: InstallVmFixtureManifest = { - schemaVersion: 1, + schemaVersion: 2, sourceIdentity, + daemonUpgradeBuildInputIdentity: daemonUpgrade.daemonUpgradeBuildInputIdentity, currentVersion, versionA: FIXTURE_VERSION_A, versionB: FIXTURE_VERSION_B, + daemonUpgrade: { + versionA: daemonUpgrade.versionA, + versionB: daemonUpgrade.versionB, + revisionA: daemonUpgrade.revisionA, + revisionB: daemonUpgrade.revisionB, + binarySha256A: daemonUpgrade.binarySha256A, + binarySha256B: daemonUpgrade.binarySha256B, + }, packages, }; writeJson(path.join(temporaryRoot, "fixture-manifest.json"), fixtureManifest); @@ -510,6 +731,9 @@ export async function prepareInstallVmFixtures(repoRoot: string, outputRoot: str }; writeJson(path.join(temporaryRoot, "curl-versions.json"), curlVersions); writeJson(path.join(httpRoot, "curl-versions.json"), curlVersions); + // Staging inputs are not release fixtures; discard their duplicate archives and source trees + // before the atomically published directory becomes visible to reusable VM runs. + rmSync(stageRoot, { recursive: true, force: true }); verifyInstallVmFixtures(repoRoot, temporaryRoot); if (existsSync(outputRoot)) renameSync(outputRoot, backupRoot); diff --git a/test/cli/install-vm/results.test.ts b/test/cli/install-vm/results.test.ts index c4471cbe9..4f948a6f3 100644 --- a/test/cli/install-vm/results.test.ts +++ b/test/cli/install-vm/results.test.ts @@ -1,5 +1,13 @@ import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { @@ -20,6 +28,140 @@ const scenario = { network: "local" as const, }; +const daemonScenario = { + id: "authenticated-daemon-upgrade", + description: "Daemon upgrade evidence", + profile: "node" as const, + script: "authenticated-daemon-upgrade.sh", + network: "local" as const, + requiredEvidence: { + commands: ["upgrade-daemon-b"], + commandExpectations: { "upgrade-daemon-b": "exit 0" }, + }, +}; + +/** Write a compact but semantically complete authenticated-upgrade release result. */ +function writeDaemonReleaseEvidence(output: string) { + const directory = path.join(output, "scenarios", daemonScenario.id); + mkdirSync(directory, { recursive: true }); + const observations = { + daemonPackageVersionA: "899.0.0", + daemonPackageVersionB: "899.0.1", + daemonRevisionA: "10", + daemonRevisionB: "11", + daemonUpgradeBuildInputIdentity: "c".repeat(64), + oldDaemonPid: "100", + oldDaemonStartToken: "1000", + newDaemonPid: "200", + newDaemonStartToken: "2000", + oldClientPid: "101", + oldClientStartToken: "1001", + newFirstClientPid: "201", + newFirstClientStartToken: "2001", + newSecondClientPid: "202", + newSecondClientStartToken: "2002", + newFirstWrapperStartToken: "3001", + newSecondWrapperStartToken: "3002", + oldExecutableDigest: "a".repeat(64), + newExecutableDigest: "b".repeat(64), + oldExecutableLocation: "/fixture/a", + newExecutableLocation: "/fixture/b", + oldExecutablePath: "old-executable.txt", + newExecutablePath: "new-executable.txt", + fixtureManifestPath: "daemon-fixture-manifest.json", + reconnectDurationMs: "70000", + overlapHealthPath: "overlap-health.json", + recoveredHealthPath: "recovered-health.json", + oldMetadataPath: "old-metadata.json", + recoveredMetadataPath: "recovered-metadata.json", + oldSessionListPath: "old-session-list.json", + firstRecoveredSessionListPath: "first-recovered-session-list.json", + recoveredSessionListPath: "recovered-session-list.json", + incompatibleWarningPath: "incompatible-warning.log", + }; + const files: Record = { + "overlap-health.json": '{"ok":true}', + "recovered-health.json": '{"ok":true}', + "old-metadata.json": '{"pid":100}', + "recovered-metadata.json": '{"pid":200}', + "old-session-list.json": '{"sessions":[{"pid":101}]}', + "first-recovered-session-list.json": '{"sessions":[{"pid":201}]}', + "recovered-session-list.json": '{"sessions":[{"pid":201},{"pid":202}]}', + "incompatible-warning.log": + "Close older Hunk windows; this window will reconnect automatically.\n", + "old-executable.txt": `pid=100\nstartToken=1000\nlocation=/fixture/a\ndigest=${"a".repeat(64)}\n`, + "new-executable.txt": `pid=200\nstartToken=2000\nlocation=/fixture/b\ndigest=${"b".repeat(64)}\n`, + "daemon-fixture-manifest.json": JSON.stringify({ + schemaVersion: 2, + sourceIdentity, + daemonUpgradeBuildInputIdentity: "c".repeat(64), + daemonUpgrade: { + versionA: "899.0.0", + versionB: "899.0.1", + revisionA: 10, + revisionB: 11, + binarySha256A: "a".repeat(64), + binarySha256B: "b".repeat(64), + }, + }), + }; + for (const [relativePath, contents] of Object.entries(files)) { + writeFileSync(path.join(directory, relativePath), contents); + } + const artifacts = Object.keys(files).map((relativePath) => + path.posix.join("scenarios", daemonScenario.id, relativePath), + ); + const result = { + schemaVersion: 1 as const, + run: { + id: "run", + startedAt: "2026-01-01T00:00:00Z", + finishedAt: "2026-01-01T00:02:00Z", + platform: "linux-x64" as const, + sourceIdentity, + status: "passed" as const, + }, + tools: { + firecracker: "Firecracker v1.16.1", + kernel: "6.18.44", + node: "v24.14.1", + npm: "11.11.0", + pnpm: "11.23.0", + verdaccio: "v6.10.1", + }, + scenarios: [ + { + id: daemonScenario.id, + description: daemonScenario.description, + status: "passed" as const, + durationMs: 100, + exitCode: 0, + commands: [ + { + id: "upgrade-daemon-b", + status: "passed" as const, + expectation: "exit 0", + exitCode: 0, + logPath: "incompatible-warning.log", + }, + ], + observations, + assertions: [ + { + id: "migration", + status: "passed" as const, + expected: "recovered", + actual: "recovered", + message: "evidence matched", + }, + ], + artifacts, + }, + ], + }; + return { result, directory }; +} + describe("install VM results", () => { test("parses assertion protocol and rejects malformed fields", () => { expect(parseAssertionTsv("missing\tpassed\texit 1\texit 1\texpected failure\n")).toEqual([ @@ -156,6 +298,25 @@ describe("install VM results", () => { releaseExpected, ), ).toThrow("malformed command evidence"); + expect(() => + validateInstallVmReleaseResult( + { + ...result, + scenarios: [ + { + ...result.scenarios[0], + commands: [ + { + ...result.scenarios[0]!.commands[0], + expectation: "looks successful", + }, + ], + }, + ], + }, + releaseExpected, + ), + ).toThrow("unsupported expectation"); for (const tools of [ { ...result.tools, pnpm: "11.22.0" }, @@ -185,6 +346,260 @@ describe("install VM results", () => { } }); + test("rejects tampered authenticated daemon upgrade artifacts and observations", () => { + const output = mkdtempSync(path.join(tmpdir(), "hunk-daemon-release-evidence-")); + try { + const { result, directory } = writeDaemonReleaseEvidence(output); + const expected = { + sourceIdentity, + pnpmVersion: "11.23.0", + scenarios: [daemonScenario], + resultDirectory: output, + daemonUpgradeBuildInputIdentity: "c".repeat(64), + daemonRevision: 11, + daemonUpgradeBinaryDigests: { + binarySha256A: "a".repeat(64), + binarySha256B: "b".repeat(64), + }, + }; + expect(validateInstallVmReleaseResult(result, expected)).toBe(result); + const mutate = (update: (copy: typeof result) => void) => { + const copy = structuredClone(result); + update(copy); + return () => validateInstallVmReleaseResult(copy, expected); + }; + + expect(() => + mutate((copy) => { + copy.scenarios[0]!.observations.daemonRevisionB = "10"; + })(), + ).toThrow("revisions must be adjacent"); + expect(() => + mutate((copy) => { + copy.scenarios[0]!.observations.daemonRevisionA = "1"; + copy.scenarios[0]!.observations.daemonRevisionB = "2"; + })(), + ).toThrow("does not match this checkout"); + expect(() => + mutate((copy) => { + copy.scenarios[0]!.observations.newDaemonPid = "100"; + copy.scenarios[0]!.observations.newDaemonStartToken = "1000"; + })(), + ).toThrow("reused the incumbent process identity"); + expect(() => + mutate((copy) => { + copy.scenarios[0]!.observations.newExecutableDigest = "a".repeat(64); + })(), + ).toThrow("digests are invalid or equal"); + expect(() => + mutate((copy) => { + copy.scenarios[0]!.observations.reconnectDurationMs = "120001"; + })(), + ).toThrow("duration exceeds its bound"); + + writeFileSync(path.join(directory, "overlap-health.json"), '{"ok":true,"pid":100}'); + expect(() => validateInstallVmReleaseResult(result, expected)).toThrow( + "not exact minimal health", + ); + writeFileSync(path.join(directory, "overlap-health.json"), '{"ok":true}'); + writeFileSync(path.join(directory, "old-metadata.json"), '{"pid":999}'); + expect(() => validateInstallVmReleaseResult(result, expected)).toThrow( + "PID does not match observations", + ); + writeFileSync(path.join(directory, "old-metadata.json"), '{"pid":100}'); + writeFileSync( + path.join(directory, "recovered-session-list.json"), + '{"sessions":[{"pid":201}]}', + ); + expect(() => validateInstallVmReleaseResult(result, expected)).toThrow( + "not the original clients", + ); + writeFileSync( + path.join(directory, "recovered-session-list.json"), + '{"sessions":[{"pid":201},{"pid":202}]}', + ); + writeFileSync( + path.join(directory, "old-executable.txt"), + `pid=100\nstartToken=9999\nlocation=/fixture/a\ndigest=${"a".repeat(64)}\n`, + ); + expect(() => validateInstallVmReleaseResult(result, expected)).toThrow( + "does not match observations", + ); + writeFileSync( + path.join(directory, "old-executable.txt"), + `pid=100\nstartToken=1000\nlocation=/fixture/a\ndigest=${"a".repeat(64)}\n`, + ); + const fixtureManifestPath = path.join(directory, "daemon-fixture-manifest.json"); + const fixtureManifest = JSON.parse(readFileSync(fixtureManifestPath, "utf8")); + fixtureManifest.daemonUpgrade.binarySha256A = "d".repeat(64); + writeFileSync(fixtureManifestPath, JSON.stringify(fixtureManifest)); + expect(() => validateInstallVmReleaseResult(result, expected)).toThrow( + "fixture manifest does not match", + ); + fixtureManifest.daemonUpgrade.binarySha256A = "a".repeat(64); + fixtureManifest.daemonUpgrade.extra = true; + writeFileSync(fixtureManifestPath, JSON.stringify(fixtureManifest)); + expect(() => validateInstallVmReleaseResult(result, expected)).toThrow( + "fixture manifest does not match", + ); + delete fixtureManifest.daemonUpgrade.extra; + writeFileSync(fixtureManifestPath, JSON.stringify(fixtureManifest)); + expect(() => + mutate((copy) => { + copy.scenarios[0]!.commands[0]!.expectation = "exit 0"; + copy.scenarios[0]!.commands[0]!.exitCode = 1; + })(), + ).toThrow("impossible exit expectation"); + expect(() => + mutate((copy) => { + copy.scenarios[0]!.commands[0]!.expectation = "observed exit"; + copy.scenarios[0]!.commands[0]!.exitCode = 97; + })(), + ).toThrow("command upgrade-daemon-b expected exit 0"); + + const coherentlyTampered = structuredClone(result); + coherentlyTampered.scenarios[0]!.observations.oldExecutableDigest = "d".repeat(64); + coherentlyTampered.scenarios[0]!.observations.newExecutableDigest = "e".repeat(64); + writeFileSync( + path.join(directory, "old-executable.txt"), + `pid=100\nstartToken=1000\nlocation=/fixture/a\ndigest=${"d".repeat(64)}\n`, + ); + writeFileSync( + path.join(directory, "new-executable.txt"), + `pid=200\nstartToken=2000\nlocation=/fixture/b\ndigest=${"e".repeat(64)}\n`, + ); + fixtureManifest.daemonUpgrade.binarySha256A = "d".repeat(64); + fixtureManifest.daemonUpgrade.binarySha256B = "e".repeat(64); + writeFileSync(fixtureManifestPath, JSON.stringify(fixtureManifest)); + expect(() => validateInstallVmReleaseResult(coherentlyTampered, expected)).toThrow( + "fixture manifest does not match", + ); + writeFileSync( + path.join(directory, "old-executable.txt"), + `pid=100\nstartToken=1000\nlocation=/fixture/a\ndigest=${"a".repeat(64)}\n`, + ); + writeFileSync( + path.join(directory, "new-executable.txt"), + `pid=200\nstartToken=2000\nlocation=/fixture/b\ndigest=${"b".repeat(64)}\n`, + ); + fixtureManifest.daemonUpgrade.binarySha256A = "a".repeat(64); + fixtureManifest.daemonUpgrade.binarySha256B = "b".repeat(64); + writeFileSync(fixtureManifestPath, JSON.stringify(fixtureManifest)); + + const outside = path.join(output, "outside-health.json"); + writeFileSync(outside, '{"ok":true}'); + unlinkSync(path.join(directory, "overlap-health.json")); + symlinkSync(outside, path.join(directory, "overlap-health.json")); + expect(() => validateInstallVmReleaseResult(result, expected)).toThrow( + "may not be a symlink", + ); + unlinkSync(path.join(directory, "overlap-health.json")); + symlinkSync("recovered-health.json", path.join(directory, "overlap-health.json")); + expect(() => validateInstallVmReleaseResult(result, expected)).toThrow( + "may not be a symlink", + ); + unlinkSync(path.join(directory, "overlap-health.json")); + writeFileSync(path.join(directory, "overlap-health.json"), '{"ok":true}'); + + unlinkSync(path.join(directory, "recovered-health.json")); + expect(() => validateInstallVmReleaseResult(result, expected)).toThrow( + "references missing artifact", + ); + } finally { + rmSync(output, { recursive: true, force: true }); + } + }); + + test("enforces scenario-specific required evidence during aggregation and release validation", () => { + const output = mkdtempSync(path.join(tmpdir(), "hunk-install-vm-required-")); + const requiredScenario = { + ...scenario, + requiredEvidence: { + commands: ["upgrade"], + commandExpectations: { upgrade: "exit 0" }, + assertions: ["daemon-preserved"], + observations: ["daemonPid", "transcriptPath"], + }, + }; + try { + const scenarioDir = path.join(output, "scenarios", scenario.id); + mkdirSync(path.join(scenarioDir, "commands"), { recursive: true }); + writeFileSync( + path.join(scenarioDir, "result.json"), + `${JSON.stringify({ id: scenario.id, exitCode: 0, durationMs: 10 })}\n`, + ); + writeFileSync( + path.join(scenarioDir, "commands.tsv"), + "upgrade\tpassed\texit 0\t0\tcommands/upgrade.log\n", + ); + writeFileSync(path.join(scenarioDir, "commands", "upgrade.log"), "ok\n"); + writeFileSync( + path.join(scenarioDir, "assertions.tsv"), + "daemon-preserved\tpassed\talive\talive\told daemon survived\n", + ); + writeFileSync(path.join(scenarioDir, "transcript.log"), "transcript\n"); + writeFileSync( + path.join(scenarioDir, "observations.tsv"), + "daemonPid\t123\ntranscriptPath\ttranscript.log\n", + ); + const aggregated = aggregateInstallVmResults({ + outputDir: output, + runId: "run", + startedAt: "2026-01-01T00:00:00Z", + finishedAt: "2026-01-01T00:00:01Z", + sourceIdentity, + scenarios: [requiredScenario], + tools: {}, + }); + expect(aggregated.run.status).toBe("passed"); + + writeFileSync(path.join(scenarioDir, "observations.tsv"), "daemonPid\t123\n"); + expect( + aggregateInstallVmResults({ + outputDir: output, + runId: "run", + startedAt: "2026-01-01T00:00:00Z", + finishedAt: "2026-01-01T00:00:01Z", + sourceIdentity, + scenarios: [requiredScenario], + tools: {}, + }).run.status, + ).toBe("failed"); + + const releaseExpected = { + sourceIdentity, + pnpmVersion: "11.23.0", + scenarios: [requiredScenario], + }; + const release = { + ...aggregated, + tools: { + firecracker: "Firecracker v1.16.1", + kernel: "6.18.44", + node: "v24.14.1", + npm: "11.11.0", + pnpm: releaseExpected.pnpmVersion, + verdaccio: "v6.10.1", + }, + }; + expect(validateInstallVmReleaseResult(release, releaseExpected)).toBe(release); + const missing = { + ...release, + scenarios: [ + { + ...release.scenarios[0]!, + observations: { daemonPid: "123" }, + }, + ], + }; + expect(() => validateInstallVmReleaseResult(missing, releaseExpected)).toThrow( + "required observation transcriptPath", + ); + } finally { + rmSync(output, { recursive: true, force: true }); + } + }); + test("writes deterministic JSON and JUnit projections", () => { const output = mkdtempSync(path.join(tmpdir(), "hunk-install-vm-result-")); try { diff --git a/test/cli/install-vm/results.ts b/test/cli/install-vm/results.ts index 722471e30..8f06d6ebb 100644 --- a/test/cli/install-vm/results.ts +++ b/test/cli/install-vm/results.ts @@ -1,7 +1,15 @@ -import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { + existsSync, + lstatSync, + readFileSync, + readdirSync, + realpathSync, + writeFileSync, +} from "node:fs"; import path from "node:path"; import { buildInstallVmJunit, + validateInstallVmCommandExpectation, type InstallVmAssertion, type InstallVmCommandResult, type InstallVmRunResult, @@ -9,6 +17,10 @@ import { type InstallVmScenario, type InstallVmScenarioResult, } from "./contract"; +import { + DAEMON_UPGRADE_VERSION_A, + DAEMON_UPGRADE_VERSION_B, +} from "./prepare-daemon-upgrade-fixtures"; interface RawScenarioResult { id: string; @@ -34,6 +46,34 @@ function safeArtifactPath(value: string) { return normalized; } +/** Resolve an artifact without allowing any symlink component or root escape. */ +function containedArtifact( + root: string, + relativePath: string, + expectedKind: "file" | "directory" | "either" = "either", +) { + const safe = safeArtifactPath(relativePath); + const realRoot = realpathSync(root); + let current = realRoot; + for (const segment of safe.split("/")) { + current = path.join(current, segment); + const stat = lstatSync(current); + if (stat.isSymbolicLink()) throw new Error(`Install VM artifact may not be a symlink: ${safe}`); + } + const resolved = realpathSync(current); + if (resolved !== realRoot && !resolved.startsWith(`${realRoot}${path.sep}`)) { + throw new Error(`Install VM artifact escapes its run directory: ${safe}`); + } + const stat = lstatSync(resolved); + if (expectedKind === "file" && !stat.isFile()) { + throw new Error(`Install VM artifact is not a regular file: ${safe}`); + } + if (expectedKind === "directory" && !stat.isDirectory()) { + throw new Error(`Install VM artifact is not a directory: ${safe}`); + } + return resolved; +} + /** Parse guest assertion TSV without allowing embedded control fields. */ export function parseAssertionTsv(contents: string): InstallVmAssertion[] { if (!contents.trim()) return []; @@ -80,7 +120,13 @@ export function parseCommandTsv(contents: string): InstallVmCommandResult[] { throw new Error(`Invalid command status for ${id}: ${status}`); } if (!Number.isSafeInteger(exitCode)) throw new Error(`Invalid command exit code for ${id}.`); - return { id, status, expectation, exitCode, logPath: safeArtifactPath(logPath) }; + return { + id, + status, + expectation, + exitCode, + logPath: safeArtifactPath(logPath), + }; }); } @@ -112,6 +158,273 @@ function hasUniqueIds(records: readonly Record[]) { return ids.every((id) => typeof id === "string") && new Set(ids).size === ids.length; } +/** Require each scenario-declared proof item to exist exactly once and be successful/nonempty. */ +function validateRequiredEvidence( + scenario: InstallVmScenario, + evidence: { + commands: readonly { id: string; status: string; expectation: string }[]; + assertions: readonly { id: string; status: string }[]; + observations: Readonly>; + }, +) { + for (const id of scenario.requiredEvidence?.commands ?? []) { + const matches = evidence.commands.filter((command) => command.id === id); + if (matches.length !== 1 || matches[0]?.status !== "passed") { + throw new Error(`Install VM scenario ${scenario.id} is missing required command ${id}.`); + } + const expectedExpectation = scenario.requiredEvidence?.commandExpectations?.[id]; + if (expectedExpectation !== undefined && matches[0]?.expectation !== expectedExpectation) { + throw new Error( + `Install VM scenario ${scenario.id} command ${id} expected ${expectedExpectation}, got ${matches[0]?.expectation}.`, + ); + } + } + for (const id of scenario.requiredEvidence?.assertions ?? []) { + const matches = evidence.assertions.filter((assertion) => assertion.id === id); + if (matches.length !== 1 || matches[0]?.status !== "passed") { + throw new Error(`Install VM scenario ${scenario.id} is missing required assertion ${id}.`); + } + } + for (const key of scenario.requiredEvidence?.observations ?? []) { + const value = evidence.observations[key]; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Install VM scenario ${scenario.id} is missing required observation ${key}.`); + } + } +} + +const DAEMON_UPGRADE_SCENARIO_ID = "authenticated-daemon-upgrade"; +const DAEMON_UPGRADE_WARNING = "Close older Hunk windows"; +const MAX_DAEMON_RECONNECT_DURATION_MS = 120_000; + +/** Parse one required positive integer observation. */ +function positiveObservation(observations: Record, key: string) { + const value = Number(observations[key]); + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`Authenticated daemon upgrade has invalid ${key}.`); + } + return value; +} + +/** Read one scenario artifact beneath the validated run directory. */ +function readScenarioArtifact(resultDirectory: string, scenarioId: string, relativePath: string) { + const safeRelativePath = safeArtifactPath(relativePath); + try { + return readFileSync( + containedArtifact( + resultDirectory, + path.posix.join("scenarios", scenarioId, safeRelativePath), + "file", + ), + "utf8", + ); + } catch (error) { + if (error instanceof Error && error.message.startsWith("Install VM artifact")) throw error; + throw new Error(`Install VM scenario ${scenarioId} is missing artifact ${safeRelativePath}.`); + } +} + +/** Validate the daemon migration scenario's process and protocol evidence from guest artifacts. */ +function validateAuthenticatedDaemonUpgradeEvidence( + scenario: Record, + resultDirectory: string, + runSourceIdentity: string, + expectedBuildInputIdentity?: string, + expectedDaemonRevision?: number, + expectedBinaryDigests?: { readonly binarySha256A: string; readonly binarySha256B: string }, +) { + const observations = scenario.observations as Record; + if ( + observations.daemonPackageVersionA !== DAEMON_UPGRADE_VERSION_A || + observations.daemonPackageVersionB !== DAEMON_UPGRADE_VERSION_B + ) { + throw new Error("Authenticated daemon upgrade fixture versions are not fixed."); + } + const revisionA = positiveObservation(observations, "daemonRevisionA"); + const revisionB = positiveObservation(observations, "daemonRevisionB"); + if (revisionB !== revisionA + 1) { + throw new Error("Authenticated daemon upgrade revisions must be adjacent."); + } + if (expectedDaemonRevision !== undefined && revisionB !== expectedDaemonRevision) { + throw new Error("Authenticated daemon upgrade revision does not match this checkout."); + } + + const oldDaemonPid = positiveObservation(observations, "oldDaemonPid"); + const oldDaemonStartToken = positiveObservation(observations, "oldDaemonStartToken"); + const newDaemonPid = positiveObservation(observations, "newDaemonPid"); + const newDaemonStartToken = positiveObservation(observations, "newDaemonStartToken"); + if (oldDaemonPid === newDaemonPid && oldDaemonStartToken === newDaemonStartToken) { + throw new Error("Authenticated daemon upgrade reused the incumbent process identity."); + } + const oldClientPid = positiveObservation(observations, "oldClientPid"); + const newFirstClientPid = positiveObservation(observations, "newFirstClientPid"); + const newSecondClientPid = positiveObservation(observations, "newSecondClientPid"); + positiveObservation(observations, "oldClientStartToken"); + positiveObservation(observations, "newFirstClientStartToken"); + positiveObservation(observations, "newSecondClientStartToken"); + positiveObservation(observations, "newFirstWrapperStartToken"); + positiveObservation(observations, "newSecondWrapperStartToken"); + + const oldDigest = observations.oldExecutableDigest ?? ""; + const newDigest = observations.newExecutableDigest ?? ""; + if ( + !SOURCE_IDENTITY_PATTERN.test(oldDigest) || + !SOURCE_IDENTITY_PATTERN.test(newDigest) || + oldDigest === newDigest + ) { + throw new Error("Authenticated daemon upgrade executable digests are invalid or equal."); + } + const parseExecutableEvidence = (key: "oldExecutablePath" | "newExecutablePath") => { + const fields = Object.fromEntries( + readScenarioArtifact(resultDirectory, DAEMON_UPGRADE_SCENARIO_ID, observations[key]!) + .trimEnd() + .split("\n") + .map((line) => { + const index = line.indexOf("="); + if (index < 1) throw new Error(`Authenticated daemon upgrade ${key} is malformed.`); + return [line.slice(0, index), line.slice(index + 1)]; + }), + ); + if ( + Object.keys(fields).sort().join("\0") !== + ["digest", "location", "pid", "startToken"].join("\0") + ) { + throw new Error(`Authenticated daemon upgrade ${key} is malformed.`); + } + return fields; + }; + const oldExecutable = parseExecutableEvidence("oldExecutablePath"); + const newExecutable = parseExecutableEvidence("newExecutablePath"); + for (const [evidence, pid, token, location, digest, key] of [ + [ + oldExecutable, + oldDaemonPid, + oldDaemonStartToken, + observations.oldExecutableLocation, + oldDigest, + "oldExecutablePath", + ], + [ + newExecutable, + newDaemonPid, + newDaemonStartToken, + observations.newExecutableLocation, + newDigest, + "newExecutablePath", + ], + ] as const) { + if ( + evidence.pid !== String(pid) || + evidence.startToken !== String(token) || + evidence.location !== location || + evidence.digest !== digest + ) { + throw new Error(`Authenticated daemon upgrade ${key} does not match observations.`); + } + } + const fixtureManifest = JSON.parse( + readScenarioArtifact( + resultDirectory, + DAEMON_UPGRADE_SCENARIO_ID, + observations.fixtureManifestPath!, + ), + ) as Record; + const fixtureUpgrade = fixtureManifest.daemonUpgrade as Record | undefined; + const buildInputIdentity = fixtureManifest.daemonUpgradeBuildInputIdentity; + if ( + fixtureManifest.sourceIdentity !== runSourceIdentity || + fixtureManifest.schemaVersion !== 2 || + !isRecord(fixtureUpgrade) || + Object.keys(fixtureUpgrade).sort().join("\0") !== + ["binarySha256A", "binarySha256B", "revisionA", "revisionB", "versionA", "versionB"].join( + "\0", + ) || + fixtureUpgrade.versionA !== DAEMON_UPGRADE_VERSION_A || + fixtureUpgrade.versionB !== DAEMON_UPGRADE_VERSION_B || + fixtureUpgrade.revisionA !== revisionA || + fixtureUpgrade.revisionB !== revisionB || + fixtureUpgrade.binarySha256A !== oldDigest || + fixtureUpgrade.binarySha256B !== newDigest || + expectedBinaryDigests?.binarySha256A !== oldDigest || + expectedBinaryDigests?.binarySha256B !== newDigest || + typeof buildInputIdentity !== "string" || + !SOURCE_IDENTITY_PATTERN.test(buildInputIdentity) || + observations.daemonUpgradeBuildInputIdentity !== buildInputIdentity || + (expectedBuildInputIdentity !== undefined && buildInputIdentity !== expectedBuildInputIdentity) + ) { + throw new Error( + "Authenticated daemon upgrade fixture manifest does not match release evidence.", + ); + } + + const reconnectDuration = positiveObservation(observations, "reconnectDurationMs"); + if (reconnectDuration > MAX_DAEMON_RECONNECT_DURATION_MS) { + throw new Error("Authenticated daemon upgrade reconnect duration exceeds its bound."); + } + + for (const key of ["overlapHealthPath", "recoveredHealthPath"] as const) { + if ( + readScenarioArtifact(resultDirectory, DAEMON_UPGRADE_SCENARIO_ID, observations[key]!) !== + '{"ok":true}' + ) { + throw new Error(`Authenticated daemon upgrade ${key} is not exact minimal health.`); + } + } + for (const [key, expectedPid] of [ + ["oldMetadataPath", oldDaemonPid], + ["recoveredMetadataPath", newDaemonPid], + ] as const) { + const metadata = JSON.parse( + readScenarioArtifact(resultDirectory, DAEMON_UPGRADE_SCENARIO_ID, observations[key]!), + ) as { pid?: unknown }; + if (metadata.pid !== expectedPid) { + throw new Error(`Authenticated daemon upgrade ${key} PID does not match observations.`); + } + } + + const readSessionPids = ( + key: "oldSessionListPath" | "firstRecoveredSessionListPath" | "recoveredSessionListPath", + ) => { + const value = JSON.parse( + readScenarioArtifact(resultDirectory, DAEMON_UPGRADE_SCENARIO_ID, observations[key]!), + ) as { sessions?: Array<{ pid?: unknown }> }; + if (!Array.isArray(value.sessions) || value.sessions.some((entry) => !isRecord(entry))) { + throw new Error(`Authenticated daemon upgrade ${key} has malformed sessions.`); + } + return value.sessions + .map((entry) => entry.pid) + .sort((left, right) => Number(left) - Number(right)); + }; + if (JSON.stringify(readSessionPids("oldSessionListPath")) !== JSON.stringify([oldClientPid])) { + throw new Error("Authenticated daemon upgrade old session PID does not match its client."); + } + if ( + JSON.stringify(readSessionPids("firstRecoveredSessionListPath")) !== + JSON.stringify([newFirstClientPid]) + ) { + throw new Error( + "Authenticated daemon upgrade first successor session is not its original client.", + ); + } + const recoveredPids = [newFirstClientPid, newSecondClientPid].sort((left, right) => left - right); + if ( + JSON.stringify(readSessionPids("recoveredSessionListPath")) !== JSON.stringify(recoveredPids) + ) { + throw new Error( + "Authenticated daemon upgrade recovered session PIDs are not the original clients.", + ); + } + if ( + !readScenarioArtifact( + resultDirectory, + DAEMON_UPGRADE_SCENARIO_ID, + observations.incompatibleWarningPath!, + ).includes(DAEMON_UPGRADE_WARNING) + ) { + throw new Error("Authenticated daemon upgrade warning evidence is missing required guidance."); + } +} + /** Validate that release evidence is complete, consistent, and matches this checkout. */ export function validateInstallVmReleaseResult( value: unknown, @@ -119,6 +432,13 @@ export function validateInstallVmReleaseResult( sourceIdentity: string; pnpmVersion: string; scenarios: readonly InstallVmScenario[]; + resultDirectory?: string; + daemonUpgradeBuildInputIdentity?: string; + daemonRevision?: number; + daemonUpgradeBinaryDigests?: { + readonly binarySha256A: string; + readonly binarySha256B: string; + }; }, ) { if (!isRecord(value) || value.schemaVersion !== 1 || !isRecord(value.run)) { @@ -214,6 +534,7 @@ export function validateInstallVmReleaseResult( throw new Error(`Install VM release result has malformed command evidence for ${id}.`); } safeArtifactPath(command.logPath); + validateInstallVmCommandExpectation(command.expectation, command.exitCode as number); } const assertionRecords = scenario.assertions.filter(isRecord); @@ -236,7 +557,57 @@ export function validateInstallVmReleaseResult( if (typeof artifact !== "string") { throw new Error(`Install VM release result has malformed artifacts for ${id}.`); } - safeArtifactPath(artifact); + const relativePath = safeArtifactPath(artifact); + if (expected.resultDirectory) { + try { + containedArtifact(expected.resultDirectory, relativePath); + } catch (error) { + if (error instanceof Error && error.message.startsWith("Install VM artifact")) + throw error; + throw new Error(`Install VM release result references missing artifact ${relativePath}.`); + } + } + } + if (expected.resultDirectory) { + for (const command of commandRecords as Array<{ + logPath: string; + expectation: string; + exitCode: number; + status: string; + }>) { + const declaredPath = path.posix.join("scenarios", id, command.logPath); + const declared = (scenario.artifacts as string[]).some( + (artifact) => artifact === declaredPath || declaredPath.startsWith(`${artifact}/`), + ); + if (!declared) throw new Error(`Install VM command log is not declared: ${declaredPath}.`); + containedArtifact(expected.resultDirectory, declaredPath, "file"); + } + } + validateRequiredEvidence(definition, { + commands: commandRecords as Array<{ id: string; status: string; expectation: string }>, + assertions: assertionRecords as Array<{ id: string; status: string }>, + observations: scenario.observations as Record, + }); + for (const key of definition.requiredEvidence?.observations ?? []) { + if (!key.endsWith("Path")) continue; + const relativePath = (scenario.observations as Record)[key]!; + const expectedArtifact = path.posix.join("scenarios", id, relativePath); + if (!(scenario.artifacts as string[]).includes(expectedArtifact)) { + throw new Error(`Install VM scenario ${id} is missing required path artifact ${key}.`); + } + } + if (id === DAEMON_UPGRADE_SCENARIO_ID) { + if (!expected.resultDirectory) { + throw new Error("Authenticated daemon upgrade validation requires its run directory."); + } + validateAuthenticatedDaemonUpgradeEvidence( + scenario, + expected.resultDirectory, + value.run.sourceIdentity as string, + expected.daemonUpgradeBuildInputIdentity, + expected.daemonRevision, + expected.daemonUpgradeBinaryDigests, + ); } } @@ -301,6 +672,21 @@ export function aggregateInstallVmResults(options: { message: "guest returned no command evidence", }); } + try { + validateRequiredEvidence(scenario, { + commands, + assertions, + observations, + }); + } catch (error) { + assertions.push({ + id: "required-evidence", + status: "failed", + expected: "complete declared evidence", + actual: "missing", + message: error instanceof Error ? error.message : "required evidence missing", + }); + } const artifacts = readdirSync(directory) .filter( (entry) => diff --git a/test/cli/install-vm/scenarios.json b/test/cli/install-vm/scenarios.json index 5e9bbd08c..68a2fd708 100644 --- a/test/cli/install-vm/scenarios.json +++ b/test/cli/install-vm/scenarios.json @@ -15,6 +15,90 @@ "script": "npm-global-upgrade.sh", "network": "local" }, + { + "id": "authenticated-daemon-upgrade", + "description": "Keep an incompatible authenticated daemon alive until quiescence, then reconnect upgraded windows to one successor.", + "profile": "node", + "script": "authenticated-daemon-upgrade.sh", + "network": "local", + "requiredEvidence": { + "commands": [ + "install-daemon-a", + "old-session-list", + "upgrade-daemon-b", + "incompatible-daemon-b", + "suspend-new-second", + "resume-new-second", + "recovered-session-list" + ], + "commandExpectations": { + "install-daemon-a": "exit 0", + "old-session-list": "exit 0", + "upgrade-daemon-b": "exit 0", + "incompatible-daemon-b": "nonzero exit", + "suspend-new-second": "SIGSTOP exact owned B client", + "resume-new-second": "SIGCONT exact owned B client", + "recovered-session-list": "exit 0" + }, + "assertions": [ + "old-producer-registered", + "new-clients-incompatible", + "old-daemon-survived-overlap", + "old-session-still-usable", + "one-incumbent-metadata", + "delayed-client-suspended", + "old-daemon-retired-after-quiescence", + "first-client-established-successor", + "delayed-client-recovered", + "new-clients-not-relaunched", + "new-daemon-binary", + "new-producers-registered", + "minimal-health-before", + "minimal-health-after", + "test-process-cleanup" + ], + "observations": [ + "daemonPackageVersionA", + "daemonPackageVersionB", + "daemonRevisionA", + "daemonRevisionB", + "daemonUpgradeBuildInputIdentity", + "oldDaemonPid", + "newDaemonPid", + "oldDaemonStartToken", + "newDaemonStartToken", + "oldExecutableDigest", + "newExecutableDigest", + "oldExecutableLocation", + "newExecutableLocation", + "oldExecutablePath", + "newExecutablePath", + "fixtureManifestPath", + "oldClientPid", + "oldClientStartToken", + "newFirstWrapperPid", + "newFirstWrapperStartToken", + "newSecondWrapperPid", + "newSecondWrapperStartToken", + "newFirstClientPid", + "newFirstClientStartToken", + "newSecondClientPid", + "newSecondClientStartToken", + "reconnectDurationMs", + "oldTranscriptPath", + "newFirstTranscriptPath", + "newSecondTranscriptPath", + "incompatibleWarningPath", + "oldMetadataPath", + "recoveredMetadataPath", + "overlapHealthPath", + "recoveredHealthPath", + "oldSessionListPath", + "firstRecoveredSessionListPath", + "recoveredSessionListPath" + ] + } + }, { "id": "pnpm-prebuilt-no-bun", "description": "Install the current prebuilt package with pnpm and no Bun runtime.", diff --git a/test/cli/install-vm/scenarios/authenticated-daemon-upgrade.sh b/test/cli/install-vm/scenarios/authenticated-daemon-upgrade.sh new file mode 100755 index 000000000..f3d5e4d53 --- /dev/null +++ b/test/cli/install-vm/scenarios/authenticated-daemon-upgrade.sh @@ -0,0 +1,507 @@ +#!/usr/bin/env bash +# This scenario intentionally relies on Linux /proc and GNU timeout, date, readlink, and sha256sum. +# shellcheck source=../guest/scenario-lib.sh +# shellcheck disable=SC1091,SC2154 +source /tmp/hunk-install-vm/scenario-lib.sh +setup_profile + +old_wrapper= +old_wrapper_token= +old_client= +old_client_token= +new_first_wrapper= +new_first_wrapper_token= +new_first_client= +new_first_client_token= +new_second_wrapper= +new_second_wrapper_token= +new_second_client= +new_second_client_token= +new_second_stopped=0 +fd3_open=0 +fd4_open=0 +fd5_open=0 + +wait_for() { + local timeout_seconds=$1 + shift + local deadline=$((SECONDS + timeout_seconds)) + while ((SECONDS < deadline)); do + "$@" && return 0 + sleep 0.5 + done + return 1 +} + +process_identity_snapshot() { + local pid=$1 + [[ -r /proc/$pid/stat ]] || return 1 + # One read returns Linux stat fields 3 (state) and 22 (starttime). + awk '{line=$0; sub(/^[^)]*\) /,"",line); split(line,fields," "); print fields[1], fields[20]}' "/proc/$pid/stat" +} + +process_start_token() { + local state token + read -r state token < <(process_identity_snapshot "$1") || return 1 + [[ $state != Z ]] || return 1 + printf '%s' "$token" +} + +process_identity_is() { + local pid=$1 expected_token=$2 state token + [[ -n $pid && -n $expected_token ]] || return 1 + read -r state token < <(process_identity_snapshot "$pid") || return 1 + [[ $state != Z && $token == "$expected_token" ]] +} + +process_identity_state_is() { + local pid=$1 expected_token=$2 expected_state=$3 state token + read -r state token < <(process_identity_snapshot "$pid") || return 1 + [[ $token == "$expected_token" && $state == "$expected_state" ]] +} + +process_identity_not_stopped() { + local pid=$1 expected_token=$2 state token + read -r state token < <(process_identity_snapshot "$pid") || return 1 + [[ $token == "$expected_token" && $state != T && $state != t && $state != Z ]] +} + +process_identity_gone() { + ! process_identity_is "$1" "$2" +} + +pidfd_signal_owned_identity() { + local pid=$1 token=$2 signal_name=$3 + python3 - "$pid" "$token" "$signal_name" <<'PY' +import os, signal, sys +pid, expected, signal_name = int(sys.argv[1]), sys.argv[2], sys.argv[3] +def identity(): + with open(f"/proc/{pid}/stat", "r", encoding="utf-8") as stream: + fields = stream.read().rsplit(") ", 1)[1].split() + return fields[0], fields[19] +state, token = identity() +if state == "Z" or token != expected: + raise SystemExit(1) +fd = os.pidfd_open(pid, 0) +try: + state, token = identity() + if state == "Z" or token != expected: + raise SystemExit(1) + signal.pidfd_send_signal(fd, getattr(signal, f"SIG{signal_name}")) +finally: + os.close(fd) +PY +} + +terminate_owned_identity() { + local pid=$1 token=$2 + process_identity_is "$pid" "$token" || return 0 + pidfd_signal_owned_identity "$pid" "$token" TERM 2>/dev/null || return 1 + if ! wait_for 3 process_identity_gone "$pid" "$token"; then + pidfd_signal_owned_identity "$pid" "$token" KILL 2>/dev/null || return 1 + wait_for 3 process_identity_gone "$pid" "$token" || return 1 + fi +} + +cleanup_upgrade() { + local status=$? + trap - EXIT + set +e + if [[ $new_second_stopped == 1 ]] && process_identity_is "$new_second_client" "$new_second_client_token"; then + pidfd_signal_owned_identity "$new_second_client" "$new_second_client_token" CONT 2>/dev/null || true + new_second_stopped=0 + fi + [[ $fd3_open == 1 ]] && printf 'q' >&3 + [[ $fd4_open == 1 ]] && printf 'q' >&4 + [[ $fd5_open == 1 ]] && printf 'q' >&5 + sleep 0.5 + local cleanup_failed=0 + terminate_owned_identity "$old_client" "$old_client_token" || cleanup_failed=1 + terminate_owned_identity "$new_first_client" "$new_first_client_token" || cleanup_failed=1 + terminate_owned_identity "$new_second_client" "$new_second_client_token" || cleanup_failed=1 + terminate_owned_identity "$old_wrapper" "$old_wrapper_token" || cleanup_failed=1 + terminate_owned_identity "$new_first_wrapper" "$new_first_wrapper_token" || cleanup_failed=1 + terminate_owned_identity "$new_second_wrapper" "$new_second_wrapper_token" || cleanup_failed=1 + if [[ -n $old_wrapper ]] && process_identity_gone "$old_wrapper" "$old_wrapper_token"; then wait "$old_wrapper" 2>/dev/null || true; fi + if [[ -n $new_first_wrapper ]] && process_identity_gone "$new_first_wrapper" "$new_first_wrapper_token"; then wait "$new_first_wrapper" 2>/dev/null || true; fi + if [[ -n $new_second_wrapper ]] && process_identity_gone "$new_second_wrapper" "$new_second_wrapper_token"; then wait "$new_second_wrapper" 2>/dev/null || true; fi + if [[ $cleanup_failed == 0 ]]; then + record_assertion test-process-cleanup passed "all test-owned identities gone" gone "bounded pidfd cleanup completed" + else + record_assertion test-process-cleanup failed "all test-owned identities gone" alive "bounded cleanup left a test-owned identity" + status=1 + fi + [[ $fd3_open == 1 ]] && exec 3>&- + [[ $fd4_open == 1 ]] && exec 4>&- + [[ $fd5_open == 1 ]] && exec 5>&- + exit "$status" +} +trap cleanup_upgrade EXIT + +metadata_pid() { + timeout 5 node -e 'const fs=require("fs"); const value=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if(!Number.isInteger(value.pid)||value.pid<1) process.exit(1); process.stdout.write(String(value.pid));' "$1" +} + +health_is_minimal() { + timeout 5 node -e 'const fs=require("fs"); const value=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); const keys=Object.keys(value); process.exit(keys.length===1&&keys[0]==="ok"&&value.ok===true?0:1);' "$1" +} + +session_pids_are() { + local binary=$1 expected_csv=$2 output=$3 + timeout 8 "$binary" session list --json >"$output" 2>/dev/null || return 1 + timeout 5 node -e 'const fs=require("fs"); const value=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); const actual=Array.isArray(value.sessions)?value.sessions.map((session)=>session.pid).sort((a,b)=>a-b):[]; const expected=process.argv[2].split(",").filter(Boolean).map(Number).sort((a,b)=>a-b); process.exit(JSON.stringify(actual)===JSON.stringify(expected)?0:1);' "$output" "$expected_csv" +} + +wrapper_alive() { + process_identity_is "$1" "$2" +} + +find_descendant_executable() { + local root=$1 expected_binary=$2 current child executable + local expected_executable + expected_executable=$(readlink -f "$expected_binary") || return 1 + local -a queue=("$root") + while ((${#queue[@]} > 0)); do + current=${queue[0]} + queue=("${queue[@]:1}") + [[ -r /proc/$current/task/$current/children ]] || continue + for child in $(<"/proc/$current/task/$current/children"); do + queue+=("$child") + executable=$(readlink -f "/proc/$child/exe" 2>/dev/null || true) + if [[ $executable == "$expected_executable" ]]; then + printf '%s' "$child" + return 0 + fi + done + done + return 1 +} + +capture_client_identity() { + local wrapper=$1 binary=$2 pid_variable=$3 token_variable=$4 pid token + pid=$(find_descendant_executable "$wrapper" "$binary") || return 1 + token=$(process_start_token "$pid") || return 1 + printf -v "$pid_variable" '%s' "$pid" + printf -v "$token_variable" '%s' "$token" +} + +start_tui() { + local binary=$1 input_fd=$2 transcript=$3 warning_log=$4 result_variable=$5 + local command wrapper_pid + printf -v command 'env HUNK_DISABLE_UPDATE_NOTICE=1 %q --no-extensions patch %q 2>>%q' \ + "$binary" "$patch_file" "$warning_log" + script --quiet --return --flush --command "$command" "$transcript" <&"$input_fd" >/dev/null 2>&1 & + wrapper_pid=$! + printf -v "$result_variable" '%s' "$wrapper_pid" +} + +successor_metadata_ready() { + local metadata_file=$1 old_pid=$2 old_token=$3 candidate_pid candidate_token + [[ -f $metadata_file ]] || return 1 + candidate_pid=$(metadata_pid "$metadata_file") || return 1 + candidate_token=$(process_start_token "$candidate_pid" 2>/dev/null) || return 1 + [[ $candidate_pid != "$old_pid" || $candidate_token != "$old_token" ]] || return 1 + curl --max-time 3 -fsS "http://127.0.0.1:$HUNK_MCP_PORT/health" >/dev/null +} + +for tool in script sha256sum timeout node readlink python3; do + command -v "$tool" >/dev/null 2>&1 || { + record_assertion linux-tooling failed present "$tool missing" "authenticated daemon upgrade requires Linux/GNU tooling" + scenario_finish + } +done +[[ -r /proc/self/stat ]] || { + record_assertion linux-proc failed present missing "authenticated daemon upgrade requires Linux /proc" + scenario_finish +} + +manifest_file="$artifact_dir/daemon-fixture-manifest.json" +timeout 10 curl --max-time 8 -fsS "$HTTP_URL/fixture-manifest.json" >"$manifest_file" +daemon_version_a=$(timeout 5 node -p 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).daemonUpgrade.versionA' "$manifest_file") +daemon_version_b=$(timeout 5 node -p 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).daemonUpgrade.versionB' "$manifest_file") +daemon_revision_a=$(timeout 5 node -p 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).daemonUpgrade.revisionA' "$manifest_file") +daemon_revision_b=$(timeout 5 node -p 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).daemonUpgrade.revisionB' "$manifest_file") +daemon_binary_sha256_a=$(timeout 5 node -p 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).daemonUpgrade.binarySha256A' "$manifest_file") +daemon_binary_sha256_b=$(timeout 5 node -p 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).daemonUpgrade.binarySha256B' "$manifest_file") +daemon_build_input_identity=$(timeout 5 node -p 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).daemonUpgradeBuildInputIdentity' "$manifest_file") +record_observation daemonPackageVersionA "$daemon_version_a" +record_observation daemonPackageVersionB "$daemon_version_b" +record_observation daemonRevisionA "$daemon_revision_a" +record_observation daemonRevisionB "$daemon_revision_b" +record_observation daemonUpgradeBuildInputIdentity "$daemon_build_input_identity" +record_observation fixtureManifestPath daemon-fixture-manifest.json + +export XDG_RUNTIME_DIR="$HOME/runtime" +export HUNK_MCP_HOST=127.0.0.1 +export HUNK_MCP_PORT=48761 +export HUNK_DISABLE_UPDATE_NOTICE=1 +unset HUNK_MCP_DISABLE +mkdir -p "$XDG_RUNTIME_DIR" +chmod 0700 "$XDG_RUNTIME_DIR" + +cat >"$HOME/change.patch" <<'PATCH' +diff --git a/example.ts b/example.ts +index 3f52c4e..33d4f84 100644 +--- a/example.ts ++++ b/example.ts +@@ -1 +1 @@ +-export const answer = 41; ++export const answer = 42; +PATCH +patch_file="$HOME/change.patch" + +run_expect install-daemon-a 0 timeout 240 npm install -g "hunkdiff@$daemon_version_a" --registry "$REGISTRY_URL" +installed_binary="$npm_config_prefix/lib/node_modules/hunkdiff/node_modules/hunkdiff-linux-x64/bin/hunk" +old_binary="$HOME/hunk-daemon-a" +cp "$installed_binary" "$old_binary" +chmod 0755 "$old_binary" +run_expect version-daemon-a 0 timeout 10 "$old_binary" --version +assert_contains version-a-matches "$command_dir/version-daemon-a.log" "$daemon_version_a" + +mkfifo "$HOME/old-input" "$HOME/new-first-input" "$HOME/new-second-input" +exec 3<>"$HOME/old-input" +fd3_open=1 +exec 4<>"$HOME/new-first-input" +fd4_open=1 +exec 5<>"$HOME/new-second-input" +fd5_open=1 +old_transcript="$artifact_dir/old-transcript.log" +new_first_transcript="$artifact_dir/new-first-transcript.log" +new_second_transcript="$artifact_dir/new-second-transcript.log" +old_warning="$artifact_dir/old-warning.log" +new_first_warning="$artifact_dir/new-first-warning.log" +new_second_warning="$artifact_dir/new-second-warning.log" +start_tui "$old_binary" 3 "$old_transcript" "$old_warning" old_wrapper +old_wrapper_token=$(process_start_token "$old_wrapper") +record_command start-old-tui passed "background PTY remains live" 0 "old-transcript.log" +if wait_for 20 capture_client_identity "$old_wrapper" "$old_binary" old_client old_client_token; then + record_observation oldWrapperPid "$old_wrapper" + record_observation oldWrapperStartToken "$old_wrapper_token" + record_observation oldClientPid "$old_client" + record_observation oldClientStartToken "$old_client_token" +else + record_assertion old-client-identity failed "owned Hunk descendant" missing "could not identify old TUI process" +fi + +old_list="$artifact_dir/old-session-list.json" +if wait_for 20 session_pids_are "$old_binary" "$old_client" "$old_list"; then + record_assertion old-producer-registered passed "one authenticated A client PID" present "old TUI registered" +else + record_assertion old-producer-registered failed "one authenticated A client PID" missing "see old transcript" +fi +run_expect old-session-list 0 timeout 10 "$old_binary" session list --json +cp "$command_dir/old-session-list.log" "$old_list" + +metadata="$XDG_RUNTIME_DIR/hunk-mcp/daemon-127-0-0-1-$HUNK_MCP_PORT.json" +if ! wait_for 10 test -f "$metadata"; then + record_assertion old-metadata failed present missing "scenario-owned launch metadata was not published" + scenario_finish +fi +cp "$metadata" "$artifact_dir/old-metadata.json" +old_daemon_pid=$(metadata_pid "$metadata") +old_start_token=$(process_start_token "$old_daemon_pid") +old_executable_location=$(readlink "/proc/$old_daemon_pid/exe") +old_executable_digest=$(timeout 10 sha256sum "/proc/$old_daemon_pid/exe" | awk '{print $1}') +printf 'pid=%s\nstartToken=%s\nlocation=%s\ndigest=%s\n' \ + "$old_daemon_pid" "$old_start_token" "$old_executable_location" "$old_executable_digest" \ + >"$artifact_dir/old-executable.txt" +record_observation oldDaemonPid "$old_daemon_pid" +record_observation oldDaemonStartToken "$old_start_token" +record_observation oldExecutableDigest "$old_executable_digest" +record_observation oldExecutableLocation "$old_executable_location" +record_observation oldExecutablePath old-executable.txt +record_observation oldMetadataPath old-metadata.json +record_observation oldTranscriptPath old-transcript.log +record_observation oldSessionListPath old-session-list.json + +timeout 10 curl --max-time 8 -fsS "http://127.0.0.1:$HUNK_MCP_PORT/health" >"$artifact_dir/overlap-health.json" +if health_is_minimal "$artifact_dir/overlap-health.json"; then + record_assertion minimal-health-before passed '{"ok":true}' exact "public health is liveness-only" +else + record_assertion minimal-health-before failed '{"ok":true}' different "public health leaked extra fields" +fi +record_observation overlapHealthPath overlap-health.json + +run_expect upgrade-daemon-b 0 timeout 240 npm install -g "hunkdiff@$daemon_version_b" --registry "$REGISTRY_URL" +run_expect version-daemon-b 0 timeout 10 hunk --version +assert_contains version-b-matches "$command_dir/version-daemon-b.log" "$daemon_version_b" +new_binary=$(command -v hunk) + +start_tui "$new_binary" 4 "$new_first_transcript" "$new_first_warning" new_first_wrapper +new_first_wrapper_token=$(process_start_token "$new_first_wrapper") +start_tui "$new_binary" 5 "$new_second_transcript" "$new_second_warning" new_second_wrapper +new_second_wrapper_token=$(process_start_token "$new_second_wrapper") +record_command start-new-first passed "background PTY remains live" 0 "new-first-transcript.log" +record_command start-new-second passed "background PTY remains live" 0 "new-second-transcript.log" +if ! wait_for 20 capture_client_identity "$new_first_wrapper" "$installed_binary" new_first_client new_first_client_token; then + record_assertion new-first-client-identity failed "owned Hunk descendant" missing "could not identify first B TUI" +fi +if ! wait_for 20 capture_client_identity "$new_second_wrapper" "$installed_binary" new_second_client new_second_client_token; then + record_assertion new-second-client-identity failed "owned Hunk descendant" missing "could not identify second B TUI" +fi +record_observation newFirstWrapperPid "$new_first_wrapper" +record_observation newFirstWrapperStartToken "$new_first_wrapper_token" +record_observation newSecondWrapperPid "$new_second_wrapper" +record_observation newSecondWrapperStartToken "$new_second_wrapper_token" +record_observation newFirstClientPid "$new_first_client" +record_observation newFirstClientStartToken "$new_first_client_token" +record_observation newSecondClientPid "$new_second_client" +record_observation newSecondClientStartToken "$new_second_client_token" +record_observation newFirstTranscriptPath new-first-transcript.log +record_observation newSecondTranscriptPath new-second-transcript.log + +# The TUI renderer owns its terminal; retain stable one-shot warning evidence while both original +# interactive B processes remain in pre-authentication quiescent wait. +run_expect_nonzero incompatible-daemon-b timeout 12 "$new_binary" session list --json +cp "$command_dir/incompatible-daemon-b.log" "$artifact_dir/incompatible-warning.log" +record_observation incompatibleWarningPath incompatible-warning.log +if grep -Fq 'Close older Hunk windows' "$artifact_dir/incompatible-warning.log" && \ + wrapper_alive "$new_first_wrapper" "$new_first_wrapper_token" && \ + wrapper_alive "$new_second_wrapper" "$new_second_wrapper_token" && \ + process_identity_is "$new_first_client" "$new_first_client_token" && \ + process_identity_is "$new_second_client" "$new_second_client_token"; then + record_assertion new-clients-incompatible passed "stable warning with both original B clients alive" present "new clients wait without replacement" +else + record_assertion new-clients-incompatible failed "stable warning with both original B clients alive" missing "see warning and transcripts" +fi + +# Observe multiple reconnect intervals while A still owns live work. +sleep 8 +overlap_pid=$(metadata_pid "$metadata") +overlap_token=$(process_start_token "$overlap_pid" 2>/dev/null || true) +if [[ $overlap_pid == "$old_daemon_pid" && $overlap_token == "$old_start_token" ]] && \ + wrapper_alive "$old_wrapper" "$old_wrapper_token"; then + record_assertion old-daemon-survived-overlap passed "same live daemon PID/start token" preserved "A remained available throughout B overlap" +else + record_assertion old-daemon-survived-overlap failed "same live daemon PID/start token" changed "A daemon or producer disappeared" +fi +if session_pids_are "$old_binary" "$old_client" "$artifact_dir/old-overlap-session-list.json"; then + record_assertion old-session-still-usable passed "original authenticated A client" usable "old session survived B retries" +else + record_assertion old-session-still-usable failed "original authenticated A client" unavailable "old session stopped serving" +fi +if [[ $(find "$XDG_RUNTIME_DIR/hunk-mcp" -maxdepth 1 -name 'daemon-*.json' | wc -l) == 1 && $overlap_pid == "$old_daemon_pid" ]]; then + record_assertion one-incumbent-metadata passed "one incumbent metadata record" one "fixed endpoint published one incumbent record" +else + record_assertion one-incumbent-metadata failed "one incumbent metadata record" multiple "duplicate metadata evidence found" +fi + +# Suspend only the exact test-owned second B client. It must miss endpoint absence and recover after +# the first B client has established a healthy successor. +if process_identity_is "$new_second_client" "$new_second_client_token" && \ + pidfd_signal_owned_identity "$new_second_client" "$new_second_client_token" STOP && \ + (wait_for 5 process_identity_state_is "$new_second_client" "$new_second_client_token" T || \ + wait_for 1 process_identity_state_is "$new_second_client" "$new_second_client_token" t); then + new_second_stopped=1 + record_command suspend-new-second passed "SIGSTOP exact owned B client" 0 "new-second-transcript.log" + record_assertion delayed-client-suspended passed "exact original second B identity stopped" stopped "test delayed one client across migration" +else + record_command suspend-new-second failed "SIGSTOP exact owned B client" 1 "new-second-transcript.log" + record_assertion delayed-client-suspended failed "exact original second B identity stopped" running "could not suspend owned client" +fi + +reconnect_started_ms=$(date +%s%3N) +printf 'q' >&3 +exec 3>&- +fd3_open=0 +if wait_for 10 process_identity_gone "$old_wrapper" "$old_wrapper_token"; then + wait "$old_wrapper" 2>/dev/null || true + old_wrapper= + old_wrapper_token= + if process_identity_is "$old_client" "$old_client_token"; then + record_assertion old-client-close failed "old client exits with wrapper" alive "surviving descendant requires cleanup" + else + old_client= + old_client_token= + fi +else + record_assertion old-window-close failed "old test-owned wrapper exits after q" alive "old window did not close promptly" +fi + +# The daemon owns its production 60-second idle shutdown. Runtime evidence shows that the same +# incumbent survives overlap and later retires after its last producer closes; source tests prove +# Hunk has no metadata/PID signalling authority. +if wait_for 90 process_identity_gone "$old_daemon_pid" "$old_start_token"; then + record_assertion old-daemon-retired-after-quiescence passed "incumbent identity retires after quiescence" retired "production idle shutdown completed" +else + record_assertion old-daemon-retired-after-quiescence failed "incumbent identity retires after quiescence" alive "incumbent exceeded quiescent deadline" +fi + +if ! wait_for 30 successor_metadata_ready "$metadata" "$old_daemon_pid" "$old_start_token"; then + record_assertion successor-ready failed "new daemon identity and health" missing "first B client did not establish successor" +fi +first_recovered_list="$artifact_dir/first-recovered-session-list.json" +if wait_for 20 session_pids_are "$new_binary" "$new_first_client" "$first_recovered_list"; then + record_assertion first-client-established-successor passed "exact original first B client" present "first waiter registered before delayed client resumed" +else + record_assertion first-client-established-successor failed "exact original first B client" missing "successor ownership was not established by first client" +fi +record_observation firstRecoveredSessionListPath first-recovered-session-list.json + +if [[ $new_second_stopped == 1 ]] && process_identity_is "$new_second_client" "$new_second_client_token" && \ + pidfd_signal_owned_identity "$new_second_client" "$new_second_client_token" CONT && \ + wait_for 5 process_identity_not_stopped "$new_second_client" "$new_second_client_token"; then + new_second_stopped=0 + record_command resume-new-second passed "SIGCONT exact owned B client" 0 "new-second-transcript.log" +else + record_command resume-new-second failed "SIGCONT exact owned B client" 1 "new-second-transcript.log" +fi + +recovered_list="$artifact_dir/recovered-session-list.json" +if wait_for 30 session_pids_are "$new_binary" "$new_first_client,$new_second_client" "$recovered_list"; then + record_assertion new-producers-registered passed "two exact original B client PIDs" present "both waiting clients registered" +else + record_assertion new-producers-registered failed "two exact original B client PIDs" missing "B clients did not recover" +fi +if process_identity_is "$new_first_client" "$new_first_client_token" && \ + process_identity_is "$new_second_client" "$new_second_client_token"; then + record_assertion delayed-client-recovered passed "original delayed PID/start token registered" unchanged "stopped B client authenticated without restart" +else + record_assertion delayed-client-recovered failed "original delayed PID/start token registered" changed "delayed B process identity was lost" +fi +run_expect recovered-session-list 0 timeout 10 "$new_binary" session list --json +cp "$command_dir/recovered-session-list.log" "$recovered_list" + +cp "$metadata" "$artifact_dir/recovered-metadata.json" +new_daemon_pid=$(metadata_pid "$metadata") +new_start_token=$(process_start_token "$new_daemon_pid") +new_executable_location=$(readlink "/proc/$new_daemon_pid/exe") +new_executable_digest=$(timeout 10 sha256sum "/proc/$new_daemon_pid/exe" | awk '{print $1}') +printf 'pid=%s\nstartToken=%s\nlocation=%s\ndigest=%s\n' \ + "$new_daemon_pid" "$new_start_token" "$new_executable_location" "$new_executable_digest" \ + >"$artifact_dir/new-executable.txt" +record_observation newDaemonPid "$new_daemon_pid" +record_observation newDaemonStartToken "$new_start_token" +record_observation newExecutableDigest "$new_executable_digest" +record_observation newExecutableLocation "$new_executable_location" +record_observation newExecutablePath new-executable.txt +record_observation recoveredMetadataPath recovered-metadata.json +record_observation recoveredSessionListPath recovered-session-list.json +record_observation reconnectDurationMs "$(( $(date +%s%3N) - reconnect_started_ms ))" + +installed_digest=$(timeout 10 sha256sum "$installed_binary" | awk '{print $1}') +if [[ ($new_daemon_pid != "$old_daemon_pid" || $new_start_token != "$old_start_token") && \ + $old_executable_digest == "$daemon_binary_sha256_a" && \ + $new_executable_digest == "$daemon_binary_sha256_b" && \ + $new_executable_digest == "$installed_digest" && \ + $new_executable_digest != "$old_executable_digest" ]]; then + record_assertion new-daemon-binary passed "manifest-bound A/B executables and successor identity" matched "runtime binaries match compiled fixture provenance" +else + record_assertion new-daemon-binary failed "manifest-bound A/B executables and successor identity" mismatched "runtime executable evidence differs from fixture manifest" +fi +if wrapper_alive "$new_first_wrapper" "$new_first_wrapper_token" && \ + wrapper_alive "$new_second_wrapper" "$new_second_wrapper_token"; then + record_assertion new-clients-not-relaunched passed "original B wrappers remain alive" unchanged "B recovered without app restart" +else + record_assertion new-clients-not-relaunched failed "original B wrappers remain alive" exited "a waiting B wrapper disappeared" +fi + +timeout 10 curl --max-time 8 -fsS "http://127.0.0.1:$HUNK_MCP_PORT/health" >"$artifact_dir/recovered-health.json" +if health_is_minimal "$artifact_dir/recovered-health.json"; then + record_assertion minimal-health-after passed '{"ok":true}' exact "successor health is liveness-only" +else + record_assertion minimal-health-after failed '{"ok":true}' different "successor health leaked extra fields" +fi +record_observation recoveredHealthPath recovered-health.json + +scenario_finish diff --git a/test/cli/install-vm/validate-release-result.ts b/test/cli/install-vm/validate-release-result.ts index 3a0d29f73..90808e00f 100644 --- a/test/cli/install-vm/validate-release-result.ts +++ b/test/cli/install-vm/validate-release-result.ts @@ -4,25 +4,77 @@ import { readFileSync } from "node:fs"; import path from "node:path"; -import { loadScenarioManifest, validateInstallVmPins } from "./contract"; -import { computeInstallVmFixtureSourceIdentity } from "./prepare-fixtures"; +import { loadScenarioManifest, selectScenarios, validateInstallVmPins } from "./contract"; +import { + computeDaemonUpgradeBuildInputIdentity, + readDaemonRevision, +} from "./prepare-daemon-upgrade-fixtures"; +import { + computeInstallVmFixtureSourceIdentity, + deriveVerifiedDaemonUpgradeBinaryDigests, + verifyInstallVmFixtures, +} from "./prepare-fixtures"; import { validateInstallVmReleaseResult } from "./results"; const repoRoot = path.resolve(import.meta.dir, "../../.."); const resultPath = process.argv[2]; -if (!resultPath || process.argv.length !== 3) { - throw new Error("Usage: validate-release-result.ts "); +const targetedScenario = process.argv[3] === "--scenario" ? process.argv[4] : undefined; +if ( + !resultPath || + (process.argv.length !== 3 && + !(process.argv.length === 5 && process.argv[3] === "--scenario" && targetedScenario)) +) { + throw new Error("Usage: validate-release-result.ts [--scenario ]"); } const manifest = loadScenarioManifest(path.join(import.meta.dir, "scenarios.json")); const pins = validateInstallVmPins( JSON.parse(readFileSync(path.join(import.meta.dir, "pins.json"), "utf8")), ); -const result = validateInstallVmReleaseResult(JSON.parse(readFileSync(resultPath, "utf8")), { - sourceIdentity: computeInstallVmFixtureSourceIdentity(repoRoot), - pnpmVersion: pins.pnpmVersion, - scenarios: manifest.scenarios, -}); +const resolvedResultPath = path.resolve(resultPath); +const scenarios = targetedScenario + ? selectScenarios(manifest, [targetedScenario]) + : manifest.scenarios; +let daemonUpgradeBinaryDigests; +let daemonUpgradeBuildInputIdentity; +let daemonRevision; +if (scenarios.some((scenario) => scenario.id === "authenticated-daemon-upgrade")) { + const fixtureDirectory = path.join(repoRoot, "tmp", "install-vm", "fixtures"); + let fixtureManifest; + try { + fixtureManifest = verifyInstallVmFixtures(repoRoot, fixtureDirectory); + } catch (error) { + throw new Error( + `Trusted install VM fixture set is missing or stale: ${error instanceof Error ? error.message : String(error)}`, + ); + } + try { + daemonUpgradeBinaryDigests = await deriveVerifiedDaemonUpgradeBinaryDigests( + fixtureDirectory, + fixtureManifest, + ); + } catch (error) { + throw new Error( + `Trusted install VM fixture binaries are invalid: ${error instanceof Error ? error.message : String(error)}`, + ); + } + daemonUpgradeBuildInputIdentity = computeDaemonUpgradeBuildInputIdentity(repoRoot); + daemonRevision = readDaemonRevision( + readFileSync(path.join(repoRoot, "src", "session", "protocol.ts"), "utf8"), + ); +} +const result = validateInstallVmReleaseResult( + JSON.parse(readFileSync(resolvedResultPath, "utf8")), + { + sourceIdentity: computeInstallVmFixtureSourceIdentity(repoRoot), + pnpmVersion: pins.pnpmVersion, + scenarios, + resultDirectory: path.dirname(resolvedResultPath), + daemonUpgradeBuildInputIdentity, + daemonRevision, + daemonUpgradeBinaryDigests, + }, +); console.log( - `Validated ${result.scenarios.length} install VM scenarios for source ${result.run.sourceIdentity}.`, + `Validated ${targetedScenario ? "targeted" : "complete"} install VM evidence for ${result.scenarios.length} scenario(s) and source ${result.run.sourceIdentity}.`, ); diff --git a/test/fixtures/sessionBrokerAdapterConformance.json b/test/fixtures/sessionBrokerAdapterConformance.json index 381186040..a763579f9 100644 --- a/test/fixtures/sessionBrokerAdapterConformance.json +++ b/test/fixtures/sessionBrokerAdapterConformance.json @@ -4,7 +4,8 @@ }, "inbound": { "oversizedCloseCode": 1009, - "pressureCloseCode": 1013, + "bunNativeOversizedCloseCodes": [1006, 1009], + "admissionHttpStatus": 503, "maxMessageBytes": 8388608 }, "outbound": { diff --git a/test/session-broker-node/adapter.test.mjs b/test/session-broker-node/adapter.test.mjs index aa583c85b..90f8d9439 100644 --- a/test/session-broker-node/adapter.test.mjs +++ b/test/session-broker-node/adapter.test.mjs @@ -99,11 +99,13 @@ function fakeDaemon(overrides = {}, behavior = {}) { maxHttpResponseBytes: 8 * 1024 * 1024, maxInFlightHttpResponseBytes: 64 * 1024 * 1024, maxUnauthenticatedSockets: 64, + maxHandshakeDurationMs: 15_000, ...overrides, }; return { limits, stopped: new Promise(() => {}), + requiresProducerAuthentication: behavior.requiresProducerAuthentication ?? false, matchesSocketPath: (pathname) => pathname === "/session", handleConnectionMessage: behavior.handleConnectionMessage ?? (() => {}), handleConnectionClose() {}, @@ -203,11 +205,15 @@ test("Node adapter waits for active HTTP handlers and preserves bodyless framing test("Node adapter consumes the shared text/binary/oversize/pressure corpus", async () => { const port = await reservePort(); const running = await serveSessionBrokerDaemon({ - daemon: fakeDaemon({ - maxWsMessageBytes: 8, - maxHttpResponseBytes: 8, - maxUnauthenticatedSockets: 1, - }), + daemon: fakeDaemon( + { + maxWsMessageBytes: 8, + maxHttpResponseBytes: 8, + maxUnauthenticatedSockets: 1, + maxHandshakeDurationMs: 1_000, + }, + { requiresProducerAuthentication: true }, + ), hostname: "127.0.0.1", port, handleRequest: (request) => @@ -216,6 +222,17 @@ test("Node adapter consumes the shared text/binary/oversize/pressure corpus", as : undefined, }); try { + const malformedUpgrade = await rawHttp(port, [ + "GET * HTTP/1.1", + `Host: 127.0.0.1:${port}`, + "Connection: Upgrade", + "Upgrade: websocket", + "", + "", + ]); + assert.match(malformedUpgrade, /^HTTP\/1\.1 400/); + + // A successful follow-up request proves the malformed upgrade did not escape the listener. const boundedResponse = await fetch(`http://127.0.0.1:${port}/large`); assert.equal(boundedResponse.status, 503); assert.equal(await boundedResponse.text(), ""); @@ -223,7 +240,17 @@ test("Node adapter consumes the shared text/binary/oversize/pressure corpus", as exact.send("12345678"); await new Promise((resolve) => setTimeout(resolve, 20)); assert.equal(exact.readyState, WebSocket.OPEN); - await assert.rejects(openSocket(`ws://127.0.0.1:${port}/session`)); + const fullAdmission = await rawHttp(port, [ + "GET /session HTTP/1.1", + `Host: 127.0.0.1:${port}`, + "Connection: Upgrade", + "Upgrade: websocket", + "Sec-WebSocket-Version: 13", + "Sec-WebSocket-Key: dGVzdC1zZXNzaW9uLWtleQ==", + "", + "", + ]); + assert.match(fullAdmission, new RegExp(`^HTTP/1.1 ${corpus.inbound.admissionHttpStatus}`)); const exactClosed = closeCode(exact); exact.close(); await exactClosed; @@ -264,25 +291,32 @@ test("Node adapter consumes the shared text/binary/oversize/pressure corpus", as const outbound = await openSocket(`ws://127.0.0.1:${outboundPort}/session`); const outboundClosed = closeCode(outbound); outbound.send("trigger"); - assert.equal(await outboundClosed, 1013); + assert.equal(await outboundClosed, corpus.outbound.pressureCloseCode); } finally { await outboundRunning.stop(); await outboundRunning.stopped; } - const pressurePort = await reservePort(); - const pressureRunning = await serveSessionBrokerDaemon({ - daemon: fakeDaemon({ maxWsMessageBytes: 8, maxInFlightWsBytes: 0 }), + const handlerPort = await reservePort(); + const handlerRunning = await serveSessionBrokerDaemon({ + daemon: fakeDaemon( + {}, + { + handleConnectionMessage: () => { + throw new Error("unexpected handler failure"); + }, + }, + ), hostname: "127.0.0.1", - port: pressurePort, + port: handlerPort, }); try { - const pressure = await openSocket(`ws://127.0.0.1:${pressurePort}/session`); - const pressureClosed = closeCode(pressure); - pressure.send("{}"); - assert.equal(await pressureClosed, corpus.inbound.pressureCloseCode); + const handlerFailure = await openSocket(`ws://127.0.0.1:${handlerPort}/session`); + const handlerFailureClosed = closeCode(handlerFailure); + handlerFailure.send("trigger"); + assert.equal(await handlerFailureClosed, 1011); } finally { - await pressureRunning.stop(); - await pressureRunning.stopped; + await handlerRunning.stop(); + await handlerRunning.stopped; } }); diff --git a/test/session/broker-e2e.test.ts b/test/session/broker-e2e.test.ts index 52ac4a208..51c2033f1 100644 --- a/test/session/broker-e2e.test.ts +++ b/test/session/broker-e2e.test.ts @@ -1,5 +1,5 @@ import { afterAll, afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -32,8 +32,6 @@ const ttyToolsAvailable = supportsControllableScript(); interface HealthResponse { ok: boolean; - pid: number; - sessions: number; } interface SessionListJson { @@ -224,6 +222,19 @@ async function waitUntil( } } +/** Read the PID only from this test's launch metadata for teardown, never from public health. */ +function readLaunchedDaemonPid(port: number) { + try { + const runtimeBase = process.env.XDG_RUNTIME_DIR?.trim() || tmpdir(); + const metadata = JSON.parse( + readFileSync(join(runtimeBase, "hunk-mcp", `daemon-127-0-0-1-${port}.json`), "utf8"), + ) as { pid?: unknown }; + return typeof metadata.pid === "number" && metadata.pid > 0 ? metadata.pid : null; + } catch { + return null; + } +} + async function waitForHealth(port: number, timeoutMs = 15_000) { return waitUntil( "session daemon health endpoint", @@ -288,7 +299,7 @@ describe("session broker end-to-end", () => { try { const health = await waitForHealth(port); - daemonPid = health.pid; + daemonPid = readLaunchedDaemonPid(port); expect(health.ok).toBe(true); const listed = await waitUntil("registered Hunk session", async () => { @@ -394,7 +405,7 @@ describe("session broker end-to-end", () => { try { const health = await waitForHealth(port); - daemonPid = health.pid; + daemonPid = readLaunchedDaemonPid(port); expect(health.ok).toBe(true); const listed = await waitUntil("registered Hunk session", async () => { @@ -489,7 +500,7 @@ describe("session broker end-to-end", () => { try { const health = await waitForHealth(port); - daemonPid = health.pid; + daemonPid = readLaunchedDaemonPid(port); expect(health.ok).toBe(true); const listed = await waitUntil("registered Hunk session", async () => { @@ -621,7 +632,7 @@ describe("session broker end-to-end", () => { try { const health = await waitForHealth(port, 20_000); - daemonPid = health.pid; + daemonPid = readLaunchedDaemonPid(port); expect(health.ok).toBe(true); const sessions = await waitUntil("two registered Hunk sessions", async () => { diff --git a/test/session/cli.test.ts b/test/session/cli.test.ts index f24058d59..e9bfd22ff 100644 --- a/test/session/cli.test.ts +++ b/test/session/cli.test.ts @@ -1,5 +1,5 @@ import { afterAll, afterEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -214,20 +214,24 @@ async function quitHunkSession( const ownedDaemonPids = new Map(); -/** Poll daemon health directly before exercising the CLI boundary once. */ +/** Poll through the authenticated CLI because public health intentionally exposes no session facts. */ async function waitForRegisteredSessions(port: number) { - await waitUntil("registered live session", async () => { - const health = await readDaemonHealth(port); - if (!health || (health.sessions ?? 0) === 0) return null; - ownedDaemonPids.set(port, health.pid); - return true; + return waitUntil("registered live session", () => { + const { proc, stdout } = runSessionCli(["list", "--json"], port); + if (proc.exitCode !== 0) return null; + const sessions = (JSON.parse(stdout) as SessionListJson).sessions; + if (sessions.length === 0) return null; + try { + const metadata = JSON.parse( + readFileSync(join(testRuntimeDir, "hunk-mcp", `daemon-127-0-0-1-${port}.json`), "utf8"), + ) as { pid?: unknown }; + if (typeof metadata.pid === "number" && metadata.pid > 0) + ownedDaemonPids.set(port, metadata.pid); + } catch { + // Teardown can still rely on daemon idleness if metadata publication raced this read. + } + return sessions; }); - - const { proc, stdout, stderr } = runSessionCli(["list", "--json"], port); - if (proc.exitCode !== 0) { - throw new Error(stderr.trim() || "Failed to list the registered Hunk session."); - } - return (JSON.parse(stdout) as SessionListJson).sessions; } /** Read one test daemon's health without leaking connection failures into teardown. */ @@ -235,7 +239,7 @@ async function readDaemonHealth(port: number) { try { const response = await fetch(`http://127.0.0.1:${port}/health`); if (!response.ok) return null; - return (await response.json()) as { pid: number; sessions?: number }; + return (await response.json()) as { ok: boolean }; } catch { return null; } @@ -267,9 +271,6 @@ async function waitForDaemonExit(port: number, pid: number, label: string) { label, async () => { const health = await readDaemonHealth(port); - if (health && health.pid !== pid) { - throw new Error(`Refusing to manage unexpected daemon ${health.pid} on port ${port}.`); - } return !isProcessRunning(pid) && health === null ? true : null; }, 1_500, @@ -283,17 +284,10 @@ async function stopTestDaemon(port: number) { ownedDaemonPids.delete(port); if (pid === undefined) return; - const health = await readDaemonHealth(port); - if (health && health.pid !== pid) { - throw new Error(`Refusing to stop unexpected daemon ${health.pid} on port ${port}.`); - } - signalProcess(pid, "SIGTERM"); try { await waitForDaemonExit(port, pid, "session daemon exit"); - } catch (error) { - const remaining = await readDaemonHealth(port); - if (remaining && remaining.pid !== pid) throw error; + } catch { signalProcess(pid, "SIGKILL"); await waitForDaemonExit(port, pid, "killed session daemon exit"); } @@ -491,7 +485,7 @@ sessionDescribe("session CLI integration", () => { } }, 20_000); - test("reload refuses option-like VCS ranges sent directly to the session API", async () => { + test("raw session API callers cannot present option-like VCS ranges", async () => { const port = await reserveLoopbackPort(); const fixture = createFixtureFiles( "reload-injection", @@ -505,8 +499,7 @@ sessionDescribe("session CLI integration", () => { const listed = await waitForRegisteredSessions(port); const sessionId = listed[0]!.sessionId; - // Bypass the CLI parser on purpose: the raw daemon surface is the attacker-controlled - // path, so reproduce the injected flag exactly as a hostile /session-api caller would. + // Raw callers never reach app parsing without the owner-private signed caller session. const sentinel = join(fixture.dir, "hunk-poc"); const response = await fetch(`http://127.0.0.1:${port}/session-api`, { method: "POST", @@ -523,9 +516,10 @@ sessionDescribe("session CLI integration", () => { }), }); - expect(response.status).toBe(400); + expect(response.status).toBe(401); await expect(response.json()).resolves.toMatchObject({ - error: expect.stringContaining("looks like a VCS option"), + error: "authentication-required", + message: expect.stringContaining("upgraded"), }); expect(existsSync(sentinel)).toBe(false); diff --git a/test/session/daemon.test.ts b/test/session/daemon.test.ts index c068dc7a2..b8c155daa 100644 --- a/test/session/daemon.test.ts +++ b/test/session/daemon.test.ts @@ -52,7 +52,7 @@ async function readHealth(port: number) { return null; } - return (await response.json()) as { ok: boolean; pid: number }; + return (await response.json()) as { ok: boolean }; } catch { return null; } @@ -96,8 +96,8 @@ describe("session daemon lifecycle", () => { exited = true; }); - // Windows may keep the `bun run` launcher separate from the child serving the daemon. - process.kill(health.pid, "SIGTERM"); + // This test owns the spawned process handle; public health intentionally exposes no PID. + proc.kill("SIGTERM"); await waitUntil("daemon serve process exit", () => (exited ? true : null), 1_500, 25); await waitUntil("daemon port close", async () =>