Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/calm-brokers-bound.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/secure-session-broker-authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
5 changes: 5 additions & 0 deletions .changeset/secure-session-broker-integration.md
Original file line number Diff line number Diff line change
@@ -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 and refuse unsafe PID-based replacement of legacy listeners.
2 changes: 2 additions & 0 deletions .changeset/secure-session-peer-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/strict-session-broker-runtime-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
10 changes: 1 addition & 9 deletions docs/agent-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions docs/session-broker-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,19 @@ Security outranks wire compatibility: no migration accepts unauthenticated contr
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. 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
interval for authenticated coordinator publication, then reports an unverifiable listener and launches
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"test": "bun run ./scripts/run-test-suite.ts",
"test:theme-contrast": "bun test src/ui/themes.test.ts --test-name-pattern contrast",
"test:integration": "\"${npm_execpath:-bun}\" test ./test/pty",
"test:session-broker-node": "bun run ./scripts/test-session-broker-node.ts",
"test:tty-smoke": "HUNK_RUN_TTY_SMOKE=1 \"${npm_execpath:-bun}\" test ./test/smoke",
"check:pack": "bun run ./scripts/check-pack.ts",
"check:prebuilt-pack": "bun run ./scripts/check-prebuilt-pack.ts",
Expand Down Expand Up @@ -170,7 +171,7 @@
"pre-commit": "bunx lint-staged"
},
"engines": {
"node": ">=18"
"node": ">=22"
},
"packageManager": "bun@1.3.14",
"pi": {
Expand Down
2 changes: 1 addition & 1 deletion packages/session-broker-bun/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@
},
"engines": {
"bun": ">=1.0.0",
"node": ">=18"
"node": ">=22"
}
}
214 changes: 196 additions & 18 deletions packages/session-broker-bun/src/serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import {
type SessionRegistration,
type SessionSnapshot,
} from "@hunk/session-broker-core";
import { SessionBroker, createSessionBrokerDaemon } from "@hunk/session-broker";
import {
SessionBroker,
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 {
Expand Down Expand Up @@ -53,7 +58,9 @@ function createRegistration(overrides: Partial<SessionRegistration<TestSessionIn
}

function createSnapshot(
overrides: Partial<SessionSnapshot<TestSessionState>["state"]> & { updatedAt?: string } = {},
overrides: Partial<SessionSnapshot<TestSessionState>["state"]> & {
updatedAt?: string;
} = {},
) {
const { updatedAt = "2026-04-15T00:00:00.000Z", ...stateOverrides } = overrides;
return {
Expand All @@ -65,6 +72,14 @@ function createSnapshot(
} satisfies SessionSnapshot<TestSessionState>;
}

const protocolParsers = createSessionBrokerProtocolParsers({
appRevision: 1,
features: [],
parseRegistration: (value) => parseSessionRegistrationEnvelope(value, parseInfo),
parseSnapshot: (value) => parseSessionSnapshotEnvelope(value, parseState),
commands: [],
});

async function reserveLoopbackPort() {
const listener = createServer(() => undefined);
await new Promise<void>((resolve, reject) => {
Expand Down Expand Up @@ -100,6 +115,51 @@ async function waitUntil<T>(
}
}

async function openTestSocket(url: string) {
const socket = new WebSocket(url);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error("Timed out waiting for websocket open.")),
1_000,
);
socket.addEventListener(
"open",
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
socket.addEventListener(
"error",
() => {
clearTimeout(timer);
reject(new Error("Websocket failed to open."));
},
{ once: true },
);
});
return socket;
}

function testSocketCloseCode(socket: WebSocket) {
return new Promise<number>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error("Timed out waiting for websocket close.")),
1_000,
);
socket.addEventListener(
"close",
(event) => {
clearTimeout(timer);
resolve(event.code);
},
{ once: true },
);
socket.addEventListener("error", () => {}, { once: true });
});
}

async function readHealth(port: number) {
try {
const response = await fetch(`http://127.0.0.1:${port}/health`);
Expand All @@ -124,8 +184,10 @@ async function waitForSessionCount(port: number, count: number) {
return null;
}

const payload = (await response.json()) as { sessions: { sessionId: string }[] };
return payload.sessions.length === count ? payload : null;
const payload = (await response.json()) as {
body: { sessions: { sessionId: string }[] };
};
return payload.body.sessions.length === count ? payload : null;
});
}

Expand All @@ -134,15 +196,103 @@ afterEach(() => {
});

