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:
24
.changeset/cli-agent-hook-ingestion.md
Normal file
24
.changeset/cli-agent-hook-ingestion.md
Normal 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).
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ────────────────────────────
|
||||
//
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
231
packages/dashboard/src/routes/cli-agent-hooks.ts
Normal file
231
packages/dashboard/src/routes/cli-agent-hooks.ts
Normal 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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
121
packages/engine/src/cli-agent/__tests__/hook-scripts.test.ts
Normal file
121
packages/engine/src/cli-agent/__tests__/hook-scripts.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
197
packages/engine/src/cli-agent/hook-scripts.ts
Normal file
197
packages/engine/src/cli-agent/hook-scripts.ts
Normal 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 });
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user