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>
403 lines
21 KiB
Markdown
403 lines
21 KiB
Markdown
# grok-glance
|
|
|
|
A Grok Build plugin that puts a small web dashboard behind a passkey, so you can glance at what
|
|
your agents are doing from your phone — and tap **approve** or **deny** when one wants to run
|
|
something risky. Several agents at once is the normal case, not an edge case.
|
|
|
|
It is deliberately small: read-only, plus remote approve/deny. It cannot send prompts, edit files,
|
|
or drive a session.
|
|
|
|
```
|
|
┌────────────────────────────┐
|
|
│ ● grok-glance 2 working ·│
|
|
│ 1 waiting │
|
|
├────────────────────────────┤
|
|
│ Waiting on you 62s │
|
|
│ Bash · in ●2 remote-grok │
|
|
│ rm -rf ./dist │
|
|
│ ▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░ │
|
|
│ [ Deny ] [ Approve ] │
|
|
├────────────────────────────┤
|
|
│ Agents 3 live │
|
|
│ All agents 3 │
|
|
│ ●1 remote-grok working │
|
|
│ Read 4s state.ts +2 │
|
|
│ 31 tools now │
|
|
│ ●2 remote-grok waiting │
|
|
│ Bash 1m rm -rf ./dist │
|
|
│ 12 tools 1 failed 4s │
|
|
│ ●3 docs-site error │
|
|
│ build failed 2m │
|
|
├────────────────────────────┤
|
|
│ Activity │
|
|
│ ● Read state.ts ●1 14:22 │
|
|
│ ● Bash npm test ●3 14:21 │
|
|
└────────────────────────────┘
|
|
```
|
|
|
|
## Requirements
|
|
|
|
- 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.
|
|
- 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.
|
|
|
|
## Install
|
|
|
|
Grok Build loads plugins straight out of `~/.grok/plugins/`, so the shortest install is a clone:
|
|
|
|
```sh
|
|
git clone <this repo> ~/.grok/plugins/grok-glance
|
|
```
|
|
|
|
That is the whole thing — no `npm install`, no build. Open `/plugins` in Grok Build and enable
|
|
**grok-glance**. The first hook to fire after that — your next prompt, or the first tool call —
|
|
starts the daemon in the background, and the dashboard is on `http://127.0.0.1:8791`. (Not the
|
|
`SessionStart` hook, which for a plugin never runs; see [Hook wiring](#hook-wiring).)
|
|
|
|
### …or from a marketplace, by URL
|
|
|
|
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:
|
|
`.grok-plugin/marketplace.json` lists exactly one plugin, sourced from `./`, which is the repo
|
|
root the catalog itself lives in. So the repo's git URL is a complete marketplace, with no commit
|
|
SHA to pin (a self-referencing remote source would have to pin the SHA of the commit that contains
|
|
the pin).
|
|
|
|
Add it as a marketplace source from the TUI — `/plugins`, Marketplace tab — or from the CLI:
|
|
|
|
```sh
|
|
grok plugin marketplace add https://your-git-host/you/grok-glance.git
|
|
grok plugin install grok-glance --trust
|
|
```
|
|
|
|
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.
|
|
|
|
### Why there is no build step
|
|
|
|
`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
|
|
|
|
The daemon binds to `127.0.0.1` only and never opens a port to your network. Tailscale Serve
|
|
publishes it inside your tailnet with real TLS:
|
|
|
|
```sh
|
|
tailscale serve --bg 127.0.0.1:8791
|
|
tailscale serve status # note the https://<box>.<tailnet>.ts.net URL
|
|
```
|
|
|
|
Tell grok-glance which origin it is being served on — this is also the WebAuthn relying-party ID,
|
|
so it has to be exact:
|
|
|
|
```sh
|
|
node bin/glance set-origin https://<box>.<tailnet>.ts.net
|
|
```
|
|
|
|
## Enrol the phone
|
|
|
|
```sh
|
|
node bin/glance enroll
|
|
```
|
|
|
|
That prints a URL and an 8-character code, good for 10 minutes, single use:
|
|
|
|
```
|
|
Open this on your phone:
|
|
|
|
https://mybox.tailnet-1234.ts.net/?enroll
|
|
|
|
Enrollment code: K7QM4RTX
|
|
Valid for: 10 minutes (single use)
|
|
```
|
|
|
|
Open the URL on the phone, type the code, tap **Create passkey**, and confirm with Face ID / a
|
|
fingerprint / the device PIN. From then on the phone unlocks the dashboard with that passkey and
|
|
nothing else gets in.
|
|
|
|
Repeat for each device you want. `node bin/glance devices` lists them; `node bin/glance revoke
|
|
<id-prefix>` removes one (and kills its live session immediately).
|
|
|
|
## What the dashboard shows
|
|
|
|
- **Agents** — one row per agent whenever there is more than one: badge, workspace, state, what it
|
|
is running right now, and how long ago it last did anything. Tap one to focus it; tap **All
|
|
agents** to come back. Ended sessions are folded away behind a toggle.
|
|
- **Now** — the focused agent: its workspace, state (working / waiting on you / idle / error /
|
|
ended), the last thing you asked, **every** tool it currently has in flight with a live elapsed
|
|
timer each, and running counts of tools, failures and denials.
|
|
- **Activity** — a merged timeline of prompts, tool calls with durations, failures, permission
|
|
denials, notifications, subagents, compactions, session start/end. Each row is stamped with the
|
|
badge of the agent it came from; focusing an agent filters it down to that one.
|
|
- **Pending approvals** — a card per waiting tool call, with the command, which agent is asking, a
|
|
countdown, and two large buttons.
|
|
|
|
Updates arrive over Server-Sent Events. The server sends whole snapshots rather than deltas, so a
|
|
phone that slept through twenty events still wakes up showing the truth.
|
|
|
|
## Several agents at once
|
|
|
|
Watching four agents on a phone is a different problem from watching one, so a few things are not
|
|
what you might assume:
|
|
|
|
- **Badges, not names.** Labels are workspace basenames, so two agents in the same repo are both
|
|
"remote-grok". The daemon hands each session a small ordinal in arrival order — `●1`, `●2` — and
|
|
the dashboard colours everything belonging to that agent with it: its row, its timeline lines, its
|
|
approval cards. The ordinal survives a daemon restart.
|
|
- **Sorted by who needs you, then fixed.** Rows are ordered *waiting → error → working → idle →
|
|
ended*, and ties break on badge. Within a state an agent never changes position, because a list
|
|
that re-sorts on every event moves the row out from under a thumb already heading for it.
|
|
- **A chatty agent cannot bury the others.** The event ring is global, but eviction always takes
|
|
from whichever session currently holds the most of it. One agent in a tight loop trims itself
|
|
rather than blanking everyone else's history.
|
|
- **Parallel tool calls are all shown.** An agent runs several tools at once; the focused card lists
|
|
them and the overview row shows the first with a `+2`. Durations are matched oldest-first per tool
|
|
name, since hook payloads carry no call id.
|
|
- **A restart does not lose the roster.** Events carry no workspace root, so the session map is
|
|
persisted separately (`sessions.json`) and reloaded on boot — otherwise every agent would come
|
|
back nameless until it happened to speak again. In-flight tools are deliberately *not* restored:
|
|
they belonged to a process that no longer exists.
|
|
|
|
`glance status` shows the same breakdown from a terminal:
|
|
|
|
```
|
|
sessions : 4 (1 waiting on you, 1 error, 2 working)
|
|
```
|
|
|
|
## Remote approve / deny
|
|
|
|
Off by default. Turn it on from the phone's settings panel, or:
|
|
|
|
```sh
|
|
node bin/glance approval risky # Bash, Write, Edit, MultiEdit, NotebookEdit
|
|
node bin/glance approval all # every tool call — noisy
|
|
node bin/glance approval off
|
|
```
|
|
|
|
Defaults worth knowing:
|
|
|
|
| Behaviour | Default | Changeable from the phone | Why |
|
|
|---|---|---|---|
|
|
| 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. |
|
|
| 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. |
|
|
|
|
**This is a convenience gate, not a security boundary.** Every failure path is fail-open: daemon
|
|
down, hook timeout, malformed response, port mismatch, a hook secret the daemon no longer
|
|
recognises — the tool call proceeds. If you need calls actually blocked, use Grok Build's own
|
|
permission settings.
|
|
|
|
## CLI
|
|
|
|
`bin/glance` is plain Node with no dependencies. Run it as `node bin/glance <command>`.
|
|
|
|
| Command | What it does |
|
|
|---|---|
|
|
| `status` | Running? On which origin, with how many devices? |
|
|
| `up` | Start the daemon in the background |
|
|
| `serve` | Run it in the foreground (for debugging) |
|
|
| `stop` | Stop it |
|
|
| `logs` | Last 60 lines of the daemon log |
|
|
| `enroll` | Mint a one-time enrolment code and URL |
|
|
| `set-origin <url>` | Set the public https origin and RP ID |
|
|
| `devices` | List enrolled devices |
|
|
| `revoke <id-prefix>` | Revoke a device |
|
|
| `approval <off\|risky\|all>` | Set the approval policy |
|
|
|
|
## Files and configuration
|
|
|
|
Everything lives in `~/.grok/glance` (mode 0700), or `$GLANCE_HOME` if you set it:
|
|
|
|
| File | Contents |
|
|
|---|---|
|
|
| `config.json` | Port, bind host, public origin, RP ID, approval settings |
|
|
| `credentials.json` | Enrolled passkeys — credential IDs, public keys, counters. No secrets of yours. |
|
|
| `auth-sessions.json` | Live dashboard sessions, stored as SHA-256 hashes of the cookie tokens |
|
|
| `secret.key` | 32-byte HMAC key used to sign session cookies |
|
|
| `admin.token` | Rotated every daemon start; authenticates the CLI |
|
|
| `hook.secret` | Shared secret the hook scripts present on `/hook/*`. Created once, mode 0600, never rotated — a rotation mid-session would 403 whatever was already in flight. Delete it and the daemon mints a new one on its next start; hooks then need that restart to agree again, which `glance status` will tell you about. |
|
|
| `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. |
|
|
| `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
|
|
instance without touching your real one:
|
|
|
|
| Variable | Effect |
|
|
|---|---|
|
|
| `GLANCE_HOME` | Where all of the above lives. Read by the daemon, the CLI, and the hook scripts. |
|
|
| `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 |
|
|
|
|
Nothing in `hooks/hooks.json` is machine-specific: the hook scripts read `config.json` themselves,
|
|
so changing the port or `approval.timeoutMs` needs nothing but a daemon restart.
|
|
|
|
## Security notes
|
|
|
|
**Network surface.** The daemon listens on `127.0.0.1:8791` and makes no outbound connections of
|
|
its own. Three classes of caller:
|
|
|
|
| Path | Caller | Authentication |
|
|
|---|---|---|
|
|
| `/hook/record`, `/hook/approve` | Grok Build's hooks, from this machine | shared secret from `hook.secret`, plus a refusal of any proxied request |
|
|
| `/api/*`, `/events` | the dashboard | passkey session cookie + CSRF header |
|
|
| `/local/*` | the `glance` CLI | rotating admin token from `admin.token` |
|
|
|
|
None of the three trusts the source address, because `tailscale serve` proxies remote traffic to
|
|
`127.0.0.1` — the daemon cannot tell a local caller from a tunnelled one by address alone. Without
|
|
the hook secret, anyone who could reach the tunnel could forge timeline events and answer approval
|
|
prompts on your behalf; `/hook/*` compares the secret in constant time before it reads a body, and
|
|
additionally refuses any request carrying `x-forwarded-for` or `x-forwarded-proto`, which a local
|
|
hook process never sends and a tunnelled caller always does.
|
|
|
|
That refusal costs nothing, because no legitimate hook traffic comes through the tunnel: hooks are
|
|
local processes talking to loopback. It applies **only** to `/hook/*` — the dashboard arrives
|
|
through `tailscale serve` with those headers on every request and is unaffected.
|
|
|
|
**Session cookie** is `HttpOnly`, `SameSite=Strict`, HMAC-signed, and `Secure` whenever the request
|
|
arrived over https. Only a SHA-256 hash of the token is stored, compared in constant time. Sessions
|
|
last 30 days; revoking a device drops its session at once.
|
|
|
|
**Cross-site defence.** Every POST must carry exactly `content-type: application/json`, which is
|
|
not a CORS-safelisted type, so a hostile page cannot post here without a preflight that is never
|
|
answered. `/api/*` additionally requires an `x-glance-csrf` header. The pages themselves are served
|
|
with a strict CSP (`script-src 'self'`, no framing, no form actions) and `no-store`.
|
|
|
|
**User verification is required**, for both enrolment and sign-in: the phone asks for a biometric
|
|
or PIN every time, so a stolen unlocked phone is not automatically a way in. Passkeys are created
|
|
as resident keys, so the phone offers the right one without you typing a username.
|
|
|
|
**Rate limits.** Authentication attempts are capped (40 per 5 minutes), enrolment attempts more
|
|
tightly (12 per 5 minutes, and a code burns itself after 5 wrong guesses). The limiter is keyed
|
|
globally on purpose: behind a tunnel every request arrives from `127.0.0.1`, so per-IP buckets
|
|
would be one bucket wearing a hat.
|
|
|
|
**What crosses the wire.** Tool names, truncated arguments, prompt first lines, file paths,
|
|
durations, and exit statuses — a summary, not a transcript. Known secret shapes are redacted before
|
|
storage: `sk-`/`rk-`/`pk-` keys, `xai-` keys, GitHub and Slack tokens, AWS access key IDs, JWTs,
|
|
PEM private-key blocks, `Authorization:`/`Bearer` headers, and `password`/`secret`/`token`/`api_key`
|
|
assignments. This is a filter, not a guarantee — a secret in an unusual shape will show up in the
|
|
timeline. Treat the dashboard as being as sensitive as your terminal.
|
|
|
|
**Threat model.** grok-glance assumes the machine it runs on is trusted. It protects against
|
|
someone else on your tailnet, or a browser tab you left open, reaching the dashboard. It does not
|
|
protect against a local attacker who can read `~/.grok/glance` — with `admin.token` they can enrol
|
|
their own device.
|
|
|
|
### Why Tailscale, and not just the LAN IP?
|
|
|
|
WebAuthn will not run outside a secure context, and — separately — a bare IP address cannot be a
|
|
relying-party ID. So `http://192.168.1.20:8791` can never hold a passkey, no matter what the
|
|
browser is willing to render. You need a hostname with valid TLS. Tailscale Serve gives you one for
|
|
free, with the tunnel closed to everything outside your tailnet.
|
|
|
|
Any other route to a real https hostname works too: set `origin` in `config.json` (or use
|
|
`set-origin`) to whatever your reverse proxy terminates on. If you put grok-glance behind a proxy
|
|
reachable from the public internet, the passkey gate is the only thing standing in front of it.
|
|
|
|
Changing the origin changes the RP ID, and **passkeys are bound to the RP ID** — existing devices
|
|
stop working and must be enrolled again.
|
|
|
|
## Hook wiring
|
|
|
|
`hooks/hooks.json` subscribes to all 14 lifecycle events and is a plain checked-in file — edit it
|
|
directly.
|
|
|
|
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**
|
|
and refuses the resolved address if it is private, link-local or CGNAT
|
|
(`xai-grok-hooks/src/runner/http.rs`, `validate_hook_url` + `is_blocked_ip`). Plain http on loopback
|
|
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
|
|
request header but `Content-Type`, with no configuration surface for one, so such a hook could not
|
|
authenticate itself even if it could connect. This plugin shipped `http` hooks for a while, and the
|
|
result was 13 passive hooks failing validation silently on every event.
|
|
|
|
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
|
|
`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:
|
|
|
|
- `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
|
|
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
|
|
decision; keeping the match narrow means the gate's cost is paid only for calls that could
|
|
actually need a tap. Its `timeout` is a fixed 125s, and the daemon clamps its own wait to 90s
|
|
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
|
|
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
|
|
|
|
Not oversights — decisions:
|
|
|
|
- **Sending prompts or steering the session.** Hooks cannot inject input, so a phone-to-agent
|
|
channel would need a second transport and a much larger threat model.
|
|
- **Full transcripts and tool output.** Summaries only. Streaming assistant text through a phone
|
|
would mean shipping your codebase through it.
|
|
- **Push notifications.** Needs a VAPID key, a service worker, and a subscription store, for a
|
|
dashboard you open on purpose.
|
|
- **Multi-user accounts and roles.** One user, several devices.
|
|
- **Diff views, file browsing, cost/token charts, log search, session resume.** All out of scope
|
|
for a glance.
|
|
- **Editing the risky-tool pattern from the phone.** Shown but not editable: a typo'd regex there
|
|
would silently change what gets gated. Edit `config.json` instead.
|
|
|
|
## Licence
|
|
|
|
MIT.
|