hooks/hooks.json was generated from a template by scripts/gen-hooks.mjs. Once every
hook became a command hook that reads hook.secret from $GLANCE_HOME itself, the
template had exactly two placeholders left: {{HOOK_TOKEN}}, which nothing had ever
substituted into anything, and {{APPROVAL_TIMEOUT_SECS}}. Generating a whole file to
compute one number is not a good trade, so the number is now fixed at 125s in the
committed hooks.json and the coupling is enforced in code instead: the daemon clamps
approval.timeoutMs to APPROVAL_MAX_WAIT_MS (90s), which keeps the script inside its
own hook timeout no matter what a hand-edited config.json says. Losing that clamp is
what would actually hurt — a killed script never runs its fail-open path.
Also removed:
- `glance sync-hooks`, `npm run build:hooks`, and hookSecret({create}). The daemon is
the only thing that should ever mint the secret.
- The ?k= query-string carrier for the hook secret. It existed for hooks that cannot
set headers; there are none, and a secret in a URL lands in logs and shell history.
- Snapshot.now and SessionView.startedAt, which were written on every snapshot and
every persist and read by nobody.
- An unused crypto import.
Docs and the e2e suite follow. The suite's ~12 sync-hooks assertions become static
checks on the committed file, plus new ones that hooks.json, APPROVAL_HOOK_TIMEOUT_SECS
and APPROVAL_MAX_WAIT_MS still agree, and that ?k= is refused. 220 checks, all passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
143 lines
4.5 KiB
JavaScript
143 lines
4.5 KiB
JavaScript
/**
|
|
* Shared helpers for the grok-glance hook scripts and CLI.
|
|
*
|
|
* Deliberately dependency-free and stdlib-only: these run on the critical path of
|
|
* every Grok Build tool call, so they must start fast and never wedge a session.
|
|
*/
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export const PLUGIN_ROOT = path.resolve(fileURLToPath(import.meta.url), "../..");
|
|
|
|
export const DEFAULT_PORT = 8791;
|
|
|
|
/**
|
|
* State lives in one fixed place so that hooks (which get GROK_PLUGIN_DATA) and the
|
|
* CLI (which does not) always agree on where config, credentials and events are.
|
|
*/
|
|
export function glanceHome() {
|
|
if (process.env.GLANCE_HOME) return path.resolve(process.env.GLANCE_HOME);
|
|
return path.join(os.homedir(), ".grok", "glance");
|
|
}
|
|
|
|
export function readConfig() {
|
|
const file = path.join(glanceHome(), "config.json");
|
|
let raw = {};
|
|
try {
|
|
raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
} catch {
|
|
// No config yet, or unreadable: defaults are always usable.
|
|
}
|
|
const port = Number(process.env.GLANCE_PORT ?? raw.port ?? DEFAULT_PORT);
|
|
return {
|
|
...raw,
|
|
port: Number.isFinite(port) ? port : DEFAULT_PORT,
|
|
host: raw.host ?? "127.0.0.1",
|
|
};
|
|
}
|
|
|
|
export function baseUrl(cfg = readConfig()) {
|
|
return `http://127.0.0.1:${cfg.port}`;
|
|
}
|
|
|
|
/** Header the daemon expects on /hook/*; kept in step with server/src/auth.ts. */
|
|
export const HOOK_HEADER = "x-glance-hook";
|
|
|
|
/**
|
|
* The shared secret that admits a caller to /hook/*. Read fresh on every invocation so a
|
|
* regenerated secret is picked up without restarting anything.
|
|
*
|
|
* Only the daemon ever creates it. A hook must never be the thing that creates state, and a
|
|
* missing secret has to degrade to "no telemetry", not "no tool call" — so this returns null
|
|
* and the callers carry on.
|
|
*/
|
|
export function hookSecret() {
|
|
try {
|
|
return fs.readFileSync(path.join(glanceHome(), "hook.secret"), "utf8").trim() || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function hookHeaders() {
|
|
const token = hookSecret();
|
|
return token ? { [HOOK_HEADER]: token } : {};
|
|
}
|
|
|
|
/**
|
|
* How long the PreToolUse approval hook is allowed to run, in seconds — the `timeout` written
|
|
* next to glance-approve.mjs in hooks/hooks.json. Change one, change the other.
|
|
*
|
|
* It bounds everything downstream: if the script outlives its hook timeout, Grok Build kills
|
|
* it and the fail-open path never runs. So the daemon caps its own wait well inside it (see
|
|
* APPROVAL_MAX_WAIT_MS in server/src/config.ts), and the script leaves itself 10s on top of
|
|
* that to answer.
|
|
*/
|
|
export const APPROVAL_HOOK_TIMEOUT_SECS = 125;
|
|
|
|
/** Read the hook payload that Grok Build writes to stdin. Returns {} if there is none. */
|
|
export async function readStdinJson() {
|
|
if (process.stdin.isTTY) return {};
|
|
const chunks = [];
|
|
try {
|
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
} catch {
|
|
return {};
|
|
}
|
|
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
if (!text) return {};
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Grok Build also passes the event in the environment. We merge it in so a payload that
|
|
* is missing fields (or absent entirely) still produces a usable event.
|
|
*/
|
|
export function envEnvelope(payload) {
|
|
return {
|
|
hookEventName: payload.hookEventName ?? process.env.GROK_HOOK_EVENT ?? "Unknown",
|
|
sessionId: payload.sessionId ?? process.env.GROK_SESSION_ID ?? "unknown",
|
|
workspaceRoot:
|
|
payload.workspaceRoot ?? process.env.GROK_WORKSPACE_ROOT ?? payload.cwd ?? process.cwd(),
|
|
cwd: payload.cwd ?? process.cwd(),
|
|
hookName: process.env.GROK_HOOK_NAME ?? undefined,
|
|
...payload,
|
|
};
|
|
}
|
|
|
|
export async function postJson(url, body, timeoutMs, extraHeaders = {}) {
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", ...extraHeaders },
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
const text = await res.text();
|
|
if (!text) return { status: res.status, data: null };
|
|
try {
|
|
return { status: res.status, data: JSON.parse(text) };
|
|
} catch {
|
|
return { status: res.status, data: null };
|
|
}
|
|
}
|
|
|
|
export async function isDaemonUp(cfg = readConfig(), timeoutMs = 400) {
|
|
try {
|
|
const res = await fetch(`${baseUrl(cfg)}/healthz`, {
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
return res.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|