describe("session broker bun adapter", () => {
test("serves the generic daemon API and websocket path through Bun", async () => {
const broker = new SessionBroker({
parseRegistration: (value) => parseSessionRegistrationEnvelope(value, parseInfo),
parseSnapshot: (value) => parseSessionSnapshotEnvelope(value, parseState),
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 () => {
const broker = new SessionBroker({ protocolParsers });
const daemon = createSessionBrokerDaemon({
broker,
limits: { maxWsMessageBytes: 8, maxInFlightWsBytes: 0 },
});
const port = await reserveLoopbackPort();
const server = serveSessionBrokerDaemon({ daemon, hostname: "127.0.0.1", port });
try {
const binary = await openTestSocket(`ws://127.0.0.1:${port}/session`);
const binaryClosed = testSocketCloseCode(binary);
binary.send(new Uint8Array([1]));
expect(await binaryClosed).toBe(SESSION_BROKER_ADAPTER_CONFORMANCE.textOnly.binaryCloseCode);

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,
);

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,
);
} finally {
server.stop(true);
await server.stopped;
}
});

test("admits exactly the configured number of unauthenticated websocket peers", async () => {
const broker = new SessionBroker({ protocolParsers });
const daemon = createSessionBrokerDaemon({
broker,
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 });
try {
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);
expect(await closed).toBe(1008);
const afterRelease = await openTestSocket(`ws://127.0.0.1:${port}/session`);
afterRelease.close();
} finally {
server.stop(true);
await server.stopped;
}
});

test("serves the generic daemon API and websocket path through Bun", async () => {
const broker = new SessionBroker({ protocolParsers });
const daemon = createSessionBrokerDaemon({
broker,
capabilities: { version: 1 },
exposeHttpApi: true,
appId: "test.app",
appRevision: 1,
callerAuthenticator: {
authenticate: async () => ({
principal: {
kind: "caller" as const,
appId: "test.app",
principalId: "test-caller",
keyId: "test-key",
grantId: "test-grant",
operations: ["list", "get"] as const,
commands: [],
},
requestId: "request-1",
assertActive() {},
signResponse: async ({ httpStatus, appContract }) => ({
generation: "generation-1",
brokerRevision: 1 as const,
...(appContract ? { appContract } : {}),
requestId: "request-1",
httpStatus,
bodyDigest: "test-digest",
daemonKeyId: "daemon-key-1",
daemonSignature: "test-signature",
}),
}),
},
authorizer: async () => true,
});
const port = await reserveLoopbackPort();
const server = serveSessionBrokerDaemon({
Expand All @@ -152,7 +302,7 @@ describe("session broker bun adapter", () => {
});

try {
await expect(readHealth(port)).resolves.toMatchObject({ ok: true, sessions: 0 });
await expect(readHealth(port)).resolves.toMatchObject({ ok: true });

const socket = new WebSocket(`ws://127.0.0.1:${port}/session`);
await new Promise<void>((resolve, reject) => {
Expand Down Expand Up @@ -191,13 +341,18 @@ describe("session broker bun adapter", () => {
const response = await fetch(`http://127.0.0.1:${port}/broker`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action: "get", selector: { sessionId: "session-1" } }),
body: JSON.stringify({
action: "get",
selector: { sessionId: "session-1" },
}),
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
session: {
registration: { sessionId: "session-1" },
snapshot: { state: { selectedIndex: 0 } },
body: {
session: {
registration: { sessionId: "session-1" },
snapshot: { state: { selectedIndex: 0 } },
},
},
});

Expand All @@ -208,12 +363,32 @@ describe("session broker bun adapter", () => {
}
});

test("falls back to an empty 503 when even the capacity envelope exceeds the response cap", async () => {
const broker = new SessionBroker({ protocolParsers });
const daemon = createSessionBrokerDaemon({ broker, limits: { maxHttpResponseBytes: 1 } });
const port = await reserveLoopbackPort();
const server = serveSessionBrokerDaemon({
daemon,
hostname: "127.0.0.1",
port,
handleRequest: () => new Response("too large"),
});
try {
const response = await fetch(`http://127.0.0.1:${port}/large`);
expect(response.status).toBe(503);
expect(await response.text()).toBe("");
} finally {
server.stop(true);
await server.stopped;
}
});

test("lets custom request handlers override generic routes", async () => {
const broker = new SessionBroker({
parseRegistration: (value) => parseSessionRegistrationEnvelope(value, parseInfo),
parseSnapshot: (value) => parseSessionSnapshotEnvelope(value, parseState),
const broker = new SessionBroker({ protocolParsers });
const daemon = createSessionBrokerDaemon({
broker,
capabilities: { version: 1 },
});
const daemon = createSessionBrokerDaemon({ broker, capabilities: { version: 1 } });
const port = await reserveLoopbackPort();
const server = serveSessionBrokerDaemon({
daemon,
Expand All @@ -231,7 +406,10 @@ describe("session broker bun adapter", () => {

try {
const response = await fetch(`http://127.0.0.1:${port}/health`);
await expect(response.json()).resolves.toEqual({ ok: true, overridden: true });
await expect(response.json()).resolves.toEqual({
ok: true,
overridden: true,
});
} finally {
server.stop(true);
await server.stopped;
Expand Down
Loading
Loading