first commit
This commit is contained in:
@@ -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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, "");
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user