feat(dashboard): add cli-agent hook ingestion route and session hook scripts (U17)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-04 23:39:07 -07:00
parent 773ba76209
commit d8248b4c4f
9 changed files with 893 additions and 2 deletions

View File

@@ -0,0 +1,24 @@
---
"@runfusion/fusion": minor
---
Add the CLI Agent Executor hook ingestion route and per-session hook scripts
(U17). The dashboard now serves a localhost-only `POST /api/cli-agent/hooks`
endpoint that authenticates per-session hook POSTs from a spawned CLI agent and
forwards the validated payload in-process to the engine telemetry hub (the engine
has no HTTP server — only the dashboard serves HTTP).
The route is hardened because localhost is not a trust boundary: it validates the
high-entropy per-session token against the engine-held registry (a session id
alone is never sufficient, and a token for one session never validates for
another), rejects browser-context requests via Origin/Host CSRF checks, caps the
payload size, and treats an unknown/non-live session as a 200 no-op rather than a
crash. It is exempt from the daemon bearer-token middleware (hook scripts only
hold the per-session token) but authenticates with that token instead.
The engine gains `hook-scripts.ts`: it generates the per-session hook script and
notify shim (Orca `agent-hooks` shape — `curl` POST of the stdin JSON with the
session token header, short timeouts, always exit 0), writes them into a
session-scoped config dir (owner-only, executable), and deletes that dir on
session end (the token is registry-invalidated at the same moment, bounding its
at-rest exposure to the session lifetime).

View File

