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,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,
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";