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
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, refuse unsafe PID-based replacement, and let interactive Hunk windows reconnect automatically after an incompatible incumbent becomes idle.
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
2 changes: 1 addition & 1 deletion src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ async function main() {
}

if (startupPlan.kind === "daemon-serve") {
const server = serveSessionBrokerDaemon();
const server = await serveSessionBrokerDaemon();
await server.stopped;
return;
}
Expand Down
13 changes: 8 additions & 5 deletions src/session/agent/cliClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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.",
Expand All @@ -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.",
);
Expand All @@ -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);
Expand All @@ -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 () =>
Expand Down
98 changes: 75 additions & 23 deletions src/session/agent/cliClient.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<Response>;
}

class HttpHunkSessionCliClient implements HunkSessionCliClient {
private readonly config = resolveSessionBrokerConfig();

constructor(private readonly timeoutMs = HUNK_SESSION_DAEMON_HTTP_TIMEOUT_MS) {}
private callerPromise: Promise<HunkCallerTransport> | 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<Action extends SessionDaemonAction>(
input: Extract<SessionDaemonRequest, { action: Action }>,
): Promise<SessionDaemonResponses[Action]> {
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();
Expand All @@ -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() {
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 0 additions & 1 deletion src/session/agent/commands.daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});

Expand Down
Loading
Loading