@@ -16,8 +16,18 @@ import type { IncomingMessage } from "node:http";
*/ */
export const TOKEN_QUERY_PARAM = "fn_token"; export const TOKEN_QUERY_PARAM = "fn_token";
/** Paths that are exempt from authentication (liveness probes). */ /**
const EXEMPT_PATHS = ["/api/health"]; * Paths exempt from the daemon bearer-token middleware.
*
* - `/api/health` — liveness probes.
* - `/api/cli-agent/hooks` — the CLI-agent hook ingestion route (U17). Hook
* scripts run inside the spawned CLI process and only hold the per-session hook
* token, NOT the daemon bearer token. That route does its OWN authentication:
* it validates the per-session token against the engine-held registry
* (constant-time) and rejects browser-context requests (Origin/Host CSRF
* defense). It must therefore bypass the daemon-token gate, not weaken it.
*/
const EXEMPT_PATHS = ["/api/health", "/api/cli-agent/hooks"];
/** /**
* Only /api/* paths are gated by this middleware. The SPA shell (index.html, * Only /api/* paths are gated by this middleware. The SPA shell (index.html,

View File

@@ -172,6 +172,7 @@ import { registerRuntimeProviderRoutes } from "./routes/register-runtime-provide
import { registerFnBinaryRoutes } from "./routes/register-fn-binary-routes.js"; import { registerFnBinaryRoutes } from "./routes/register-fn-binary-routes.js";
import { registerUpdateCheckRoutes } from "./routes/register-update-check-routes.js"; import { registerUpdateCheckRoutes } from "./routes/register-update-check-routes.js";
import { registerDiagnosticsRoutes } from "./routes/register-diagnostics-routes.js"; import { registerDiagnosticsRoutes } from "./routes/register-diagnostics-routes.js";
import { registerCliAgentHooksRoute } from "./routes/cli-agent-hooks.js";
import { registerIntegratedRouters, registerIntegratedDevServerRouter } from "./routes/register-integrated-routers.js"; import { registerIntegratedRouters, registerIntegratedDevServerRouter } from "./routes/register-integrated-routers.js";
import { registerApprovalRoutes } from "./routes/register-approval-routes.js"; import { registerApprovalRoutes } from "./routes/register-approval-routes.js";
import { registerWorktrunkRoutes } from "./routes/register-worktrunk-routes.js"; import { registerWorktrunkRoutes } from "./routes/register-worktrunk-routes.js";
@@ -1952,6 +1953,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
registerUsageRoutes(routeContext); registerUsageRoutes(routeContext);
registerUpdateCheckRoutes(routeContext); registerUpdateCheckRoutes(routeContext);
registerDiagnosticsRoutes(routeContext); registerDiagnosticsRoutes(routeContext);
// CLI Agent Executor hook ingestion (U17) — per-session token auth, exempt from
// the daemon bearer-token middleware (hook scripts only hold the session token).
registerCliAgentHooksRoute(routeContext);
// ── Automation / Scheduled Task Routes ──────────────────────────── // ── Automation / Scheduled Task Routes ────────────────────────────
// //

View File

@@ -0,0 +1,269 @@
// @vitest-environment node
import express from "express";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { rm } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CliSessionStore, Database } from "@fusion/core";
import { TelemetryHub } from "@fusion/engine";
import { request as performRequest } from "../../test-request.js";
import {
createCliAgentHooksRouterForTest,
HOOK_PAYLOAD_LIMIT_BYTES,
type CliAgentHookHub,
} from "../cli-agent-hooks.js";
const PATH = "/api/cli-agent/hooks";
const TOKEN_HEADER = "x-fusion-cli-session-token";
const SESSION_HEADER = "x-fusion-cli-session-id";
/** Mount the hook route on a bare express app with a JSON error handler. */
function mount(resolver: (projectId: string | undefined, sessionId: string) => CliAgentHookHub | undefined) {
const router = createCliAgentHooksRouterForTest(resolver);
const app = express();
app.use("/api", router);
// express.json's PayloadTooLargeError surfaces here as 413.
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(err?.statusCode ?? err?.status ?? 500).json({ error: err?.message ?? String(err) });
});
return app;
}
function post(
app: express.Express,
body: string,
headers: Record<string, string> = {},
path = PATH,
) {
return performRequest(app, "POST", path, body, {
"content-type": "application/json",
host: "127.0.0.1",
...headers,
});
}
describe("cli-agent-hooks route (stub hub)", () => {
function stubHub(overrides: Partial<CliAgentHookHub> = {}): CliAgentHookHub & { ingested: Array<{ sessionId: string; event: unknown }> } {
const ingested: Array<{ sessionId: string; event: unknown }> = [];
return {
ingested,
validateToken: (sessionId, token) => token === "good-token" && sessionId === "sess-1",
ingest: (sessionId, event) => {
ingested.push({ sessionId, event });
return event;
},
...overrides,
};
}
it("forwards a valid token + session to the hub", async () => {
const hub = stubHub();
const app = mount(() => hub);
const res = await post(app, JSON.stringify({ session_id: "native-1", hello: "world" }) , {
[TOKEN_HEADER]: "good-token",
[SESSION_HEADER]: "sess-1",
}, `${PATH}?event=Stop`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
expect(hub.ingested).toHaveLength(1);
expect(hub.ingested[0].sessionId).toBe("sess-1");
expect(hub.ingested[0].event).toMatchObject({
kind: "done",
payload: { nativeSessionId: "native-1" },
});
});
it("rejects a missing token with 401", async () => {
const hub = stubHub();
const app = mount(() => hub);
const res = await post(app, "{}", { [SESSION_HEADER]: "sess-1" });
expect(res.status).toBe(401);
expect(hub.ingested).toHaveLength(0);
});
it("rejects a wrong token with 401", async () => {
const hub = stubHub();
const app = mount(() => hub);
const res = await post(app, "{}", {
[TOKEN_HEADER]: "wrong-token",
[SESSION_HEADER]: "sess-1",
});
expect(res.status).toBe(401);
expect(hub.ingested).toHaveLength(0);
});
it("rejects a valid-format token issued for the WRONG session", async () => {
const hub = stubHub();
const app = mount(() => hub);
// good-token only validates for sess-1; present it for sess-2.
const res = await post(app, "{}", {
[TOKEN_HEADER]: "good-token",
[SESSION_HEADER]: "sess-2",
});
expect(res.status).toBe(401);
expect(hub.ingested).toHaveLength(0);
});
it("rejects a request carrying a browser Origin header (CSRF)", async () => {
const hub = stubHub();
const app = mount(() => hub);
const res = await post(app, "{}", {
[TOKEN_HEADER]: "good-token",
[SESSION_HEADER]: "sess-1",
origin: "http://evil.example.com",
});
expect(res.status).toBe(403);
expect(hub.ingested).toHaveLength(0);
});
it("rejects a cross-site Host header", async () => {
const hub = stubHub();
const app = mount(() => hub);
const res = await post(app, "{}", {
[TOKEN_HEADER]: "good-token",
[SESSION_HEADER]: "sess-1",
host: "evil.example.com",
});
expect(res.status).toBe(403);
expect(hub.ingested).toHaveLength(0);
});
it("accepts loopback Host with a port", async () => {
const hub = stubHub();
const app = mount(() => hub);
const res = await post(app, "{}", {
[TOKEN_HEADER]: "good-token",
[SESSION_HEADER]: "sess-1",
host: "127.0.0.1:4040",
});
expect(res.status).toBe(200);
});
it("rejects an oversized payload", async () => {
const hub = stubHub();
const app = mount(() => hub);
const big = JSON.stringify({ blob: "x".repeat(HOOK_PAYLOAD_LIMIT_BYTES + 1024) });
const res = await post(app, big, {
[TOKEN_HEADER]: "good-token",
[SESSION_HEADER]: "sess-1",
});
expect(res.status).toBe(413);
expect(hub.ingested).toHaveLength(0);
});
it("treats an unknown session as a no-op (200), never a crash, when the hub accepts it", async () => {
// A hub that validates any token but whose ingest is a no-op for unknown
// sessions (the real hub's contract). The route returns 200 and never throws.
const hub: CliAgentHookHub = {
validateToken: () => true,
ingest: () => undefined, // unknown session → no-op (returns undefined)
};
const app = mount(() => hub);
const res = await post(app, "{}", {
[TOKEN_HEADER]: "any",
[SESSION_HEADER]: "ghost",
});
expect(res.status).toBe(200);
});
it("returns 401 when no hub is resolvable for the session", async () => {
const app = mount(() => undefined);
const res = await post(app, "{}", {
[TOKEN_HEADER]: "good-token",
[SESSION_HEADER]: "sess-1",
});
expect(res.status).toBe(401);
});
it("returns 200 even when hub.ingest throws (best-effort telemetry)", async () => {
const hub: CliAgentHookHub = {
validateToken: () => true,
ingest: () => {
throw new Error("boom");
},
};
const app = mount(() => hub);
const res = await post(app, "{}", {
[TOKEN_HEADER]: "good-token",
[SESSION_HEADER]: "sess-1",
});
expect(res.status).toBe(200);
});
});
describe("cli-agent-hooks route (real TelemetryHub lifecycle)", () => {
let tmpDir: string;
let db: Database;
let store: CliSessionStore;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "fusion-hook-route-"));
const fusionDir = join(tmpDir, ".fusion");
db = new Database(fusionDir, { inMemory: true });
db.init();
store = new CliSessionStore(fusionDir, db);
});
afterEach(async () => {
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
function seed(agentState = "busy"): string {
return store.createSession({
purpose: "execute",
projectId: "proj",
adapterId: "claude-code",
agentState: agentState as never,
}).id;
}
it("end-to-end: valid token forwards and advances state; lifecycle revokes it", async () => {
const sessionId = seed("busy");
const hub = new TelemetryHub({ store });
const token = hub.issueToken(sessionId);
const app = mount((_proj, sid) => (hub.hasSession(sid) ? (hub as unknown as CliAgentHookHub) : undefined));
// Valid POST → 200, state machine advances to done.
const ok = await post(app, "{}", {
[TOKEN_HEADER]: token,
[SESSION_HEADER]: sessionId,
}, `${PATH}?event=Stop`);
expect(ok.status).toBe(200);
expect(hub.getStateMachine(sessionId)?.getState()).toBe("done");
// Lifecycle: session end invalidates the token.
hub.invalidate(sessionId);
// Replayed POST with the old token → 401 (no hub / not validating).
const replay = await post(app, "{}", {
[TOKEN_HEADER]: token,
[SESSION_HEADER]: sessionId,
}, `${PATH}?event=Stop`);
expect(replay.status).toBe(401);
});
it("after registry rebuild from non-live sessions, old tokens are rejected", async () => {
const sessionId = seed("busy");
const hub1 = new TelemetryHub({ store });
const oldToken = hub1.issueToken(sessionId);
// Simulate engine death mid-session: the session is no longer live.
store.updateSession(sessionId, { agentState: "dead" as never });
// New hub rebuilt from the store mints NO token for the non-live session.
const hub2 = new TelemetryHub({ store });
expect(hub2.hasSession(sessionId)).toBe(false);
const app = mount((_proj, sid) => (hub2.hasSession(sid) ? (hub2 as unknown as CliAgentHookHub) : undefined));
const res = await post(app, "{}", {
[TOKEN_HEADER]: oldToken,
[SESSION_HEADER]: sessionId,
}, `${PATH}?event=Stop`);
expect(res.status).toBe(401);
});
});

