Compare commits
4
Commits
6d34a17d9d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
966133eeda | ||
|
|
b586323fdb | ||
|
|
3cfa011d0c | ||
|
|
d20eb9255c |
@@ -1,4 +1,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -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": "./" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
Vendored
+23758
File diff suppressed because it is too large
Load Diff
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+15
File diff suppressed because one or more lines are too long
Vendored
+6
@@ -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 |
Vendored
+22
@@ -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>
|
||||||
Vendored
+19
@@ -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
@@ -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": [
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
+1
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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> = {};
|
||||||
|
|||||||
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
Reference in New Issue
Block a user