Compare commits

...
4 Commits
Author SHA1 Message Date
iceBear67andClaude Opus 5 966133eeda Start the daemon from whichever hook fires first
A plugin's SessionStart hook never runs, so nothing was starting the
daemon: no daemon.log, nothing on :8791, and a manual `glance up`
working perfectly.

Grok Build dispatches SessionStart from inside session creation
(xai-grok-shell, agent_ops.rs -> DispatchSessionStartHook) and resolves
it against the session's hook registry as it stands at that moment.
That registry comes from discover_hooks(), whose sources are the config
layers and the global/project settings files; plugin directories are not
among them. Plugin hooks are appended later, under a plugin/ prefix, by
reload_hooks_impl and reload_plugins_impl - which run in response to a
plugin action, a /hooks reload, or a folder-trust grant. So the entry is
always registered after the event it subscribes to has been dispatched.
The other thirteen events work because they happen later in the session.

There is no boot event to move to, so every recorder boots the daemon
instead and whichever fires first wins. The cost is one loopback request
to /healthz per event once it is up, which is the steady state. A
daemon.lock (O_EXCL, 15s staleness takeover) keeps a burst of concurrent
events from starting five daemons and leaving four to die on EADDRINUSE.

glance-up.mjs stays wired: it costs nothing when it does not fire, and
it is the right hook for the job if that ordering is ever fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 08:04:03 +00:00
iceBear67andClaude Opus 5 b586323fdb Be honest about which install commands I could not verify
I have no grok CLI on this machine, so `grok plugin marketplace add` is taken from
the docs rather than run. Say so, point at the TUI's Marketplace tab as the route
that does not depend on a subcommand name, and note that the plain clone into
~/.grok/plugins/ needs none of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 07:34:06 +00:00
iceBear67andClaude Opus 5 3cfa011d0c Ship prebuilt, so a git URL is the whole install
Grok Build loads plugins out of ~/.grok/plugins/ and clones marketplace sources
straight from git; it never runs npm install or a build for you. So a plugin that
ships TypeScript is a plugin you have to build by hand before it does anything,
and the SessionStart hook's first act is to print "not built yet".

dist/ is now committed, and the tree is arranged so that a bare clone can run:

- The daemon is bundled to a single ESM file with rolldown, platform node. Its one
  runtime dependency (@simplewebauthn/server, plus the asn1/cbor tree under it) is
  inlined; the only imports left in the output are node: builtins. Not minified —
  a committed blob nobody can read is worse than no committed blob.
- tsc no longer emits for the server, it only typechecks (noEmit). rolldown emits.
- dist/web was already a self-contained static bundle.
- The hook scripts under bin/ were stdlib-only from the start.

.grok-plugin/marketplace.json makes the repo its own one-entry catalog with a local
source of "./", so `grok plugin marketplace add <git-url>` followed by
`grok plugin install grok-glance` works without pinning a SHA of itself.

`npm run check:dist` rebuilds and fails if the committed output is stale — the one
real hazard of checking in build output.

Also drops the daemon's "non-default port, run `glance sync-hooks`" startup note,
which the previous commit should have taken with the rest of that scheme; the hook
scripts read config.json themselves, so a non-default port needs nothing.

The e2e suite now takes GLANCE_ROOT and was run twice: once against the repo, once
against a copy containing only tracked files plus dist/ and no node_modules — which
is what actually demonstrates the claim, passkey registration and assertion
included. 233 checks, both runs green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 07:32:49 +00:00
iceBear67andClaude Opus 5 d20eb9255c Drop the hook-template scheme and other dead weight
hooks/hooks.json was generated from a template by scripts/gen-hooks.mjs. Once every
hook became a command hook that reads hook.secret from $GLANCE_HOME itself, the
template had exactly two placeholders left: {{HOOK_TOKEN}}, which nothing had ever
substituted into anything, and {{APPROVAL_TIMEOUT_SECS}}. Generating a whole file to
compute one number is not a good trade, so the number is now fixed at 125s in the
committed hooks.json and the coupling is enforced in code instead: the daemon clamps
approval.timeoutMs to APPROVAL_MAX_WAIT_MS (90s), which keeps the script inside its
own hook timeout no matter what a hand-edited config.json says. Losing that clamp is
what would actually hurt — a killed script never runs its fail-open path.

Also removed:

- `glance sync-hooks`, `npm run build:hooks`, and hookSecret({create}). The daemon is
  the only thing that should ever mint the secret.
- The ?k= query-string carrier for the hook secret. It existed for hooks that cannot
  set headers; there are none, and a secret in a URL lands in logs and shell history.
- Snapshot.now and SessionView.startedAt, which were written on every snapshot and
  every persist and read by nobody.
- An unused crypto import.

