From d8248b4c4f53607b25f7bbc141c78c19e7da262e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 23:39:07 -0700 Subject: [PATCH] feat(dashboard): add cli-agent hook ingestion route and session hook scripts (U17) Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/cli-agent-hook-ingestion.md | 24 ++ packages/dashboard/src/auth-middleware.ts | 14 +- packages/dashboard/src/routes.ts | 4 + .../__tests__/cli-agent-hooks-route.test.ts | 269 ++++++++++++++++++ .../dashboard/src/routes/cli-agent-hooks.ts | 231 +++++++++++++++ packages/dashboard/src/server.ts | 12 + .../cli-agent/__tests__/hook-scripts.test.ts | 121 ++++++++ packages/engine/src/cli-agent/hook-scripts.ts | 197 +++++++++++++ packages/engine/src/index.ts | 23 ++ 9 files changed, 893 insertions(+), 2 deletions(-) create mode 100644 .changeset/cli-agent-hook-ingestion.md create mode 100644 packages/dashboard/src/routes/__tests__/cli-agent-hooks-route.test.ts create mode 100644 packages/dashboard/src/routes/cli-agent-hooks.ts create mode 100644 packages/engine/src/cli-agent/__tests__/hook-scripts.test.ts create mode 100644 packages/engine/src/cli-agent/hook-scripts.ts diff --git a/.changeset/cli-agent-hook-ingestion.md b/.changeset/cli-agent-hook-ingestion.md new file mode 100644 index 0000000000..1986780338 --- /dev/null +++ b/.changeset/cli-agent-hook-ingestion.md @@ -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). diff --git a/packages/dashboard/src/auth-middleware.ts b/packages/dashboard/src/auth-middleware.ts index 78387a200c..3766edd4cb 100644 --- a/packages/dashboard/src/auth-middleware.ts +++ b/packages/dashboard/src/auth-middleware.ts @@ -16,8 +16,18 @@ import type { IncomingMessage } from "node:http"; */ 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, diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 57ec951ebd..bb192362c9 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -172,6 +172,7 @@ import { registerRuntimeProviderRoutes } from "./routes/register-runtime-provide import { registerFnBinaryRoutes } from "./routes/register-fn-binary-routes.js"; import { registerUpdateCheckRoutes } from "./routes/register-update-check-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 { registerApprovalRoutes } from "./routes/register-approval-routes.js"; import { registerWorktrunkRoutes } from "./routes/register-worktrunk-routes.js"; @@ -1952,6 +1953,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout registerUsageRoutes(routeContext); registerUpdateCheckRoutes(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 ──────────────────────────── // diff --git a/packages/dashboard/src/routes/__tests__/cli-agent-hooks-route.test.ts b/packages/dashboard/src/routes/__tests__/cli-agent-hooks-route.test.ts new file mode 100644 index 0000000000..b564e3fd27 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/cli-agent-hooks-route.test.ts @@ -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 = {}, + 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 & { 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); + }); +}); diff --git a/packages/dashboard/src/routes/cli-agent-hooks.ts b/packages/dashboard/src/routes/cli-agent-hooks.ts new file mode 100644 index 0000000000..bbbe0def7d --- /dev/null +++ b/packages/dashboard/src/routes/cli-agent-hooks.ts @@ -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) { + 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 = {}; + 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; + 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; +} diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 23eda9a4fa..fc972cd24f 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -194,6 +194,18 @@ export interface ServerOptions { engineManager?: import("@fusion/engine").ProjectEngineManager; /** Optional HybridExecutor orchestration context for multi-project runtime plumbing. */ 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. * Routes that mutate central runtime state should use this instance so * in-process listeners (for example global concurrency changes) are notified. */ diff --git a/packages/engine/src/cli-agent/__tests__/hook-scripts.test.ts b/packages/engine/src/cli-agent/__tests__/hook-scripts.test.ts new file mode 100644 index 0000000000..5357885b20 --- /dev/null +++ b/packages/engine/src/cli-agent/__tests__/hook-scripts.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/engine/src/cli-agent/hook-scripts.ts b/packages/engine/src/cli-agent/hook-scripts.ts new file mode 100644 index 0000000000..1b2ff2e891 --- /dev/null +++ b/packages/engine/src/cli-agent/hook-scripts.ts @@ -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 { + 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 { + await rm(dir, { recursive: true, force: true }); +} diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index addb8e5e78..95d34b13dc 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -623,3 +623,26 @@ export { getNativePrebuildName, resetPtyModuleCacheForTests, } 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";