Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ All notable user-visible changes to Hunk are documented in this file.
### Fixed

- Hardened the local session daemon against browser-originated requests by validating Host and Origin headers and requiring JSON content types for API posts.
- Disabled the generic broker HTTP API by default so Hunk's supported session API is the only app-daemon command surface.

## [0.13.0] - 2026-05-18

Expand Down
4 changes: 2 additions & 2 deletions packages/session-broker-bun/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Use this package when you want to serve a runtime-neutral `SessionBrokerDaemon`
- upgrades websocket requests on the daemon socket path
- forwards websocket messages and close events into the daemon
- exposes a `stopped` promise compatible with Hunk's daemon lifecycle
- lets callers override or add custom HTTP routes before the generic broker routes
- lets callers override or add custom HTTP routes before the daemon's built-in routes

## Usage

Expand Down Expand Up @@ -55,7 +55,7 @@ const server = serveSessionBrokerDaemon({
});
```

Return `undefined` to fall through to the generic broker routes.
Return `undefined` to fall through to the daemon's built-in routes. The raw `/broker` HTTP API is available only when the daemon was created with `exposeHttpApi: true`.

## License

Expand Down
6 changes: 5 additions & 1 deletion packages/session-broker-bun/src/serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,11 @@ describe("session broker bun adapter", () => {
parseRegistration: (value) => parseSessionRegistrationEnvelope(value, parseInfo),
parseSnapshot: (value) => parseSessionSnapshotEnvelope(value, parseState),
});
const daemon = createSessionBrokerDaemon({ broker, capabilities: { version: 1 } });
const daemon = createSessionBrokerDaemon({
broker,
capabilities: { version: 1 },
exposeHttpApi: true,
});
const port = await reserveLoopbackPort();
const server = serveSessionBrokerDaemon({
daemon,
Expand Down
6 changes: 5 additions & 1 deletion packages/session-broker-node/src/serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,11 @@ describe("session broker node adapter", () => {
parseRegistration: (value) => parseSessionRegistrationEnvelope(value, parseInfo),
parseSnapshot: (value) => parseSessionSnapshotEnvelope(value, parseState),
});
const daemon = createSessionBrokerDaemon({ broker, capabilities: { version: 1 } });
const daemon = createSessionBrokerDaemon({
broker,
capabilities: { version: 1 },
exposeHttpApi: true,
});
const port = await reserveLoopbackPort();
const server = await serveSessionBrokerDaemon({
daemon,
Expand Down
21 changes: 16 additions & 5 deletions packages/session-broker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Use this package when you want to:
- track live sessions
- register and update session snapshots
- route commands to one live session
- expose broker health and raw list/get/dispatch APIs
- expose broker health and optional raw list/get/dispatch APIs
- manage session-side websocket connection state

## Package roles
Expand All @@ -29,7 +29,7 @@ If you are choosing one package to build against, start here.
- `SessionBrokerDaemon` runtime-neutral daemon engine
- `SessionBrokerConnection` runtime-neutral session-side websocket helper
- raw broker HTTP request types
- health and capabilities handling
- health handling and optional capabilities API handling
- stale-session pruning and idle shutdown

## What this package does not own
Expand Down Expand Up @@ -103,11 +103,22 @@ const daemon = createSessionBrokerDaemon({
At this point the daemon can:

- handle health requests
- handle capabilities requests
- handle raw `list` / `get` / `dispatch` broker API requests
- process websocket register/snapshot/heartbeat/result messages
- prune stale sessions and request idle shutdown

The raw HTTP broker API is opt-in. Enable it only when your host application wants to expose the generic `list` / `get` / `dispatch` command surface:

```ts
const daemon = createSessionBrokerDaemon({
broker,
capabilities: {
version: 1,
name: "example-broker",
},
exposeHttpApi: true,
});
```

### 3. Serve it through a runtime adapter

#### Bun
Expand Down Expand Up @@ -167,7 +178,7 @@ The helper owns:

## Raw broker API

The daemon's runtime-neutral HTTP API is intentionally small:
The daemon's runtime-neutral HTTP API is intentionally small and disabled by default. When `exposeHttpApi: true` is set, it serves:

- `GET /health`
- `GET /broker/capabilities`
Expand Down
32 changes: 31 additions & 1 deletion packages/session-broker/src/daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,11 @@ function createConnection() {
}

describe("session broker daemon", () => {
test("serves health and raw list/get requests", async () => {
test("serves health and raw list/get requests when the HTTP API is enabled", async () => {
const daemon = createSessionBrokerDaemon({
broker: createBroker(),
capabilities: { version: 1, name: "test-broker" },
exposeHttpApi: true,
});
const { connection } = createConnection();
daemon.handleConnectionMessage(
Expand Down Expand Up @@ -149,10 +150,38 @@ describe("session broker daemon", () => {
daemon.shutdown();
});

test("does not expose the raw broker HTTP API by default", async () => {
const daemon = createSessionBrokerDaemon({
broker: createBroker(),
capabilities: { version: 1 },
});

await expect(
daemon.handleRequest(new Request("http://broker.test/broker/capabilities")),
).resolves.toBeNull();

await expect(
daemon.handleRequest(
new Request("http://broker.test/broker", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action: "list" }),
}),
),
).resolves.toBeNull();

await expect(
daemon.handleRequest(new Request("http://broker.test/health")),
).resolves.toBeInstanceOf(Response);
expect(daemon.paths).toEqual({ health: "/health", socket: "/session" });
daemon.shutdown();
});

test("requires JSON content type for raw broker API posts", async () => {
const daemon = createSessionBrokerDaemon({
broker: createBroker(),
capabilities: { version: 1 },
exposeHttpApi: true,
});

const response = await daemon.handleRequest(
Expand All @@ -174,6 +203,7 @@ describe("session broker daemon", () => {
const daemon = createSessionBrokerDaemon({
broker: createBroker(),
capabilities: { version: 1 },
exposeHttpApi: true,
});
const session = createConnection();
const { connection, sent } = session;
Expand Down
12 changes: 8 additions & 4 deletions packages/session-broker/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface SessionBrokerDaemonOptions<
broker: SessionBrokerController<SessionView, ServerMessage, CommandResult>;
capabilities?: SessionBrokerCapabilities;
paths?: Partial<SessionBrokerHttpPaths>;
exposeHttpApi?: boolean;
idleTimeoutMs?: number;
staleSessionTtlMs?: number;
staleSessionSweepIntervalMs?: number;
Expand Down Expand Up @@ -105,11 +106,14 @@ export class SessionBrokerDaemon<
"broker"
> = {},
) {
const exposeHttpApi = options.exposeHttpApi ?? false;
this.paths = {
health: options.paths?.health ?? DEFAULT_SESSION_BROKER_HEALTH_PATH,
api: options.paths?.api ?? DEFAULT_SESSION_BROKER_API_PATH,
capabilities: options.paths?.capabilities ?? DEFAULT_SESSION_BROKER_CAPABILITIES_PATH,
socket: options.paths?.socket ?? DEFAULT_SESSION_BROKER_SOCKET_PATH,
api: exposeHttpApi ? (options.paths?.api ?? DEFAULT_SESSION_BROKER_API_PATH) : undefined,
capabilities: exposeHttpApi
? (options.paths?.capabilities ?? DEFAULT_SESSION_BROKER_CAPABILITIES_PATH)
: undefined,
};
this.capabilities = options.capabilities ?? { version: 1 };
this.idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
Expand Down Expand Up @@ -162,12 +166,12 @@ export class SessionBrokerDaemon<
return Response.json(this.getHealth());
}

if (url.pathname === this.paths.capabilities) {
if (this.paths.capabilities && url.pathname === this.paths.capabilities) {
this.noteActivity();
return Response.json(this.capabilities);
}

if (url.pathname === this.paths.api) {
if (this.paths.api && url.pathname === this.paths.api) {
this.noteActivity();
return this.handleApiRequest(request);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/session-broker/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ export interface SessionBrokerCapabilities {

export interface SessionBrokerHttpPaths {
health: string;
api: string;
capabilities: string;
socket: string;
api?: string;
capabilities?: string;
}

export type SessionBrokerDaemonRequest<
Expand Down
1 change: 1 addition & 0 deletions src/hunk-session/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,7 @@ describe("Hunk session CLI formatters", () => {
"Selected hunk: -",
"Agent notes visible: no",
"Live comments: 1",
"Review notes: 0",
"Files:",
" - src/first.ts (+2 -1, hunks: 2)",
" hunk 1: @@ -1,1 +1,2 @@",
Expand Down
31 changes: 30 additions & 1 deletion src/session-broker/brokerServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ interface HealthResponse {
pid: number;
sessions: number;
pendingCommands: number;
paths?: Record<string, string>;
sessionApi?: string;
sessionCapabilities?: string;
sessionSocket?: string;
}

async function reserveLoopbackPort() {
Expand Down Expand Up @@ -250,14 +254,39 @@ describe("Hunk session daemon server", () => {
}
});

test("exposes session capabilities and rejects the old MCP tool endpoint", async () => {
test("exposes only Hunk session endpoints and rejects the old MCP tool endpoint", async () => {
const port = await reserveLoopbackPort();
process.env.HUNK_MCP_HOST = "127.0.0.1";
process.env.HUNK_MCP_PORT = String(port);

const server = 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`,
});