View File

@@ -0,0 +1,231 @@
/**
* CLI-agent hook ingestion route (CLI Agent Executor, U17).
*
* A localhost POST endpoint that authenticates per-session hook POSTs from a
* spawned CLI agent (Claude Code, Codex, Droid, …) and forwards the validated,
* parsed payload IN-PROCESS to the engine-held telemetry hub. The engine has no
* HTTP server — only the dashboard serves HTTP (the Orca pattern, adapted).
*
* Security posture (KTD — hook-endpoint security; localhost is NOT a trust
* boundary: any local process or browser page can reach 127.0.0.1):
*
* 1. Per-session token, constant-time. The request must carry the high-entropy
* per-session hook token AND the session id; the route validates that the
* token was issued for exactly that session against the engine-held registry
* (`hub.validateToken`). A session id alone is NEVER sufficient, and a valid
* token for session B never validates for session A. Comparison is
* constant-time inside the hub registry lookup; the header presence check here
* avoids leaking timing on the cheap path only.
*
* 2. Origin / Host CSRF defense. A browser page on any origin can POST to
* 127.0.0.1, so a forged `Stop`/completion could otherwise advance incomplete
* work or suppress the stall detector. We REJECT any request carrying a
* browser `Origin` header, and any request whose `Host` is not a loopback
* host. Hook scripts are plain `curl` (no Origin); browsers always attach one
* on cross-origin fetch — so this cleanly separates the two.
*
* 3. Payload cap. Oversized bodies are rejected (413) — both at parse time (a
* route-scoped `express.json` limit) and defensively via `Content-Length`.
*
* 4. No daemon bearer token. Hook scripts only hold the per-session token, so
* this path is EXEMPT from the daemon-token middleware (see auth-middleware
* `EXEMPT_PATHS`). It is not unauthenticated — it authenticates with the
* per-session token instead.
*
* 5. Never crash. An unknown / non-live session key is a 200 no-op (the hub's
* `ingest` is itself a no-op for unknown sessions); malformed JSON is a 400;
* nothing here throws into the agent's hook chain.
*/
import { Router, type Request, type Response } from "express";
import express from "express";
import type { ApiRouteRegistrar } from "./types.js";
/** Max accepted hook payload size. Hook payloads are small JSON envelopes. */
export const HOOK_PAYLOAD_LIMIT_BYTES = 256 * 1024;
/** Header carrying the per-session hook token (matches the engine hook scripts). */
const TOKEN_HEADER = "x-fusion-cli-session-token";
/** Header carrying the session id the token must validate for. */
const SESSION_HEADER = "x-fusion-cli-session-id";
/** The minimal hub surface the route depends on (validate + ingest). */
export interface CliAgentHookHub {
validateToken(sessionId: string, token: string | null | undefined): boolean;
ingest(sessionId: string, event: unknown): unknown;
}
/** Loopback hosts the route accepts. Anything else is treated as cross-site. */
function isLoopbackHost(host: string | undefined): boolean {
if (!host) return false;
// Strip a :port suffix (but keep IPv6 brackets intact for the comparison).
const bare = host.replace(/:\d+$/, "").toLowerCase();
return (
bare === "127.0.0.1" ||
bare === "localhost" ||
bare === "[::1]" ||
bare === "::1" ||
bare === "0.0.0.0"
);
}
/** First value of a (possibly array) header, trimmed. */
function headerValue(req: Request, name: string): string | undefined {
const raw = req.headers[name];
const value = Array.isArray(raw) ? raw[0] : raw;
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
/**
* Map a host CLI hook event name (from the `?event=` query param the scripts add)
* onto a normalized telemetry event kind. Unknown / absent events fall back to a
* generic activity signal, so an unrecognized hook never advances state on its own
* (positive completion gating lives in the state machine, not here).
*/
function normalizeHookEvent(eventName: string | undefined, body: Record<string, unknown>) {
const name = (eventName ?? "").toLowerCase();
// Carry the native session id whenever the payload reports one (Claude:
// `session_id` in every payload) so the hub can persist it.
const nativeSessionId =
typeof body.session_id === "string"
? body.session_id
: typeof body.sessionId === "string"
? body.sessionId
: undefined;
const basePayload: Record<string, unknown> = {};
if (nativeSessionId) basePayload.nativeSessionId = nativeSessionId;
switch (name) {
case "sessionstart":
return { kind: "sessionStart" as const, payload: basePayload };
case "stop":
case "subagentstop":
return { kind: "done" as const, payload: basePayload };
case "notification":
case "permissionrequest":
case "notify":
return {
kind: "waitingOnInput" as const,
payload: { ...basePayload, notification: body },
};
case "pretooluse":
case "posttooluse":
return { kind: "toolActivity" as const, payload: basePayload };
case "userpromptsubmit":
return { kind: "busy" as const, payload: basePayload };
default:
// Unknown / absent event → activity only (re-arms watchdog, never advances).
return { kind: "outputProgress" as const, payload: basePayload };
}
}
export const registerCliAgentHooksRoute: ApiRouteRegistrar = (ctx) => {
const { router } = ctx;
const logger = ctx.runtimeLogger.child("cli-agent-hooks");
// Route-scoped JSON parser with a hard size cap. An oversized body is rejected
// at parse time (express throws a 413 PayloadTooLargeError, surfaced by the
// error handler) before any handler logic runs.
const parseHookBody = express.json({ limit: HOOK_PAYLOAD_LIMIT_BYTES });
const handler = (req: Request, res: Response): void => {
// ── 1. CSRF defense: reject browser-context requests ──────────────────────
// Any request carrying an Origin header came from a browser fetch — a hook
// script never sets one. Reject outright (localhost is not a trust boundary).
if (headerValue(req, "origin") !== undefined) {
res.status(403).json({ error: "Origin not allowed" });
return;
}
// Host must be a loopback host. A cross-site Host (DNS-rebinding style) is
// rejected even absent an Origin header.
if (!isLoopbackHost(headerValue(req, "host"))) {
res.status(403).json({ error: "Host not allowed" });
return;
}
// ── 2. Defensive payload cap on Content-Length ────────────────────────────
const contentLength = Number(req.headers["content-length"] ?? 0);
if (Number.isFinite(contentLength) && contentLength > HOOK_PAYLOAD_LIMIT_BYTES) {
res.status(413).json({ error: "Payload too large" });
return;
}
// ── 3. Identify session + token ───────────────────────────────────────────
const sessionId = headerValue(req, SESSION_HEADER);
const token = headerValue(req, TOKEN_HEADER);
if (!sessionId || !token) {
// Missing credentials — never a no-op (a no-op is reserved for a *valid*
// request against an unknown session). No token == not authenticated.
res.status(401).json({ error: "Missing session token" });
return;
}
// ── 4. Resolve the engine-held hub for this session ───────────────────────
const projectId = ctx.getProjectIdFromRequest(req);
const resolver = ctx.options?.cliAgentHubResolver;
const hub = resolver?.(projectId, sessionId) as CliAgentHookHub | undefined;
// No hub at all (e.g. engine not wired / no live sessions). A forged token
// cannot validate; treat as unauthorized rather than no-op so a wrong token
// is never silently accepted.
if (!hub) {
res.status(401).json({ error: "Invalid session token" });
return;
}
// ── 5. Validate the per-session token (token-belongs-to-session) ──────────
// The hub validates that this exact token was issued for THIS session —
// session id alone is never sufficient, and a valid token for another session
// is rejected. Missing/wrong/expired/invalidated tokens all fail here.
if (!hub.validateToken(sessionId, token)) {
res.status(401).json({ error: "Invalid session token" });
return;
}
// ── 6. Forward the validated payload in-process to the hub ────────────────
const body = (req.body ?? {}) as Record<string, unknown>;
const eventName = typeof req.query.event === "string" ? req.query.event : undefined;
const event = normalizeHookEvent(eventName, body);
try {
// ingest is itself a no-op for unknown/non-live sessions — never crashes.
hub.ingest(sessionId, event);
} catch (error) {
// Telemetry ingestion is best-effort. Log and still return 200 so the
// agent's hook chain is never disturbed by an engine-side hiccup.
logger.warn("hook ingest failed", {
sessionId,
error: error instanceof Error ? error.message : String(error),
});
}
res.status(200).json({ ok: true });
};
// POST only. The route does its own auth (per-session token) and is exempt
// from the daemon bearer-token middleware (see auth-middleware EXEMPT_PATHS).
router.post("/cli-agent/hooks", parseHookBody, handler);
};
/**
* Build a standalone Express router carrying just the hook route — used by the
* route test to mount the handler without the full server. Mirrors the
* production registration (`registerCliAgentHooksRoute`).
*/
export function createCliAgentHooksRouterForTest(
resolver: (projectId: string | undefined, sessionId: string) => CliAgentHookHub | undefined,
logger: { warn: (msg: string, ctx?: unknown) => void } = { warn: () => {} },
): Router {
const router = Router();
registerCliAgentHooksRoute({
router,
options: { cliAgentHubResolver: resolver as never },
getProjectIdFromRequest: (req: Request) =>
typeof req.query.projectId === "string" ? req.query.projectId : undefined,
runtimeLogger: { child: () => logger } as never,
} as never);
return router;
}

View File

@@ -194,6 +194,18 @@ export interface ServerOptions {
engineManager?: import("@fusion/engine").ProjectEngineManager; engineManager?: import("@fusion/engine").ProjectEngineManager;
/** Optional HybridExecutor orchestration context for multi-project runtime plumbing. */ /** Optional HybridExecutor orchestration context for multi-project runtime plumbing. */
hybridExecutor?: import("@fusion/engine").HybridExecutor; hybridExecutor?: import("@fusion/engine").HybridExecutor;
/**
* Resolver for the engine-held CLI-agent telemetry hub (U17 hook route).
* Given a request's projectId (if any) and the target session id, returns the
* in-process TelemetryHub that owns that session's token registry, or undefined
* when no hub / session is live. The hook route validates the per-session token
* against this hub and forwards validated payloads to `hub.ingest`. Injected
* here (rather than reached through the engine) so the engine↔dashboard wiring
* can be supplied by later units and stubbed in tests. */
cliAgentHubResolver?: (
projectId: string | undefined,
sessionId: string,
) => import("@fusion/engine").TelemetryHub | undefined;
/** Shared CentralCore instance used by the engine manager. /** Shared CentralCore instance used by the engine manager.
* Routes that mutate central runtime state should use this instance so * Routes that mutate central runtime state should use this instance so
* in-process listeners (for example global concurrency changes) are notified. */ * in-process listeners (for example global concurrency changes) are notified. */

View File

@@ -0,0 +1,121 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, statSync, existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { rm } from "node:fs/promises";
import {
writeSessionHookScripts,
cleanupSessionHookDir,
buildHookScriptContent,
buildNotifyShimContent,
HOOK_SCRIPT_NAMES,
} from "../hook-scripts.js";
describe("hook-scripts", () => {
let tmpDir: string;
let dir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "fusion-hook-scripts-"));
dir = join(tmpDir, "session-config");
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
const opts = {
sessionId: "sess-123",
token: "abc123def456token",
endpointUrl: "http://127.0.0.1:4040/api/cli-agent/hooks",
};
describe("buildHookScriptContent", () => {
it("POSTs to the endpoint URL with the token + session headers", () => {
const script = buildHookScriptContent(opts);
expect(script).toContain("http://127.0.0.1:4040/api/cli-agent/hooks");
expect(script).toContain("X-Fusion-Cli-Session-Token: $TOKEN");
expect(script).toContain("X-Fusion-Cli-Session-Id: $SESSION_ID");
expect(script).toContain("abc123def456token");
expect(script).toContain("sess-123");
});
it("uses curl with short timeouts and always exits 0", () => {
const script = buildHookScriptContent(opts);
expect(script).toContain("curl");
expect(script).toContain("--connect-timeout");
expect(script).toContain("--max-time");
// Failure tolerance: the curl line is `|| true` and the script ends `exit 0`.
expect(script).toContain("|| true");
expect(script.trimEnd().endsWith("exit 0")).toBe(true);
});
it("never sets an Origin header (CSRF-safe)", () => {
const script = buildHookScriptContent(opts);
expect(script.toLowerCase()).not.toContain("origin:");
});
it("starts with a sh shebang", () => {
expect(buildHookScriptContent(opts).startsWith("#!/bin/sh")).toBe(true);
});
it("shell-escapes a token containing a single quote", () => {
const script = buildHookScriptContent({ ...opts, token: "to'ken" });
// The single quote is escaped via the '\'' idiom — no raw unbalanced quote.
expect(script).toContain(`'\\''`);
});
});
describe("buildNotifyShimContent", () => {
it("forwards argv[1] (else stdin) and exits 0", () => {
const script = buildNotifyShimContent(opts);
expect(script.startsWith("#!/bin/sh")).toBe(true);
expect(script).toContain('"$1"');
expect(script).toContain("event=notify");
expect(script).toContain("X-Fusion-Cli-Session-Token: $TOKEN");
expect(script.trimEnd().endsWith("exit 0")).toBe(true);
});
});
describe("writeSessionHookScripts", () => {
it("writes both scripts into the dir, marked executable", async () => {
const result = await writeSessionHookScripts({ ...opts, dir });
expect(result.hookScriptPath).toBe(join(dir, HOOK_SCRIPT_NAMES.hook));
expect(result.notifyScriptPath).toBe(join(dir, HOOK_SCRIPT_NAMES.notify));
expect(existsSync(result.hookScriptPath)).toBe(true);
expect(existsSync(result.notifyScriptPath)).toBe(true);
// Owner-executable bit set on both files.
const hookMode = statSync(result.hookScriptPath).mode;
const notifyMode = statSync(result.notifyScriptPath).mode;
expect(hookMode & 0o100).toBe(0o100);
expect(notifyMode & 0o100).toBe(0o100);
const hookContent = await readFile(result.hookScriptPath, "utf8");
expect(hookContent).toContain(opts.endpointUrl);
expect(hookContent).toContain(opts.token);
});
it("creates the dir if it does not exist", async () => {
expect(existsSync(dir)).toBe(false);
await writeSessionHookScripts({ ...opts, dir });
expect(existsSync(dir)).toBe(true);
});
});
describe("cleanupSessionHookDir", () => {
it("removes the dir and its contents", async () => {
await writeSessionHookScripts({ ...opts, dir });
expect(existsSync(dir)).toBe(true);
await cleanupSessionHookDir(dir);
expect(existsSync(dir)).toBe(false);
});
it("is a no-op for a missing dir (never throws)", async () => {
await expect(cleanupSessionHookDir(join(tmpDir, "does-not-exist"))).resolves.toBeUndefined();
});
});
});

