Files
grok-glance/bin/glance-lib.mjs
T
iceBear67andClaude Opus 5 d20eb9255c Drop the hook-template scheme and other dead weight
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>
2026-08-09 07:04:50 +00:00

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));