first commit
This commit is contained in:
Executable
+247
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* grok-glance CLI.
|
||||
*
|
||||
* Privileged operations (minting enrollment codes, revoking devices) are authenticated with
|
||||
* a local admin token read from $GLANCE_HOME/admin.token, not by "is this request from
|
||||
* localhost". That distinction matters: `tailscale serve` proxies remote traffic to
|
||||
* 127.0.0.1, so the daemon cannot tell a local caller from a tunnelled one by address alone.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
PLUGIN_ROOT,
|
||||
baseUrl,
|
||||
glanceHome,
|
||||
isDaemonUp,
|
||||
readConfig,
|
||||
sleep,
|
||||
} from "./glance-lib.mjs";
|
||||
|
||||
const SERVER_ENTRY = path.join(PLUGIN_ROOT, "dist", "server", "index.js");
|
||||
const cfg = readConfig();
|
||||
const cmd = process.argv[2] ?? "status";
|
||||
const args = process.argv.slice(3);
|
||||
|
||||
function adminToken() {
|
||||
try {
|
||||
return fs.readFileSync(path.join(glanceHome(), "admin.token"), "utf8").trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function api(pathname, { method = "GET", body, admin = false } = {}) {
|
||||
const headers = { "content-type": "application/json" };
|
||||
if (admin) {
|
||||
const token = adminToken();
|
||||
if (!token) {
|
||||
throw new Error("no admin token found - is the daemon running? try `glance up`");
|
||||
}
|
||||
headers["x-glance-admin"] = token;
|
||||
}
|
||||
const res = await fetch(`${baseUrl(cfg)}${pathname}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
if (!res.ok) throw new Error(data?.error ?? `${res.status} ${res.statusText}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function requireBuild() {
|
||||
if (!fs.existsSync(SERVER_ENTRY)) {
|
||||
console.error(`grok-glance is not built yet.\n\n cd ${PLUGIN_ROOT}\n npm install && npm run build\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureUp() {
|
||||
if (await isDaemonUp(cfg)) return true;
|
||||
requireBuild();
|
||||
const home = glanceHome();
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
const logFd = fs.openSync(path.join(home, "daemon.log"), "a");
|
||||
const child = spawn(process.execPath, [SERVER_ENTRY], {
|
||||
detached: true,
|
||||
stdio: ["ignore", logFd, logFd],
|
||||
});
|
||||
child.unref();
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await sleep(200);
|
||||
if (await isDaemonUp(cfg, 300)) return true;
|
||||
}
|
||||
console.error(`daemon did not come up; see ${path.join(home, "daemon.log")}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case "serve":
|
||||
case "start": {
|
||||
requireBuild();
|
||||
await import(SERVER_ENTRY);
|
||||
break;
|
||||
}
|
||||
|
||||
case "up": {
|
||||
if (await ensureUp()) console.log(`grok-glance running on ${baseUrl(cfg)}`);
|
||||
else process.exit(1);
|
||||
break;
|
||||
}
|
||||
|
||||
case "stop": {
|
||||
if (!(await isDaemonUp(cfg))) {
|
||||
console.log("not running");
|
||||
break;
|
||||
}
|
||||
await api("/local/shutdown", { method: "POST", admin: true });
|
||||
console.log("stopped");
|
||||
break;
|
||||
}
|
||||
|
||||
case "status": {
|
||||
if (!(await isDaemonUp(cfg))) {
|
||||
console.log(`grok-glance: not running (port ${cfg.port})`);
|
||||
console.log("start it with: glance up");
|
||||
break;
|
||||
}
|
||||
const s = await api("/local/status", { admin: true });
|
||||
console.log(`grok-glance ${s.version} on ${baseUrl(cfg)}`);
|
||||
console.log(` public origin : ${s.origin ?? "(not configured - see README)"}`);
|
||||
console.log(` rp id : ${s.rpId ?? "(not configured)"}`);
|
||||
console.log(` devices : ${s.devices}`);
|
||||
console.log(` approval mode : ${s.approval.mode}`);
|
||||
console.log(` watchers : ${s.watchers}`);
|
||||
console.log(` sessions : ${s.sessions}`);
|
||||
console.log(` events kept : ${s.events}`);
|
||||
if (s.devices === 0) console.log("\nNo device enrolled yet. Run: glance enroll");
|
||||
break;
|
||||
}
|
||||
|
||||
case "enroll": {
|
||||
if (!(await ensureUp())) process.exit(1);
|
||||
const out = await api("/local/enroll", { method: "POST", admin: true });
|
||||
console.log("\n Open this on your phone:\n");
|
||||
console.log(` ${out.url}\n`);
|
||||
console.log(` Enrollment code: ${out.code}`);
|
||||
console.log(` Valid for: ${Math.round(out.expiresInMs / 60000)} minutes (single use)\n`);
|
||||
if (!out.originConfigured) {
|
||||
console.log(" Note: no public origin configured yet, so the URL above is localhost.");
|
||||
console.log(" Set one up first (see README), e.g.:\n");
|
||||
console.log(" tailscale serve --bg 127.0.0.1:" + cfg.port);
|
||||
console.log(" glance set-origin https://<your-box>.<tailnet>.ts.net\n");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "set-origin": {
|
||||
const origin = args[0];
|
||||
if (!origin) {
|
||||
console.error("usage: glance set-origin https://your-box.tailnet.ts.net");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!(await ensureUp())) process.exit(1);
|
||||
const out = await api("/local/origin", { method: "POST", admin: true, body: { origin } });
|
||||
console.log(`origin : ${out.origin}`);
|
||||
console.log(`rp id : ${out.rpId}`);
|
||||
console.log("\nEnrolled devices are bound to the rp id. Changing it invalidates them.");
|
||||
break;
|
||||
}
|
||||
|
||||
case "devices": {
|
||||
if (!(await isDaemonUp(cfg))) {
|
||||
console.error("not running");
|
||||
process.exit(1);
|
||||
}
|
||||
const out = await api("/local/devices", { admin: true });
|
||||
if (!out.devices.length) {
|
||||
console.log("no devices enrolled - run: glance enroll");
|
||||
break;
|
||||
}
|
||||
for (const d of out.devices) {
|
||||
console.log(`${d.id.slice(0, 16)}… ${d.label.padEnd(24)} added ${new Date(d.createdAt).toISOString().slice(0, 10)} last seen ${d.lastUsedAt ? new Date(d.lastUsedAt).toISOString().slice(0, 16).replace("T", " ") : "never"}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "revoke": {
|
||||
if (!args[0]) {
|
||||
console.error("usage: glance revoke <device-id-prefix>");
|
||||
process.exit(1);
|
||||
}
|
||||
const out = await api("/local/devices/revoke", {
|
||||
method: "POST",
|
||||
admin: true,
|
||||
body: { idPrefix: args[0] },
|
||||
});
|
||||
console.log(`revoked ${out.revoked} device(s)`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "approval": {
|
||||
const mode = args[0];
|
||||
if (!["off", "risky", "all"].includes(mode)) {
|
||||
console.error("usage: glance approval <off|risky|all>");
|
||||
process.exit(1);
|
||||
}
|
||||
const out = await api("/local/approval", { method: "POST", admin: true, body: { mode } });
|
||||
console.log(`approval mode: ${out.mode}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sync-hooks": {
|
||||
const file = path.join(PLUGIN_ROOT, "hooks", "hooks.json");
|
||||
const doc = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
let changed = 0;
|
||||
for (const groups of Object.values(doc.hooks ?? {})) {
|
||||
for (const group of groups) {
|
||||
for (const h of group.hooks ?? []) {
|
||||
if (h.type === "http" && typeof h.url === "string") {
|
||||
const next = h.url.replace(/127\.0\.0\.1:\d+/, `127.0.0.1:${cfg.port}`);
|
||||
if (next !== h.url) changed++;
|
||||
h.url = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
|
||||
console.log(`rewrote ${changed} hook url(s) to port ${cfg.port}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "logs": {
|
||||
const file = path.join(glanceHome(), "daemon.log");
|
||||
if (!fs.existsSync(file)) {
|
||||
console.log("no log yet");
|
||||
break;
|
||||
}
|
||||
process.stdout.write(fs.readFileSync(file, "utf8").split("\n").slice(-60).join("\n") + "\n");
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.log(`grok-glance - glance at Grok Build from your phone
|
||||
|
||||
glance up start the daemon in the background
|
||||
glance serve run it in the foreground
|
||||
glance stop stop it
|
||||
glance status show what is running
|
||||
glance enroll mint a one-time code to enrol a phone
|
||||
glance set-origin <url> set the public https origin (and webauthn rp id)
|
||||
glance devices list enrolled devices
|
||||
glance revoke <id-prefix> revoke a device
|
||||
glance approval <off|risky|all> remote approval policy
|
||||
glance sync-hooks rewrite hook urls after a port change
|
||||
glance logs tail the daemon log
|
||||
`);
|
||||
}
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* PreToolUse hook: the remote approval gate.
|
||||
*
|
||||
* Asks the daemon what to do with this tool call. The daemon holds the request open while
|
||||
* your phone decides, then answers allow/deny. Every failure path here is fail-open —
|
||||
* a daemon that is down, slow, or confused must not be able to block your agent.
|
||||
*
|
||||
* Denying is the only outcome that changes behaviour, and approving only lets through a
|
||||
* call Grok was already about to make. This hook can never introduce a new command.
|
||||
*/
|
||||
|
||||
import { baseUrl, envEnvelope, postJson, readConfig, readStdinJson } from "./glance-lib.mjs";
|
||||
|
||||
function allow() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function deny(reason) {
|
||||
// Belt and braces: the documented deny signals are a JSON decision on stdout *and*
|
||||
// exit code 2. We emit both so a change in precedence cannot silently allow.
|
||||
process.stdout.write(
|
||||
JSON.stringify({ decision: "deny", reason: reason || "Denied from grok-glance" }),
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const payload = envEnvelope(await readStdinJson());
|
||||
const cfg = readConfig();
|
||||
|
||||
// Stay inside the hook timeout declared in hooks/hooks.json (125s).
|
||||
const waitMs = Math.min(115_000, Number(cfg.approval?.timeoutMs ?? 90_000) + 15_000);
|
||||
|
||||
try {
|
||||
const { data } = await postJson(`${baseUrl(cfg)}/hook/approve`, payload, waitMs);
|
||||
if (data && data.decision === "deny") deny(data.reason);
|
||||
allow();
|
||||
} catch {
|
||||
allow();
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
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));
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* SessionStart hook: make sure the glance daemon is running, then record the event.
|
||||
*
|
||||
* This is the only hook that spawns anything. It always exits 0 — a monitoring
|
||||
* dashboard must never be the reason a Grok Build session fails to start.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
PLUGIN_ROOT,
|
||||
baseUrl,
|
||||
envEnvelope,
|
||||
glanceHome,
|
||||
isDaemonUp,
|
||||
postJson,
|
||||
readConfig,
|
||||
readStdinJson,
|
||||
sleep,
|
||||
} from "./glance-lib.mjs";
|
||||
|
||||
const SERVER_ENTRY = path.join(PLUGIN_ROOT, "dist", "server", "index.js");
|
||||
|
||||
async function ensureDaemon(cfg) {
|
||||
if (await isDaemonUp(cfg)) return true;
|
||||
|
||||
if (!fs.existsSync(SERVER_ENTRY)) {
|
||||
// Not built yet. Say so once, on stderr, where it is recorded but harmless.
|
||||
process.stderr.write(
|
||||
`[grok-glance] not built yet - run \`npm install && npm run build\` in ${PLUGIN_ROOT}\n`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const home = glanceHome();
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
const logFd = fs.openSync(path.join(home, "daemon.log"), "a");
|
||||
|
||||
const child = spawn(process.execPath, [SERVER_ENTRY], {
|
||||
detached: true,
|
||||
stdio: ["ignore", logFd, logFd],
|
||||
env: { ...process.env, GLANCE_STARTED_BY: "hook" },
|
||||
});
|
||||
child.unref();
|
||||
|
||||
// Give it a moment to bind before the first http hook fires.
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await sleep(200);
|
||||
if (await isDaemonUp(cfg, 300)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = envEnvelope(await readStdinJson());
|
||||
const cfg = readConfig();
|
||||
|
||||
try {
|
||||
const up = await ensureDaemon(cfg);
|
||||
if (up) {
|
||||
await postJson(`${baseUrl(cfg)}/hook/record`, payload, 2500);
|
||||
}
|
||||
} catch {
|
||||
// Fail open, always.
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
Reference in New Issue
Block a user