/** * 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 crypto from "node:crypto"; 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 touching hooks.json. * * `create` is used by the build-time generator; the hook scripts pass false and simply get * null when there is no secret yet. That is deliberate: a hook must never be the thing that * creates state, and a missing secret has to degrade to "no telemetry", not "no tool call". */ export function hookSecret({ create = false } = {}) { const file = path.join(glanceHome(), "hook.secret"); for (let attempt = 0; attempt < 2; attempt++) { try { const existing = fs.readFileSync(file, "utf8").trim(); if (existing) return existing; } catch { /* fall through */ } if (!create) return null; fs.mkdirSync(glanceHome(), { recursive: true, mode: 0o700 }); const token = crypto.randomBytes(32).toString("base64url"); try { // Exclusive: if the daemon created one a millisecond ago, read theirs instead. fs.writeFileSync(file, token + "\n", { mode: 0o600, flag: "wx" }); return token; } catch { /* lost the race; loop re-reads */ } } try { return fs.readFileSync(file, "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. * * One formula, two consumers: scripts/gen-hooks.mjs writes it into hooks.json as the hook's * `timeout`, and glance-approve.mjs derives its own wait from it. They must agree — if the * script outlives its hook timeout, Grok Build kills it and the fail-open path never runs. */ export function approvalHookTimeoutSecs(cfg = readConfig()) { const ms = Number(cfg.approval?.timeoutMs ?? 90_000); const base = Number.isFinite(ms) && ms > 0 ? ms : 90_000; return Math.ceil(base / 1000) + 35; } /** 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));