View File

@@ -0,0 +1,197 @@
/**
* Per-session hook scripts + notify shim generation (CLI Agent Executor, U17).
*
* Fusion launches a CLI agent (Claude Code, Codex, Droid, …) with a
* session-scoped settings/config dir whose hooks point at small `sh` scripts —
* the Orca `~/.orca/agent-hooks/*.sh` shape, adapted. Each script reads the hook
* payload JSON from stdin and POSTs it to the dashboard-served localhost hook
* endpoint (the engine has NO HTTP server — only the dashboard serves HTTP), with
* the per-session token carried in a request header.
*
* Security / robustness invariants (KTD — hook-endpoint security):
* - The token is the ONLY authenticator the script holds; the session id alone
* is never sufficient server-side (the route validates token-belongs-to-session
* against the engine-held registry). The token's at-rest exposure inside the
* session-scoped config dir is an accepted, lifetime-bounded risk: the dir is
* deleted on session end (`cleanupSessionHookDir`) and the token is
* registry-invalidated at the same moment (`hub.invalidate`).
* - The script NEVER sets an `Origin` header — the route rejects browser-context
* requests (Origin/Host CSRF defense). A plain `curl` POST has no Origin.
* - `curl` uses short connect/total timeouts and the script ALWAYS exits 0, so a
* slow / down / wedged endpoint can never block or fail the agent's own hook
* chain (telemetry is best-effort; it must not gate the CLI).
*
* This module is pure engine code: it only generates script text and writes /
* removes files. It performs no networking and never mutates the user's global
* agent config (`~/.claude`, etc.) — only the session-scoped dir it is handed.
*/
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
/** Filenames written into the session hook dir. */
export const HOOK_SCRIPT_NAMES = {
/** Main hook script: POSTs stdin payload (with the hook event name) to the route. */
hook: "fusion-hook.sh",
/** Notify shim (Codex `notify` config etc.): POSTs its argv-supplied JSON. */
notify: "fusion-notify.sh",
} as const;
/** Connect / total curl timeouts (seconds) — must be short; telemetry is best-effort. */
const CURL_CONNECT_TIMEOUT_S = "0.5";
const CURL_MAX_TIME_S = "1.5";
/** HTTP header carrying the per-session hook token (matches the U17 route). */
export const HOOK_TOKEN_HEADER = "X-Fusion-Cli-Session-Token";
/** HTTP header carrying the session id the token must validate for. */
export const HOOK_SESSION_HEADER = "X-Fusion-Cli-Session-Id";
export interface WriteSessionHookScriptsOptions {
/** Fusion CLI-session id (server validates token-belongs-to-this-session). */
sessionId: string;
/** High-entropy per-session hook token minted by the telemetry hub. */
token: string;
/**
* Absolute URL of the dashboard hook ingestion endpoint, e.g.
* `http://127.0.0.1:4040/api/cli-agent/hooks`. The script POSTs here.
*/
endpointUrl: string;
/** Session-scoped config dir to write the scripts into (created if absent). */
dir: string;
}
export interface WrittenHookScripts {
/** Absolute path to the main hook script. */
hookScriptPath: string;
/** Absolute path to the notify shim script. */
notifyScriptPath: string;
}
/** Shell-quote a value for safe single-quoted embedding in a generated script. */
function shellSingleQuote(value: string): string {
// Replace each ' with '\'' (close, escaped quote, reopen).
return `'${value.replace(/'/g, `'\\''`)}'`;
}
/**
* Build the main hook script. It reads the hook payload JSON from stdin and POSTs
* it to the endpoint with the session token + session id headers. The hook event
* name (when the agent exposes it via an env var) is forwarded as a query param.
*
* Always exits 0; a missing `curl`, an unreachable endpoint, or a non-2xx
* response must never break the agent's hook execution.
*/
export function buildHookScriptContent(opts: {
sessionId: string;
token: string;
endpointUrl: string;
}): string {
const endpoint = shellSingleQuote(opts.endpointUrl);
const token = shellSingleQuote(opts.token);
const sessionId = shellSingleQuote(opts.sessionId);
return `#!/bin/sh
# Fusion CLI-agent hook script (generated per session — do not edit).
# Reads the hook payload JSON from stdin and forwards it to the Fusion dashboard
# hook ingestion endpoint. Best-effort: ALWAYS exits 0; never sets an Origin.
set -u
ENDPOINT=${endpoint}
TOKEN=${token}
SESSION_ID=${sessionId}
# Hook event name, when the host CLI exposes it (Claude: CLAUDE_HOOK_EVENT;
# generic fallbacks). Forwarded as a query param so the route can normalize it.
EVENT="\${CLAUDE_HOOK_EVENT:-\${FUSION_HOOK_EVENT:-\${HOOK_EVENT_NAME:-}}}"
PAYLOAD="$(cat)"
if [ -z "$PAYLOAD" ]; then
PAYLOAD='{}'
fi
URL="$ENDPOINT"
if [ -n "$EVENT" ]; then
URL="$ENDPOINT?event=$EVENT"
fi
if command -v curl >/dev/null 2>&1; then
printf '%s' "$PAYLOAD" | curl -sS -X POST "$URL" \\
--connect-timeout ${CURL_CONNECT_TIMEOUT_S} --max-time ${CURL_MAX_TIME_S} \\
-H 'Content-Type: application/json' \\
-H "${HOOK_TOKEN_HEADER}: $TOKEN" \\
-H "${HOOK_SESSION_HEADER}: $SESSION_ID" \\
--data-binary @- >/dev/null 2>&1 || true
fi
exit 0
`;
}
/**
* Build the notify shim. Some CLIs (Codex `notify`) invoke a program with the
* notification JSON as a single argv argument rather than on stdin. The shim
* forwards `$1` (falling back to stdin) to the same endpoint with the same auth.
*/
export function buildNotifyShimContent(opts: {
sessionId: string;
token: string;
endpointUrl: string;
}): string {
const endpoint = shellSingleQuote(opts.endpointUrl);
const token = shellSingleQuote(opts.token);
const sessionId = shellSingleQuote(opts.sessionId);
return `#!/bin/sh
# Fusion CLI-agent notify shim (generated per session — do not edit).
# Forwards the notification JSON (argv[1], else stdin) to the Fusion dashboard
# hook ingestion endpoint. Best-effort: ALWAYS exits 0; never sets an Origin.
set -u
ENDPOINT=${endpoint}
TOKEN=${token}
SESSION_ID=${sessionId}
if [ "$#" -gt 0 ] && [ -n "$1" ]; then
PAYLOAD="$1"
else
PAYLOAD="$(cat)"
fi
if [ -z "$PAYLOAD" ]; then
PAYLOAD='{}'
fi
if command -v curl >/dev/null 2>&1; then
printf '%s' "$PAYLOAD" | curl -sS -X POST "$ENDPOINT?event=notify" \\
--connect-timeout ${CURL_CONNECT_TIMEOUT_S} --max-time ${CURL_MAX_TIME_S} \\
-H 'Content-Type: application/json' \\
-H "${HOOK_TOKEN_HEADER}: $TOKEN" \\
-H "${HOOK_SESSION_HEADER}: $SESSION_ID" \\
--data-binary @- >/dev/null 2>&1 || true
fi
exit 0
`;
}
/**
* Write the per-session hook script + notify shim into `dir` (created if absent),
* marked executable (0o700 — owner-only, since they carry the session token).
* Returns the absolute paths of the written scripts.
*/
export async function writeSessionHookScripts(
opts: WriteSessionHookScriptsOptions,
): Promise<WrittenHookScripts> {
const { sessionId, token, endpointUrl, dir } = opts;
// 0o700: the dir holds the at-rest token — restrict to the owner.
await mkdir(dir, { recursive: true, mode: 0o700 });
const hookScriptPath = join(dir, HOOK_SCRIPT_NAMES.hook);
const notifyScriptPath = join(dir, HOOK_SCRIPT_NAMES.notify);
await writeFile(hookScriptPath, buildHookScriptContent({ sessionId, token, endpointUrl }), {
mode: 0o700,
});
await writeFile(notifyScriptPath, buildNotifyShimContent({ sessionId, token, endpointUrl }), {
mode: 0o700,
});
return { hookScriptPath, notifyScriptPath };
}
/**
* Delete the session-scoped hook config dir on session end. The token's at-rest
* exposure is bounded to the session lifetime; the caller invalidates the token
* in the hub at the same moment (`hub.invalidate(sessionId)`). Best-effort: a
* missing dir is not an error.
*/
export async function cleanupSessionHookDir(dir: string): Promise<void> {
await rm(dir, { recursive: true, force: true });
}

View File

@@ -623,3 +623,26 @@ export {
getNativePrebuildName, getNativePrebuildName,
resetPtyModuleCacheForTests, resetPtyModuleCacheForTests,
} from "./pty-native.js"; } from "./pty-native.js";
// CLI Agent Executor — telemetry hub (U3) consumed by the dashboard hook route (U17).
export {
TelemetryHub,
stripAnsiControl,
DEFAULT_MAX_EVENT_CHARS,
DEFAULT_MAX_EVENTS_PER_TURN,
DEFAULT_CHUNK_CARRY_CHARS,
type TelemetryHubOptions,
type TelemetryEvent,
type TelemetryEventKind,
type SanitizedTelemetryEvent,
type NotificationDispatch,
} from "./cli-agent/telemetry-hub.js";
// CLI Agent Executor — per-session hook scripts / notify shim (U17).
export {
writeSessionHookScripts,
cleanupSessionHookDir,
buildHookScriptContent,
buildNotifyShimContent,
HOOK_SCRIPT_NAMES,
type WriteSessionHookScriptsOptions,
type WrittenHookScripts,
} from "./cli-agent/hook-scripts.js";