Docs and the e2e suite follow. The suite's ~12 sync-hooks assertions become static
checks on the committed file, plus new ones that hooks.json, APPROVAL_HOOK_TIMEOUT_SECS
and APPROVAL_MAX_WAIT_MS still agree, and that ?k= is refused. 220 checks, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 07:04:50 +00:00
29 changed files with 24175 additions and 526 deletions
-1
View File
@@ -1,4 +1,3 @@
node_modules/ node_modules/
dist/
*.log *.log
.DS_Store .DS_Store
+22
View File
@@ -0,0 +1,22 @@
{
"name": "grok-glance",
"description": "One-entry marketplace: this repo is both the catalog and the plugin, so `grok plugin marketplace add <git-url>` is enough to install it.",
"owner": {
"name": "grok-glance"
},
"plugins": [
{
"name": "grok-glance",
"description": "A passkey-guarded web dashboard that lets you glance at what Grok Build is doing from your phone, and approve or deny risky tool calls remotely.",
"category": "monitoring",
"keywords": [
"grok-glance",
"glance dashboard",
"webauthn passkey",
"remote approval",
"session monitor"
],
"source": { "type": "local", "path": "./" }
}
]
}
+86 -44
View File
@@ -37,49 +37,70 @@ or drive a session.
## Requirements ## Requirements
- Node.js 20 or newer, and npm. - Node.js 20 or newer. **npm is only needed to develop it**`dist/` is committed, and the
daemon bundle carries its one runtime dependency inside it, so an installed copy never runs
a build or an install step.
- Grok Build. - Grok Build.
- For phone access: [Tailscale](https://tailscale.com/) on both the machine and the phone. See - For phone access: [Tailscale](https://tailscale.com/) on both the machine and the phone. See
[Why Tailscale](#why-tailscale-and-not-just-the-lan-ip) — a LAN IP genuinely cannot work. [Why Tailscale](#why-tailscale-and-not-just-the-lan-ip) — a LAN IP genuinely cannot work.
## Install ## Install
Grok Build loads plugins straight out of `~/.grok/plugins/`, so the shortest install is a clone:
```sh ```sh
git clone <this repo> grok-glance git clone <this repo> ~/.grok/plugins/grok-glance
cd grok-glance
npm install && npm run build
``` ```
The build produces `dist/server` (the daemon) and `dist/web` (the dashboard). Both are required; That is the whole thing — no `npm install`, no build. Open `/plugins` in Grok Build and enable
the daemon serves the dashboard itself. It also generates `hooks/hooks.json` from **grok-glance**. The first hook to fire after that — your next prompt, or the first tool call —
`hooks/hooks.template.json`, and creates `~/.grok/glance/hook.secret` (mode 0600) if it does not starts the daemon in the background, and the dashboard is on `http://127.0.0.1:8791`. (Not the
exist yet — the shared secret the hook scripts authenticate with. Neither the secret nor anything `SessionStart` hook, which for a plugin never runs; see [Hook wiring](#hook-wiring).)
derived from it ends up in `hooks.json`.
Then register the directory with Grok Build. Plugins are installed from a marketplace catalog, so ### …or from a marketplace, by URL
for a local checkout the shortest path is a one-entry catalog. Create
`.grok-plugin/marketplace.json` in a directory that contains your checkout:
```json If you would rather install it the way marketplace plugins are installed — or point several
{ machines at one URL — this repo is also its own one-entry marketplace:
"name": "local", `.grok-plugin/marketplace.json` lists exactly one plugin, sourced from `./`, which is the repo
"description": "Local plugins", root the catalog itself lives in. So the repo's git URL is a complete marketplace, with no commit
"owner": { "name": "me" }, SHA to pin (a self-referencing remote source would have to pin the SHA of the commit that contains
"plugins": [ the pin).
{
"name": "grok-glance", Add it as a marketplace source from the TUI — `/plugins`, Marketplace tab — or from the CLI:
"description": "Passkey-guarded phone dashboard for Grok Build.",
"category": "monitoring", ```sh
"source": { "type": "local", "path": "./grok-glance" } grok plugin marketplace add https://your-git-host/you/grok-glance.git
} grok plugin install grok-glance --trust
]
}
``` ```
…then add that marketplace and install `grok-glance` from Grok Build's `/plugin` interface. Configured sources are recorded in `~/.grok/config.toml` under `[[marketplace.sources]]` and in
`~/.grok/plugins/known_marketplaces.json`; the TUI and `grok plugin marketplace list` read the same
list. Check `grok plugin marketplace --help` if the subcommand names have moved — the clone above
does not depend on any of this.
Once installed, the daemon starts by itself: the `SessionStart` hook boots it in the background on ### Why there is no build step
the first session after installation.
`dist/` is checked in:
- `dist/server/index.js` — the daemon, bundled to a single dependency-free ESM file. Its only
runtime dependency, `@simplewebauthn/server`, is inlined; everything else it uses is the Node
standard library. It is *not* minified, so what ships is what you can read.
- `dist/web/` — the dashboard, already a static bundle, which the daemon serves itself.
The hook scripts under `bin/` were stdlib-only from the start. So a clone has nothing to resolve
and nothing to compile, which is what makes a bare git URL enough.
Working on it instead? Then you do need the toolchain:
```sh
npm install
npm run build # tsc typechecks, rolldown bundles the server, vite builds the web app
npm run check:dist # rebuilds and fails if the committed dist/ is stale
```
`hooks/hooks.json` is checked in as-is — nothing about it is generated or machine-specific. The
shared secret the hook scripts authenticate with lives in `~/.grok/glance/hook.secret` (mode 0600)
and is created by the daemon on first start; it never appears in `hooks.json`.
## Get it onto your phone ## Get it onto your phone
@@ -184,7 +205,7 @@ Defaults worth knowing:
|---|---|---|---| |---|---|---|---|
| Only wait when a phone is watching | on | yes | Otherwise a closed browser tab stalls the agent for 90s per tool call. | | Only wait when a phone is watching | on | yes | Otherwise a closed browser tab stalls the agent for 90s per tool call. |
| On timeout | allow | yes | Flip to *deny* if you would rather fail closed. | | On timeout | allow | yes | Flip to *deny* if you would rather fail closed. |
| Timeout | 90s | no — edit `config.json` | The hook's own timeout is derived from this (`+35s` of slack) when `hooks.json` is generated, so re-run `sync-hooks` after changing it. | | Timeout | 90s | no — edit `config.json` | Also the ceiling: the approval hook gets 125s in `hooks.json`, and the daemon clamps a larger `timeoutMs` down to 90s so the script always outlives its own wait. |
| Risky-tool pattern | `^(Bash\|Write\|Edit\|MultiEdit\|NotebookEdit)$` | no — edit `config.json` | Shown on the phone but not editable: a typo'd regex would silently change what gets gated. | | Risky-tool pattern | `^(Bash\|Write\|Edit\|MultiEdit\|NotebookEdit)$` | no — edit `config.json` | Shown on the phone but not editable: a typo'd regex would silently change what gets gated. |
**This is a convenience gate, not a security boundary.** Every failure path is fail-open: daemon **This is a convenience gate, not a security boundary.** Every failure path is fail-open: daemon
@@ -208,7 +229,6 @@ permission settings.
| `devices` | List enrolled devices | | `devices` | List enrolled devices |
| `revoke <id-prefix>` | Revoke a device | | `revoke <id-prefix>` | Revoke a device |
| `approval <off\|risky\|all>` | Set the approval policy | | `approval <off\|risky\|all>` | Set the approval policy |
| `sync-hooks` | Regenerate `hooks/hooks.json` from the template (after changing `config.json`) |
## Files and configuration ## Files and configuration
@@ -225,6 +245,7 @@ Everything lives in `~/.grok/glance` (mode 0700), or `$GLANCE_HOME` if you set i
| `events.jsonl` | Append-only event log, one JSON object per line, rotated at 5 MB | | `events.jsonl` | Append-only event log, one JSON object per line, rotated at 5 MB |
| `sessions.json` | The agent roster — label, badge, workspace, state, counts — so a restart comes back with the overview intact. Written debounced, flushed on shutdown; sessions older than 12 hours are dropped on load. | | `sessions.json` | The agent roster — label, badge, workspace, state, counts — so a restart comes back with the overview intact. Written debounced, flushed on shutdown; sessions older than 12 hours are dropped on load. |
| `daemon.log` | Daemon stdout/stderr | | `daemon.log` | Daemon stdout/stderr |
| `daemon.lock` | Held while a hook script is starting the daemon, so a burst of events starts one and not five. Created with `O_EXCL`, deleted on the way out, and ignored by anyone else once 15s stale. |
Three environment variables override `config.json`, which is mostly useful for testing a second Three environment variables override `config.json`, which is mostly useful for testing a second
instance without touching your real one: instance without touching your real one:
@@ -235,10 +256,8 @@ instance without touching your real one:
| `GLANCE_PORT` | Port to listen on (and, for the CLI and hooks, to talk to) | | `GLANCE_PORT` | Port to listen on (and, for the CLI and hooks, to talk to) |
| `GLANCE_ORIGIN` | Public origin, as if set with `set-origin` — but not persisted | | `GLANCE_ORIGIN` | Public origin, as if set with `set-origin` — but not persisted |
The port is not baked into `hooks/hooks.json` the hook scripts read `config.json` themselves — so Nothing in `hooks/hooks.json` is machine-specific: the hook scripts read `config.json` themselves,
changing it needs nothing but a daemon restart. Changing `approval.timeoutMs` does affect the so changing the port or `approval.timeoutMs` needs nothing but a daemon restart.
generated file: run `node bin/glance sync-hooks` afterwards so the approval hook's own timeout still
outlasts the wait.
## Security notes ## Security notes
@@ -308,9 +327,8 @@ stop working and must be enrolled again.
## Hook wiring ## Hook wiring
`hooks/hooks.json` subscribes to all 14 lifecycle events, and **is generated** — from `hooks/hooks.json` subscribes to all 14 lifecycle events and is a plain checked-in file — edit it
`hooks/hooks.template.json` by `scripts/gen-hooks.mjs`, which runs as part of `npm run build` and on directly.
`node bin/glance sync-hooks`. Edit the template, not the output.
Every entry is a `command` hook. That is not a style choice: an `http` hook cannot reach this daemon Every entry is a `command` hook. That is not a style choice: an `http` hook cannot reach this daemon
by any route. Grok Build's http runner rejects every scheme but `https`, then **resolves the host** by any route. Grok Build's http runner rejects every scheme but `https`, then **resolves the host**
@@ -319,26 +337,50 @@ and refuses the resolved address if it is private, link-local or CGNAT
fails the scheme check; the tailnet fails the address check, because `*.ts.net` resolves into fails the scheme check; the tailnet fails the address check, because `*.ts.net` resolves into
`100.64/10` (and `fd7a::/48`, inside the blocked `fc00::/7`). On top of that the runner sends no `100.64/10` (and `fd7a::/48`, inside the blocked `fc00::/7`). On top of that the runner sends no
request header but `Content-Type`, with no configuration surface for one, so such a hook could not request header but `Content-Type`, with no configuration surface for one, so such a hook could not
authenticate itself even if it could connect. The generator refuses to emit an `http` handler whose authenticate itself even if it could connect. This plugin shipped `http` hooks for a while, and the
URL is not `https://`, because the alternative is what this plugin shipped for a while: 13 passive result was 13 passive hooks failing validation silently on every event.
hooks that failed validation silently on every event.
A command hook has none of those problems. It is a local process, so no URL is validated, nothing A command hook has none of those problems. It is a local process, so no URL is validated, nothing
traverses the tunnel, and it can present the shared secret — hook traffic goes straight to traverses the tunnel, and it can present the shared secret — hook traffic goes straight to
`http://127.0.0.1:8791` and never leaves the machine. So each observed event runs `http://127.0.0.1:8791` and never leaves the machine. So each observed event runs
`bin/glance-record.mjs`, which costs a Node start (~40 ms) and POSTs one event. Two entries differ: `bin/glance-record.mjs`, which costs a Node start (~40 ms) and POSTs one event. Two entries differ:
- `SessionStart` runs `bin/glance-up.mjs`, which is what boots the daemon. - `SessionStart` runs `bin/glance-up.mjs`. It is the obvious hook to boot the daemon from, and it
never runs — see below.
- `PreToolUse` is wired **twice** — a recording entry for the timeline, and a second entry matching - `PreToolUse` is wired **twice** — a recording entry for the timeline, and a second entry matching
only `^(Bash|Write|Edit|MultiEdit|NotebookEdit)$` that runs `bin/glance-approve.mjs`. PreToolUse only `^(Bash|Write|Edit|MultiEdit|NotebookEdit)$` that runs `bin/glance-approve.mjs`. PreToolUse
is the only blocking event, and a command hook is the only documented way to return a deny is the only blocking event, and a command hook is the only documented way to return a deny
decision; keeping the match narrow means the gate's cost is paid only for calls that could decision; keeping the match narrow means the gate's cost is paid only for calls that could
actually need a tap. Its `timeout` is derived from `approval.timeoutMs` at generation time rather actually need a tap. Its `timeout` is a fixed 125s, and the daemon clamps its own wait to 90s
than hand-copied, which is the other thing `sync-hooks` refreshes. against it (`APPROVAL_MAX_WAIT_MS`) so the script is never killed before it can fail open.
The hook scripts use nothing but the Node standard library and always exit 0 unless they are The hook scripts use nothing but the Node standard library and always exit 0 unless they are
deliberately denying — including when the daemon rejects their token. deliberately denying — including when the daemon rejects their token.
### Why every recorder boots the daemon
A plugin gets no usable boot event, so `bin/glance-record.mjs` starts the daemon itself when it
finds it missing, and whichever event fires first wins.
`SessionStart` looks like the right answer and cannot work. Grok Build dispatches it from inside
session creation (`xai-grok-shell`, `agent_ops.rs``SessionCommand::DispatchSessionStartHook`),
and the dispatch resolves against the session's hook registry **as it stands at that moment**. That
registry comes from `discover_hooks()`, whose sources are the config layers and the global/project
settings files — `~/.grok/settings.json`, `<git_root>/.grok/hooks`, the vendor-compat paths. Plugin
directories are not among them. Plugin hooks are appended separately, under a `plugin/` prefix, by
`reload_hooks_impl` and `reload_plugins_impl` — both of which run later, in response to a plugin
action, a `/hooks reload`, or a folder-trust grant. So a plugin's `SessionStart` entry is always
registered after `SessionStart` has already been dispatched, and is never called. Every other event
this plugin subscribes to happens later in the session, once the plugin registry has landed, which
is why they all work.
The symptom, if you hit this from the other end: no `daemon.log` at all, nothing on `:8791`, and a
manual `glance up` working perfectly.
Asking costs one loopback request to `/healthz` per event, which is the steady state once the
daemon is up. The spawn path is taken once. A `daemon.lock` (`O_EXCL`, 15s staleness) keeps a burst
of concurrent events from starting five daemons and leaving four of them to die on `EADDRINUSE`.
## Deliberately omitted ## Deliberately omitted
Not oversights — decisions: Not oversights — decisions:
+10 -36
View File
@@ -10,17 +10,16 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { spawn, spawnSync } from "node:child_process";
import { import {
PLUGIN_ROOT, PLUGIN_ROOT,
SERVER_ENTRY,
baseUrl, baseUrl,
ensureDaemon,
glanceHome, glanceHome,
isDaemonUp, isDaemonUp,
readConfig, readConfig,
sleep,
} from "./glance-lib.mjs"; } from "./glance-lib.mjs";
const SERVER_ENTRY = path.join(PLUGIN_ROOT, "dist", "server", "index.js");
const cfg = readConfig(); const cfg = readConfig();
const cmd = process.argv[2] ?? "status"; const cmd = process.argv[2] ?? "status";
const args = process.argv.slice(3); const args = process.argv.slice(3);
@@ -77,27 +76,20 @@ function sessionBreakdown(states) {
function requireBuild() { function requireBuild() {
if (!fs.existsSync(SERVER_ENTRY)) { if (!fs.existsSync(SERVER_ENTRY)) {
console.error(`grok-glance is not built yet.\n\n cd ${PLUGIN_ROOT}\n npm install && npm run build\n`); console.error(
`grok-glance: ${SERVER_ENTRY} is missing.\n\n` +
`dist/ ships with the plugin, so this checkout is incomplete. Rebuild it:\n\n` +
` cd ${PLUGIN_ROOT}\n npm install && npm run build\n`,
);
process.exit(1); process.exit(1);
} }
} }
async function ensureUp() { async function ensureUp() {
if (await isDaemonUp(cfg)) return true;
requireBuild(); requireBuild();
const home = glanceHome(); // No hook timeout to fit inside here, so wait long enough that a slow cold start still counts.
fs.mkdirSync(home, { recursive: true }); if (await ensureDaemon(cfg, { waitMs: 8000, startedBy: "cli" })) return true;
const logFd = fs.openSync(path.join(home, "daemon.log"), "a"); console.error(`daemon did not come up; see ${path.join(glanceHome(), "daemon.log")}`);
const child = spawn(process.execPath, [SERVER_ENTRY], {
detached: true,
stdio: ["ignore", logFd, logFd],
});
child.unref();
for (let i = 0; i < 40; i++) {
await sleep(200);
if (await isDaemonUp(cfg, 300)) return true;
}
console.error(`daemon did not come up; see ${path.join(home, "daemon.log")}`);
return false; return false;
} }
@@ -221,23 +213,6 @@ switch (cmd) {
break; break;
} }
case "sync-hooks": {
// hooks.json is generated, not edited: regenerate it from hooks/hooks.template.json.
// The port is not baked into it any more — the hook scripts read config.json themselves —
// so the thing this actually refreshes is the approval gate's timeout, plus the hook
// secret if it has gone missing.
const { status } = spawnSync(
process.execPath,
[path.join(PLUGIN_ROOT, "scripts", "gen-hooks.mjs")],
{ stdio: "inherit" },
);
if (status !== 0) process.exit(status ?? 1);
if (await isDaemonUp(cfg)) {
console.log("restart the daemon to pick up config changes: glance stop && glance up");
}
break;
}
case "logs": { case "logs": {
const file = path.join(glanceHome(), "daemon.log"); const file = path.join(glanceHome(), "daemon.log");
if (!fs.existsSync(file)) { if (!fs.existsSync(file)) {
@@ -260,7 +235,6 @@ switch (cmd) {
glance devices list enrolled devices glance devices list enrolled devices
glance revoke <id-prefix> revoke a device glance revoke <id-prefix> revoke a device
glance approval <off|risky|all> remote approval policy glance approval <off|risky|all> remote approval policy
glance sync-hooks regenerate hooks/hooks.json from the template
glance logs tail the daemon log glance logs tail the daemon log
`); `);
} }
+4 -4
View File
@@ -11,7 +11,7 @@
*/ */
import { import {
approvalHookTimeoutSecs, APPROVAL_HOOK_TIMEOUT_SECS,
baseUrl, baseUrl,
envEnvelope, envEnvelope,
hookHeaders, hookHeaders,
@@ -36,10 +36,10 @@ function deny(reason) {
const payload = envEnvelope(await readStdinJson()); const payload = envEnvelope(await readStdinJson());
const cfg = readConfig(); const cfg = readConfig();
// Finish inside the hook timeout that scripts/gen-hooks.mjs wrote into hooks.json, with room // Finish inside the hook's own timeout from hooks.json, with room to spare: if Grok Build
// to spare: if Grok Build kills us first, the fail-open path below never gets to run. // kills us first, the fail-open path below never gets to run.
const waitMs = Math.min( const waitMs = Math.min(
approvalHookTimeoutSecs(cfg) * 1000 - 10_000, APPROVAL_HOOK_TIMEOUT_SECS * 1000 - 10_000,
Number(cfg.approval?.timeoutMs ?? 90_000) + 15_000, Number(cfg.approval?.timeoutMs ?? 90_000) + 15_000,
); );
+120 -35
View File
@@ -8,11 +8,13 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
import crypto from "node:crypto"; import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
export const PLUGIN_ROOT = path.resolve(fileURLToPath(import.meta.url), "../.."); export const PLUGIN_ROOT = path.resolve(fileURLToPath(import.meta.url), "../..");
export const SERVER_ENTRY = path.join(PLUGIN_ROOT, "dist", "server", "index.js");
export const DEFAULT_PORT = 8791; export const DEFAULT_PORT = 8791;
/** /**
@@ -49,34 +51,15 @@ export const HOOK_HEADER = "x-glance-hook";
/** /**
* The shared secret that admits a caller to /hook/*. Read fresh on every invocation so a * The shared secret that admits a caller to /hook/*. Read fresh on every invocation so a
* regenerated secret is picked up without touching hooks.json. * regenerated secret is picked up without restarting anything.
* *
* `create` is used by the build-time generator; the hook scripts pass false and simply get * Only the daemon ever creates it. A hook must never be the thing that creates state, and a
* null when there is no secret yet. That is deliberate: a hook must never be the thing that * missing secret has to degrade to "no telemetry", not "no tool call" — so this returns null
* creates state, and a missing secret has to degrade to "no telemetry", not "no tool call". * and the callers carry on.
*/ */
export function hookSecret({ create = false } = {}) { export function hookSecret() {
const file = path.join(glanceHome(), "hook.secret");
for (let attempt = 0; attempt < 2; attempt++) {
try {
const existing = fs.readFileSync(file, "utf8").trim();
if (existing) return existing;
} catch {
/* fall through */
}
if (!create) return null;
fs.mkdirSync(glanceHome(), { recursive: true, mode: 0o700 });
const token = crypto.randomBytes(32).toString("base64url");
try {
// Exclusive: if the daemon created one a millisecond ago, read theirs instead.
fs.writeFileSync(file, token + "\n", { mode: 0o600, flag: "wx" });
return token;
} catch {
/* lost the race; loop re-reads */
}
}
try { try {
return fs.readFileSync(file, "utf8").trim() || null; return fs.readFileSync(path.join(glanceHome(), "hook.secret"), "utf8").trim() || null;
} catch { } catch {
return null; return null;
} }
@@ -88,17 +71,15 @@ export function hookHeaders() {
} }
/** /**
* How long the PreToolUse approval hook is allowed to run, in seconds. * How long the PreToolUse approval hook is allowed to run, in seconds — the `timeout` written
* next to glance-approve.mjs in hooks/hooks.json. Change one, change the other.
* *
* One formula, two consumers: scripts/gen-hooks.mjs writes it into hooks.json as the hook's * It bounds everything downstream: if the script outlives its hook timeout, Grok Build kills
* `timeout`, and glance-approve.mjs derives its own wait from it. They must agree — if the * it and the fail-open path never runs. So the daemon caps its own wait well inside it (see
* script outlives its hook timeout, Grok Build kills it and the fail-open path never runs. * APPROVAL_MAX_WAIT_MS in server/src/config.ts), and the script leaves itself 10s on top of
* that to answer.
*/ */
export function approvalHookTimeoutSecs(cfg = readConfig()) { export const APPROVAL_HOOK_TIMEOUT_SECS = 125;
const ms = Number(cfg.approval?.timeoutMs ?? 90_000);
const base = Number.isFinite(ms) && ms > 0 ? ms : 90_000;
return Math.ceil(base / 1000) + 35;
}
/** Read the hook payload that Grok Build writes to stdin. Returns {} if there is none. */ /** Read the hook payload that Grok Build writes to stdin. Returns {} if there is none. */
export async function readStdinJson() { export async function readStdinJson() {
@@ -162,3 +143,107 @@ export async function isDaemonUp(cfg = readConfig(), timeoutMs = 400) {
} }
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/* ------------------------------------------------------------------ daemon start */
/**
* How long a spawn lock is believed before another hook takes it over. Long enough to cover a
* cold Node start and a bind, short enough that a script killed mid-spawn cannot wedge startup
* for the rest of the session.
*/
const SPAWN_LOCK_STALE_MS = 15_000;
/** Never hold the lock for less than this, so a fire-and-forget caller still covers the bind. */
const MIN_SPAWN_WAIT_MS = 600;
/**
* Claim the right to spawn the daemon, so a burst of hooks does not start a race in which every
* loser dies on EADDRINUSE and litters daemon.log.
*
* `wx` is the whole mechanism: an atomic create-or-fail. A lock that is already there and still
* fresh means another script is mid-spawn, and we wait for its daemon rather than starting a
* second one. Taking over a *stale* lock is deliberately not atomic — two scripts could both
* decide it is stale and both spawn — because the consequence is only the EADDRINUSE we had
* before, and it takes a 15s-dead lock to get there at all.
*/
function acquireSpawnLock(home) {
const file = path.join(home, "daemon.lock");
try {
fs.writeFileSync(file, String(process.pid), { flag: "wx", mode: 0o600 });
return file;
} catch {
try {
if (Date.now() - fs.statSync(file).mtimeMs < SPAWN_LOCK_STALE_MS) return null;
fs.writeFileSync(file, String(process.pid), { mode: 0o600 });
return file;
} catch {
return null;
}
}
}
async function waitUntilUp(cfg, budgetMs) {
const deadline = Date.now() + budgetMs;
while (Date.now() < deadline) {
await sleep(100);
const left = deadline - Date.now();
if (left <= 0) break;
if (await isDaemonUp(cfg, Math.min(300, Math.max(50, left)))) return true;
}
return false;
}
/**
* Make sure the daemon is listening, starting it if it is not. Never throws.
*
* Every recording hook calls this, not just one designated boot hook, because Grok Build gives
* a plugin no usable boot event. `SessionStart` is dispatched from inside session creation
* (xai-grok-shell agent_ops.rs, `DispatchSessionStartHook`) and dispatch reads the session's
* hook registry as it stands at that moment. That registry is built by `discover_hooks()`,
* whose sources are the config layers and the global/project settings files — plugin hooks are
* not among them. They are appended later, with a `plugin/` prefix, only by `reload_hooks_impl`
* and `reload_plugins_impl`. So a plugin's `SessionStart` entry is registered strictly after
* `SessionStart` has already fired, and never runs. Every other event we subscribe to happens
* later in the session, once the plugin registry has landed — so whichever of them fires first
* is the one that has to boot us.
*
* The cost of asking is one loopback request to /healthz once the daemon is up, which is the
* steady state; the spawn path is taken once per machine boot.
*/
export async function ensureDaemon(cfg = readConfig(), options = {}) {
const { waitMs = 8000, startedBy = "hook", onMissingBuild } = options;
try {
if (await isDaemonUp(cfg)) return true;
if (!fs.existsSync(SERVER_ENTRY)) {
onMissingBuild?.(SERVER_ENTRY);
return false;
}
const home = glanceHome();
fs.mkdirSync(home, { recursive: true });
const budget = Math.max(waitMs, MIN_SPAWN_WAIT_MS);
const lock = acquireSpawnLock(home);
// Someone else is already starting it: wait on theirs instead of racing it.
if (!lock) return await waitUntilUp(cfg, budget);
try {
const logFd = fs.openSync(path.join(home, "daemon.log"), "a");
const child = spawn(process.execPath, [SERVER_ENTRY], {
detached: true,
stdio: ["ignore", logFd, logFd],
env: { ...process.env, GLANCE_STARTED_BY: startedBy },
});
child.unref();
return await waitUntilUp(cfg, budget);
} finally {
try {
fs.unlinkSync(lock);
} catch {
/* best effort */
}
}
} catch {
// A dashboard that cannot start must still not be the reason a tool call fails.
return false;
}
}
+19 -2
View File
@@ -11,11 +11,16 @@
* A command hook costs a Node start (~40ms) per event, and buys back the ability to send an * A command hook costs a Node start (~40ms) per event, and buys back the ability to send an
* authentication header, which the http runner has no config surface for. * authentication header, which the http runner has no config surface for.
* *
* It also boots the daemon if nothing else has. That is not this hook being greedy: a plugin's
* `SessionStart` entry provably never runs (see `ensureDaemon` in glance-lib.mjs), so there is
* no single boot event to delegate to and whichever recorder fires first has to do it.
*
* Always exits 0. A dashboard must never be the reason a tool call fails. * Always exits 0. A dashboard must never be the reason a tool call fails.
*/ */
import { import {
baseUrl, baseUrl,
ensureDaemon,
envEnvelope, envEnvelope,
hookHeaders, hookHeaders,
postJson, postJson,
@@ -23,11 +28,23 @@ import {
readStdinJson, readStdinJson,
} from "./glance-lib.mjs"; } from "./glance-lib.mjs";
/**
* hooks/hooks.json gives this hook 5s. Everything below has to finish inside that with room to
* spare, because a killed script is a lost event either way — and losing one is fine, the next
* event is 40ms behind it.
*/
const START_BUDGET_MS = 1200;
const POST_BUDGET_MS = 1500;
try { try {
const payload = envEnvelope(await readStdinJson()); const payload = envEnvelope(await readStdinJson());
const cfg = readConfig(); const cfg = readConfig();
// Short: if the daemon is not listening this must fail fast, not hold up the tool call. // Cheap when the daemon is already up, which is every call but the first of the session.
await postJson(`${baseUrl(cfg)}/hook/record`, payload, 2000, hookHeaders()); if (await ensureDaemon(cfg, { waitMs: START_BUDGET_MS, startedBy: "record-hook" })) {
// hookHeaders() is read after the daemon is up: on a first-ever run it is the daemon we
// just started that created hook.secret.
await postJson(`${baseUrl(cfg)}/hook/record`, payload, POST_BUDGET_MS, hookHeaders());
}
} catch { } catch {
// Daemon down, no secret yet, malformed payload — all the same answer: carry on. // Daemon down, no secret yet, malformed payload — all the same answer: carry on.
} }
+23 -42
View File
@@ -2,63 +2,44 @@
/** /**
* SessionStart hook: make sure the glance daemon is running, then record the event. * SessionStart hook: make sure the glance daemon is running, then record the event.
* *
* This is the only hook that spawns anything. It always exits 0 — a monitoring * Note that as of Grok Build's current hook wiring this never actually runs: a plugin's
* dashboard must never be the reason a Grok Build session fails to start. * `SessionStart` entry is registered after `SessionStart` has already been dispatched, so the
* event finds no plugin hooks to call. `ensureDaemon` in glance-lib.mjs has the details, and
* bin/glance-record.mjs is what really boots the daemon.
*
* It stays wired anyway. It costs nothing when it does not fire, it is the correct hook for the
* job on the day that ordering is fixed, and it keeps the answer to "what starts this thing"
* in the obvious place.
*
* It always exits 0 — a monitoring dashboard must never be the reason a session fails to start.
*/ */
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import { import {
PLUGIN_ROOT,
baseUrl, baseUrl,
ensureDaemon,
envEnvelope, envEnvelope,
glanceHome,
hookHeaders, hookHeaders,
isDaemonUp,
postJson, postJson,
readConfig, readConfig,
readStdinJson, readStdinJson,
sleep,
} from "./glance-lib.mjs"; } from "./glance-lib.mjs";
const SERVER_ENTRY = path.join(PLUGIN_ROOT, "dist", "server", "index.js");
async function ensureDaemon(cfg) {
if (await isDaemonUp(cfg)) return true;
if (!fs.existsSync(SERVER_ENTRY)) {
// Not built yet. Say so once, on stderr, where it is recorded but harmless.
process.stderr.write(
`[grok-glance] not built yet - run \`npm install && npm run build\` in ${PLUGIN_ROOT}\n`,
);
return false;
}
const home = glanceHome();
fs.mkdirSync(home, { recursive: true });
const logFd = fs.openSync(path.join(home, "daemon.log"), "a");
const child = spawn(process.execPath, [SERVER_ENTRY], {
detached: true,
stdio: ["ignore", logFd, logFd],
env: { ...process.env, GLANCE_STARTED_BY: "hook" },
});
child.unref();
// Give it a moment to bind before the first http hook fires.
for (let i = 0; i < 40; i++) {
await sleep(200);
if (await isDaemonUp(cfg, 300)) return true;
}
return false;
}
const payload = envEnvelope(await readStdinJson()); const payload = envEnvelope(await readStdinJson());
const cfg = readConfig(); const cfg = readConfig();
try { try {
const up = await ensureDaemon(cfg); // hooks/hooks.json allows this hook 20s; leave most of it as headroom.
const up = await ensureDaemon(cfg, {
waitMs: 8000,
startedBy: "session-start-hook",
onMissingBuild: (entry) => {
// dist/ ships with the plugin, so this means an incomplete checkout. Say so once, on
// stderr, where it is recorded but harmless — a hook must never fail a session.
process.stderr.write(
`[grok-glance] ${entry} is missing - run \`npm install && npm run build\` in the plugin root\n`,
);
},
});
if (up) { if (up) {
// hookHeaders() is read here, not at import time: on a first-ever run the daemon we just // hookHeaders() is read here, not at import time: on a first-ever run the daemon we just
// spawned is what created hook.secret. // spawned is what created hook.secret.
+3 -2
View File
@@ -12,8 +12,9 @@ If no argument was given, treat it as `status`.
Then: Then:
1. Run `node "$GROK_PLUGIN_ROOT/bin/glance" $ARGUMENTS`. 1. Run `node "$GROK_PLUGIN_ROOT/bin/glance" $ARGUMENTS`.
2. If it says the plugin is not built, run `npm install && npm run build` in `$GROK_PLUGIN_ROOT` 2. The plugin ships prebuilt, so this should just work. If it does say the plugin is not built,
(this takes a minute or two) and try again. `dist/` is missing from the checkout: run `npm install && npm run build` in
`$GROK_PLUGIN_ROOT` (a minute or two) and try again.
3. Report what came back. For `enroll`, show the URL and the code verbatim — the user needs to 3. Report what came back. For `enroll`, show the URL and the code verbatim — the user needs to
type them on their phone, so do not paraphrase or reformat them. type them on their phone, so do not paraphrase or reformat them.
4. If the output mentions that no public origin is configured, explain the Tailscale Serve setup: 4. If the output mentions that no public origin is configured, explain the Tailscale Serve setup:
+23758
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" rx="14" fill="#09090b" />
<circle cx="32" cy="32" r="15" fill="none" stroke="#fafafa" stroke-width="3.5" />
<circle cx="32" cy="32" r="5.5" fill="#fafafa" />
<path d="M32 9v5M32 50v5M9 32h5M50 32h5" stroke="#71717a" stroke-width="3.5" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 389 B

+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- viewport-fit=cover so the sticky header sits under the notch rather than beside it. -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#fafafa" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#09090b" media="(prefers-color-scheme: dark)" />
<title>grok-glance</title>
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="apple-touch-icon" href="/icon.svg" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="glance" />
<script type="module" crossorigin src="/assets/index-D-dJ5pn0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BZSiLyex.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
{
"name": "grok-glance",
"short_name": "glance",
"description": "Glance at what Grok Build is doing.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#09090b",
"theme_color": "#09090b",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}
+19 -21
View File
@@ -1,9 +1,6 @@
{ {
"_comment": [ "_comment": [
"TEMPLATE. Do not edit hooks/hooks.json by hand - it is generated from this file by", "Every entry is a `command` hook, including the 13 passive recorders. That is not a style",
"`scripts/gen-hooks.mjs`, which runs as part of `npm run build` and on `glance sync-hooks`.",
"",
"Everything is a `command` hook, including the 13 passive recorders. That is not a style",
"choice: an `http` hook cannot reach this daemon by any route. Grok Build's http runner", "choice: an `http` hook cannot reach this daemon by any route. Grok Build's http runner",
"(xai-grok-hooks/src/runner/http.rs, `validate_hook_url`) rejects every scheme but https,", "(xai-grok-hooks/src/runner/http.rs, `validate_hook_url`) rejects every scheme but https,",
"then resolves the host and refuses the resolved address if it is private, link-local or", "then resolves the host and refuses the resolved address if it is private, link-local or",
@@ -14,26 +11,27 @@
"could connect.", "could connect.",
"", "",
"A command hook has none of those problems: it is a local process, so there is no URL to", "A command hook has none of those problems: it is a local process, so there is no URL to",
"validate, no proxy in the path, and it can present the shared secret. It costs one Node", "validate, no proxy in the path, and it reads the shared secret out of $GLANCE_HOME itself.",
"start (~40ms) per event.", "It costs one Node start (~40ms) per event. Nothing here is secret, and nothing here is",
"derived from config.json - the scripts read that themselves - so this file is plain,",
"committed, and edited by hand.",
"", "",
"SessionStart boots the daemon. PreToolUse is wired twice on purpose: one entry records", "Every recorder boots the daemon if it is not already up, rather than one designated boot",
"every call for the timeline, and a second, narrowly matched entry runs the approval gate,", "hook doing it. Grok Build gives a plugin no usable boot event: SessionStart is dispatched",
"because PreToolUse is the only blocking event and only a command hook can return a deny.", "from inside session creation, against the hook registry as it stands at that moment, and",
"that registry holds only config-layer and settings-file hooks. Plugin hooks are appended",
"afterwards, under a 'plugin/' prefix, by reload_hooks_impl and reload_plugins_impl - so the",
"SessionStart entry below is registered strictly after SessionStart has already fired and",
"never runs. It is kept because it costs nothing and is the right hook once that is fixed.",
"", "",
"Placeholders, written in the template as a name wrapped in double braces:", "PreToolUse is wired twice on purpose: one entry records every call for the timeline, and a",
" APPROVAL_TIMEOUT_SECS derived from approval.timeoutMs in ~/.grok/glance/config.json.", "second, narrowly matched entry runs the approval gate, because PreToolUse is the only",
" HOOK_TOKEN the secret from ~/.grok/glance/hook.secret, for a caller that", "blocking event and only a command hook can return a deny.",
" cannot set a header: the daemon accepts it as a ?k= query",
" parameter too. Nothing below uses it and no http hook can (see",
" above); it stays because the daemon's ?k= path is real. Note the",
" daemon also refuses any /hook/* request carrying x-forwarded-*,",
" so a reverse-proxied transport is out as well. Using this",
" placeholder makes the generated hooks.json secret-bearing, so",
" gen-hooks writes it 0600 and it must not be committed.",
"", "",
"Do not spell those names with their braces anywhere in this comment block: the comment is", "The gate's 125s timeout is the ceiling for the whole approval round trip. The daemon caps",
"copied verbatim into hooks.json, and substitution would happily expand it there too." "approval.timeoutMs at 90s against it, so the script always outlives its own wait and gets",
"to fail open. Changing the number here means changing APPROVAL_HOOK_TIMEOUT_SECS in",
"bin/glance-lib.mjs and APPROVAL_MAX_WAIT_MS in server/src/config.ts to match."
], ],
"hooks": { "hooks": {
"SessionStart": [ "SessionStart": [
-156
View File
@@ -1,156 +0,0 @@
{
"_comment": [
"TEMPLATE. Do not edit hooks/hooks.json by hand - it is generated from this file by",
"`scripts/gen-hooks.mjs`, which runs as part of `npm run build` and on `glance sync-hooks`.",
"",
"Everything is a `command` hook, including the 13 passive recorders. That is not a style",
"choice: an `http` hook cannot reach this daemon by any route. Grok Build's http runner",
"(xai-grok-hooks/src/runner/http.rs, `validate_hook_url`) rejects every scheme but https,",
"then resolves the host and refuses the resolved address if it is private, link-local or",
"CGNAT - so plain http on loopback is out, and so is the tailnet, because *.ts.net resolves",
"into 100.64/10 (and fd7a::/48, inside the blocked fc00::/7). Pointing a hook at the public",
"https origin therefore fails upstream, before a request is ever sent. The runner also sends",
"no request header but Content-Type, so such a hook could not authenticate itself even if it",
"could connect.",
"",
"A command hook has none of those problems: it is a local process, so there is no URL to",
"validate, no proxy in the path, and it can present the shared secret. It costs one Node",
"start (~40ms) per event.",
"",
"SessionStart boots the daemon. PreToolUse is wired twice on purpose: one entry records",
"every call for the timeline, and a second, narrowly matched entry runs the approval gate,",
"because PreToolUse is the only blocking event and only a command hook can return a deny.",
"",
"Placeholders, written in the template as a name wrapped in double braces:",
" APPROVAL_TIMEOUT_SECS derived from approval.timeoutMs in ~/.grok/glance/config.json.",
" HOOK_TOKEN the secret from ~/.grok/glance/hook.secret, for a caller that",
" cannot set a header: the daemon accepts it as a ?k= query",
" parameter too. Nothing below uses it and no http hook can (see",
" above); it stays because the daemon's ?k= path is real. Note the",
" daemon also refuses any /hook/* request carrying x-forwarded-*,",
" so a reverse-proxied transport is out as well. Using this",
" placeholder makes the generated hooks.json secret-bearing, so",
" gen-hooks writes it 0600 and it must not be committed.",
"",
"Do not spell those names with their braces anywhere in this comment block: the comment is",
"copied verbatim into hooks.json, and substitution would happily expand it there too."
],
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"$GROK_PLUGIN_ROOT/bin/glance-up.mjs\"",
"timeout": 20
}
]
}
],
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"",
"timeout": 5
}
]
},
{
"matcher": "^(Bash|Write|Edit|MultiEdit|NotebookEdit)$",
"hooks": [
{
"type": "command",
"command": "node \"$GROK_PLUGIN_ROOT/bin/glance-approve.mjs\"",
"timeout": "{{APPROVAL_TIMEOUT_SECS}}"
}
]
}
],
"PostToolUse": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"PostToolUseFailure": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"PermissionDenied": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"Notification": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"StopFailure": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"SubagentStart": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"SubagentStop": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"PreCompact": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"PostCompact": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"SessionEnd": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
]
}
}
+1
View File
@@ -21,6 +21,7 @@
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "6.0.2", "@vitejs/plugin-react": "6.0.2",
"rolldown": "1.0.3",
"tailwind-variants": "3.3.0", "tailwind-variants": "3.3.0",
"tailwindcss": "4.3.1", "tailwindcss": "4.3.1",
"typescript": "5.6.3", "typescript": "5.6.3",
+5 -4
View File
@@ -8,13 +8,13 @@
"node": ">=20" "node": ">=20"
}, },
"scripts": { "scripts": {
"build": "npm run build:server && npm run build:web && npm run build:hooks", "build": "npm run build:server && npm run build:web",
"build:server": "tsc -p tsconfig.server.json", "build:server": "tsc -p tsconfig.server.json && rolldown server/src/index.ts -o dist/server/index.js -f esm -p node",
"build:web": "tsc -p tsconfig.web.json && vite build", "build:web": "tsc -p tsconfig.web.json && vite build",
"build:hooks": "node scripts/gen-hooks.mjs",
"dev": "vite", "dev": "vite",
"start": "node dist/server/index.js", "start": "node dist/server/index.js",
"glance": "node bin/glance" "glance": "node bin/glance",
"check:dist": "npm run build && test -z \"$(git status --porcelain dist)\""
}, },
"dependencies": { "dependencies": {
"@heroui/react": "3.2.4", "@heroui/react": "3.2.4",
@@ -30,6 +30,7 @@
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "6.0.2", "@vitejs/plugin-react": "6.0.2",
"rolldown": "1.0.3",
"tailwind-variants": "3.3.0", "tailwind-variants": "3.3.0",
"tailwindcss": "4.3.1", "tailwindcss": "4.3.1",
"typescript": "5.6.3", "typescript": "5.6.3",
-141
View File
@@ -1,141 +0,0 @@
#!/usr/bin/env node
/**
* Generate hooks/hooks.json from hooks/hooks.template.json.
*
* Runs as part of `npm run build`, and again on `glance sync-hooks`. Two jobs:
*
* 1. Make sure the /hook/* shared secret exists (mode 0600, in $GLANCE_HOME). The daemon
* requires it; without it the hook scripts are just anonymous POSTs, which is what this
* whole mechanism exists to stop.
* 2. Substitute the placeholders the template declares, so values that are really derived
* from config.json - the approval hook's timeout above all - stop being hand-copied
* constants that drift.
*
* It also refuses to emit a hook that cannot work. An `http` handler pointed at a non-https
* URL is the specific mistake that made every passive hook in this plugin a no-op for a
* while: Grok Build's http runner puts every URL through SSRF validation and rejects any
* other scheme outright (xai-grok-hooks/src/runner/http.rs, `validate_hook_url`).
*/
import fs from "node:fs";
import path from "node:path";
import { PLUGIN_ROOT, approvalHookTimeoutSecs, hookSecret, readConfig } from "../bin/glance-lib.mjs";
const TEMPLATE = path.join(PLUGIN_ROOT, "hooks", "hooks.template.json");
const OUTPUT = path.join(PLUGIN_ROOT, "hooks", "hooks.json");
const cfg = readConfig();
const token = hookSecret({ create: true });
if (!token) {
console.error(`gen-hooks: could not create the hook secret in ${process.env.GLANCE_HOME ?? "~/.grok/glance"}`);
process.exit(1);
}
const timeoutSecs = approvalHookTimeoutSecs(cfg);
let text = fs.readFileSync(TEMPLATE, "utf8");
// The quoted form first, so a JSON-valid template can carry a value that must end up numeric.
text = text
.split(`"{{APPROVAL_TIMEOUT_SECS}}"`)
.join(String(timeoutSecs))
.split("{{APPROVAL_TIMEOUT_SECS}}")
.join(String(timeoutSecs));
const embedsToken = text.includes("{{HOOK_TOKEN}}");
text = text.split("{{HOOK_TOKEN}}").join(token);
// Trust the emitted bytes, not the placeholder: a template that mentions the placeholder in a
// comment would otherwise ship the real secret in a world-readable file. (It did once.)
const tokenIsInOutput = text.includes(token);
if (tokenIsInOutput && !embedsToken) {
console.error("gen-hooks: the hook secret leaked into hooks.json from somewhere unexpected");
process.exit(1);
}
const leftover = text.match(/\{\{[A-Z_]+\}\}/);
if (leftover) {
console.error(`gen-hooks: unknown placeholder ${leftover[0]} in hooks.template.json`);
process.exit(1);
}
/* ------------------------------------------------------------------ validate */
let doc;
try {
doc = JSON.parse(text);
} catch (err) {
console.error(`gen-hooks: template did not produce valid JSON: ${err.message}`);
process.exit(1);
}
const problems = [];
let handlers = 0;
for (const [event, groups] of Object.entries(doc.hooks ?? {})) {
if (!Array.isArray(groups)) {
problems.push(`${event}: expected an array of matcher groups`);
continue;
}
for (const group of groups) {
for (const h of group.hooks ?? []) {
handlers++;
const where = `${event} -> ${h.command ?? h.url ?? "(no target)"}`;
if (h.type !== "command" && h.type !== "http") {
problems.push(`${where}: type must be "command" or "http", got ${JSON.stringify(h.type)}`);
continue;
}
if (typeof h.timeout !== "number" || !Number.isFinite(h.timeout) || h.timeout <= 0) {
problems.push(`${where}: timeout must be a positive number of seconds`);
}
if (h.type === "http") {
// The runner rejects every scheme but https, and treats RFC1918 / CGNAT / link-local
// targets as SSRF. That rules out both loopback-over-http and Tailscale's 100.64/10.
if (!/^https:\/\//.test(h.url ?? "")) {
problems.push(
`${where}: http handlers must use an https:// URL - Grok Build's SSRF check ` +
`rejects anything else, so this hook would never fire`,
);
}
} else {
const script = /bin\/([A-Za-z0-9._-]+)/.exec(h.command ?? "");
if (!script) {
problems.push(`${where}: could not tell which script this command runs`);
} else if (!fs.existsSync(path.join(PLUGIN_ROOT, "bin", script[1]))) {
problems.push(`${where}: bin/${script[1]} does not exist`);
}
}
}
}
}
if (problems.length) {
console.error("gen-hooks: refusing to write hooks.json\n");
for (const p of problems) console.error(` - ${p}`);
process.exit(1);
}
/* --------------------------------------------------------------------- write */
const mode = tokenIsInOutput ? 0o600 : 0o644;
const previous = fs.existsSync(OUTPUT) ? fs.readFileSync(OUTPUT, "utf8") : null;
if (previous === text) {
// Leave the mtime alone: a no-op build should not look like a change.
fs.chmodSync(OUTPUT, mode);
console.log(`hooks.json already current (${handlers} handlers, approval timeout ${timeoutSecs}s)`);
} else {
fs.writeFileSync(OUTPUT, text, { mode });
fs.chmodSync(OUTPUT, mode);
console.log(
`wrote hooks/hooks.json - ${handlers} handlers, approval timeout ${timeoutSecs}s` +
(previous === null ? " (new file)" : ""),
);
}
if (tokenIsInOutput) {
console.warn(
"gen-hooks: hooks.json now contains the hook secret (via the HOOK_TOKEN placeholder); it is " +
"mode 0600 and must not be committed.",
);
}
-2
View File
@@ -7,8 +7,6 @@ export const SESSION_COOKIE = "glance_session";
export const CSRF_HEADER = "x-glance-csrf"; export const CSRF_HEADER = "x-glance-csrf";
/** Shared-secret header presented by the hook scripts on /hook/*. */ /** Shared-secret header presented by the hook scripts on /hook/*. */
export const HOOK_HEADER = "x-glance-hook"; export const HOOK_HEADER = "x-glance-hook";
/** Query-string carrier for the same secret, for hooks that cannot set headers. */
export const HOOK_QUERY_PARAM = "k";
export function parseCookies(header: string | undefined): Record<string, string> { export function parseCookies(header: string | undefined): Record<string, string> {
const out: Record<string, string> = {}; const out: Record<string, string> = {};
+16
View File
@@ -82,6 +82,21 @@ export function ensureHome(): void {
} }
} }
/**
* The longest the daemon may hold a tool call waiting for a tap.
*
* hooks/hooks.json gives the approval hook a fixed 125s timeout, and glance-approve.mjs keeps
* 10s of that for itself. Waiting longer than this would get the script killed mid-wait, and a
* killed script never runs its fail-open path — so a hand-edited config.json is clamped rather
* than believed.
*/
export const APPROVAL_MAX_WAIT_MS = 90_000;
function clampApprovalWait(ms: number): number {
if (!Number.isFinite(ms) || ms <= 0) return DEFAULTS.approval.timeoutMs;
return Math.min(ms, APPROVAL_MAX_WAIT_MS);
}
export function loadConfig(): Config { export function loadConfig(): Config {
ensureHome(); ensureHome();
let stored: Partial<Config> = {}; let stored: Partial<Config> = {};
@@ -95,6 +110,7 @@ export function loadConfig(): Config {
...stored, ...stored,
approval: { ...DEFAULTS.approval, ...(stored.approval ?? {}) }, approval: { ...DEFAULTS.approval, ...(stored.approval ?? {}) },
}; };
merged.approval.timeoutMs = clampApprovalWait(merged.approval.timeoutMs);
if (process.env.GLANCE_PORT) { if (process.env.GLANCE_PORT) {
const p = Number(process.env.GLANCE_PORT); const p = Number(process.env.GLANCE_PORT);
if (Number.isFinite(p)) merged.port = p; if (Number.isFinite(p)) merged.port = p;
+9 -10
View File
@@ -18,7 +18,6 @@ import http from "node:http";
import crypto from "node:crypto"; import crypto from "node:crypto";
import { URL } from "node:url"; import { URL } from "node:url";
import { import {
DEFAULT_PORT,
VERSION, VERSION,
deriveRpId, deriveRpId,
ensureHome, ensureHome,
@@ -32,7 +31,6 @@ import {
CSRF_HEADER, CSRF_HEADER,
EnrollmentCodes, EnrollmentCodes,
HOOK_HEADER, HOOK_HEADER,
HOOK_QUERY_PARAM,
RateLimiter, RateLimiter,
SESSION_COOKIE, SESSION_COOKIE,
buildSessionCookie, buildSessionCookie,
@@ -119,16 +117,16 @@ function isAdmin(req: http.IncomingMessage): boolean {
* loopback, so without a check anyone on the tailnet could POST forged events into the * loopback, so without a check anyone on the tailnet could POST forged events into the
* timeline and answer /hook/approve on your behalf. Two independent barriers: * timeline and answer /hook/approve on your behalf. Two independent barriers:
* *
* 1. A shared secret from $GLANCE_HOME/hook.secret (mode 0600), presented as a header or, * 1. A shared secret from $GLANCE_HOME/hook.secret (mode 0600), sent as a header and
* for hook types that cannot set one, as `?k=`. Compared in constant time. * compared in constant time. Header only: a secret in a query string ends up in logs
* and shell history, and every caller here is a local process that can set one.
* 2. The request must not have been proxied. Tailscale stamps `x-forwarded-*` on anything * 2. The request must not have been proxied. Tailscale stamps `x-forwarded-*` on anything
* it tunnels, so their presence means the caller is not a local process — which no * it tunnels, so their presence means the caller is not a local process — which no
* real hook ever is. This keeps a leaked secret from being usable off-box. * real hook ever is. This keeps a leaked secret from being usable off-box.
*/ */
function isHookCaller(req: http.IncomingMessage, url: URL): boolean { function isHookCaller(req: http.IncomingMessage): boolean {
if (header(req, "x-forwarded-for") || header(req, "x-forwarded-proto")) return false; if (header(req, "x-forwarded-for") || header(req, "x-forwarded-proto")) return false;
const provided = header(req, HOOK_HEADER) ?? url.searchParams.get(HOOK_QUERY_PARAM); return sameSecret(header(req, HOOK_HEADER), hookToken);
return sameSecret(provided, hookToken);
} }
/** /**
@@ -185,7 +183,7 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom
return; return;
} }
// Before reading a body: an unauthenticated caller gets to spend nothing here. // Before reading a body: an unauthenticated caller gets to spend nothing here.
if (!isHookCaller(req, url)) { if (!isHookCaller(req)) {
out.json(403, { error: "hook token required" }); out.json(403, { error: "hook token required" });
return; return;
} }
@@ -565,8 +563,9 @@ server.listen(cfg.port, cfg.host, () => {
console.log(`[glance] state: ${paths.home}`); console.log(`[glance] state: ${paths.home}`);
console.log(`[glance] origin: ${cfg.origin ?? "(none set - see README)"} rpId: ${cfg.rpId ?? "localhost"}`); console.log(`[glance] origin: ${cfg.origin ?? "(none set - see README)"} rpId: ${cfg.rpId ?? "localhost"}`);
console.log(`[glance] accepts assertions from: ${expectedOrigins(cfg).join(", ")}`); console.log(`[glance] accepts assertions from: ${expectedOrigins(cfg).join(", ")}`);
if (!webBuildExists()) console.log("[glance] web app not built yet: npm install && npm run build"); // dist/ is committed, so this only fires for a developer who deleted it. Nothing warns about a
if (cfg.port !== DEFAULT_PORT) console.log(`[glance] note: non-default port, run \`glance sync-hooks\``); // non-default port any more: the hook scripts read config.json themselves.
if (!webBuildExists()) console.log("[glance] web app missing from dist/: npm install && npm run build");
}); });
server.on("error", (err) => { server.on("error", (err) => {
-2
View File
@@ -59,7 +59,6 @@ export interface SessionView {
badge: number; badge: number;
cwd: string; cwd: string;
state: SessionState; state: SessionState;
startedAt: number;
lastActivity: number; lastActivity: number;
lastPrompt?: string; lastPrompt?: string;
/** Tool calls in flight, oldest first — an agent can run several at once. */ /** Tool calls in flight, oldest first — an agent can run several at once. */
@@ -93,7 +92,6 @@ export interface ApprovalSettings {
} }
export interface Snapshot { export interface Snapshot {
now: number;
version: string; version: string;
sessions: SessionView[]; sessions: SessionView[];
events: GlanceEvent[]; events: GlanceEvent[];
-3
View File
@@ -126,7 +126,6 @@ export class GlanceState {
badge: this.nextBadge++, badge: this.nextBadge++,
cwd: payload.workspaceRoot ?? payload.cwd ?? "", cwd: payload.workspaceRoot ?? payload.cwd ?? "",
state: "idle", state: "idle",
startedAt: Date.now(),
lastActivity: Date.now(), lastActivity: Date.now(),
running: [], running: [],
counts: { tools: 0, failures: 0, denials: 0 }, counts: { tools: 0, failures: 0, denials: 0 },
@@ -397,7 +396,6 @@ export class GlanceState {
.sort((a, b) => ATTENTION_RANK[a.state] - ATTENTION_RANK[b.state] || a.badge - b.badge); .sort((a, b) => ATTENTION_RANK[a.state] - ATTENTION_RANK[b.state] || a.badge - b.badge);
return { return {
now,
version: VERSION, version: VERSION,
sessions, sessions,
events: [...this.events].sort((a, b) => b.ts - a.ts || b.id - a.id), events: [...this.events].sort((a, b) => b.ts - a.ts || b.id - a.id),
@@ -458,7 +456,6 @@ function restoreSession(raw: unknown): SessionView | null {
badge: Math.max(1, Math.floor(s.badge)), badge: Math.max(1, Math.floor(s.badge)),
cwd: typeof s.cwd === "string" ? s.cwd : "", cwd: typeof s.cwd === "string" ? s.cwd : "",
state: s.state && s.state in ATTENTION_RANK ? s.state : "idle", state: s.state && s.state in ATTENTION_RANK ? s.state : "idle",
startedAt: typeof s.startedAt === "number" ? s.startedAt : Date.now(),
lastActivity: typeof s.lastActivity === "number" ? s.lastActivity : 0, lastActivity: typeof s.lastActivity === "number" ? s.lastActivity : 0,
lastPrompt: typeof s.lastPrompt === "string" ? s.lastPrompt : undefined, lastPrompt: typeof s.lastPrompt === "string" ? s.lastPrompt : undefined,
// Nothing survives the restart: whatever reports the end of a tool call was talking to // Nothing survives the restart: whatever reports the end of a tool call was talking to
+1 -1
View File
@@ -32,7 +32,7 @@ export function webBuildExists(): boolean {
export function serveStatic(urlPath: string, res: ServerResponse): void { export function serveStatic(urlPath: string, res: ServerResponse): void {
if (!webBuildExists()) { if (!webBuildExists()) {
res.writeHead(503, { ...SECURITY_HEADERS, "content-type": "text/plain; charset=utf-8" }); 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"); res.end("grok-glance: dist/web is missing. Run `npm install && npm run build`.\n");
return; return;
} }
+13 -14
View File
@@ -11,19 +11,19 @@ behind a WebAuthn passkey. It can also pause risky tool calls until someone taps
One daemon covers every session on the machine, so several agents running at once all appear on the One daemon covers every session on the machine, so several agents running at once all appear on the
same dashboard — no per-session setup. same dashboard — no per-session setup.
The daemon is started automatically by the `SessionStart` hook. Everything below is done through The daemon starts itself: every recording hook boots it if it is not already up, so the first
the `glance` CLI at `$GROK_PLUGIN_ROOT/bin/glance`. prompt or tool call of a session brings it back. (Not the `SessionStart` hook — a plugin's
`SessionStart` entry is registered after the event has already fired, so it never runs. If someone
reports an empty `:8791` and no `daemon.log`, that is the reason, and any hook firing will fix it.)
## First check whether it is even built Everything below is done through the `glance` CLI at `$GROK_PLUGIN_ROOT/bin/glance`.
The plugin ships as TypeScript and must be built once: ## No build step
```sh The plugin ships prebuilt: `dist/` is committed, and the daemon is a single dependency-free
cd "$GROK_PLUGIN_ROOT" && npm install && npm run build bundle. A clone is ready to run. Only reach for `npm install && npm run build` in
``` `$GROK_PLUGIN_ROOT` if `glance status` actually says it is not built, which means `dist/` was
deleted from the checkout.
`glance status` prints a "not built" error with this same instruction if it is missing. Do not
attempt to skip the build — the daemon entry point is `dist/server/index.js`.
## The commands ## The commands
@@ -37,7 +37,6 @@ glance set-origin <https-url> # set the public origin and WebAuthn RP ID
glance devices # list enrolled devices glance devices # list enrolled devices
glance revoke <id-prefix> # revoke one glance revoke <id-prefix> # revoke one
glance approval <off|risky|all> # remote approve/deny policy glance approval <off|risky|all> # remote approve/deny policy
glance sync-hooks # regenerate hooks/hooks.json from hooks/hooks.template.json
``` ```
## Watching several agents ## Watching several agents
@@ -99,9 +98,9 @@ and wait for a tap on the phone. Defaults that matter:
- **Dashboard loads but shows nothing** → hooks are not firing. `glance status` warns if the hook - **Dashboard loads but shows nothing** → hooks are not firing. `glance status` warns if the hook
secret in `$GLANCE_HOME/hook.secret` no longer matches the one the daemon loaded (events are being secret in `$GLANCE_HOME/hook.secret` no longer matches the one the daemon loaded (events are being
dropped with a 403); `glance stop && glance up` fixes that. Otherwise check that dropped with a 403); `glance stop && glance up` fixes that. Otherwise check that
`hooks/hooks.json` exists and is registered — it is generated, so `glance sync-hooks` rebuilds it `hooks/hooks.json` exists and that the plugin is registered with Grok Build.
from the template. - **Page says "run npm install && npm run build"** → `dist/web` is missing from the checkout,
- **Page says "run npm install && npm run build"** → the web bundle is missing; build it. which should not happen in a clone. Re-clone, or build it.
- **Only one agent shows up** → the others were started before the plugin was installed, or in an - **Only one agent shows up** → the others were started before the plugin was installed, or in an
environment where the hooks are not registered. A session appears on its next hook event; nothing environment where the hooks are not registered. A session appears on its next hook event; nothing
can be back-filled for one that already ran. can be back-filled for one that already ran.
+2 -4
View File
@@ -5,8 +5,7 @@
"module": "NodeNext", "module": "NodeNext",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"types": ["node"], "types": ["node"],
"outDir": "dist/server", "noEmit": true,
"rootDir": "server/src",
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
@@ -15,8 +14,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"declaration": false, "declaration": false
"sourceMap": true
}, },
"include": ["server/src"] "include": ["server/src"]
} }
-2
View File
@@ -59,7 +59,6 @@ export interface SessionView {
badge: number; badge: number;
cwd: string; cwd: string;
state: SessionState; state: SessionState;
startedAt: number;
lastActivity: number; lastActivity: number;
lastPrompt?: string; lastPrompt?: string;
/** Tool calls in flight, oldest first — an agent can run several at once. */ /** Tool calls in flight, oldest first — an agent can run several at once. */
@@ -93,7 +92,6 @@ export interface ApprovalSettings {
} }
export interface Snapshot { export interface Snapshot {
now: number;
version: string; version: string;
sessions: SessionView[]; sessions: SessionView[];
events: GlanceEvent[]; events: GlanceEvent[];