const genericCapabilities = await fetch(`http://127.0.0.1:${port}/broker/capabilities`);
expect(genericCapabilities.status).toBe(404);

const genericBroker = await fetch(`http://127.0.0.1:${port}/broker`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ action: "list" }),
});
expect(genericBroker.status).toBe(404);

const capabilities = await fetch(`http://127.0.0.1:${port}/session-api/capabilities`);
expect(capabilities.status).toBe(200);
await expect(capabilities.json()).resolves.toMatchObject({
Expand Down
36 changes: 19 additions & 17 deletions src/ui/diff/plannedReviewRows.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import type { VisibleAgentNote } from "../lib/agentAnnotations";
import { reviewRowId } from "../lib/ids";
import type { PlannedReviewRow } from "./reviewRenderPlan";
import {
Expand Down Expand Up @@ -78,33 +79,34 @@ function splitLine(key: string, hunkIndex: number, anchorId?: string): PlannedRe
}

function inlineNote(key: string, hunkIndex: number): PlannedReviewRow {
const annotation = {
id: "note-1",
newRange: [1, 1] as [number, number],
summary: "Explain why this branch changed.",
rationale: "The note should reserve space in the hunk bounds.",
};
const note: VisibleAgentNote = { id: "note-1", annotation };

return {
kind: "inline-note",
key,
stableKey: key,
fileId: "file-1",
hunkIndex,
annotationId: "note-1",
annotation: {
id: "note-1",
newRange: [1, 1],
summary: "Explain why this branch changed.",
rationale: "The note should reserve space in the hunk bounds.",
},
annotation,
note,
anchorSide: "new",
noteCount: 1,
noteIndex: 0,
};
}

function guideCap(key: string, hunkIndex: number): PlannedReviewRow {
function guidedLine(key: string, hunkIndex: number): PlannedReviewRow {
const row = splitLine(key, hunkIndex) as Extract<PlannedReviewRow, { kind: "diff-row" }>;
return {
kind: "note-guide-cap",
key,
stableKey: key,
fileId: "file-1",
hunkIndex,
side: "new",
...row,
noteGuideSide: "new",
};
}

Expand All @@ -124,17 +126,17 @@ describe("planned review row geometry", () => {
}),
).toBe(false);
expect(plannedReviewRowHeight(splitLine("line", 0), baseOptions)).toBe(1);
expect(plannedReviewRowHeight(guideCap("cap", 0), baseOptions)).toBe(1);
expect(plannedReviewRowHeight(guidedLine("guide", 0), baseOptions)).toBe(1);
expect(plannedReviewRowHeight(inlineNote("note", 0), baseOptions)).toBeGreaterThan(3);
});

test("measured hunk bounds ignore collapsed gaps but include inline notes and guide caps", () => {
test("measured hunk bounds ignore collapsed gaps but include inline notes and guide rows", () => {
const rows = [
hunkHeader("h0", 0, "hunk-0"),
splitLine("line-0", 0),
collapsedRow("gap", 0),
inlineNote("note", 0),
guideCap("cap", 0),
guidedLine("guide", 0),
hunkHeader("h1", 1, "hunk-1"),
splitLine("line-1", 1),
];
Expand All @@ -149,7 +151,7 @@ describe("planned review row geometry", () => {
top: 0,
height: 3 + noteHeight,
startRowId: reviewRowId("h0"),
endRowId: reviewRowId("cap"),
endRowId: reviewRowId("guide"),
});
expect(measured.hunkBounds.get(1)).toEqual({
top: 4 + noteHeight,
Expand Down
Loading