first commit

This commit is contained in:
iceBear67
2026-08-09 04:00:13 +00:00
commit b3b6bf3f70
46 changed files with 7146 additions and 0 deletions
+143
View File
@@ -0,0 +1,143 @@
import crypto from "node:crypto";
import type { Config } from "./config.js";
import type { GlanceState, HookPayload } from "./state.js";
import { summarizeTool } from "./summarize.js";
import type { PendingApproval } from "./protocol.js";
export interface Decision {
decision: "allow" | "deny";
reason?: string;
}
interface Waiter {
approval: PendingApproval;
settle: (decision: Decision) => void;
timer: NodeJS.Timeout;
}
/**
* Holds a PreToolUse hook open while your phone decides.
*
* Every path that is not an explicit tap is designed to get out of the way: approval off,
* tool not risky, nobody watching, or the request timing out all resolve immediately so a
* dashboard can never become the reason your agent stalls.
*/
export class ApprovalBroker {
private readonly waiters = new Map<string, Waiter>();
constructor(
private readonly cfg: Config,
private readonly state: GlanceState,
/** Is at least one browser currently streaming events? */
private readonly hasWatcher: () => boolean,
) {}
private gates(toolName: string): boolean {
const { mode, riskyPattern } = this.cfg.approval;
if (mode === "off") return false;
if (mode === "all") return true;
try {
return new RegExp(riskyPattern).test(toolName);
} catch {
// A bad pattern should not silently gate everything.
return false;
}
}
pending(): PendingApproval[] {
return [...this.waiters.values()]
.map((w) => w.approval)
.sort((a, b) => a.createdAt - b.createdAt);
}
async request(payload: HookPayload): Promise<Decision> {
const tool = typeof payload.toolName === "string" ? payload.toolName : "tool";
if (!this.gates(tool)) return { decision: "allow" };
if (this.cfg.approval.requireWatcher && !this.hasWatcher()) {
return { decision: "allow" };
}
const sessionId = payload.sessionId ?? "unknown";
const summary = summarizeTool(tool, payload.toolInput);
const now = Date.now();
const approval: PendingApproval = {
id: crypto.randomBytes(9).toString("base64url"),
sessionId,
sessionLabel: this.state.sessionLabel(sessionId),
tool,
title: summary.title,
detail: summary.detail,
createdAt: now,
expiresAt: now + this.cfg.approval.timeoutMs,
};
this.state.record(sessionId, "approval_request", `Waiting on you: ${tool}`, {
tool,
detail: summary.title,
});
return new Promise<Decision>((resolve) => {
const timer = setTimeout(() => {
this.waiters.delete(approval.id);
const onTimeout = this.cfg.approval.onTimeout;
this.state.record(
sessionId,
"approval_expired",
onTimeout === "deny"
? `No answer in time - denied ${tool}`
: `No answer in time - allowed ${tool}`,
{ tool, detail: summary.title },
);
resolve(
onTimeout === "deny"
? { decision: "deny", reason: "grok-glance: no answer from your device in time" }
: { decision: "allow" },
);
}, this.cfg.approval.timeoutMs);
// Do not hold the process open just for a pending approval.
timer.unref?.();
this.waiters.set(approval.id, {
approval,
timer,
settle: (decision) => resolve(decision),
});
});
}
/** Called by the API when you tap Approve or Deny. */
resolve(id: string, decision: "allow" | "deny", by: string): boolean {
const waiter = this.waiters.get(id);
if (!waiter) return false;
clearTimeout(waiter.timer);
this.waiters.delete(id);
const { approval } = waiter;
this.state.record(
approval.sessionId,
decision === "allow" ? "approval_allowed" : "approval_denied",
decision === "allow"
? `Approved ${approval.tool} from ${by}`
: `Denied ${approval.tool} from ${by}`,
{ tool: approval.tool, detail: approval.title },
);
waiter.settle(
decision === "allow"
? { decision: "allow" }
: { decision: "deny", reason: `Denied from grok-glance (${by})` },
);
return true;
}
/** Resolve everything as allow — used on shutdown so no hook is left hanging. */
drain(): void {
for (const [id, waiter] of this.waiters) {
clearTimeout(waiter.timer);
this.waiters.delete(id);
waiter.settle({ decision: "allow" });
}
}
}
+173
View File
@@ -0,0 +1,173 @@
import crypto from "node:crypto";
import type { IncomingMessage } from "node:http";
/* ------------------------------------------------------------------- cookies */
export const SESSION_COOKIE = "glance_session";
export const CSRF_HEADER = "x-glance-csrf";
export function parseCookies(header: string | undefined): Record<string, string> {
const out: Record<string, string> = {};
if (!header) return out;
for (const part of header.split(";")) {
const idx = part.indexOf("=");
if (idx < 0) continue;
const key = part.slice(0, idx).trim();
const value = part.slice(idx + 1).trim();
if (key) out[key] = decodeURIComponent(value);
}
return out;
}
/** token.hmac(token) — lets us reject forged cookies without touching disk. */
export function signToken(token: string, secret: Buffer): string {
const mac = crypto.createHmac("sha256", secret).update(token).digest("base64url");
return `${token}.${mac}`;
}
export function unsignToken(signed: string | undefined, secret: Buffer): string | null {
if (!signed) return null;
const idx = signed.lastIndexOf(".");
if (idx <= 0) return null;
const token = signed.slice(0, idx);
const mac = signed.slice(idx + 1);
const expected = crypto.createHmac("sha256", secret).update(token).digest("base64url");
const a = Buffer.from(mac);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
return token;
}
/**
* The daemon always listens on plain http (a tunnel terminates TLS), so whether the cookie
* may carry the Secure flag depends on how the *browser* reached us. Setting Secure on a
* genuinely-http localhost connection would make the browser throw the cookie away.
*/
export function requestIsHttps(req: IncomingMessage): boolean {
const proto = header(req, "x-forwarded-proto");
if (proto) return proto.split(",")[0].trim() === "https";
return false;
}
export function buildSessionCookie(
value: string,
opts: { secure: boolean; maxAgeSec: number },
): string {
const parts = [
`${SESSION_COOKIE}=${encodeURIComponent(value)}`,
"Path=/",
"HttpOnly",
"SameSite=Strict",
`Max-Age=${opts.maxAgeSec}`,
];
if (opts.secure) parts.push("Secure");
return parts.join("; ");
}
export function clearSessionCookie(secure: boolean): string {
const parts = [`${SESSION_COOKIE}=`, "Path=/", "HttpOnly", "SameSite=Strict", "Max-Age=0"];
if (secure) parts.push("Secure");
return parts.join("; ");
}
export function header(req: IncomingMessage, name: string): string | undefined {
const value = req.headers[name];
if (Array.isArray(value)) return value[0];
return value;
}
/* -------------------------------------------------------------- rate limiting */
/**
* Fixed-window counter. Keyed globally rather than per-IP on purpose: behind a tunnel every
* request arrives from 127.0.0.1, so per-IP buckets would be a single bucket wearing a hat.
*/
export class RateLimiter {
private hits = new Map<string, { count: number; resetAt: number }>();
constructor(
private readonly limit: number,
private readonly windowMs: number,
) {}
/** Returns true when the caller is still within budget. */
allow(key: string): boolean {
const now = Date.now();
const entry = this.hits.get(key);
if (!entry || entry.resetAt <= now) {
this.hits.set(key, { count: 1, resetAt: now + this.windowMs });
return true;
}
entry.count += 1;
return entry.count <= this.limit;
}
reset(key: string): void {
this.hits.delete(key);
}
}
/* ---------------------------------------------------------- enrolment codes */
// No 0/O/1/I/L — these get read off a terminal and typed on a phone.
const CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
const CODE_LENGTH = 8;
const CODE_TTL_MS = 10 * 60_000;
const MAX_CODE_ATTEMPTS = 5;
interface EnrollmentCode {
code: string;
expiresAt: number;
attempts: number;
}
export class EnrollmentCodes {
private current: EnrollmentCode | null = null;
mint(): { code: string; expiresInMs: number } {
const bytes = crypto.randomBytes(CODE_LENGTH);
let code = "";
for (let i = 0; i < CODE_LENGTH; i++) {
code += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length];
}
this.current = { code, expiresAt: Date.now() + CODE_TTL_MS, attempts: 0 };
return { code, expiresInMs: CODE_TTL_MS };
}
/** Constant-time compare. Counts the attempt, and burns the code after too many misses. */
check(candidate: string): boolean {
const entry = this.current;
if (!entry) return false;
if (entry.expiresAt <= Date.now()) {
this.current = null;
return false;
}
entry.attempts += 1;
if (entry.attempts > MAX_CODE_ATTEMPTS) {
this.current = null;
return false;
}
const a = Buffer.from(normalize(candidate));
const b = Buffer.from(entry.code);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
/**
* Single use. Registration checks the code twice — once to hand out options, once to accept
* the attestation — so only the second call consumes it, otherwise a failed prompt on the
* phone would force you back to the terminal for a fresh code.
*/
consume(candidate: string): boolean {
if (!this.check(candidate)) return false;
this.current = null;
return true;
}
get active(): boolean {
return !!this.current && this.current.expiresAt > Date.now();
}
}
function normalize(code: string): string {
return code.trim().toUpperCase().replace(/[\s-]/g, "");
}
+142
View File
@@ -0,0 +1,142 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { ApprovalSettings } from "./protocol.js";
export const VERSION = "0.1.0";
export const DEFAULT_PORT = 8791;
export interface Config {
port: number;
host: string;
/** Public https origin the phone will use, e.g. https://box.tailnet-1234.ts.net */
origin?: string;
/** WebAuthn Relying Party ID. Derived from `origin` unless set explicitly. */
rpId?: string;
rpName: string;
approval: ApprovalSettings;
/** How many events to keep in memory and hand to the UI. */
retainEvents: number;
}
export function glanceHome(): string {
if (process.env.GLANCE_HOME) return path.resolve(process.env.GLANCE_HOME);
return path.join(os.homedir(), ".grok", "glance");
}
export const paths = {
get home() {
return glanceHome();
},
get config() {
return path.join(glanceHome(), "config.json");
},
get credentials() {
return path.join(glanceHome(), "credentials.json");
},
get authSessions() {
return path.join(glanceHome(), "auth-sessions.json");
},
get secret() {
return path.join(glanceHome(), "secret.key");
},
get adminToken() {
return path.join(glanceHome(), "admin.token");
},
get events() {
return path.join(glanceHome(), "events.jsonl");
},
};
const DEFAULTS: Config = {
port: DEFAULT_PORT,
host: "127.0.0.1",
rpName: "grok-glance",
approval: {
// Off by default: installing a dashboard should not silently start gating your tools.
// Flip it on from the phone, or with `glance approval risky`.
mode: "off",
riskyPattern: "^(Bash|Write|Edit|MultiEdit|NotebookEdit)$",
timeoutMs: 90_000,
// If nobody is actually watching, allow immediately rather than stalling the agent.
requireWatcher: true,
// Grok's own hook contract is fail-open on timeout, so match it by default.
onTimeout: "allow",
},
retainEvents: 400,
};
export function ensureHome(): void {
fs.mkdirSync(glanceHome(), { recursive: true, mode: 0o700 });
// Tighten it even if the directory already existed with looser bits.
try {
fs.chmodSync(glanceHome(), 0o700);
} catch {
/* best effort */
}
}
export function loadConfig(): Config {
ensureHome();
let stored: Partial<Config> = {};
try {
stored = JSON.parse(fs.readFileSync(paths.config, "utf8")) as Partial<Config>;
} catch {
/* first run */
}
const merged: Config = {
...DEFAULTS,
...stored,
approval: { ...DEFAULTS.approval, ...(stored.approval ?? {}) },
};
if (process.env.GLANCE_PORT) {
const p = Number(process.env.GLANCE_PORT);
if (Number.isFinite(p)) merged.port = p;
}
if (process.env.GLANCE_ORIGIN) merged.origin = process.env.GLANCE_ORIGIN;
merged.rpId = merged.rpId ?? deriveRpId(merged.origin);
return merged;
}
export function saveConfig(cfg: Config): void {
ensureHome();
fs.writeFileSync(paths.config, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
}
/**
* The RP ID is the origin's hostname. Note that a bare IP address is not a valid RP ID,
* so a LAN address like 192.168.1.20 can never work — that is a WebAuthn rule, not ours.
*/
export function deriveRpId(origin?: string): string | undefined {
if (!origin) return undefined;
try {
const url = new URL(origin);
const host = url.hostname;
if (isIpAddress(host)) return undefined;
return host;
} catch {
return undefined;
}
}
export function isIpAddress(host: string): boolean {
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
if (host.includes(":")) return true; // IPv6
return false;
}
/**
* Origins we will accept assertions from. The configured public origin, plus localhost so
* that you can enrol and test on the machine itself before setting up a tunnel.
*/
export function expectedOrigins(cfg: Config): string[] {
const list = [`http://localhost:${cfg.port}`, `http://127.0.0.1:${cfg.port}`];
if (cfg.origin) list.unshift(cfg.origin.replace(/\/$/, ""));
return list;
}
export function expectedRpIds(cfg: Config): string[] {
const ids = new Set<string>(["localhost"]);
if (cfg.rpId) ids.add(cfg.rpId);
return [...ids];
}
+84
View File
@@ -0,0 +1,84 @@
import type { IncomingMessage, ServerResponse } from "node:http";
const MAX_BODY_BYTES = 256 * 1024;
export interface Res {
json(status: number, body: unknown, headers?: Record<string, string>): void;
text(status: number, body: string, headers?: Record<string, string>): void;
empty(status: number, headers?: Record<string, string>): void;
}
/** Headers applied to every response. The UI is entirely self-hosted, so the CSP can be strict. */
export const SECURITY_HEADERS: Record<string, string> = {
"x-content-type-options": "nosniff",
"referrer-policy": "no-referrer",
"x-frame-options": "DENY",
"cross-origin-opener-policy": "same-origin",
"content-security-policy": [
"default-src 'self'",
"script-src 'self'",
// HeroUI/Tailwind inject style attributes and inline blocks at runtime.
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self' data:",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'none'",
"form-action 'none'",
].join("; "),
};
export function responder(res: ServerResponse): Res {
const send = (status: number, body: string | null, headers: Record<string, string> = {}) => {
if (res.headersSent) return;
res.writeHead(status, { ...SECURITY_HEADERS, ...headers });
res.end(body ?? undefined);
};
return {
json(status, body, headers = {}) {
send(status, JSON.stringify(body), {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
...headers,
});
},
text(status, body, headers = {}) {
send(status, body, {
"content-type": "text/plain; charset=utf-8",
"cache-control": "no-store",
...headers,
});
},
empty(status, headers = {}) {
send(status, null, headers);
},
};
}
export async function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let size = 0;
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
reject(new Error("body too large"));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
export async function readJson<T = unknown>(req: IncomingMessage): Promise<T | null> {
const raw = await readBody(req);
if (!raw.trim()) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
+534
View File
@@ -0,0 +1,534 @@
/**
* grok-glance daemon.
*
* One small http server with three kinds of caller:
*
* /hook/* the plugin's hook scripts, on loopback. /hook/approve is the blocking one.
* /api/* the web app, gated by a passkey-backed cookie session.
* /local/* the `glance` CLI, gated by a rotating admin token on disk.
*
* Everything the hooks touch is written to fail open: if this process is confused, wedged, or
* gone, Grok Build keeps working.
*/
import http from "node:http";
import crypto from "node:crypto";
import { URL } from "node:url";
import {
DEFAULT_PORT,
VERSION,
deriveRpId,
ensureHome,
expectedOrigins,
isIpAddress,
loadConfig,
paths,
saveConfig,
} from "./config.js";
import {
CSRF_HEADER,
EnrollmentCodes,
RateLimiter,
SESSION_COOKIE,
buildSessionCookie,
clearSessionCookie,
header,
parseCookies,
requestIsHttps,
signToken,
unsignToken,
} from "./auth.js";
import { readJson, responder } from "./http.js";
import { serveStatic, webBuildExists } from "./static.js";
import { SseHub } from "./sse.js";
import { GlanceState, type HookPayload } from "./state.js";
import { ApprovalBroker } from "./approvals.js";
import { SESSION_TTL_MS, WebAuthnService } from "./webauthn.js";
import {
destroyAuthSession,
deviceList,
lookupAuthSession,
revokeCredentials,
rotateAdminToken,
sessionSecret,
} from "./store.js";
import type { ApprovalMode, GateInfo } from "./protocol.js";
import type { AuthenticationResponseJSON, RegistrationResponseJSON } from "@simplewebauthn/server";
ensureHome();
const cfg = loadConfig();
const secret = sessionSecret();
const adminToken = rotateAdminToken();
const webauthn = new WebAuthnService(cfg);
const codes = new EnrollmentCodes();
// Generous enough for a fumbled passkey prompt, tight enough that the code is not brute-forceable.
const authLimiter = new RateLimiter(40, 5 * 60_000);
const enrollLimiter = new RateLimiter(12, 5 * 60_000);
const state = new GlanceState(cfg);
let hub: SseHub | null = null;
const broker = new ApprovalBroker(cfg, state, () => hub?.hasWatcher() ?? false);
const sse = new SseHub(() => state.snapshot(broker.pending()));
hub = sse;
state.onChange(() => sse.publish());
/* ------------------------------------------------------------------ request auth */
interface Session {
token: string;
credentialId: string;
label: string;
}
function currentSession(req: http.IncomingMessage): Session | null {
const cookies = parseCookies(req.headers.cookie);
const token = unsignToken(cookies[SESSION_COOKIE], secret);
if (!token) return null;
const record = lookupAuthSession(token);
if (!record) return null;
return { token, credentialId: record.credentialId, label: record.label };
}
function isAdmin(req: http.IncomingMessage): boolean {
const provided = header(req, "x-glance-admin");
if (!provided) return false;
const a = Buffer.from(provided);
const b = Buffer.from(adminToken);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
/**
* `application/json` is not a CORS-safelisted content type, so requiring it exactly means a
* hostile page cannot post here without a preflight we never answer. The custom header on
* /api/* is a second, independent barrier on top of the SameSite=Strict cookie.
*/
function isJsonPost(req: http.IncomingMessage): boolean {
const ct = (header(req, "content-type") ?? "").split(";")[0].trim().toLowerCase();
return ct === "application/json";
}
function hasCsrfHeader(req: http.IncomingMessage): boolean {
return !!header(req, CSRF_HEADER);
}
/* ---------------------------------------------------------------------- routing */
const server = http.createServer((req, res) => {
handle(req, res).catch((err) => {
console.error("[glance] unhandled", err);
try {
responder(res).json(500, { error: "internal error" });
} catch {
/* response already gone */
}
});
});
async function handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const out = responder(res);
const url = new URL(req.url ?? "/", `http://localhost:${cfg.port}`);
const p = url.pathname;
const method = req.method ?? "GET";
if (method === "OPTIONS") {
// No CORS. Cross-origin callers get nothing, which is the point.
out.empty(405, { allow: "GET, POST" });
return;
}
/* ---------------------------------------------------------------- health */
if (p === "/healthz") {
out.json(200, { ok: true, version: VERSION });
return;
}
/* ----------------------------------------------------------------- hooks */
if (p.startsWith("/hook/")) {
if (method !== "POST" || !isJsonPost(req)) {
out.json(405, { error: "post json" });
return;
}
const payload = ((await readJson<HookPayload>(req)) ?? {}) as HookPayload;
if (p === "/hook/record") {
const event = state.ingest(payload);
out.json(200, { ok: true, id: event?.id ?? null });
return;
}
if (p === "/hook/approve") {
// Note: this deliberately does not ingest an event. The PreToolUse http hook already
// recorded the tool call; recording it here too would double every entry.
const decision = await broker.request(payload);
out.json(200, decision);
return;
}
out.json(404, { error: "unknown hook" });
return;
}
/* -------------------------------------------------------- local admin API */
if (p.startsWith("/local/")) {
if (!isAdmin(req)) {
out.json(403, { error: "admin token required" });
return;
}
if (p === "/local/status" && method === "GET") {
out.json(200, {
version: VERSION,
origin: cfg.origin,
rpId: cfg.rpId,
devices: deviceList().length,
approval: cfg.approval,
watchers: sse.count,
sessions: state.sessionCount,
events: state.eventCount,
webBuilt: webBuildExists(),
home: paths.home,
});
return;
}
if (p === "/local/enroll" && method === "POST") {
const minted = codes.mint();
const base = cfg.origin?.replace(/\/$/, "") ?? `http://localhost:${cfg.port}`;
out.json(200, {
code: minted.code,
expiresInMs: minted.expiresInMs,
url: `${base}/?enroll=1`,
originConfigured: !!cfg.origin,
});
return;
}
if (p === "/local/origin" && method === "POST") {
const body = await readJson<{ origin?: string }>(req);
const raw = (body?.origin ?? "").trim().replace(/\/$/, "");
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
out.json(400, { error: "not a url" });
return;
}
if (isIpAddress(parsed.hostname)) {
out.json(400, {
error:
"a bare IP address cannot be a WebAuthn relying party id - use a hostname with TLS",
});
return;
}
if (parsed.protocol !== "https:" && parsed.hostname !== "localhost") {
out.json(400, { error: "passkeys need https (or localhost for local testing)" });
return;
}
cfg.origin = `${parsed.protocol}//${parsed.host}`;
cfg.rpId = deriveRpId(cfg.origin);
saveConfig(cfg);
out.json(200, { origin: cfg.origin, rpId: cfg.rpId });
return;
}
if (p === "/local/devices" && method === "GET") {
out.json(200, { devices: deviceList() });
return;
}
if (p === "/local/devices/revoke" && method === "POST") {
const body = await readJson<{ idPrefix?: string }>(req);
const prefix = (body?.idPrefix ?? "").trim();
if (prefix.length < 4) {
out.json(400, { error: "give at least 4 characters of the device id" });
return;
}
out.json(200, { revoked: revokeCredentials(prefix) });
sse.publish();
return;
}
if (p === "/local/approval" && method === "POST") {
const body = await readJson<{ mode?: ApprovalMode }>(req);
if (!applyApprovalMode(body?.mode)) {
out.json(400, { error: "mode must be off, risky or all" });
return;
}
out.json(200, cfg.approval);
return;
}
if (p === "/local/shutdown" && method === "POST") {
out.json(200, { ok: true });
shutdown("cli");
return;
}
out.json(404, { error: "unknown local endpoint" });
return;
}
/* -------------------------------------------------------------- web API */
if (p.startsWith("/api/")) {
if (method === "POST" && (!isJsonPost(req) || !hasCsrfHeader(req))) {
out.json(400, { error: "bad request" });
return;
}
const session = currentSession(req);
const secure = requestIsHttps(req);
if (p === "/api/gate" && method === "GET") {
const gate: GateInfo = {
authenticated: !!session,
enrolled: webauthn.enrolled,
enrollmentOpen: codes.active,
version: VERSION,
deviceLabel: session?.label,
rpId: cfg.rpId,
};
out.json(200, gate);
return;
}
/* --- enrolment: a one-time code from the terminal, or an already-trusted device --- */
if (p === "/api/auth/register/options" && method === "POST") {
if (!enrollLimiter.allow("enroll")) {
out.json(429, { error: "too many attempts, wait a few minutes" });
return;
}
const body = await readJson<{ code?: string }>(req);
if (!session && !codes.check(body?.code ?? "")) {
out.json(403, { error: "that enrolment code is not valid" });
return;
}
out.json(200, await webauthn.registrationOptions());
return;
}
if (p === "/api/auth/register/verify" && method === "POST") {
if (!enrollLimiter.allow("enroll")) {
out.json(429, { error: "too many attempts, wait a few minutes" });
return;
}
const body = await readJson<{
code?: string;
label?: string;
response?: RegistrationResponseJSON;
}>(req);
if (!body?.response) {
out.json(400, { error: "missing response" });
return;
}
if (!session && !codes.consume(body.code ?? "")) {
out.json(403, { error: "that enrolment code is not valid" });
return;
}
const label = body.label?.trim() || "phone";
const result = await webauthn.verifyRegistration(body.response, label);
if (!result.ok || !result.token) {
out.json(400, { error: result.error ?? "registration failed" });
return;
}
enrollLimiter.reset("enroll");
out.json(
200,
{ ok: true, label },
{
"set-cookie": buildSessionCookie(signToken(result.token, secret), {
secure,
maxAgeSec: Math.floor(SESSION_TTL_MS / 1000),
}),
},
);
console.log(`[glance] enrolled device "${label}"`);
return;
}
/* --------------------------------- sign in ---------------------------------- */
if (p === "/api/auth/login/options" && method === "POST") {
if (!authLimiter.allow("login")) {
out.json(429, { error: "too many attempts, wait a few minutes" });
return;
}
if (!webauthn.enrolled) {
out.json(409, { error: "no device enrolled yet - run `glance enroll`" });
return;
}
out.json(200, await webauthn.authenticationOptions());
return;
}
if (p === "/api/auth/login/verify" && method === "POST") {
if (!authLimiter.allow("login")) {
out.json(429, { error: "too many attempts, wait a few minutes" });
return;
}
const body = await readJson<{ response?: AuthenticationResponseJSON }>(req);
if (!body?.response) {
out.json(400, { error: "missing response" });
return;
}
const result = await webauthn.verifyAuthentication(body.response);
if (!result.ok || !result.token) {
out.json(403, { error: result.error ?? "sign in failed" });
return;
}
authLimiter.reset("login");
out.json(
200,
{ ok: true, label: result.label },
{
"set-cookie": buildSessionCookie(signToken(result.token, secret), {
secure,
maxAgeSec: Math.floor(SESSION_TTL_MS / 1000),
}),
},
);
return;
}
if (p === "/api/auth/logout" && method === "POST") {
if (session) destroyAuthSession(session.token);
out.json(200, { ok: true }, { "set-cookie": clearSessionCookie(secure) });
return;
}
/* ------------------------- everything below needs a passkey ------------------ */
if (!session) {
out.json(401, { error: "not signed in" });
return;
}
if (p === "/api/snapshot" && method === "GET") {
out.json(200, state.snapshot(broker.pending()));
return;
}
if (p === "/api/approvals/resolve" && method === "POST") {
const body = await readJson<{ id?: string; decision?: "allow" | "deny" }>(req);
const id = body?.id ?? "";
const decision = body?.decision;
if (!id || (decision !== "allow" && decision !== "deny")) {
out.json(400, { error: "need id and decision" });
return;
}
const settled = broker.resolve(id, decision, session.label);
// A miss is normal: the prompt may have timed out, or another device answered first.
out.json(settled ? 200 : 409, settled ? { ok: true } : { error: "no longer pending" });
sse.publish();
return;
}
if (p === "/api/approval" && method === "POST") {
const body = await readJson<{
mode?: ApprovalMode;
requireWatcher?: boolean;
onTimeout?: "allow" | "deny";
}>(req);
if (body?.mode !== undefined && !applyApprovalMode(body.mode)) {
out.json(400, { error: "mode must be off, risky or all" });
return;
}
if (typeof body?.requireWatcher === "boolean") {
cfg.approval.requireWatcher = body.requireWatcher;
}
if (body?.onTimeout === "allow" || body?.onTimeout === "deny") {
cfg.approval.onTimeout = body.onTimeout;
}
saveConfig(cfg);
out.json(200, cfg.approval);
sse.publish();
return;
}
if (p === "/api/devices" && method === "GET") {
out.json(200, { devices: deviceList(), current: session.credentialId });
return;
}
out.json(404, { error: "unknown endpoint" });
return;
}
/* ---------------------------------------------------------------- SSE stream */
if (p === "/events") {
if (method !== "GET") {
out.json(405, { error: "get only" });
return;
}
if (!currentSession(req)) {
out.json(401, { error: "not signed in" });
return;
}
sse.add(res);
return;
}
/* ------------------------------------------------------------- static files */
if (method !== "GET") {
out.json(405, { error: "get only" });
return;
}
serveStatic(p, res);
}
/** Shared by the CLI and the web UI so both paths validate the same way. */
function applyApprovalMode(mode: unknown): boolean {
if (mode !== "off" && mode !== "risky" && mode !== "all") return false;
cfg.approval.mode = mode;
saveConfig(cfg);
sse.publish();
return true;
}
/* --------------------------------------------------------------------- lifecycle */
let shuttingDown = false;
function shutdown(why: string): void {
if (shuttingDown) return;
shuttingDown = true;
console.log(`[glance] shutting down (${why})`);
// Anything still waiting on a decision gets allowed, so no hook is left hanging.
broker.drain();
sse.closeAll();
server.close(() => process.exit(0));
// Don't let a lingering keep-alive socket hold the process forever.
setTimeout(() => process.exit(0), 1500).unref();
}
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("uncaughtException", (err) => {
console.error("[glance] uncaught", err);
});
process.on("unhandledRejection", (err) => {
console.error("[glance] unhandled rejection", err);
});
server.listen(cfg.port, cfg.host, () => {
console.log(`[glance] ${VERSION} listening on http://${cfg.host}:${cfg.port}`);
console.log(`[glance] state: ${paths.home}`);
console.log(`[glance] origin: ${cfg.origin ?? "(none set - see README)"} rpId: ${cfg.rpId ?? "localhost"}`);
console.log(`[glance] accepts assertions from: ${expectedOrigins(cfg).join(", ")}`);
if (!webBuildExists()) console.log("[glance] web app not built yet: npm install && npm run build");
if (cfg.port !== DEFAULT_PORT) console.log(`[glance] note: non-default port, run \`glance sync-hooks\``);
});
server.on("error", (err) => {
console.error(`[glance] listen failed: ${(err as Error).message}`);
process.exit(1);
});
+107
View File
@@ -0,0 +1,107 @@
/**
* Wire protocol shared between the daemon and the web app.
*
* NOTE: web/src/protocol.ts is a copy of this file. Keep the two in sync — they are
* duplicated rather than shared because the server compiles under NodeNext while the web
* app compiles under a bundler resolution, and a single rootDir cannot span both.
*/
export type EventKind =
| "session_start"
| "session_end"
| "prompt"
| "tool_start"
| "tool_end"
| "tool_fail"
| "permission_denied"
| "turn_end"
| "turn_error"
| "notification"
| "subagent_start"
| "subagent_end"
| "compact"
| "approval_request"
| "approval_allowed"
| "approval_denied"
| "approval_expired";
export type SessionState = "working" | "idle" | "waiting" | "error" | "ended";
export interface GlanceEvent {
id: number;
ts: number;
sessionId: string;
kind: EventKind;
/** Tool name, for tool-shaped events. */
tool?: string;
/** One-line human summary, already truncated and redacted. */
title: string;
/** Optional second line, e.g. a file path or an error message. */
detail?: string;
durationMs?: number;
}
export interface SessionView {
id: string;
/** Basename of the workspace root — what you actually recognise on a phone. */
label: string;
cwd: string;
state: SessionState;
startedAt: number;
lastActivity: number;
lastPrompt?: string;
currentTool?: { name: string; title: string; startedAt: number };
counts: { tools: number; failures: number; denials: number };
}
export interface PendingApproval {
id: string;
sessionId: string;
sessionLabel: string;
tool: string;
title: string;
detail?: string;
createdAt: number;
expiresAt: number;
}
export type ApprovalMode = "off" | "risky" | "all";
export interface ApprovalSettings {
mode: ApprovalMode;
riskyPattern: string;
timeoutMs: number;
/** Skip gating entirely when no browser is streaming, so an unwatched agent never stalls. */
requireWatcher: boolean;
/** What to do when nobody answers in time. Allow keeps the agent moving; deny is stricter. */
onTimeout: "allow" | "deny";
}
export interface Snapshot {
now: number;
version: string;
sessions: SessionView[];
events: GlanceEvent[];
pending: PendingApproval[];
approval: ApprovalSettings;
}
export interface DeviceInfo {
id: string;
label: string;
createdAt: number;
lastUsedAt?: number;
}
/** Everything the app needs before it knows whether you are signed in. */
export interface GateInfo {
authenticated: boolean;
/** False when no passkey has been enrolled yet — the app then asks for an enrolment code. */
enrolled: boolean;
/** True while a one-time enrolment code minted by `glance enroll` is still valid. */
enrollmentOpen: boolean;
version: string;
deviceLabel?: string;
/** The WebAuthn RP ID in force. Shown so a hostname mismatch is diagnosable from the phone. */
rpId?: string;
}
+120
View File
@@ -0,0 +1,120 @@
import type { ServerResponse } from "node:http";
import { SECURITY_HEADERS } from "./http.js";
import type { Snapshot } from "./protocol.js";
/** Coalesce bursts — a single tool call can fire several hooks in a few milliseconds. */
const THROTTLE_MS = 250;
/** Proxies and phone radios drop idle connections; a comment frame keeps them honest. */
const HEARTBEAT_MS = 25_000;
interface Client {
id: number;
res: ServerResponse;
}
export class SseHub {
private clients = new Map<number, Client>();
private nextId = 1;
private pending = false;
private lastSentAt = 0;
private timer: NodeJS.Timeout | null = null;
private heartbeat: NodeJS.Timeout | null = null;
constructor(private readonly snapshot: () => Snapshot) {}
/** True when at least one browser is listening — the approval broker asks before gating. */
hasWatcher(): boolean {
return this.clients.size > 0;
}
get count(): number {
return this.clients.size;
}
add(res: ServerResponse): void {
res.writeHead(200, {
...SECURITY_HEADERS,
"content-type": "text/event-stream",
"cache-control": "no-store, no-transform",
connection: "keep-alive",
// Belt and braces for any buffering proxy in front of us.
"x-accel-buffering": "no",
});
res.write(": connected\n\n");
const client: Client = { id: this.nextId++, res };
this.clients.set(client.id, client);
const drop = () => {
this.clients.delete(client.id);
if (this.clients.size === 0) this.stopHeartbeat();
};
res.on("close", drop);
res.on("error", drop);
this.send(client, "snapshot", this.snapshot());
this.startHeartbeat();
}
private startHeartbeat(): void {
if (this.heartbeat) return;
this.heartbeat = setInterval(() => {
for (const client of this.clients.values()) {
try {
client.res.write(": ping\n\n");
} catch {
this.clients.delete(client.id);
}
}
}, HEARTBEAT_MS);
this.heartbeat.unref?.();
}
private stopHeartbeat(): void {
if (!this.heartbeat) return;
clearInterval(this.heartbeat);
this.heartbeat = null;
}
private send(client: Client, event: string, data: unknown): void {
try {
client.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
} catch {
this.clients.delete(client.id);
}
}
/**
* Push a fresh full snapshot, throttled. Sending the whole state rather than deltas keeps
* the client dumb: a phone that slept through twenty events still lands on the truth.
*/
publish(): void {
if (this.clients.size === 0) return;
if (this.pending) return;
const wait = Math.max(0, THROTTLE_MS - (Date.now() - this.lastSentAt));
this.pending = true;
this.timer = setTimeout(() => {
this.pending = false;
this.lastSentAt = Date.now();
const snap = this.snapshot();
for (const client of [...this.clients.values()]) {
this.send(client, "snapshot", snap);
}
}, wait);
this.timer.unref?.();
}
closeAll(): void {
if (this.timer) clearTimeout(this.timer);
this.stopHeartbeat();
for (const client of this.clients.values()) {
try {
client.res.write("event: bye\ndata: {}\n\n");
client.res.end();
} catch {
/* going away anyway */
}
}
this.clients.clear();
}
}
+297
View File
@@ -0,0 +1,297 @@
import { VERSION, type Config } from "./config.js";
import { appendEventLog, readRecentEvents } from "./store.js";
import {
labelForWorkspace,
summarizeNotification,
summarizePrompt,
summarizeTool,
truncateDetail,
truncateTitle,
} from "./summarize.js";
import type {
EventKind,
GlanceEvent,
PendingApproval,
SessionState,
SessionView,
Snapshot,
} from "./protocol.js";
/** A session that has said nothing for this long is treated as idle, not working. */
const STALE_WORKING_MS = 10 * 60_000;
const EVENT_KIND_BY_HOOK: Record<string, EventKind> = {
SessionStart: "session_start",
SessionEnd: "session_end",
UserPromptSubmit: "prompt",
PreToolUse: "tool_start",
PostToolUse: "tool_end",
PostToolUseFailure: "tool_fail",
PermissionDenied: "permission_denied",
Stop: "turn_end",
StopFailure: "turn_error",
Notification: "notification",
SubagentStart: "subagent_start",
SubagentStop: "subagent_end",
PreCompact: "compact",
PostCompact: "compact",
};
export interface HookPayload {
hookEventName?: string;
sessionId?: string;
cwd?: string;
workspaceRoot?: string;
toolName?: string;
toolInput?: unknown;
[key: string]: unknown;
}
export class GlanceState {
private events: GlanceEvent[] = [];
private sessions = new Map<string, SessionView>();
/** sessionId|toolName -> start timestamp, so PostToolUse can report a duration. */
private toolStarts = new Map<string, number>();
private nextId = 1;
private readonly listeners = new Set<() => void>();
constructor(private readonly cfg: Config) {
// Warm start: keep recent history across daemon restarts.
const recent = readRecentEvents(cfg.retainEvents);
this.events = recent;
this.nextId = recent.reduce((max, e) => Math.max(max, e.id), 0) + 1;
}
onChange(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
for (const listener of this.listeners) {
try {
listener();
} catch {
/* a broken listener must not break ingestion */
}
}
}
private session(id: string, payload: HookPayload): SessionView {
let existing = this.sessions.get(id);
if (!existing) {
existing = {
id,
label: labelForWorkspace(payload.workspaceRoot, payload.cwd ?? ""),
cwd: payload.workspaceRoot ?? payload.cwd ?? "",
state: "idle",
startedAt: Date.now(),
lastActivity: Date.now(),
counts: { tools: 0, failures: 0, denials: 0 },
};
this.sessions.set(id, existing);
} else if (payload.workspaceRoot || payload.cwd) {
// Keep the label fresh if the session moved.
existing.label = labelForWorkspace(payload.workspaceRoot, payload.cwd ?? existing.cwd);
existing.cwd = payload.workspaceRoot ?? payload.cwd ?? existing.cwd;
}
return existing;
}
private push(event: GlanceEvent): void {
this.events.push(event);
if (this.events.length > this.cfg.retainEvents) {
this.events.splice(0, this.events.length - this.cfg.retainEvents);
}
appendEventLog(event);
}
/** Record a raw hook payload. Returns the event it produced, if any. */
ingest(payload: HookPayload): GlanceEvent | null {
const hookName = payload.hookEventName ?? "";
const kind = EVENT_KIND_BY_HOOK[hookName];
if (!kind) return null;
const sessionId = payload.sessionId ?? "unknown";
const session = this.session(sessionId, payload);
const now = Date.now();
session.lastActivity = now;
const tool = typeof payload.toolName === "string" ? payload.toolName : undefined;
let title = hookName;
let detail: string | undefined;
let durationMs: number | undefined;
switch (kind) {
case "session_start":
session.state = "idle";
title = `Session started in ${session.label}`;
detail = session.cwd || undefined;
break;
case "session_end":
session.state = "ended";
session.currentTool = undefined;
title = "Session ended";
break;
case "prompt":
session.state = "working";
session.lastPrompt = summarizePrompt(payload);
title = session.lastPrompt;
break;
case "tool_start": {
const summary = summarizeTool(tool ?? "tool", payload.toolInput);
session.state = "working";
session.currentTool = { name: tool ?? "tool", title: summary.title, startedAt: now };
this.toolStarts.set(`${sessionId}|${tool ?? "tool"}`, now);
title = summary.title;
detail = summary.detail;
break;
}
case "tool_end":
case "tool_fail": {
const summary = summarizeTool(tool ?? "tool", payload.toolInput);
const key = `${sessionId}|${tool ?? "tool"}`;
const startedAt = this.toolStarts.get(key);
if (startedAt) {
durationMs = now - startedAt;
this.toolStarts.delete(key);
}
if (session.currentTool?.name === tool) session.currentTool = undefined;
session.state = "working";
title = summary.title;
detail = summary.detail;
if (kind === "tool_end") {
session.counts.tools += 1;
} else {
session.counts.failures += 1;
detail = truncateDetail(String(payload["error"] ?? payload["message"] ?? "")) || detail;
}
break;
}
case "permission_denied":
session.counts.denials += 1;
title = tool ? `Permission denied: ${tool}` : "Permission denied";
detail = summarizeTool(tool ?? "tool", payload.toolInput).title;
break;
case "turn_end":
session.state = "idle";
session.currentTool = undefined;
title = "Turn finished";
break;
case "turn_error":
session.state = "error";
session.currentTool = undefined;
title = "Turn failed";
detail = truncateDetail(String(payload["error"] ?? payload["message"] ?? "")) || undefined;
break;
case "notification":
title = summarizeNotification(payload);
break;
case "subagent_start":
title = "Subagent started";
detail = truncateDetail(String(payload["description"] ?? payload["subagentType"] ?? "")) || undefined;
break;
case "subagent_end":
title = "Subagent finished";
break;
case "compact":
title = hookName === "PreCompact" ? "Compacting conversation" : "Compaction done";
break;
default:
break;
}
const event: GlanceEvent = {
id: this.nextId++,
ts: now,
sessionId,
kind,
tool,
title: truncateTitle(title),
detail,
durationMs,
};
this.push(event);
this.notify();
return event;
}
/** Record something the daemon itself decided, e.g. an approval outcome. */
record(
sessionId: string,
kind: EventKind,
title: string,
opts: { tool?: string; detail?: string } = {},
): GlanceEvent {
const now = Date.now();
const session = this.sessions.get(sessionId);
if (session) {
session.lastActivity = now;
if (kind === "approval_request") session.state = "waiting";
else if (kind === "approval_allowed" || kind === "approval_denied") session.state = "working";
}
const event: GlanceEvent = {
id: this.nextId++,
ts: now,
sessionId,
kind,
tool: opts.tool,
title: truncateTitle(title),
detail: opts.detail,
};
this.push(event);
this.notify();
return event;
}
private effectiveState(session: SessionView, now: number): SessionState {
if (session.state === "working" && now - session.lastActivity > STALE_WORKING_MS) {
return "idle";
}
return session.state;
}
snapshot(pending: PendingApproval[]): Snapshot {
const now = Date.now();
const waiting = new Set(pending.map((p) => p.sessionId));
const sessions = [...this.sessions.values()]
.map((s) => ({
...s,
state: waiting.has(s.id) ? ("waiting" as SessionState) : this.effectiveState(s, now),
}))
.sort((a, b) => b.lastActivity - a.lastActivity);
return {
now,
version: VERSION,
sessions,
events: [...this.events].sort((a, b) => b.ts - a.ts || b.id - a.id),
pending,
approval: this.cfg.approval,
};
}
sessionLabel(sessionId: string): string {
return this.sessions.get(sessionId)?.label ?? "workspace";
}
get sessionCount(): number {
return this.sessions.size;
}
get eventCount(): number {
return this.events.length;
}
}
+69
View File
@@ -0,0 +1,69 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { ServerResponse } from "node:http";
import { SECURITY_HEADERS } from "./http.js";
const here = path.dirname(fileURLToPath(import.meta.url));
/** dist/server/* and dist/web/* are siblings after a build. */
export const WEB_ROOT = path.resolve(here, "..", "web");
const TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".webmanifest": "application/manifest+json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".ico": "image/x-icon",
".woff2": "font/woff2",
};
export function webBuildExists(): boolean {
return fs.existsSync(path.join(WEB_ROOT, "index.html"));
}
/**
* Serve the built app. Vite fingerprints everything under /assets, so those can be cached hard
* while index.html must not be — otherwise a phone keeps a stale shell after an upgrade.
*/
export function serveStatic(urlPath: string, res: ServerResponse): void {
if (!webBuildExists()) {
res.writeHead(503, { ...SECURITY_HEADERS, "content-type": "text/plain; charset=utf-8" });
res.end("grok-glance: web app not built yet. Run `npm install && npm run build`.\n");
return;
}
const clean = decodeURIComponent(urlPath.split("?")[0]);
const candidate = path.resolve(WEB_ROOT, "." + path.posix.normalize(clean));
// Anything that escapes the build directory falls back to the shell rather than leaking.
const inside = candidate === WEB_ROOT || candidate.startsWith(WEB_ROOT + path.sep);
let file = inside && isFile(candidate) ? candidate : "";
if (!file) file = path.join(WEB_ROOT, "index.html");
const ext = path.extname(file).toLowerCase();
const isHashed = file.includes(`${path.sep}assets${path.sep}`);
try {
const body = fs.readFileSync(file);
res.writeHead(200, {
...SECURITY_HEADERS,
"content-type": TYPES[ext] ?? "application/octet-stream",
"cache-control": isHashed ? "public, max-age=31536000, immutable" : "no-cache",
});
res.end(body);
} catch {
res.writeHead(404, { ...SECURITY_HEADERS, "content-type": "text/plain; charset=utf-8" });
res.end("not found\n");
}
}
function isFile(p: string): boolean {
try {
return fs.statSync(p).isFile();
} catch {
return false;
}
}
+211
View File
@@ -0,0 +1,211 @@
import fs from "node:fs";
import crypto from "node:crypto";
import type { AuthenticatorTransportFuture } from "@simplewebauthn/server";
import { ensureHome, paths } from "./config.js";
import type { DeviceInfo, GlanceEvent } from "./protocol.js";
export interface StoredCredential {
/** Base64URL credential ID. */
id: string;
/** Base64 (standard) encoded COSE public key. */
publicKey: string;
counter: number;
transports?: AuthenticatorTransportFuture[];
label: string;
createdAt: number;
lastUsedAt?: number;
deviceType?: string;
backedUp?: boolean;
}
interface AuthSessionRecord {
/** SHA-256 of the session token. The token itself is never written to disk. */
tokenHash: string;
credentialId: string;
label: string;
createdAt: number;
expiresAt: number;
}
function readJsonFile<T>(file: string, fallback: T): T {
try {
return JSON.parse(fs.readFileSync(file, "utf8")) as T;
} catch {
return fallback;
}
}
function writeJsonFile(file: string, value: unknown): void {
ensureHome();
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
fs.renameSync(tmp, file);
}
/* ------------------------------------------------------------------ secrets */
/** HMAC key used to sign session cookies. Created once, 0600. */
export function sessionSecret(): Buffer {
ensureHome();
try {
const existing = fs.readFileSync(paths.secret);
if (existing.length >= 32) return existing;
} catch {
/* create below */
}
const key = crypto.randomBytes(32);
fs.writeFileSync(paths.secret, key, { mode: 0o600 });
return key;
}
/**
* Token that authorises privileged local operations (enrol, revoke, shutdown).
* Rotated on every daemon start so a leaked token dies with the process.
*/
export function rotateAdminToken(): string {
ensureHome();
const token = crypto.randomBytes(24).toString("base64url");
fs.writeFileSync(paths.adminToken, token + "\n", { mode: 0o600 });
return token;
}
/* -------------------------------------------------------------- credentials */
export function listCredentials(): StoredCredential[] {
return readJsonFile<StoredCredential[]>(paths.credentials, []);
}
export function saveCredentials(creds: StoredCredential[]): void {
writeJsonFile(paths.credentials, creds);
}
export function addCredential(cred: StoredCredential): void {
const all = listCredentials().filter((c) => c.id !== cred.id);
all.push(cred);
saveCredentials(all);
}
export function findCredential(id: string): StoredCredential | undefined {
return listCredentials().find((c) => c.id === id);
}
export function touchCredential(id: string, counter: number): void {
const all = listCredentials();
const cred = all.find((c) => c.id === id);
if (!cred) return;
cred.counter = counter;
cred.lastUsedAt = Date.now();
saveCredentials(all);
}
export function revokeCredentials(idPrefix: string): number {
const all = listCredentials();
const keep = all.filter((c) => !c.id.startsWith(idPrefix));
saveCredentials(keep);
const removed = all.length - keep.length;
if (removed > 0) {
// A revoked device must lose any live session too, or it keeps its cookie access.
const sessions = listAuthSessions().filter((s) => !s.credentialId.startsWith(idPrefix));
writeJsonFile(paths.authSessions, sessions);
}
return removed;
}
export function deviceList(): DeviceInfo[] {
return listCredentials().map((c) => ({
id: c.id,
label: c.label,
createdAt: c.createdAt,
lastUsedAt: c.lastUsedAt,
}));
}
/* ------------------------------------------------------------ auth sessions */
function listAuthSessions(): AuthSessionRecord[] {
const now = Date.now();
return readJsonFile<AuthSessionRecord[]>(paths.authSessions, []).filter(
(s) => s.expiresAt > now,
);
}
function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
export function createAuthSession(
credentialId: string,
label: string,
ttlMs: number,
): { token: string; expiresAt: number } {
const token = crypto.randomBytes(32).toString("base64url");
const expiresAt = Date.now() + ttlMs;
const sessions = listAuthSessions();
sessions.push({
tokenHash: hashToken(token),
credentialId,
label,
createdAt: Date.now(),
expiresAt,
});
writeJsonFile(paths.authSessions, sessions);
return { token, expiresAt };
}
export function lookupAuthSession(token: string): AuthSessionRecord | undefined {
const wanted = hashToken(token);
return listAuthSessions().find((s) => {
const a = Buffer.from(s.tokenHash, "hex");
const b = Buffer.from(wanted, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}
export function destroyAuthSession(token: string): void {
const wanted = hashToken(token);
writeJsonFile(
paths.authSessions,
listAuthSessions().filter((s) => s.tokenHash !== wanted),
);
}
/* -------------------------------------------------------------- event log */
const MAX_LOG_BYTES = 5 * 1024 * 1024;
export function appendEventLog(event: GlanceEvent): void {
try {
ensureHome();
let size = 0;
try {
size = fs.statSync(paths.events).size;
} catch {
/* no log yet */
}
if (size > MAX_LOG_BYTES) {
fs.renameSync(paths.events, `${paths.events}.1`);
}
fs.appendFileSync(paths.events, JSON.stringify(event) + "\n", { mode: 0o600 });
} catch {
// The dashboard is not worth crashing over.
}
}
/** Read back the tail of the log so a restarted daemon still has recent history. */
export function readRecentEvents(limit: number): GlanceEvent[] {
try {
const text = fs.readFileSync(paths.events, "utf8");
const lines = text.split("\n").filter(Boolean).slice(-limit);
const out: GlanceEvent[] = [];
for (const line of lines) {
try {
out.push(JSON.parse(line) as GlanceEvent);
} catch {
/* skip malformed line */
}
}
return out;
} catch {
return [];
}
}
+150
View File
@@ -0,0 +1,150 @@
/**
* Turns a raw hook payload into something you can read on a phone screen.
*
* Two jobs: pick the one field that actually says what the tool is doing, and strip anything
* that looks like a credential before it leaves the machine.
*/
const TITLE_MAX = 180;
const DETAIL_MAX = 400;
/**
* Conservative redaction: only well-known credential shapes and explicit key=value
* assignments. Deliberately not "redact any long string" — that would mangle ordinary
* paths and hashes and make the timeline useless.
*/
const REDACTIONS: Array<[RegExp, string]> = [
[/\b(sk|rk|pk)-[A-Za-z0-9_-]{16,}/g, "$1-[redacted]"],
[/\bxai-[A-Za-z0-9_-]{16,}/g, "xai-[redacted]"],
[/\bgh[pousr]_[A-Za-z0-9_]{16,}/g, "gh?_[redacted]"],
[/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, "xox?-[redacted]"],
[/\bAKIA[0-9A-Z]{16}\b/g, "AKIA[redacted]"],
[/\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,}/g, "[jwt redacted]"],
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "[private key redacted]"],
[/\b(authorization|bearer)\s*[:=]?\s*[A-Za-z0-9._~+/-]{16,}=*/gi, "$1 [redacted]"],
[
/\b([A-Za-z0-9_]*(?:password|passwd|secret|token|api[_-]?key|access[_-]?key)[A-Za-z0-9_]*)\s*[:=]\s*("[^"]*"|'[^']*'|\S+)/gi,
"$1=[redacted]",
],
];
export function redact(text: string): string {
let out = text;
for (const [pattern, replacement] of REDACTIONS) out = out.replace(pattern, replacement);
return out;
}
function clean(value: unknown, max: number): string {
if (value === undefined || value === null) return "";
const raw = typeof value === "string" ? value : JSON.stringify(value);
const collapsed = redact(raw).replace(/\s+/g, " ").trim();
return collapsed.length > max ? collapsed.slice(0, max - 1) + "…" : collapsed;
}
function basename(p: string): string {
const parts = p.replace(/\/+$/, "").split("/");
return parts[parts.length - 1] || p;
}
/** Shorten a path to something that fits a phone: keep the last two segments. */
export function shortPath(p: string): string {
if (!p) return "";
const parts = p.replace(/\/+$/, "").split("/").filter(Boolean);
if (parts.length <= 2) return p;
return `…/${parts.slice(-2).join("/")}`;
}
export interface ToolSummary {
title: string;
detail?: string;
}
/**
* Grok maps Claude Code tool names onto its own, so the input keys follow the Claude
* shapes. Anything unrecognised falls back to a JSON preview.
*/
export function summarizeTool(toolName: string, input: unknown): ToolSummary {
const obj = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
const pick = (...keys: string[]): string | undefined => {
for (const key of keys) {
const value = obj[key];
if (typeof value === "string" && value.trim()) return value;
}
return undefined;
};
switch (toolName) {
case "Bash":
case "BashOutput": {
const cmd = pick("command");
return {
title: clean(cmd ?? toolName, TITLE_MAX),
detail: clean(pick("description"), DETAIL_MAX) || undefined,
};
}
case "Read":
case "Write":
case "Edit":
case "MultiEdit":
case "NotebookEdit": {
const file = pick("file_path", "notebook_path", "path");
return {
title: file ? shortPath(clean(file, TITLE_MAX)) : toolName,
detail: file ? clean(file, DETAIL_MAX) : undefined,
};
}
case "Glob":
case "Grep": {
const pattern = pick("pattern", "query");
const where = pick("path", "glob");
return {
title: clean(pattern ?? toolName, TITLE_MAX),
detail: where ? clean(where, DETAIL_MAX) : undefined,
};
}
case "WebFetch":
case "WebSearch": {
const target = pick("url", "query");
return { title: clean(target ?? toolName, TITLE_MAX) };
}
case "Task":
case "Agent": {
return {
title: clean(pick("description", "prompt") ?? toolName, TITLE_MAX),
detail: clean(pick("subagent_type"), DETAIL_MAX) || undefined,
};
}
default: {
const first = pick("command", "file_path", "path", "url", "query", "pattern", "description");
if (first) return { title: clean(first, TITLE_MAX) };
const keys = Object.keys(obj);
if (!keys.length) return { title: toolName };
return { title: clean(obj[keys[0]], TITLE_MAX) || toolName };
}
}
}
export function summarizePrompt(payload: Record<string, unknown>): string {
const prompt =
payload["prompt"] ?? payload["userPrompt"] ?? payload["message"] ?? payload["text"];
return clean(prompt, TITLE_MAX) || "(prompt)";
}
export function summarizeNotification(payload: Record<string, unknown>): string {
const message = payload["message"] ?? payload["notification"] ?? payload["text"];
return clean(message, TITLE_MAX) || "Notification";
}
export function labelForWorkspace(workspaceRoot: string | undefined, cwd: string): string {
const source = workspaceRoot || cwd || "";
return basename(source) || "workspace";
}
export function truncateTitle(text: string): string {
return clean(text, TITLE_MAX);
}
export function truncateDetail(text: string): string {
return clean(text, DETAIL_MAX);
}
+192
View File
@@ -0,0 +1,192 @@
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
type AuthenticationResponseJSON,
type RegistrationResponseJSON,
} from "@simplewebauthn/server";
import { expectedOrigins, expectedRpIds, type Config } from "./config.js";
import {
addCredential,
createAuthSession,
findCredential,
listCredentials,
touchCredential,
} from "./store.js";
/** Passkeys live for a month before the phone has to prove itself again. */
export const SESSION_TTL_MS = 30 * 24 * 60 * 60_000;
const CHALLENGE_TTL_MS = 5 * 60_000;
const MAX_CHALLENGES = 32;
/**
* There is exactly one logical user here — you. A stable handle means a second passkey
* enrolled later joins the same account instead of creating a parallel one.
*/
const USER_HANDLE = new TextEncoder().encode("grok-glance");
const USER_NAME = "grok-glance";
type Purpose = "register" | "authenticate";
/**
* Challenges are held server-side and consumed exactly once. SimpleWebAuthn lets us pass a
* predicate for `expectedChallenge`, so we never need to trust the client to tell us which
* challenge it was answering.
*/
class ChallengeStore {
private items = new Map<string, { purpose: Purpose; expiresAt: number }>();
issue(challenge: string, purpose: Purpose): void {
this.prune();
if (this.items.size >= MAX_CHALLENGES) {
// Drop the oldest rather than growing without bound.
const oldest = this.items.keys().next();
if (!oldest.done) this.items.delete(oldest.value);
}
this.items.set(challenge, { purpose, expiresAt: Date.now() + CHALLENGE_TTL_MS });
}
consume(challenge: string, purpose: Purpose): boolean {
this.prune();
const entry = this.items.get(challenge);
if (!entry || entry.purpose !== purpose) return false;
this.items.delete(challenge);
return true;
}
private prune(): void {
const now = Date.now();
for (const [key, value] of this.items) {
if (value.expiresAt <= now) this.items.delete(key);
}
}
}
export interface AuthOutcome {
ok: boolean;
error?: string;
token?: string;
label?: string;
}
export class WebAuthnService {
private challenges = new ChallengeStore();
constructor(private readonly cfg: Config) {}
private get rpId(): string {
return this.cfg.rpId ?? "localhost";
}
get enrolled(): boolean {
return listCredentials().length > 0;
}
async registrationOptions() {
const existing = listCredentials();
const options = await generateRegistrationOptions({
rpName: this.cfg.rpName,
rpID: this.rpId,
userID: USER_HANDLE,
userName: USER_NAME,
userDisplayName: this.cfg.rpName,
attestationType: "none",
// Don't let the same device enrol twice — it just confuses the device list.
excludeCredentials: existing.map((c) => ({
id: c.id,
transports: c.transports,
})),
authenticatorSelection: {
residentKey: "required",
// "Strictly guarded" means a biometric or PIN every time, not merely possession.
userVerification: "required",
},
timeout: 120_000,
});
this.challenges.issue(options.challenge, "register");
return options;
}
async verifyRegistration(
response: RegistrationResponseJSON,
label: string,
): Promise<AuthOutcome> {
let verification;
try {
verification = await verifyRegistrationResponse({
response,
expectedChallenge: (challenge) => this.challenges.consume(challenge, "register"),
expectedOrigin: expectedOrigins(this.cfg),
expectedRPID: expectedRpIds(this.cfg),
requireUserVerification: true,
});
} catch (err) {
return { ok: false, error: (err as Error).message };
}
if (!verification.verified || !verification.registrationInfo) {
return { ok: false, error: "registration could not be verified" };
}
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
addCredential({
id: credential.id,
publicKey: Buffer.from(credential.publicKey).toString("base64"),
counter: credential.counter,
transports: credential.transports,
label: label.trim().slice(0, 40) || "device",
createdAt: Date.now(),
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
});
const session = createAuthSession(credential.id, label, SESSION_TTL_MS);
return { ok: true, token: session.token, label };
}
async authenticationOptions() {
const options = await generateAuthenticationOptions({
rpID: this.rpId,
allowCredentials: listCredentials().map((c) => ({
id: c.id,
transports: c.transports,
})),
userVerification: "required",
timeout: 120_000,
});
this.challenges.issue(options.challenge, "authenticate");
return options;
}
async verifyAuthentication(response: AuthenticationResponseJSON): Promise<AuthOutcome> {
const stored = findCredential(response.id);
if (!stored) return { ok: false, error: "unknown device" };
let verification;
try {
verification = await verifyAuthenticationResponse({
response,
expectedChallenge: (challenge) => this.challenges.consume(challenge, "authenticate"),
expectedOrigin: expectedOrigins(this.cfg),
expectedRPID: expectedRpIds(this.cfg),
credential: {
id: stored.id,
publicKey: new Uint8Array(Buffer.from(stored.publicKey, "base64")),
counter: stored.counter,
transports: stored.transports,
},
requireUserVerification: true,
});
} catch (err) {
return { ok: false, error: (err as Error).message };
}
if (!verification.verified) return { ok: false, error: "assertion rejected" };
touchCredential(stored.id, verification.authenticationInfo.newCounter);
const session = createAuthSession(stored.id, stored.label, SESSION_TTL_MS);
return { ok: true, token: session.token, label: stored.label };
}
}