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:
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