first commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "grok-glance",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"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.",
|
||||||
|
"author": {
|
||||||
|
"name": "grok-glance"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"grok-glance",
|
||||||
|
"glance dashboard",
|
||||||
|
"webauthn passkey",
|
||||||
|
"remote approval",
|
||||||
|
"session monitor"
|
||||||
|
],
|
||||||
|
"hooks": "hooks/hooks.json"
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
# grok-glance
|
||||||
|
|
||||||
|
A Grok Build plugin that puts a small web dashboard behind a passkey, so you can glance at what
|
||||||
|
an agent is doing from your phone — and tap **approve** or **deny** when it wants to run something
|
||||||
|
risky.
|
||||||
|
|
||||||
|
It is deliberately small: read-only, plus remote approve/deny. It cannot send prompts, edit files,
|
||||||
|
or drive a session.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────┐
|
||||||
|
│ ● grok-glance 2 sessions│
|
||||||
|
├────────────────────────────┤
|
||||||
|
│ Waiting on you 62s │
|
||||||
|
│ Bash · in remote-grok │
|
||||||
|
│ rm -rf ./dist │
|
||||||
|
│ ▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░ │
|
||||||
|
│ [ Deny ] [ Approve ] │
|
||||||
|
├────────────────────────────┤
|
||||||
|
│ remote-grok working │
|
||||||
|
│ ~/src/remote-grok │
|
||||||
|
│ Last asked: fix the flaky │
|
||||||
|
│ ⟳ Read 4s │
|
||||||
|
│ server/src/state.ts │
|
||||||
|
│ 12 tools 0 failed 0 ✗ │
|
||||||
|
├────────────────────────────┤
|
||||||
|
│ Activity │
|
||||||
|
│ ● Read state.ts 14:22 │
|
||||||
|
│ ● Bash npm test 14:21 │
|
||||||
|
└────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Node.js 20 or newer, and npm.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone <this repo> 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;
|
||||||
|
the daemon serves the dashboard itself.
|
||||||
|
|
||||||
|
Then register the directory with Grok Build. Plugins are installed from a marketplace catalog, so
|
||||||
|
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
|
||||||
|
{
|
||||||
|
"name": "local",
|
||||||
|
"description": "Local plugins",
|
||||||
|
"owner": { "name": "me" },
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "grok-glance",
|
||||||
|
"description": "Passkey-guarded phone dashboard for Grok Build.",
|
||||||
|
"category": "monitoring",
|
||||||
|
"source": { "type": "local", "path": "./grok-glance" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
…then add that marketplace and install `grok-glance` from Grok Build's `/plugin` interface.
|
||||||
|
|
||||||
|
Once installed, the daemon starts by itself: the `SessionStart` hook boots it in the background on
|
||||||
|
the first session after installation.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- **Now** — the workspace, its state (working / waiting on you / idle / error / ended), the last
|
||||||
|
thing you asked, the tool currently running with a live elapsed timer, and running counts of
|
||||||
|
tools, failures and denials.
|
||||||
|
- **Sessions** — one row per live session when there is more than one; tap to filter.
|
||||||
|
- **Activity** — a timeline of prompts, tool calls with durations, failures, permission denials,
|
||||||
|
notifications, subagents, compactions, session start/end.
|
||||||
|
- **Pending approvals** — a card per waiting tool call, with the command, 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.
|
||||||
|
|
||||||
|
## 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` | The hook's own timeout is 125s; raising this past that would just make the hook give up first. |
|
||||||
|
| 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 — 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 |
|
||||||
|
| `sync-hooks` | Rewrite hook URLs after changing the port |
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
| `events.jsonl` | Append-only event log, one JSON object per line, rotated at 5 MB |
|
||||||
|
| `daemon.log` | Daemon stdout/stderr |
|
||||||
|
|
||||||
|
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 |
|
||||||
|
|
||||||
|
To change the port, edit `config.json`, then run `node bin/glance sync-hooks` so the hook URLs in
|
||||||
|
`hooks/hooks.json` match. Restart the daemon afterwards.
|
||||||
|
|
||||||
|
## 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 | none — loopback only |
|
||||||
|
| `/api/*`, `/events` | the dashboard | passkey session cookie + CSRF header |
|
||||||
|
| `/local/*` | the `glance` CLI | rotating admin token from `admin.token` |
|
||||||
|
|
||||||
|
`/local/*` is token-gated rather than "is it from localhost", 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.
|
||||||
|
|
||||||
|
**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. Passive events use `type: "http"`: they
|
||||||
|
POST straight into the daemon with no process spawn, so they cost close to nothing per tool call
|
||||||
|
and quietly do nothing when the daemon is down.
|
||||||
|
|
||||||
|
Two exceptions:
|
||||||
|
|
||||||
|
- `SessionStart` runs `bin/glance-up.mjs`, which is what boots the daemon.
|
||||||
|
- `PreToolUse` is wired **twice** — an `http` entry that records every call for the timeline, and a
|
||||||
|
`command` 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, so the gate has to be a spawned process; keeping the
|
||||||
|
match narrow means the cost is paid only for calls that could actually need a tap.
|
||||||
|
|
||||||
|
The hook scripts use nothing but the Node standard library and always exit 0 unless they are
|
||||||
|
deliberately denying.
|
||||||
|
|
||||||
|
## 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.
|
||||||
Executable
+247
@@ -0,0 +1,247 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* grok-glance CLI.
|
||||||
|
*
|
||||||
|
* Privileged operations (minting enrollment codes, revoking devices) are authenticated with
|
||||||
|
* a local admin token read from $GLANCE_HOME/admin.token, not by "is this request from
|
||||||
|
* localhost". That distinction matters: `tailscale serve` proxies remote traffic to
|
||||||
|
* 127.0.0.1, so the daemon cannot tell a local caller from a tunnelled one by address alone.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import {
|
||||||
|
PLUGIN_ROOT,
|
||||||
|
baseUrl,
|
||||||
|
glanceHome,
|
||||||
|
isDaemonUp,
|
||||||
|
readConfig,
|
||||||
|
sleep,
|
||||||
|
} from "./glance-lib.mjs";
|
||||||
|
|
||||||
|
const SERVER_ENTRY = path.join(PLUGIN_ROOT, "dist", "server", "index.js");
|
||||||
|
const cfg = readConfig();
|
||||||
|
const cmd = process.argv[2] ?? "status";
|
||||||
|
const args = process.argv.slice(3);
|
||||||
|
|
||||||
|
function adminToken() {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(path.join(glanceHome(), "admin.token"), "utf8").trim();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(pathname, { method = "GET", body, admin = false } = {}) {
|
||||||
|
const headers = { "content-type": "application/json" };
|
||||||
|
if (admin) {
|
||||||
|
const token = adminToken();
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("no admin token found - is the daemon running? try `glance up`");
|
||||||
|
}
|
||||||
|
headers["x-glance-admin"] = token;
|
||||||
|
}
|
||||||
|
const res = await fetch(`${baseUrl(cfg)}${pathname}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(data?.error ?? `${res.status} ${res.statusText}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireBuild() {
|
||||||
|
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`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureUp() {
|
||||||
|
if (await isDaemonUp(cfg)) return true;
|
||||||
|
requireBuild();
|
||||||
|
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],
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (cmd) {
|
||||||
|
case "serve":
|
||||||
|
case "start": {
|
||||||
|
requireBuild();
|
||||||
|
await import(SERVER_ENTRY);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "up": {
|
||||||
|
if (await ensureUp()) console.log(`grok-glance running on ${baseUrl(cfg)}`);
|
||||||
|
else process.exit(1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "stop": {
|
||||||
|
if (!(await isDaemonUp(cfg))) {
|
||||||
|
console.log("not running");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await api("/local/shutdown", { method: "POST", admin: true });
|
||||||
|
console.log("stopped");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "status": {
|
||||||
|
if (!(await isDaemonUp(cfg))) {
|
||||||
|
console.log(`grok-glance: not running (port ${cfg.port})`);
|
||||||
|
console.log("start it with: glance up");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const s = await api("/local/status", { admin: true });
|
||||||
|
console.log(`grok-glance ${s.version} on ${baseUrl(cfg)}`);
|
||||||
|
console.log(` public origin : ${s.origin ?? "(not configured - see README)"}`);
|
||||||
|
console.log(` rp id : ${s.rpId ?? "(not configured)"}`);
|
||||||
|
console.log(` devices : ${s.devices}`);
|
||||||
|
console.log(` approval mode : ${s.approval.mode}`);
|
||||||
|
console.log(` watchers : ${s.watchers}`);
|
||||||
|
console.log(` sessions : ${s.sessions}`);
|
||||||
|
console.log(` events kept : ${s.events}`);
|
||||||
|
if (s.devices === 0) console.log("\nNo device enrolled yet. Run: glance enroll");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "enroll": {
|
||||||
|
if (!(await ensureUp())) process.exit(1);
|
||||||
|
const out = await api("/local/enroll", { method: "POST", admin: true });
|
||||||
|
console.log("\n Open this on your phone:\n");
|
||||||
|
console.log(` ${out.url}\n`);
|
||||||
|
console.log(` Enrollment code: ${out.code}`);
|
||||||
|
console.log(` Valid for: ${Math.round(out.expiresInMs / 60000)} minutes (single use)\n`);
|
||||||
|
if (!out.originConfigured) {
|
||||||
|
console.log(" Note: no public origin configured yet, so the URL above is localhost.");
|
||||||
|
console.log(" Set one up first (see README), e.g.:\n");
|
||||||
|
console.log(" tailscale serve --bg 127.0.0.1:" + cfg.port);
|
||||||
|
console.log(" glance set-origin https://<your-box>.<tailnet>.ts.net\n");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "set-origin": {
|
||||||
|
const origin = args[0];
|
||||||
|
if (!origin) {
|
||||||
|
console.error("usage: glance set-origin https://your-box.tailnet.ts.net");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!(await ensureUp())) process.exit(1);
|
||||||
|
const out = await api("/local/origin", { method: "POST", admin: true, body: { origin } });
|
||||||
|
console.log(`origin : ${out.origin}`);
|
||||||
|
console.log(`rp id : ${out.rpId}`);
|
||||||
|
console.log("\nEnrolled devices are bound to the rp id. Changing it invalidates them.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "devices": {
|
||||||
|
if (!(await isDaemonUp(cfg))) {
|
||||||
|
console.error("not running");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const out = await api("/local/devices", { admin: true });
|
||||||
|
if (!out.devices.length) {
|
||||||
|
console.log("no devices enrolled - run: glance enroll");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for (const d of out.devices) {
|
||||||
|
console.log(`${d.id.slice(0, 16)}… ${d.label.padEnd(24)} added ${new Date(d.createdAt).toISOString().slice(0, 10)} last seen ${d.lastUsedAt ? new Date(d.lastUsedAt).toISOString().slice(0, 16).replace("T", " ") : "never"}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "revoke": {
|
||||||
|
if (!args[0]) {
|
||||||
|
console.error("usage: glance revoke <device-id-prefix>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const out = await api("/local/devices/revoke", {
|
||||||
|
method: "POST",
|
||||||
|
admin: true,
|
||||||
|
body: { idPrefix: args[0] },
|
||||||
|
});
|
||||||
|
console.log(`revoked ${out.revoked} device(s)`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "approval": {
|
||||||
|
const mode = args[0];
|
||||||
|
if (!["off", "risky", "all"].includes(mode)) {
|
||||||
|
console.error("usage: glance approval <off|risky|all>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const out = await api("/local/approval", { method: "POST", admin: true, body: { mode } });
|
||||||
|
console.log(`approval mode: ${out.mode}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "sync-hooks": {
|
||||||
|
const file = path.join(PLUGIN_ROOT, "hooks", "hooks.json");
|
||||||
|
const doc = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||||
|
let changed = 0;
|
||||||
|
for (const groups of Object.values(doc.hooks ?? {})) {
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const h of group.hooks ?? []) {
|
||||||
|
if (h.type === "http" && typeof h.url === "string") {
|
||||||
|
const next = h.url.replace(/127\.0\.0\.1:\d+/, `127.0.0.1:${cfg.port}`);
|
||||||
|
if (next !== h.url) changed++;
|
||||||
|
h.url = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
|
||||||
|
console.log(`rewrote ${changed} hook url(s) to port ${cfg.port}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "logs": {
|
||||||
|
const file = path.join(glanceHome(), "daemon.log");
|
||||||
|
if (!fs.existsSync(file)) {
|
||||||
|
console.log("no log yet");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
process.stdout.write(fs.readFileSync(file, "utf8").split("\n").slice(-60).join("\n") + "\n");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log(`grok-glance - glance at Grok Build from your phone
|
||||||
|
|
||||||
|
glance up start the daemon in the background
|
||||||
|
glance serve run it in the foreground
|
||||||
|
glance stop stop it
|
||||||
|
glance status show what is running
|
||||||
|
glance enroll mint a one-time code to enrol a phone
|
||||||
|
glance set-origin <url> set the public https origin (and webauthn rp id)
|
||||||
|
glance devices list enrolled devices
|
||||||
|
glance revoke <id-prefix> revoke a device
|
||||||
|
glance approval <off|risky|all> remote approval policy
|
||||||
|
glance sync-hooks rewrite hook urls after a port change
|
||||||
|
glance logs tail the daemon log
|
||||||
|
`);
|
||||||
|
}
|
||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* PreToolUse hook: the remote approval gate.
|
||||||
|
*
|
||||||
|
* Asks the daemon what to do with this tool call. The daemon holds the request open while
|
||||||
|
* your phone decides, then answers allow/deny. Every failure path here is fail-open —
|
||||||
|
* a daemon that is down, slow, or confused must not be able to block your agent.
|
||||||
|
*
|
||||||
|
* Denying is the only outcome that changes behaviour, and approving only lets through a
|
||||||
|
* call Grok was already about to make. This hook can never introduce a new command.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { baseUrl, envEnvelope, postJson, readConfig, readStdinJson } from "./glance-lib.mjs";
|
||||||
|
|
||||||
|
function allow() {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deny(reason) {
|
||||||
|
// Belt and braces: the documented deny signals are a JSON decision on stdout *and*
|
||||||
|
// exit code 2. We emit both so a change in precedence cannot silently allow.
|
||||||
|
process.stdout.write(
|
||||||
|
JSON.stringify({ decision: "deny", reason: reason || "Denied from grok-glance" }),
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = envEnvelope(await readStdinJson());
|
||||||
|
const cfg = readConfig();
|
||||||
|
|
||||||
|
// Stay inside the hook timeout declared in hooks/hooks.json (125s).
|
||||||
|
const waitMs = Math.min(115_000, Number(cfg.approval?.timeoutMs ?? 90_000) + 15_000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await postJson(`${baseUrl(cfg)}/hook/approve`, payload, waitMs);
|
||||||
|
if (data && data.decision === "deny") deny(data.reason);
|
||||||
|
allow();
|
||||||
|
} catch {
|
||||||
|
allow();
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Shared helpers for the grok-glance hook scripts and CLI.
|
||||||
|
*
|
||||||
|
* Deliberately dependency-free and stdlib-only: these run on the critical path of
|
||||||
|
* every Grok Build tool call, so they must start fast and never wedge a session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
export const PLUGIN_ROOT = path.resolve(fileURLToPath(import.meta.url), "../..");
|
||||||
|
|
||||||
|
export const DEFAULT_PORT = 8791;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State lives in one fixed place so that hooks (which get GROK_PLUGIN_DATA) and the
|
||||||
|
* CLI (which does not) always agree on where config, credentials and events are.
|
||||||
|
*/
|
||||||
|
export function glanceHome() {
|
||||||
|
if (process.env.GLANCE_HOME) return path.resolve(process.env.GLANCE_HOME);
|
||||||
|
return path.join(os.homedir(), ".grok", "glance");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readConfig() {
|
||||||
|
const file = path.join(glanceHome(), "config.json");
|
||||||
|
let raw = {};
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||||
|
} catch {
|
||||||
|
// No config yet, or unreadable: defaults are always usable.
|
||||||
|
}
|
||||||
|
const port = Number(process.env.GLANCE_PORT ?? raw.port ?? DEFAULT_PORT);
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
port: Number.isFinite(port) ? port : DEFAULT_PORT,
|
||||||
|
host: raw.host ?? "127.0.0.1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function baseUrl(cfg = readConfig()) {
|
||||||
|
return `http://127.0.0.1:${cfg.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the hook payload that Grok Build writes to stdin. Returns {} if there is none. */
|
||||||
|
export async function readStdinJson() {
|
||||||
|
if (process.stdin.isTTY) return {};
|
||||||
|
const chunks = [];
|
||||||
|
try {
|
||||||
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const text = Buffer.concat(chunks).toString("utf8").trim();
|
||||||
|
if (!text) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grok Build also passes the event in the environment. We merge it in so a payload that
|
||||||
|
* is missing fields (or absent entirely) still produces a usable event.
|
||||||
|
*/
|
||||||
|
export function envEnvelope(payload) {
|
||||||
|
return {
|
||||||
|
hookEventName: payload.hookEventName ?? process.env.GROK_HOOK_EVENT ?? "Unknown",
|
||||||
|
sessionId: payload.sessionId ?? process.env.GROK_SESSION_ID ?? "unknown",
|
||||||
|
workspaceRoot:
|
||||||
|
payload.workspaceRoot ?? process.env.GROK_WORKSPACE_ROOT ?? payload.cwd ?? process.cwd(),
|
||||||
|
cwd: payload.cwd ?? process.cwd(),
|
||||||
|
hookName: process.env.GROK_HOOK_NAME ?? undefined,
|
||||||
|
...payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postJson(url, body, timeoutMs) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text) return { status: res.status, data: null };
|
||||||
|
try {
|
||||||
|
return { status: res.status, data: JSON.parse(text) };
|
||||||
|
} catch {
|
||||||
|
return { status: res.status, data: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isDaemonUp(cfg = readConfig(), timeoutMs = 400) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl(cfg)}/healthz`, {
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
Executable
+68
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* dashboard must never be the reason a Grok Build session fails to start.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import {
|
||||||
|
PLUGIN_ROOT,
|
||||||
|
baseUrl,
|
||||||
|
envEnvelope,
|
||||||
|
glanceHome,
|
||||||
|
isDaemonUp,
|
||||||
|
postJson,
|
||||||
|
readConfig,
|
||||||
|
readStdinJson,
|
||||||
|
sleep,
|
||||||
|
} 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 cfg = readConfig();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const up = await ensureDaemon(cfg);
|
||||||
|
if (up) {
|
||||||
|
await postJson(`${baseUrl(cfg)}/hook/record`, payload, 2500);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fail open, always.
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
description: Set up or control the grok-glance phone dashboard (status, enrol a device, approval policy)
|
||||||
|
argument-hint: [status | up | stop | enroll | set-origin <url> | devices | revoke <id> | approval off|risky|all | logs]
|
||||||
|
---
|
||||||
|
|
||||||
|
Run the grok-glance CLI and report the result plainly.
|
||||||
|
|
||||||
|
The CLI lives at `$GROK_PLUGIN_ROOT/bin/glance`. The user asked for: **$ARGUMENTS**
|
||||||
|
|
||||||
|
If no argument was given, treat it as `status`.
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
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`
|
||||||
|
(this takes 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
|
||||||
|
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:
|
||||||
|
`tailscale serve --bg 127.0.0.1:8791`, then
|
||||||
|
`node "$GROK_PLUGIN_ROOT/bin/glance" set-origin https://<box>.<tailnet>.ts.net`.
|
||||||
|
|
||||||
|
For anything beyond running the command — troubleshooting a failed passkey prompt, explaining the
|
||||||
|
approval modes, or a first-time setup — read the `glance` skill and follow it.
|
||||||
|
|
||||||
|
Do not change the approval policy unless that is what was asked. Warn before running `set-origin`
|
||||||
|
on a setup that already has devices enrolled: the RP ID changes and existing passkeys stop working.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
{
|
||||||
|
"_comment": [
|
||||||
|
"grok-glance hook wiring.",
|
||||||
|
"Passive events use type=http: they POST straight into the daemon with no process spawn,",
|
||||||
|
"so they cost ~nothing per tool call and fail open if the daemon is not running.",
|
||||||
|
"SessionStart uses type=command because it is what boots the daemon.",
|
||||||
|
"PreToolUse is wired twice on purpose: an http entry records every tool call for the",
|
||||||
|
"timeline, and a command entry gates only risky tools, because only a command hook has a",
|
||||||
|
"documented way to return a deny decision.",
|
||||||
|
"If you change the port in ~/.grok/glance/config.json, run `glance sync-hooks` to rewrite",
|
||||||
|
"the URLs below, or edit them by hand."
|
||||||
|
],
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"$GROK_PLUGIN_ROOT/bin/glance-up.mjs\"",
|
||||||
|
"timeout": 20
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://127.0.0.1:8791/hook/record",
|
||||||
|
"timeout": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "^(Bash|Write|Edit|MultiEdit|NotebookEdit)$",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"$GROK_PLUGIN_ROOT/bin/glance-approve.mjs\"",
|
||||||
|
"timeout": 125
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUseFailure": [
|
||||||
|
{
|
||||||
|
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PermissionDenied": [
|
||||||
|
{
|
||||||
|
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Notification": [
|
||||||
|
{
|
||||||
|
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"StopFailure": [
|
||||||
|
{
|
||||||
|
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SubagentStart": [
|
||||||
|
{
|
||||||
|
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SubagentStop": [
|
||||||
|
{
|
||||||
|
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostCompact": [
|
||||||
|
{
|
||||||
|
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionEnd": [
|
||||||
|
{
|
||||||
|
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2283
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "grok-glance",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Glance at what Grok Build is doing, from your phone, behind a passkey.",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "npm run build:server && npm run build:web",
|
||||||
|
"build:server": "tsc -p tsconfig.server.json",
|
||||||
|
"build:web": "tsc -p tsconfig.web.json && vite build",
|
||||||
|
"dev": "vite",
|
||||||
|
"start": "node dist/server/index.js",
|
||||||
|
"glance": "node bin/glance"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@heroui/react": "3.2.4",
|
||||||
|
"@heroui/styles": "3.2.4",
|
||||||
|
"@simplewebauthn/browser": "13.3.0",
|
||||||
|
"@simplewebauthn/server": "13.3.2",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "4.3.1",
|
||||||
|
"@types/node": "22.12.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
|
"tailwind-variants": "3.3.0",
|
||||||
|
"tailwindcss": "4.3.1",
|
||||||
|
"typescript": "5.6.3",
|
||||||
|
"vite": "8.0.16"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import type { Config } from "./config.js";
|
||||||
|
import type { GlanceState, HookPayload } from "./state.js";
|
||||||
|
import { summarizeTool } from "./summarize.js";
|
||||||
|
import type { PendingApproval } from "./protocol.js";
|
||||||
|
|
||||||
|
export interface Decision {
|
||||||
|
decision: "allow" | "deny";
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Waiter {
|
||||||
|
approval: PendingApproval;
|
||||||
|
settle: (decision: Decision) => void;
|
||||||
|
timer: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds a PreToolUse hook open while your phone decides.
|
||||||
|
*
|
||||||
|
* Every path that is not an explicit tap is designed to get out of the way: approval off,
|
||||||
|
* tool not risky, nobody watching, or the request timing out all resolve immediately so a
|
||||||
|
* dashboard can never become the reason your agent stalls.
|
||||||
|
*/
|
||||||
|
export class ApprovalBroker {
|
||||||
|
private readonly waiters = new Map<string, Waiter>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly cfg: Config,
|
||||||
|
private readonly state: GlanceState,
|
||||||
|
/** Is at least one browser currently streaming events? */
|
||||||
|
private readonly hasWatcher: () => boolean,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private gates(toolName: string): boolean {
|
||||||
|
const { mode, riskyPattern } = this.cfg.approval;
|
||||||
|
if (mode === "off") return false;
|
||||||
|
if (mode === "all") return true;
|
||||||
|
try {
|
||||||
|
return new RegExp(riskyPattern).test(toolName);
|
||||||
|
} catch {
|
||||||
|
// A bad pattern should not silently gate everything.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pending(): PendingApproval[] {
|
||||||
|
return [...this.waiters.values()]
|
||||||
|
.map((w) => w.approval)
|
||||||
|
.sort((a, b) => a.createdAt - b.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(payload: HookPayload): Promise<Decision> {
|
||||||
|
const tool = typeof payload.toolName === "string" ? payload.toolName : "tool";
|
||||||
|
if (!this.gates(tool)) return { decision: "allow" };
|
||||||
|
|
||||||
|
if (this.cfg.approval.requireWatcher && !this.hasWatcher()) {
|
||||||
|
return { decision: "allow" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = payload.sessionId ?? "unknown";
|
||||||
|
const summary = summarizeTool(tool, payload.toolInput);
|
||||||
|
const now = Date.now();
|
||||||
|
const approval: PendingApproval = {
|
||||||
|
id: crypto.randomBytes(9).toString("base64url"),
|
||||||
|
sessionId,
|
||||||
|
sessionLabel: this.state.sessionLabel(sessionId),
|
||||||
|
tool,
|
||||||
|
title: summary.title,
|
||||||
|
detail: summary.detail,
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt: now + this.cfg.approval.timeoutMs,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.state.record(sessionId, "approval_request", `Waiting on you: ${tool}`, {
|
||||||
|
tool,
|
||||||
|
detail: summary.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise<Decision>((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.waiters.delete(approval.id);
|
||||||
|
const onTimeout = this.cfg.approval.onTimeout;
|
||||||
|
this.state.record(
|
||||||
|
sessionId,
|
||||||
|
"approval_expired",
|
||||||
|
onTimeout === "deny"
|
||||||
|
? `No answer in time - denied ${tool}`
|
||||||
|
: `No answer in time - allowed ${tool}`,
|
||||||
|
{ tool, detail: summary.title },
|
||||||
|
);
|
||||||
|
resolve(
|
||||||
|
onTimeout === "deny"
|
||||||
|
? { decision: "deny", reason: "grok-glance: no answer from your device in time" }
|
||||||
|
: { decision: "allow" },
|
||||||
|
);
|
||||||
|
}, this.cfg.approval.timeoutMs);
|
||||||
|
|
||||||
|
// Do not hold the process open just for a pending approval.
|
||||||
|
timer.unref?.();
|
||||||
|
|
||||||
|
this.waiters.set(approval.id, {
|
||||||
|
approval,
|
||||||
|
timer,
|
||||||
|
settle: (decision) => resolve(decision),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by the API when you tap Approve or Deny. */
|
||||||
|
resolve(id: string, decision: "allow" | "deny", by: string): boolean {
|
||||||
|
const waiter = this.waiters.get(id);
|
||||||
|
if (!waiter) return false;
|
||||||
|
clearTimeout(waiter.timer);
|
||||||
|
this.waiters.delete(id);
|
||||||
|
|
||||||
|
const { approval } = waiter;
|
||||||
|
this.state.record(
|
||||||
|
approval.sessionId,
|
||||||
|
decision === "allow" ? "approval_allowed" : "approval_denied",
|
||||||
|
decision === "allow"
|
||||||
|
? `Approved ${approval.tool} from ${by}`
|
||||||
|
: `Denied ${approval.tool} from ${by}`,
|
||||||
|
{ tool: approval.tool, detail: approval.title },
|
||||||
|
);
|
||||||
|
|
||||||
|
waiter.settle(
|
||||||
|
decision === "allow"
|
||||||
|
? { decision: "allow" }
|
||||||
|
: { decision: "deny", reason: `Denied from grok-glance (${by})` },
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve everything as allow — used on shutdown so no hook is left hanging. */
|
||||||
|
drain(): void {
|
||||||
|
for (const [id, waiter] of this.waiters) {
|
||||||
|
clearTimeout(waiter.timer);
|
||||||
|
this.waiters.delete(id);
|
||||||
|
waiter.settle({ decision: "allow" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import type { IncomingMessage } from "node:http";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------- cookies */
|
||||||
|
|
||||||
|
export const SESSION_COOKIE = "glance_session";
|
||||||
|
export const CSRF_HEADER = "x-glance-csrf";
|
||||||
|
|
||||||
|
export function parseCookies(header: string | undefined): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
if (!header) return out;
|
||||||
|
for (const part of header.split(";")) {
|
||||||
|
const idx = part.indexOf("=");
|
||||||
|
if (idx < 0) continue;
|
||||||
|
const key = part.slice(0, idx).trim();
|
||||||
|
const value = part.slice(idx + 1).trim();
|
||||||
|
if (key) out[key] = decodeURIComponent(value);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** token.hmac(token) — lets us reject forged cookies without touching disk. */
|
||||||
|
export function signToken(token: string, secret: Buffer): string {
|
||||||
|
const mac = crypto.createHmac("sha256", secret).update(token).digest("base64url");
|
||||||
|
return `${token}.${mac}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unsignToken(signed: string | undefined, secret: Buffer): string | null {
|
||||||
|
if (!signed) return null;
|
||||||
|
const idx = signed.lastIndexOf(".");
|
||||||
|
if (idx <= 0) return null;
|
||||||
|
const token = signed.slice(0, idx);
|
||||||
|
const mac = signed.slice(idx + 1);
|
||||||
|
const expected = crypto.createHmac("sha256", secret).update(token).digest("base64url");
|
||||||
|
const a = Buffer.from(mac);
|
||||||
|
const b = Buffer.from(expected);
|
||||||
|
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The daemon always listens on plain http (a tunnel terminates TLS), so whether the cookie
|
||||||
|
* may carry the Secure flag depends on how the *browser* reached us. Setting Secure on a
|
||||||
|
* genuinely-http localhost connection would make the browser throw the cookie away.
|
||||||
|
*/
|
||||||
|
export function requestIsHttps(req: IncomingMessage): boolean {
|
||||||
|
const proto = header(req, "x-forwarded-proto");
|
||||||
|
if (proto) return proto.split(",")[0].trim() === "https";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSessionCookie(
|
||||||
|
value: string,
|
||||||
|
opts: { secure: boolean; maxAgeSec: number },
|
||||||
|
): string {
|
||||||
|
const parts = [
|
||||||
|
`${SESSION_COOKIE}=${encodeURIComponent(value)}`,
|
||||||
|
"Path=/",
|
||||||
|
"HttpOnly",
|
||||||
|
"SameSite=Strict",
|
||||||
|
`Max-Age=${opts.maxAgeSec}`,
|
||||||
|
];
|
||||||
|
if (opts.secure) parts.push("Secure");
|
||||||
|
return parts.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionCookie(secure: boolean): string {
|
||||||
|
const parts = [`${SESSION_COOKIE}=`, "Path=/", "HttpOnly", "SameSite=Strict", "Max-Age=0"];
|
||||||
|
if (secure) parts.push("Secure");
|
||||||
|
return parts.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function header(req: IncomingMessage, name: string): string | undefined {
|
||||||
|
const value = req.headers[name];
|
||||||
|
if (Array.isArray(value)) return value[0];
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- rate limiting */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixed-window counter. Keyed globally rather than per-IP on purpose: behind a tunnel every
|
||||||
|
* request arrives from 127.0.0.1, so per-IP buckets would be a single bucket wearing a hat.
|
||||||
|
*/
|
||||||
|
export class RateLimiter {
|
||||||
|
private hits = new Map<string, { count: number; resetAt: number }>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly limit: number,
|
||||||
|
private readonly windowMs: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Returns true when the caller is still within budget. */
|
||||||
|
allow(key: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = this.hits.get(key);
|
||||||
|
if (!entry || entry.resetAt <= now) {
|
||||||
|
this.hits.set(key, { count: 1, resetAt: now + this.windowMs });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
entry.count += 1;
|
||||||
|
return entry.count <= this.limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(key: string): void {
|
||||||
|
this.hits.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------- enrolment codes */
|
||||||
|
|
||||||
|
// No 0/O/1/I/L — these get read off a terminal and typed on a phone.
|
||||||
|
const CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
|
||||||
|
const CODE_LENGTH = 8;
|
||||||
|
const CODE_TTL_MS = 10 * 60_000;
|
||||||
|
const MAX_CODE_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
interface EnrollmentCode {
|
||||||
|
code: string;
|
||||||
|
expiresAt: number;
|
||||||
|
attempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EnrollmentCodes {
|
||||||
|
private current: EnrollmentCode | null = null;
|
||||||
|
|
||||||
|
mint(): { code: string; expiresInMs: number } {
|
||||||
|
const bytes = crypto.randomBytes(CODE_LENGTH);
|
||||||
|
let code = "";
|
||||||
|
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||||
|
code += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length];
|
||||||
|
}
|
||||||
|
this.current = { code, expiresAt: Date.now() + CODE_TTL_MS, attempts: 0 };
|
||||||
|
return { code, expiresInMs: CODE_TTL_MS };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constant-time compare. Counts the attempt, and burns the code after too many misses. */
|
||||||
|
check(candidate: string): boolean {
|
||||||
|
const entry = this.current;
|
||||||
|
if (!entry) return false;
|
||||||
|
if (entry.expiresAt <= Date.now()) {
|
||||||
|
this.current = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
entry.attempts += 1;
|
||||||
|
if (entry.attempts > MAX_CODE_ATTEMPTS) {
|
||||||
|
this.current = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const a = Buffer.from(normalize(candidate));
|
||||||
|
const b = Buffer.from(entry.code);
|
||||||
|
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single use. Registration checks the code twice — once to hand out options, once to accept
|
||||||
|
* the attestation — so only the second call consumes it, otherwise a failed prompt on the
|
||||||
|
* phone would force you back to the terminal for a fresh code.
|
||||||
|
*/
|
||||||
|
consume(candidate: string): boolean {
|
||||||
|
if (!this.check(candidate)) return false;
|
||||||
|
this.current = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get active(): boolean {
|
||||||
|
return !!this.current && this.current.expiresAt > Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(code: string): string {
|
||||||
|
return code.trim().toUpperCase().replace(/[\s-]/g, "");
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { ApprovalSettings } from "./protocol.js";
|
||||||
|
|
||||||
|
export const VERSION = "0.1.0";
|
||||||
|
export const DEFAULT_PORT = 8791;
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
port: number;
|
||||||
|
host: string;
|
||||||
|
/** Public https origin the phone will use, e.g. https://box.tailnet-1234.ts.net */
|
||||||
|
origin?: string;
|
||||||
|
/** WebAuthn Relying Party ID. Derived from `origin` unless set explicitly. */
|
||||||
|
rpId?: string;
|
||||||
|
rpName: string;
|
||||||
|
approval: ApprovalSettings;
|
||||||
|
/** How many events to keep in memory and hand to the UI. */
|
||||||
|
retainEvents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function glanceHome(): string {
|
||||||
|
if (process.env.GLANCE_HOME) return path.resolve(process.env.GLANCE_HOME);
|
||||||
|
return path.join(os.homedir(), ".grok", "glance");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const paths = {
|
||||||
|
get home() {
|
||||||
|
return glanceHome();
|
||||||
|
},
|
||||||
|
get config() {
|
||||||
|
return path.join(glanceHome(), "config.json");
|
||||||
|
},
|
||||||
|
get credentials() {
|
||||||
|
return path.join(glanceHome(), "credentials.json");
|
||||||
|
},
|
||||||
|
get authSessions() {
|
||||||
|
return path.join(glanceHome(), "auth-sessions.json");
|
||||||
|
},
|
||||||
|
get secret() {
|
||||||
|
return path.join(glanceHome(), "secret.key");
|
||||||
|
},
|
||||||
|
get adminToken() {
|
||||||
|
return path.join(glanceHome(), "admin.token");
|
||||||
|
},
|
||||||
|
get events() {
|
||||||
|
return path.join(glanceHome(), "events.jsonl");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULTS: Config = {
|
||||||
|
port: DEFAULT_PORT,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
rpName: "grok-glance",
|
||||||
|
approval: {
|
||||||
|
// Off by default: installing a dashboard should not silently start gating your tools.
|
||||||
|
// Flip it on from the phone, or with `glance approval risky`.
|
||||||
|
mode: "off",
|
||||||
|
riskyPattern: "^(Bash|Write|Edit|MultiEdit|NotebookEdit)$",
|
||||||
|
timeoutMs: 90_000,
|
||||||
|
// If nobody is actually watching, allow immediately rather than stalling the agent.
|
||||||
|
requireWatcher: true,
|
||||||
|
// Grok's own hook contract is fail-open on timeout, so match it by default.
|
||||||
|
onTimeout: "allow",
|
||||||
|
},
|
||||||
|
retainEvents: 400,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ensureHome(): void {
|
||||||
|
fs.mkdirSync(glanceHome(), { recursive: true, mode: 0o700 });
|
||||||
|
// Tighten it even if the directory already existed with looser bits.
|
||||||
|
try {
|
||||||
|
fs.chmodSync(glanceHome(), 0o700);
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(): Config {
|
||||||
|
ensureHome();
|
||||||
|
let stored: Partial<Config> = {};
|
||||||
|
try {
|
||||||
|
stored = JSON.parse(fs.readFileSync(paths.config, "utf8")) as Partial<Config>;
|
||||||
|
} catch {
|
||||||
|
/* first run */
|
||||||
|
}
|
||||||
|
const merged: Config = {
|
||||||
|
...DEFAULTS,
|
||||||
|
...stored,
|
||||||
|
approval: { ...DEFAULTS.approval, ...(stored.approval ?? {}) },
|
||||||
|
};
|
||||||
|
if (process.env.GLANCE_PORT) {
|
||||||
|
const p = Number(process.env.GLANCE_PORT);
|
||||||
|
if (Number.isFinite(p)) merged.port = p;
|
||||||
|
}
|
||||||
|
if (process.env.GLANCE_ORIGIN) merged.origin = process.env.GLANCE_ORIGIN;
|
||||||
|
merged.rpId = merged.rpId ?? deriveRpId(merged.origin);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveConfig(cfg: Config): void {
|
||||||
|
ensureHome();
|
||||||
|
fs.writeFileSync(paths.config, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The RP ID is the origin's hostname. Note that a bare IP address is not a valid RP ID,
|
||||||
|
* so a LAN address like 192.168.1.20 can never work — that is a WebAuthn rule, not ours.
|
||||||
|
*/
|
||||||
|
export function deriveRpId(origin?: string): string | undefined {
|
||||||
|
if (!origin) return undefined;
|
||||||
|
try {
|
||||||
|
const url = new URL(origin);
|
||||||
|
const host = url.hostname;
|
||||||
|
if (isIpAddress(host)) return undefined;
|
||||||
|
return host;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIpAddress(host: string): boolean {
|
||||||
|
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
|
||||||
|
if (host.includes(":")) return true; // IPv6
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Origins we will accept assertions from. The configured public origin, plus localhost so
|
||||||
|
* that you can enrol and test on the machine itself before setting up a tunnel.
|
||||||
|
*/
|
||||||
|
export function expectedOrigins(cfg: Config): string[] {
|
||||||
|
const list = [`http://localhost:${cfg.port}`, `http://127.0.0.1:${cfg.port}`];
|
||||||
|
if (cfg.origin) list.unshift(cfg.origin.replace(/\/$/, ""));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expectedRpIds(cfg: Config): string[] {
|
||||||
|
const ids = new Set<string>(["localhost"]);
|
||||||
|
if (cfg.rpId) ids.add(cfg.rpId);
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 256 * 1024;
|
||||||
|
|
||||||
|
export interface Res {
|
||||||
|
json(status: number, body: unknown, headers?: Record<string, string>): void;
|
||||||
|
text(status: number, body: string, headers?: Record<string, string>): void;
|
||||||
|
empty(status: number, headers?: Record<string, string>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Headers applied to every response. The UI is entirely self-hosted, so the CSP can be strict. */
|
||||||
|
export const SECURITY_HEADERS: Record<string, string> = {
|
||||||
|
"x-content-type-options": "nosniff",
|
||||||
|
"referrer-policy": "no-referrer",
|
||||||
|
"x-frame-options": "DENY",
|
||||||
|
"cross-origin-opener-policy": "same-origin",
|
||||||
|
"content-security-policy": [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self'",
|
||||||
|
// HeroUI/Tailwind inject style attributes and inline blocks at runtime.
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"img-src 'self' data:",
|
||||||
|
"font-src 'self' data:",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"base-uri 'none'",
|
||||||
|
"form-action 'none'",
|
||||||
|
].join("; "),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function responder(res: ServerResponse): Res {
|
||||||
|
const send = (status: number, body: string | null, headers: Record<string, string> = {}) => {
|
||||||
|
if (res.headersSent) return;
|
||||||
|
res.writeHead(status, { ...SECURITY_HEADERS, ...headers });
|
||||||
|
res.end(body ?? undefined);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
json(status, body, headers = {}) {
|
||||||
|
send(status, JSON.stringify(body), {
|
||||||
|
"content-type": "application/json; charset=utf-8",
|
||||||
|
"cache-control": "no-store",
|
||||||
|
...headers,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
text(status, body, headers = {}) {
|
||||||
|
send(status, body, {
|
||||||
|
"content-type": "text/plain; charset=utf-8",
|
||||||
|
"cache-control": "no-store",
|
||||||
|
...headers,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
empty(status, headers = {}) {
|
||||||
|
send(status, null, headers);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readBody(req: IncomingMessage): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let size = 0;
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on("data", (chunk: Buffer) => {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > MAX_BODY_BYTES) {
|
||||||
|
reject(new Error("body too large"));
|
||||||
|
req.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||||
|
req.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readJson<T = unknown>(req: IncomingMessage): Promise<T | null> {
|
||||||
|
const raw = await readBody(req);
|
||||||
|
if (!raw.trim()) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
/**
|
||||||
|
* grok-glance daemon.
|
||||||
|
*
|
||||||
|
* One small http server with three kinds of caller:
|
||||||
|
*
|
||||||
|
* /hook/* the plugin's hook scripts, on loopback. /hook/approve is the blocking one.
|
||||||
|
* /api/* the web app, gated by a passkey-backed cookie session.
|
||||||
|
* /local/* the `glance` CLI, gated by a rotating admin token on disk.
|
||||||
|
*
|
||||||
|
* Everything the hooks touch is written to fail open: if this process is confused, wedged, or
|
||||||
|
* gone, Grok Build keeps working.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from "node:http";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { URL } from "node:url";
|
||||||
|
import {
|
||||||
|
DEFAULT_PORT,
|
||||||
|
VERSION,
|
||||||
|
deriveRpId,
|
||||||
|
ensureHome,
|
||||||
|
expectedOrigins,
|
||||||
|
isIpAddress,
|
||||||
|
loadConfig,
|
||||||
|
paths,
|
||||||
|
saveConfig,
|
||||||
|
} from "./config.js";
|
||||||
|
import {
|
||||||
|
CSRF_HEADER,
|
||||||
|
EnrollmentCodes,
|
||||||
|
RateLimiter,
|
||||||
|
SESSION_COOKIE,
|
||||||
|
buildSessionCookie,
|
||||||
|
clearSessionCookie,
|
||||||
|
header,
|
||||||
|
parseCookies,
|
||||||
|
requestIsHttps,
|
||||||
|
signToken,
|
||||||
|
unsignToken,
|
||||||
|
} from "./auth.js";
|
||||||
|
import { readJson, responder } from "./http.js";
|
||||||
|
import { serveStatic, webBuildExists } from "./static.js";
|
||||||
|
import { SseHub } from "./sse.js";
|
||||||
|
import { GlanceState, type HookPayload } from "./state.js";
|
||||||
|
import { ApprovalBroker } from "./approvals.js";
|
||||||
|
import { SESSION_TTL_MS, WebAuthnService } from "./webauthn.js";
|
||||||
|
import {
|
||||||
|
destroyAuthSession,
|
||||||
|
deviceList,
|
||||||
|
lookupAuthSession,
|
||||||
|
revokeCredentials,
|
||||||
|
rotateAdminToken,
|
||||||
|
sessionSecret,
|
||||||
|
} from "./store.js";
|
||||||
|
import type { ApprovalMode, GateInfo } from "./protocol.js";
|
||||||
|
import type { AuthenticationResponseJSON, RegistrationResponseJSON } from "@simplewebauthn/server";
|
||||||
|
|
||||||
|
ensureHome();
|
||||||
|
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const secret = sessionSecret();
|
||||||
|
const adminToken = rotateAdminToken();
|
||||||
|
const webauthn = new WebAuthnService(cfg);
|
||||||
|
const codes = new EnrollmentCodes();
|
||||||
|
|
||||||
|
// Generous enough for a fumbled passkey prompt, tight enough that the code is not brute-forceable.
|
||||||
|
const authLimiter = new RateLimiter(40, 5 * 60_000);
|
||||||
|
const enrollLimiter = new RateLimiter(12, 5 * 60_000);
|
||||||
|
|
||||||
|
const state = new GlanceState(cfg);
|
||||||
|
|
||||||
|
let hub: SseHub | null = null;
|
||||||
|
const broker = new ApprovalBroker(cfg, state, () => hub?.hasWatcher() ?? false);
|
||||||
|
const sse = new SseHub(() => state.snapshot(broker.pending()));
|
||||||
|
hub = sse;
|
||||||
|
|
||||||
|
state.onChange(() => sse.publish());
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ request auth */
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
token: string;
|
||||||
|
credentialId: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSession(req: http.IncomingMessage): Session | null {
|
||||||
|
const cookies = parseCookies(req.headers.cookie);
|
||||||
|
const token = unsignToken(cookies[SESSION_COOKIE], secret);
|
||||||
|
if (!token) return null;
|
||||||
|
const record = lookupAuthSession(token);
|
||||||
|
if (!record) return null;
|
||||||
|
return { token, credentialId: record.credentialId, label: record.label };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdmin(req: http.IncomingMessage): boolean {
|
||||||
|
const provided = header(req, "x-glance-admin");
|
||||||
|
if (!provided) return false;
|
||||||
|
const a = Buffer.from(provided);
|
||||||
|
const b = Buffer.from(adminToken);
|
||||||
|
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `application/json` is not a CORS-safelisted content type, so requiring it exactly means a
|
||||||
|
* hostile page cannot post here without a preflight we never answer. The custom header on
|
||||||
|
* /api/* is a second, independent barrier on top of the SameSite=Strict cookie.
|
||||||
|
*/
|
||||||
|
function isJsonPost(req: http.IncomingMessage): boolean {
|
||||||
|
const ct = (header(req, "content-type") ?? "").split(";")[0].trim().toLowerCase();
|
||||||
|
return ct === "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCsrfHeader(req: http.IncomingMessage): boolean {
|
||||||
|
return !!header(req, CSRF_HEADER);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- routing */
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
handle(req, res).catch((err) => {
|
||||||
|
console.error("[glance] unhandled", err);
|
||||||
|
try {
|
||||||
|
responder(res).json(500, { error: "internal error" });
|
||||||
|
} catch {
|
||||||
|
/* response already gone */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||||
|
const out = responder(res);
|
||||||
|
const url = new URL(req.url ?? "/", `http://localhost:${cfg.port}`);
|
||||||
|
const p = url.pathname;
|
||||||
|
const method = req.method ?? "GET";
|
||||||
|
|
||||||
|
if (method === "OPTIONS") {
|
||||||
|
// No CORS. Cross-origin callers get nothing, which is the point.
|
||||||
|
out.empty(405, { allow: "GET, POST" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- health */
|
||||||
|
|
||||||
|
if (p === "/healthz") {
|
||||||
|
out.json(200, { ok: true, version: VERSION });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- hooks */
|
||||||
|
|
||||||
|
if (p.startsWith("/hook/")) {
|
||||||
|
if (method !== "POST" || !isJsonPost(req)) {
|
||||||
|
out.json(405, { error: "post json" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = ((await readJson<HookPayload>(req)) ?? {}) as HookPayload;
|
||||||
|
|
||||||
|
if (p === "/hook/record") {
|
||||||
|
const event = state.ingest(payload);
|
||||||
|
out.json(200, { ok: true, id: event?.id ?? null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p === "/hook/approve") {
|
||||||
|
// Note: this deliberately does not ingest an event. The PreToolUse http hook already
|
||||||
|
// recorded the tool call; recording it here too would double every entry.
|
||||||
|
const decision = await broker.request(payload);
|
||||||
|
out.json(200, decision);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.json(404, { error: "unknown hook" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------- local admin API */
|
||||||
|
|
||||||
|
if (p.startsWith("/local/")) {
|
||||||
|
if (!isAdmin(req)) {
|
||||||
|
out.json(403, { error: "admin token required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p === "/local/status" && method === "GET") {
|
||||||
|
out.json(200, {
|
||||||
|
version: VERSION,
|
||||||
|
origin: cfg.origin,
|
||||||
|
rpId: cfg.rpId,
|
||||||
|
devices: deviceList().length,
|
||||||
|
approval: cfg.approval,
|
||||||
|
watchers: sse.count,
|
||||||
|
sessions: state.sessionCount,
|
||||||
|
events: state.eventCount,
|
||||||
|
webBuilt: webBuildExists(),
|
||||||
|
home: paths.home,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p === "/local/enroll" && method === "POST") {
|
||||||
|
const minted = codes.mint();
|
||||||
|
const base = cfg.origin?.replace(/\/$/, "") ?? `http://localhost:${cfg.port}`;
|
||||||
|
out.json(200, {
|
||||||
|
code: minted.code,
|
||||||
|
expiresInMs: minted.expiresInMs,
|
||||||
|
url: `${base}/?enroll=1`,
|
||||||
|
originConfigured: !!cfg.origin,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p === "/local/origin" && method === "POST") {
|
||||||
|
const body = await readJson<{ origin?: string }>(req);
|
||||||
|
const raw = (body?.origin ?? "").trim().replace(/\/$/, "");
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
out.json(400, { error: "not a url" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isIpAddress(parsed.hostname)) {
|
||||||
|
out.json(400, {
|
||||||
|
error:
|
||||||
|
"a bare IP address cannot be a WebAuthn relying party id - use a hostname with TLS",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== "https:" && parsed.hostname !== "localhost") {
|
||||||
|
out.json(400, { error: "passkeys need https (or localhost for local testing)" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cfg.origin = `${parsed.protocol}//${parsed.host}`;
|
||||||
|
cfg.rpId = deriveRpId(cfg.origin);
|
||||||
|
saveConfig(cfg);
|
||||||
|
out.json(200, { origin: cfg.origin, rpId: cfg.rpId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p === "/local/devices" && method === "GET") {
|
||||||
|
out.json(200, { devices: deviceList() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p === "/local/devices/revoke" && method === "POST") {
|
||||||
|
const body = await readJson<{ idPrefix?: string }>(req);
|
||||||
|
const prefix = (body?.idPrefix ?? "").trim();
|
||||||
|
if (prefix.length < 4) {
|
||||||
|
out.json(400, { error: "give at least 4 characters of the device id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out.json(200, { revoked: revokeCredentials(prefix) });
|
||||||
|
sse.publish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p === "/local/approval" && method === "POST") {
|
||||||
|
const body = await readJson<{ mode?: ApprovalMode }>(req);
|
||||||
|
if (!applyApprovalMode(body?.mode)) {
|
||||||
|
out.json(400, { error: "mode must be off, risky or all" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out.json(200, cfg.approval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p === "/local/shutdown" && method === "POST") {
|
||||||
|
out.json(200, { ok: true });
|
||||||
|
shutdown("cli");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.json(404, { error: "unknown local endpoint" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- web API */
|
||||||
|
|
||||||
|
if (p.startsWith("/api/")) {
|
||||||
|
if (method === "POST" && (!isJsonPost(req) || !hasCsrfHeader(req))) {
|
||||||
|
out.json(400, { error: "bad request" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = currentSession(req);
|
||||||
|
const secure = requestIsHttps(req);
|
||||||
|
|
||||||
|
if (p === "/api/gate" && method === "GET") {
|
||||||
|
const gate: GateInfo = {
|
||||||
|
authenticated: !!session,
|
||||||
|
enrolled: webauthn.enrolled,
|
||||||
|
enrollmentOpen: codes.active,
|
||||||
|
version: VERSION,
|
||||||
|
deviceLabel: session?.label,
|
||||||
|
rpId: cfg.rpId,
|
||||||
|
};
|
||||||
|
out.json(200, gate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- enrolment: a one-time code from the terminal, or an already-trusted device --- */
|
||||||
|
|
||||||
|
if (p === "/api/auth/register/options" && method === "POST") {
|
||||||
|
if (!enrollLimiter.allow("enroll")) {
|
||||||
|
out.json(429, { error: "too many attempts, wait a few minutes" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = await readJson<{ code?: string }>(req);
|
||||||
|
if (!session && !codes.check(body?.code ?? "")) {
|
||||||
|
out.json(403, { error: "that enrolment code is not valid" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out.json(200, await webauthn.registrationOptions());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p === "/api/auth/register/verify" && method === "POST") {
|
||||||
|
if (!enrollLimiter.allow("enroll")) {
|
||||||
|
out.json(429, { error: "too many attempts, wait a few minutes" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = await readJson<{
|
||||||
|
code?: string;
|
||||||
|
label?: string;
|
||||||
|
response?: RegistrationResponseJSON;
|
||||||
|
}>(req);
|
||||||
|
if (!body?.response) {
|
||||||
|
out.json(400, { error: "missing response" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!session && !codes.consume(body.code ?? "")) {
|
||||||
|
out.json(403, { error: "that enrolment code is not valid" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const label = body.label?.trim() || "phone";
|
||||||
|
const result = await webauthn.verifyRegistration(body.response, label);
|
||||||
|
if (!result.ok || !result.token) {
|
||||||
|
out.json(400, { error: result.error ?? "registration failed" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
enrollLimiter.reset("enroll");
|
||||||
|
out.json(
|
||||||
|
200,
|
||||||
|
{ ok: true, label },
|
||||||
|
{
|
||||||
|
"set-cookie": buildSessionCookie(signToken(result.token, secret), {
|
||||||
|
secure,
|
||||||
|
maxAgeSec: Math.floor(SESSION_TTL_MS / 1000),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
console.log(`[glance] enrolled device "${label}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------- sign in ---------------------------------- */
|
||||||
|
|
||||||
|
if (p === "/api/auth/login/options" && method === "POST") {
|
||||||
|
if (!authLimiter.allow("login")) {
|
||||||
|
out.json(429, { error: "too many attempts, wait a few minutes" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!webauthn.enrolled) {
|
||||||
|
out.json(409, { error: "no device enrolled yet - run `glance enroll`" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out.json(200, await webauthn.authenticationOptions());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p === "/api/auth/login/verify" && method === "POST") {
|
||||||
|
if (!authLimiter.allow("login")) {
|
||||||
|
out.json(429, { error: "too many attempts, wait a few minutes" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = await readJson<{ response?: AuthenticationResponseJSON }>(req);
|
||||||
|
if (!body?.response) {
|
||||||
|
out.json(400, { error: "missing response" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await webauthn.verifyAuthentication(body.response);
|
||||||
|
if (!result.ok || !result.token) {
|
||||||
|
out.json(403, { error: result.error ?? "sign in failed" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
authLimiter.reset("login");
|
||||||
|
out.json(
|
||||||
|
200,
|
||||||
|
{ ok: true, label: result.label },
|
||||||
|
{
|
||||||
|
"set-cookie": buildSessionCookie(signToken(result.token, secret), {
|
||||||
|
secure,
|
||||||
|
maxAgeSec: Math.floor(SESSION_TTL_MS / 1000),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p === "/api/auth/logout" && method === "POST") {
|
||||||
|
if (session) destroyAuthSession(session.token);
|
||||||
|
out.json(200, { ok: true }, { "set-cookie": clearSessionCookie(secure) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------- everything below needs a passkey ------------------ */
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
out.json(401, { error: "not signed in" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p === "/api/snapshot" && method === "GET") {
|
||||||
|
out.json(200, state.snapshot(broker.pending()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p === "/api/approvals/resolve" && method === "POST") {
|
||||||
|
const body = await readJson<{ id?: string; decision?: "allow" | "deny" }>(req);
|
||||||
|
const id = body?.id ?? "";
|
||||||
|
const decision = body?.decision;
|
||||||
|
if (!id || (decision !== "allow" && decision !== "deny")) {
|
||||||
|
out.json(400, { error: "need id and decision" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const settled = broker.resolve(id, decision, session.label);
|
||||||
|
// A miss is normal: the prompt may have timed out, or another device answered first.
|
||||||
|
out.json(settled ? 200 : 409, settled ? { ok: true } : { error: "no longer pending" });
|
||||||
|
sse.publish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p === "/api/approval" && method === "POST") {
|
||||||
|
const body = await readJson<{
|
||||||
|
mode?: ApprovalMode;
|
||||||
|
requireWatcher?: boolean;
|
||||||
|
onTimeout?: "allow" | "deny";
|
||||||
|
}>(req);
|
||||||
|
if (body?.mode !== undefined && !applyApprovalMode(body.mode)) {
|
||||||
|
out.json(400, { error: "mode must be off, risky or all" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof body?.requireWatcher === "boolean") {
|
||||||
|
cfg.approval.requireWatcher = body.requireWatcher;
|
||||||
|
}
|
||||||
|
if (body?.onTimeout === "allow" || body?.onTimeout === "deny") {
|
||||||
|
cfg.approval.onTimeout = body.onTimeout;
|
||||||
|
}
|
||||||
|
saveConfig(cfg);
|
||||||
|
out.json(200, cfg.approval);
|
||||||
|
sse.publish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p === "/api/devices" && method === "GET") {
|
||||||
|
out.json(200, { devices: deviceList(), current: session.credentialId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.json(404, { error: "unknown endpoint" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- SSE stream */
|
||||||
|
|
||||||
|
if (p === "/events") {
|
||||||
|
if (method !== "GET") {
|
||||||
|
out.json(405, { error: "get only" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!currentSession(req)) {
|
||||||
|
out.json(401, { error: "not signed in" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sse.add(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- static files */
|
||||||
|
|
||||||
|
if (method !== "GET") {
|
||||||
|
out.json(405, { error: "get only" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
serveStatic(p, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared by the CLI and the web UI so both paths validate the same way. */
|
||||||
|
function applyApprovalMode(mode: unknown): boolean {
|
||||||
|
if (mode !== "off" && mode !== "risky" && mode !== "all") return false;
|
||||||
|
cfg.approval.mode = mode;
|
||||||
|
saveConfig(cfg);
|
||||||
|
sse.publish();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------- lifecycle */
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
function shutdown(why: string): void {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
console.log(`[glance] shutting down (${why})`);
|
||||||
|
// Anything still waiting on a decision gets allowed, so no hook is left hanging.
|
||||||
|
broker.drain();
|
||||||
|
sse.closeAll();
|
||||||
|
server.close(() => process.exit(0));
|
||||||
|
// Don't let a lingering keep-alive socket hold the process forever.
|
||||||
|
setTimeout(() => process.exit(0), 1500).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||||
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||||
|
process.on("uncaughtException", (err) => {
|
||||||
|
console.error("[glance] uncaught", err);
|
||||||
|
});
|
||||||
|
process.on("unhandledRejection", (err) => {
|
||||||
|
console.error("[glance] unhandled rejection", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(cfg.port, cfg.host, () => {
|
||||||
|
console.log(`[glance] ${VERSION} listening on http://${cfg.host}:${cfg.port}`);
|
||||||
|
console.log(`[glance] state: ${paths.home}`);
|
||||||
|
console.log(`[glance] origin: ${cfg.origin ?? "(none set - see README)"} rpId: ${cfg.rpId ?? "localhost"}`);
|
||||||
|
console.log(`[glance] accepts assertions from: ${expectedOrigins(cfg).join(", ")}`);
|
||||||
|
if (!webBuildExists()) console.log("[glance] web app not built yet: npm install && npm run build");
|
||||||
|
if (cfg.port !== DEFAULT_PORT) console.log(`[glance] note: non-default port, run \`glance sync-hooks\``);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on("error", (err) => {
|
||||||
|
console.error(`[glance] listen failed: ${(err as Error).message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Wire protocol shared between the daemon and the web app.
|
||||||
|
*
|
||||||
|
* NOTE: web/src/protocol.ts is a copy of this file. Keep the two in sync — they are
|
||||||
|
* duplicated rather than shared because the server compiles under NodeNext while the web
|
||||||
|
* app compiles under a bundler resolution, and a single rootDir cannot span both.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type EventKind =
|
||||||
|
| "session_start"
|
||||||
|
| "session_end"
|
||||||
|
| "prompt"
|
||||||
|
| "tool_start"
|
||||||
|
| "tool_end"
|
||||||
|
| "tool_fail"
|
||||||
|
| "permission_denied"
|
||||||
|
| "turn_end"
|
||||||
|
| "turn_error"
|
||||||
|
| "notification"
|
||||||
|
| "subagent_start"
|
||||||
|
| "subagent_end"
|
||||||
|
| "compact"
|
||||||
|
| "approval_request"
|
||||||
|
| "approval_allowed"
|
||||||
|
| "approval_denied"
|
||||||
|
| "approval_expired";
|
||||||
|
|
||||||
|
export type SessionState = "working" | "idle" | "waiting" | "error" | "ended";
|
||||||
|
|
||||||
|
export interface GlanceEvent {
|
||||||
|
id: number;
|
||||||
|
ts: number;
|
||||||
|
sessionId: string;
|
||||||
|
kind: EventKind;
|
||||||
|
/** Tool name, for tool-shaped events. */
|
||||||
|
tool?: string;
|
||||||
|
/** One-line human summary, already truncated and redacted. */
|
||||||
|
title: string;
|
||||||
|
/** Optional second line, e.g. a file path or an error message. */
|
||||||
|
detail?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionView {
|
||||||
|
id: string;
|
||||||
|
/** Basename of the workspace root — what you actually recognise on a phone. */
|
||||||
|
label: string;
|
||||||
|
cwd: string;
|
||||||
|
state: SessionState;
|
||||||
|
startedAt: number;
|
||||||
|
lastActivity: number;
|
||||||
|
lastPrompt?: string;
|
||||||
|
currentTool?: { name: string; title: string; startedAt: number };
|
||||||
|
counts: { tools: number; failures: number; denials: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingApproval {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
sessionLabel: string;
|
||||||
|
tool: string;
|
||||||
|
title: string;
|
||||||
|
detail?: string;
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApprovalMode = "off" | "risky" | "all";
|
||||||
|
|
||||||
|
export interface ApprovalSettings {
|
||||||
|
mode: ApprovalMode;
|
||||||
|
riskyPattern: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
/** Skip gating entirely when no browser is streaming, so an unwatched agent never stalls. */
|
||||||
|
requireWatcher: boolean;
|
||||||
|
/** What to do when nobody answers in time. Allow keeps the agent moving; deny is stricter. */
|
||||||
|
onTimeout: "allow" | "deny";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Snapshot {
|
||||||
|
now: number;
|
||||||
|
version: string;
|
||||||
|
sessions: SessionView[];
|
||||||
|
events: GlanceEvent[];
|
||||||
|
pending: PendingApproval[];
|
||||||
|
approval: ApprovalSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceInfo {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
createdAt: number;
|
||||||
|
lastUsedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the app needs before it knows whether you are signed in. */
|
||||||
|
export interface GateInfo {
|
||||||
|
authenticated: boolean;
|
||||||
|
/** False when no passkey has been enrolled yet — the app then asks for an enrolment code. */
|
||||||
|
enrolled: boolean;
|
||||||
|
/** True while a one-time enrolment code minted by `glance enroll` is still valid. */
|
||||||
|
enrollmentOpen: boolean;
|
||||||
|
version: string;
|
||||||
|
deviceLabel?: string;
|
||||||
|
/** The WebAuthn RP ID in force. Shown so a hostname mismatch is diagnosable from the phone. */
|
||||||
|
rpId?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { ServerResponse } from "node:http";
|
||||||
|
import { SECURITY_HEADERS } from "./http.js";
|
||||||
|
import type { Snapshot } from "./protocol.js";
|
||||||
|
|
||||||
|
/** Coalesce bursts — a single tool call can fire several hooks in a few milliseconds. */
|
||||||
|
const THROTTLE_MS = 250;
|
||||||
|
/** Proxies and phone radios drop idle connections; a comment frame keeps them honest. */
|
||||||
|
const HEARTBEAT_MS = 25_000;
|
||||||
|
|
||||||
|
interface Client {
|
||||||
|
id: number;
|
||||||
|
res: ServerResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SseHub {
|
||||||
|
private clients = new Map<number, Client>();
|
||||||
|
private nextId = 1;
|
||||||
|
private pending = false;
|
||||||
|
private lastSentAt = 0;
|
||||||
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
private heartbeat: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly snapshot: () => Snapshot) {}
|
||||||
|
|
||||||
|
/** True when at least one browser is listening — the approval broker asks before gating. */
|
||||||
|
hasWatcher(): boolean {
|
||||||
|
return this.clients.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get count(): number {
|
||||||
|
return this.clients.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
add(res: ServerResponse): void {
|
||||||
|
res.writeHead(200, {
|
||||||
|
...SECURITY_HEADERS,
|
||||||
|
"content-type": "text/event-stream",
|
||||||
|
"cache-control": "no-store, no-transform",
|
||||||
|
connection: "keep-alive",
|
||||||
|
// Belt and braces for any buffering proxy in front of us.
|
||||||
|
"x-accel-buffering": "no",
|
||||||
|
});
|
||||||
|
res.write(": connected\n\n");
|
||||||
|
|
||||||
|
const client: Client = { id: this.nextId++, res };
|
||||||
|
this.clients.set(client.id, client);
|
||||||
|
|
||||||
|
const drop = () => {
|
||||||
|
this.clients.delete(client.id);
|
||||||
|
if (this.clients.size === 0) this.stopHeartbeat();
|
||||||
|
};
|
||||||
|
res.on("close", drop);
|
||||||
|
res.on("error", drop);
|
||||||
|
|
||||||
|
this.send(client, "snapshot", this.snapshot());
|
||||||
|
this.startHeartbeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
private startHeartbeat(): void {
|
||||||
|
if (this.heartbeat) return;
|
||||||
|
this.heartbeat = setInterval(() => {
|
||||||
|
for (const client of this.clients.values()) {
|
||||||
|
try {
|
||||||
|
client.res.write(": ping\n\n");
|
||||||
|
} catch {
|
||||||
|
this.clients.delete(client.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_MS);
|
||||||
|
this.heartbeat.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopHeartbeat(): void {
|
||||||
|
if (!this.heartbeat) return;
|
||||||
|
clearInterval(this.heartbeat);
|
||||||
|
this.heartbeat = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(client: Client, event: string, data: unknown): void {
|
||||||
|
try {
|
||||||
|
client.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||||
|
} catch {
|
||||||
|
this.clients.delete(client.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a fresh full snapshot, throttled. Sending the whole state rather than deltas keeps
|
||||||
|
* the client dumb: a phone that slept through twenty events still lands on the truth.
|
||||||
|
*/
|
||||||
|
publish(): void {
|
||||||
|
if (this.clients.size === 0) return;
|
||||||
|
if (this.pending) return;
|
||||||
|
const wait = Math.max(0, THROTTLE_MS - (Date.now() - this.lastSentAt));
|
||||||
|
this.pending = true;
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.pending = false;
|
||||||
|
this.lastSentAt = Date.now();
|
||||||
|
const snap = this.snapshot();
|
||||||
|
for (const client of [...this.clients.values()]) {
|
||||||
|
this.send(client, "snapshot", snap);
|
||||||
|
}
|
||||||
|
}, wait);
|
||||||
|
this.timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeAll(): void {
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
this.stopHeartbeat();
|
||||||
|
for (const client of this.clients.values()) {
|
||||||
|
try {
|
||||||
|
client.res.write("event: bye\ndata: {}\n\n");
|
||||||
|
client.res.end();
|
||||||
|
} catch {
|
||||||
|
/* going away anyway */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.clients.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { VERSION, type Config } from "./config.js";
|
||||||
|
import { appendEventLog, readRecentEvents } from "./store.js";
|
||||||
|
import {
|
||||||
|
labelForWorkspace,
|
||||||
|
summarizeNotification,
|
||||||
|
summarizePrompt,
|
||||||
|
summarizeTool,
|
||||||
|
truncateDetail,
|
||||||
|
truncateTitle,
|
||||||
|
} from "./summarize.js";
|
||||||
|
import type {
|
||||||
|
EventKind,
|
||||||
|
GlanceEvent,
|
||||||
|
PendingApproval,
|
||||||
|
SessionState,
|
||||||
|
SessionView,
|
||||||
|
Snapshot,
|
||||||
|
} from "./protocol.js";
|
||||||
|
|
||||||
|
/** A session that has said nothing for this long is treated as idle, not working. */
|
||||||
|
const STALE_WORKING_MS = 10 * 60_000;
|
||||||
|
|
||||||
|
const EVENT_KIND_BY_HOOK: Record<string, EventKind> = {
|
||||||
|
SessionStart: "session_start",
|
||||||
|
SessionEnd: "session_end",
|
||||||
|
UserPromptSubmit: "prompt",
|
||||||
|
PreToolUse: "tool_start",
|
||||||
|
PostToolUse: "tool_end",
|
||||||
|
PostToolUseFailure: "tool_fail",
|
||||||
|
PermissionDenied: "permission_denied",
|
||||||
|
Stop: "turn_end",
|
||||||
|
StopFailure: "turn_error",
|
||||||
|
Notification: "notification",
|
||||||
|
SubagentStart: "subagent_start",
|
||||||
|
SubagentStop: "subagent_end",
|
||||||
|
PreCompact: "compact",
|
||||||
|
PostCompact: "compact",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface HookPayload {
|
||||||
|
hookEventName?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
cwd?: string;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
toolName?: string;
|
||||||
|
toolInput?: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GlanceState {
|
||||||
|
private events: GlanceEvent[] = [];
|
||||||
|
private sessions = new Map<string, SessionView>();
|
||||||
|
/** sessionId|toolName -> start timestamp, so PostToolUse can report a duration. */
|
||||||
|
private toolStarts = new Map<string, number>();
|
||||||
|
private nextId = 1;
|
||||||
|
private readonly listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
constructor(private readonly cfg: Config) {
|
||||||
|
// Warm start: keep recent history across daemon restarts.
|
||||||
|
const recent = readRecentEvents(cfg.retainEvents);
|
||||||
|
this.events = recent;
|
||||||
|
this.nextId = recent.reduce((max, e) => Math.max(max, e.id), 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(listener: () => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
try {
|
||||||
|
listener();
|
||||||
|
} catch {
|
||||||
|
/* a broken listener must not break ingestion */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private session(id: string, payload: HookPayload): SessionView {
|
||||||
|
let existing = this.sessions.get(id);
|
||||||
|
if (!existing) {
|
||||||
|
existing = {
|
||||||
|
id,
|
||||||
|
label: labelForWorkspace(payload.workspaceRoot, payload.cwd ?? ""),
|
||||||
|
cwd: payload.workspaceRoot ?? payload.cwd ?? "",
|
||||||
|
state: "idle",
|
||||||
|
startedAt: Date.now(),
|
||||||
|
lastActivity: Date.now(),
|
||||||
|
counts: { tools: 0, failures: 0, denials: 0 },
|
||||||
|
};
|
||||||
|
this.sessions.set(id, existing);
|
||||||
|
} else if (payload.workspaceRoot || payload.cwd) {
|
||||||
|
// Keep the label fresh if the session moved.
|
||||||
|
existing.label = labelForWorkspace(payload.workspaceRoot, payload.cwd ?? existing.cwd);
|
||||||
|
existing.cwd = payload.workspaceRoot ?? payload.cwd ?? existing.cwd;
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private push(event: GlanceEvent): void {
|
||||||
|
this.events.push(event);
|
||||||
|
if (this.events.length > this.cfg.retainEvents) {
|
||||||
|
this.events.splice(0, this.events.length - this.cfg.retainEvents);
|
||||||
|
}
|
||||||
|
appendEventLog(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a raw hook payload. Returns the event it produced, if any. */
|
||||||
|
ingest(payload: HookPayload): GlanceEvent | null {
|
||||||
|
const hookName = payload.hookEventName ?? "";
|
||||||
|
const kind = EVENT_KIND_BY_HOOK[hookName];
|
||||||
|
if (!kind) return null;
|
||||||
|
|
||||||
|
const sessionId = payload.sessionId ?? "unknown";
|
||||||
|
const session = this.session(sessionId, payload);
|
||||||
|
const now = Date.now();
|
||||||
|
session.lastActivity = now;
|
||||||
|
|
||||||
|
const tool = typeof payload.toolName === "string" ? payload.toolName : undefined;
|
||||||
|
let title = hookName;
|
||||||
|
let detail: string | undefined;
|
||||||
|
let durationMs: number | undefined;
|
||||||
|
|
||||||
|
switch (kind) {
|
||||||
|
case "session_start":
|
||||||
|
session.state = "idle";
|
||||||
|
title = `Session started in ${session.label}`;
|
||||||
|
detail = session.cwd || undefined;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "session_end":
|
||||||
|
session.state = "ended";
|
||||||
|
session.currentTool = undefined;
|
||||||
|
title = "Session ended";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "prompt":
|
||||||
|
session.state = "working";
|
||||||
|
session.lastPrompt = summarizePrompt(payload);
|
||||||
|
title = session.lastPrompt;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "tool_start": {
|
||||||
|
const summary = summarizeTool(tool ?? "tool", payload.toolInput);
|
||||||
|
session.state = "working";
|
||||||
|
session.currentTool = { name: tool ?? "tool", title: summary.title, startedAt: now };
|
||||||
|
this.toolStarts.set(`${sessionId}|${tool ?? "tool"}`, now);
|
||||||
|
title = summary.title;
|
||||||
|
detail = summary.detail;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "tool_end":
|
||||||
|
case "tool_fail": {
|
||||||
|
const summary = summarizeTool(tool ?? "tool", payload.toolInput);
|
||||||
|
const key = `${sessionId}|${tool ?? "tool"}`;
|
||||||
|
const startedAt = this.toolStarts.get(key);
|
||||||
|
if (startedAt) {
|
||||||
|
durationMs = now - startedAt;
|
||||||
|
this.toolStarts.delete(key);
|
||||||
|
}
|
||||||
|
if (session.currentTool?.name === tool) session.currentTool = undefined;
|
||||||
|
session.state = "working";
|
||||||
|
title = summary.title;
|
||||||
|
detail = summary.detail;
|
||||||
|
if (kind === "tool_end") {
|
||||||
|
session.counts.tools += 1;
|
||||||
|
} else {
|
||||||
|
session.counts.failures += 1;
|
||||||
|
detail = truncateDetail(String(payload["error"] ?? payload["message"] ?? "")) || detail;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "permission_denied":
|
||||||
|
session.counts.denials += 1;
|
||||||
|
title = tool ? `Permission denied: ${tool}` : "Permission denied";
|
||||||
|
detail = summarizeTool(tool ?? "tool", payload.toolInput).title;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "turn_end":
|
||||||
|
session.state = "idle";
|
||||||
|
session.currentTool = undefined;
|
||||||
|
title = "Turn finished";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "turn_error":
|
||||||
|
session.state = "error";
|
||||||
|
session.currentTool = undefined;
|
||||||
|
title = "Turn failed";
|
||||||
|
detail = truncateDetail(String(payload["error"] ?? payload["message"] ?? "")) || undefined;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "notification":
|
||||||
|
title = summarizeNotification(payload);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "subagent_start":
|
||||||
|
title = "Subagent started";
|
||||||
|
detail = truncateDetail(String(payload["description"] ?? payload["subagentType"] ?? "")) || undefined;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "subagent_end":
|
||||||
|
title = "Subagent finished";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "compact":
|
||||||
|
title = hookName === "PreCompact" ? "Compacting conversation" : "Compaction done";
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event: GlanceEvent = {
|
||||||
|
id: this.nextId++,
|
||||||
|
ts: now,
|
||||||
|
sessionId,
|
||||||
|
kind,
|
||||||
|
tool,
|
||||||
|
title: truncateTitle(title),
|
||||||
|
detail,
|
||||||
|
durationMs,
|
||||||
|
};
|
||||||
|
this.push(event);
|
||||||
|
this.notify();
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record something the daemon itself decided, e.g. an approval outcome. */
|
||||||
|
record(
|
||||||
|
sessionId: string,
|
||||||
|
kind: EventKind,
|
||||||
|
title: string,
|
||||||
|
opts: { tool?: string; detail?: string } = {},
|
||||||
|
): GlanceEvent {
|
||||||
|
const now = Date.now();
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (session) {
|
||||||
|
session.lastActivity = now;
|
||||||
|
if (kind === "approval_request") session.state = "waiting";
|
||||||
|
else if (kind === "approval_allowed" || kind === "approval_denied") session.state = "working";
|
||||||
|
}
|
||||||
|
const event: GlanceEvent = {
|
||||||
|
id: this.nextId++,
|
||||||
|
ts: now,
|
||||||
|
sessionId,
|
||||||
|
kind,
|
||||||
|
tool: opts.tool,
|
||||||
|
title: truncateTitle(title),
|
||||||
|
detail: opts.detail,
|
||||||
|
};
|
||||||
|
this.push(event);
|
||||||
|
this.notify();
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
private effectiveState(session: SessionView, now: number): SessionState {
|
||||||
|
if (session.state === "working" && now - session.lastActivity > STALE_WORKING_MS) {
|
||||||
|
return "idle";
|
||||||
|
}
|
||||||
|
return session.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(pending: PendingApproval[]): Snapshot {
|
||||||
|
const now = Date.now();
|
||||||
|
const waiting = new Set(pending.map((p) => p.sessionId));
|
||||||
|
const sessions = [...this.sessions.values()]
|
||||||
|
.map((s) => ({
|
||||||
|
...s,
|
||||||
|
state: waiting.has(s.id) ? ("waiting" as SessionState) : this.effectiveState(s, now),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.lastActivity - a.lastActivity);
|
||||||
|
|
||||||
|
return {
|
||||||
|
now,
|
||||||
|
version: VERSION,
|
||||||
|
sessions,
|
||||||
|
events: [...this.events].sort((a, b) => b.ts - a.ts || b.id - a.id),
|
||||||
|
pending,
|
||||||
|
approval: this.cfg.approval,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionLabel(sessionId: string): string {
|
||||||
|
return this.sessions.get(sessionId)?.label ?? "workspace";
|
||||||
|
}
|
||||||
|
|
||||||
|
get sessionCount(): number {
|
||||||
|
return this.sessions.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
get eventCount(): number {
|
||||||
|
return this.events.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { ServerResponse } from "node:http";
|
||||||
|
import { SECURITY_HEADERS } from "./http.js";
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
/** dist/server/* and dist/web/* are siblings after a build. */
|
||||||
|
export const WEB_ROOT = path.resolve(here, "..", "web");
|
||||||
|
|
||||||
|
const TYPES: Record<string, string> = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".mjs": "text/javascript; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".webmanifest": "application/manifest+json; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".png": "image/png",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function webBuildExists(): boolean {
|
||||||
|
return fs.existsSync(path.join(WEB_ROOT, "index.html"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve the built app. Vite fingerprints everything under /assets, so those can be cached hard
|
||||||
|
* while index.html must not be — otherwise a phone keeps a stale shell after an upgrade.
|
||||||
|
*/
|
||||||
|
export function serveStatic(urlPath: string, res: ServerResponse): void {
|
||||||
|
if (!webBuildExists()) {
|
||||||
|
res.writeHead(503, { ...SECURITY_HEADERS, "content-type": "text/plain; charset=utf-8" });
|
||||||
|
res.end("grok-glance: web app not built yet. Run `npm install && npm run build`.\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clean = decodeURIComponent(urlPath.split("?")[0]);
|
||||||
|
const candidate = path.resolve(WEB_ROOT, "." + path.posix.normalize(clean));
|
||||||
|
|
||||||
|
// Anything that escapes the build directory falls back to the shell rather than leaking.
|
||||||
|
const inside = candidate === WEB_ROOT || candidate.startsWith(WEB_ROOT + path.sep);
|
||||||
|
let file = inside && isFile(candidate) ? candidate : "";
|
||||||
|
if (!file) file = path.join(WEB_ROOT, "index.html");
|
||||||
|
|
||||||
|
const ext = path.extname(file).toLowerCase();
|
||||||
|
const isHashed = file.includes(`${path.sep}assets${path.sep}`);
|
||||||
|
try {
|
||||||
|
const body = fs.readFileSync(file);
|
||||||
|
res.writeHead(200, {
|
||||||
|
...SECURITY_HEADERS,
|
||||||
|
"content-type": TYPES[ext] ?? "application/octet-stream",
|
||||||
|
"cache-control": isHashed ? "public, max-age=31536000, immutable" : "no-cache",
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(404, { ...SECURITY_HEADERS, "content-type": "text/plain; charset=utf-8" });
|
||||||
|
res.end("not found\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFile(p: string): boolean {
|
||||||
|
try {
|
||||||
|
return fs.statSync(p).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import type { AuthenticatorTransportFuture } from "@simplewebauthn/server";
|
||||||
|
import { ensureHome, paths } from "./config.js";
|
||||||
|
import type { DeviceInfo, GlanceEvent } from "./protocol.js";
|
||||||
|
|
||||||
|
export interface StoredCredential {
|
||||||
|
/** Base64URL credential ID. */
|
||||||
|
id: string;
|
||||||
|
/** Base64 (standard) encoded COSE public key. */
|
||||||
|
publicKey: string;
|
||||||
|
counter: number;
|
||||||
|
transports?: AuthenticatorTransportFuture[];
|
||||||
|
label: string;
|
||||||
|
createdAt: number;
|
||||||
|
lastUsedAt?: number;
|
||||||
|
deviceType?: string;
|
||||||
|
backedUp?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthSessionRecord {
|
||||||
|
/** SHA-256 of the session token. The token itself is never written to disk. */
|
||||||
|
tokenHash: string;
|
||||||
|
credentialId: string;
|
||||||
|
label: string;
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonFile<T>(file: string, fallback: T): T {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(file, "utf8")) as T;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJsonFile(file: string, value: unknown): void {
|
||||||
|
ensureHome();
|
||||||
|
const tmp = `${file}.tmp`;
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ secrets */
|
||||||
|
|
||||||
|
/** HMAC key used to sign session cookies. Created once, 0600. */
|
||||||
|
export function sessionSecret(): Buffer {
|
||||||
|
ensureHome();
|
||||||
|
try {
|
||||||
|
const existing = fs.readFileSync(paths.secret);
|
||||||
|
if (existing.length >= 32) return existing;
|
||||||
|
} catch {
|
||||||
|
/* create below */
|
||||||
|
}
|
||||||
|
const key = crypto.randomBytes(32);
|
||||||
|
fs.writeFileSync(paths.secret, key, { mode: 0o600 });
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token that authorises privileged local operations (enrol, revoke, shutdown).
|
||||||
|
* Rotated on every daemon start so a leaked token dies with the process.
|
||||||
|
*/
|
||||||
|
export function rotateAdminToken(): string {
|
||||||
|
ensureHome();
|
||||||
|
const token = crypto.randomBytes(24).toString("base64url");
|
||||||
|
fs.writeFileSync(paths.adminToken, token + "\n", { mode: 0o600 });
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- credentials */
|
||||||
|
|
||||||
|
export function listCredentials(): StoredCredential[] {
|
||||||
|
return readJsonFile<StoredCredential[]>(paths.credentials, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCredentials(creds: StoredCredential[]): void {
|
||||||
|
writeJsonFile(paths.credentials, creds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addCredential(cred: StoredCredential): void {
|
||||||
|
const all = listCredentials().filter((c) => c.id !== cred.id);
|
||||||
|
all.push(cred);
|
||||||
|
saveCredentials(all);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findCredential(id: string): StoredCredential | undefined {
|
||||||
|
return listCredentials().find((c) => c.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function touchCredential(id: string, counter: number): void {
|
||||||
|
const all = listCredentials();
|
||||||
|
const cred = all.find((c) => c.id === id);
|
||||||
|
if (!cred) return;
|
||||||
|
cred.counter = counter;
|
||||||
|
cred.lastUsedAt = Date.now();
|
||||||
|
saveCredentials(all);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeCredentials(idPrefix: string): number {
|
||||||
|
const all = listCredentials();
|
||||||
|
const keep = all.filter((c) => !c.id.startsWith(idPrefix));
|
||||||
|
saveCredentials(keep);
|
||||||
|
const removed = all.length - keep.length;
|
||||||
|
if (removed > 0) {
|
||||||
|
// A revoked device must lose any live session too, or it keeps its cookie access.
|
||||||
|
const sessions = listAuthSessions().filter((s) => !s.credentialId.startsWith(idPrefix));
|
||||||
|
writeJsonFile(paths.authSessions, sessions);
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deviceList(): DeviceInfo[] {
|
||||||
|
return listCredentials().map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
label: c.label,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
lastUsedAt: c.lastUsedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ auth sessions */
|
||||||
|
|
||||||
|
function listAuthSessions(): AuthSessionRecord[] {
|
||||||
|
const now = Date.now();
|
||||||
|
return readJsonFile<AuthSessionRecord[]>(paths.authSessions, []).filter(
|
||||||
|
(s) => s.expiresAt > now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashToken(token: string): string {
|
||||||
|
return crypto.createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuthSession(
|
||||||
|
credentialId: string,
|
||||||
|
label: string,
|
||||||
|
ttlMs: number,
|
||||||
|
): { token: string; expiresAt: number } {
|
||||||
|
const token = crypto.randomBytes(32).toString("base64url");
|
||||||
|
const expiresAt = Date.now() + ttlMs;
|
||||||
|
const sessions = listAuthSessions();
|
||||||
|
sessions.push({
|
||||||
|
tokenHash: hashToken(token),
|
||||||
|
credentialId,
|
||||||
|
label,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
writeJsonFile(paths.authSessions, sessions);
|
||||||
|
return { token, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lookupAuthSession(token: string): AuthSessionRecord | undefined {
|
||||||
|
const wanted = hashToken(token);
|
||||||
|
return listAuthSessions().find((s) => {
|
||||||
|
const a = Buffer.from(s.tokenHash, "hex");
|
||||||
|
const b = Buffer.from(wanted, "hex");
|
||||||
|
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroyAuthSession(token: string): void {
|
||||||
|
const wanted = hashToken(token);
|
||||||
|
writeJsonFile(
|
||||||
|
paths.authSessions,
|
||||||
|
listAuthSessions().filter((s) => s.tokenHash !== wanted),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- event log */
|
||||||
|
|
||||||
|
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
export function appendEventLog(event: GlanceEvent): void {
|
||||||
|
try {
|
||||||
|
ensureHome();
|
||||||
|
let size = 0;
|
||||||
|
try {
|
||||||
|
size = fs.statSync(paths.events).size;
|
||||||
|
} catch {
|
||||||
|
/* no log yet */
|
||||||
|
}
|
||||||
|
if (size > MAX_LOG_BYTES) {
|
||||||
|
fs.renameSync(paths.events, `${paths.events}.1`);
|
||||||
|
}
|
||||||
|
fs.appendFileSync(paths.events, JSON.stringify(event) + "\n", { mode: 0o600 });
|
||||||
|
} catch {
|
||||||
|
// The dashboard is not worth crashing over.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read back the tail of the log so a restarted daemon still has recent history. */
|
||||||
|
export function readRecentEvents(limit: number): GlanceEvent[] {
|
||||||
|
try {
|
||||||
|
const text = fs.readFileSync(paths.events, "utf8");
|
||||||
|
const lines = text.split("\n").filter(Boolean).slice(-limit);
|
||||||
|
const out: GlanceEvent[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
out.push(JSON.parse(line) as GlanceEvent);
|
||||||
|
} catch {
|
||||||
|
/* skip malformed line */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Turns a raw hook payload into something you can read on a phone screen.
|
||||||
|
*
|
||||||
|
* Two jobs: pick the one field that actually says what the tool is doing, and strip anything
|
||||||
|
* that looks like a credential before it leaves the machine.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TITLE_MAX = 180;
|
||||||
|
const DETAIL_MAX = 400;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conservative redaction: only well-known credential shapes and explicit key=value
|
||||||
|
* assignments. Deliberately not "redact any long string" — that would mangle ordinary
|
||||||
|
* paths and hashes and make the timeline useless.
|
||||||
|
*/
|
||||||
|
const REDACTIONS: Array<[RegExp, string]> = [
|
||||||
|
[/\b(sk|rk|pk)-[A-Za-z0-9_-]{16,}/g, "$1-[redacted]"],
|
||||||
|
[/\bxai-[A-Za-z0-9_-]{16,}/g, "xai-[redacted]"],
|
||||||
|
[/\bgh[pousr]_[A-Za-z0-9_]{16,}/g, "gh?_[redacted]"],
|
||||||
|
[/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, "xox?-[redacted]"],
|
||||||
|
[/\bAKIA[0-9A-Z]{16}\b/g, "AKIA[redacted]"],
|
||||||
|
[/\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,}/g, "[jwt redacted]"],
|
||||||
|
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "[private key redacted]"],
|
||||||
|
[/\b(authorization|bearer)\s*[:=]?\s*[A-Za-z0-9._~+/-]{16,}=*/gi, "$1 [redacted]"],
|
||||||
|
[
|
||||||
|
/\b([A-Za-z0-9_]*(?:password|passwd|secret|token|api[_-]?key|access[_-]?key)[A-Za-z0-9_]*)\s*[:=]\s*("[^"]*"|'[^']*'|\S+)/gi,
|
||||||
|
"$1=[redacted]",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function redact(text: string): string {
|
||||||
|
let out = text;
|
||||||
|
for (const [pattern, replacement] of REDACTIONS) out = out.replace(pattern, replacement);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean(value: unknown, max: number): string {
|
||||||
|
if (value === undefined || value === null) return "";
|
||||||
|
const raw = typeof value === "string" ? value : JSON.stringify(value);
|
||||||
|
const collapsed = redact(raw).replace(/\s+/g, " ").trim();
|
||||||
|
return collapsed.length > max ? collapsed.slice(0, max - 1) + "…" : collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function basename(p: string): string {
|
||||||
|
const parts = p.replace(/\/+$/, "").split("/");
|
||||||
|
return parts[parts.length - 1] || p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shorten a path to something that fits a phone: keep the last two segments. */
|
||||||
|
export function shortPath(p: string): string {
|
||||||
|
if (!p) return "";
|
||||||
|
const parts = p.replace(/\/+$/, "").split("/").filter(Boolean);
|
||||||
|
if (parts.length <= 2) return p;
|
||||||
|
return `…/${parts.slice(-2).join("/")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolSummary {
|
||||||
|
title: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grok maps Claude Code tool names onto its own, so the input keys follow the Claude
|
||||||
|
* shapes. Anything unrecognised falls back to a JSON preview.
|
||||||
|
*/
|
||||||
|
export function summarizeTool(toolName: string, input: unknown): ToolSummary {
|
||||||
|
const obj = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const pick = (...keys: string[]): string | undefined => {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = obj[key];
|
||||||
|
if (typeof value === "string" && value.trim()) return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (toolName) {
|
||||||
|
case "Bash":
|
||||||
|
case "BashOutput": {
|
||||||
|
const cmd = pick("command");
|
||||||
|
return {
|
||||||
|
title: clean(cmd ?? toolName, TITLE_MAX),
|
||||||
|
detail: clean(pick("description"), DETAIL_MAX) || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "Read":
|
||||||
|
case "Write":
|
||||||
|
case "Edit":
|
||||||
|
case "MultiEdit":
|
||||||
|
case "NotebookEdit": {
|
||||||
|
const file = pick("file_path", "notebook_path", "path");
|
||||||
|
return {
|
||||||
|
title: file ? shortPath(clean(file, TITLE_MAX)) : toolName,
|
||||||
|
detail: file ? clean(file, DETAIL_MAX) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "Glob":
|
||||||
|
case "Grep": {
|
||||||
|
const pattern = pick("pattern", "query");
|
||||||
|
const where = pick("path", "glob");
|
||||||
|
return {
|
||||||
|
title: clean(pattern ?? toolName, TITLE_MAX),
|
||||||
|
detail: where ? clean(where, DETAIL_MAX) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "WebFetch":
|
||||||
|
case "WebSearch": {
|
||||||
|
const target = pick("url", "query");
|
||||||
|
return { title: clean(target ?? toolName, TITLE_MAX) };
|
||||||
|
}
|
||||||
|
case "Task":
|
||||||
|
case "Agent": {
|
||||||
|
return {
|
||||||
|
title: clean(pick("description", "prompt") ?? toolName, TITLE_MAX),
|
||||||
|
detail: clean(pick("subagent_type"), DETAIL_MAX) || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const first = pick("command", "file_path", "path", "url", "query", "pattern", "description");
|
||||||
|
if (first) return { title: clean(first, TITLE_MAX) };
|
||||||
|
const keys = Object.keys(obj);
|
||||||
|
if (!keys.length) return { title: toolName };
|
||||||
|
return { title: clean(obj[keys[0]], TITLE_MAX) || toolName };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizePrompt(payload: Record<string, unknown>): string {
|
||||||
|
const prompt =
|
||||||
|
payload["prompt"] ?? payload["userPrompt"] ?? payload["message"] ?? payload["text"];
|
||||||
|
return clean(prompt, TITLE_MAX) || "(prompt)";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeNotification(payload: Record<string, unknown>): string {
|
||||||
|
const message = payload["message"] ?? payload["notification"] ?? payload["text"];
|
||||||
|
return clean(message, TITLE_MAX) || "Notification";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function labelForWorkspace(workspaceRoot: string | undefined, cwd: string): string {
|
||||||
|
const source = workspaceRoot || cwd || "";
|
||||||
|
return basename(source) || "workspace";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateTitle(text: string): string {
|
||||||
|
return clean(text, TITLE_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateDetail(text: string): string {
|
||||||
|
return clean(text, DETAIL_MAX);
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import {
|
||||||
|
generateAuthenticationOptions,
|
||||||
|
generateRegistrationOptions,
|
||||||
|
verifyAuthenticationResponse,
|
||||||
|
verifyRegistrationResponse,
|
||||||
|
type AuthenticationResponseJSON,
|
||||||
|
type RegistrationResponseJSON,
|
||||||
|
} from "@simplewebauthn/server";
|
||||||
|
import { expectedOrigins, expectedRpIds, type Config } from "./config.js";
|
||||||
|
import {
|
||||||
|
addCredential,
|
||||||
|
createAuthSession,
|
||||||
|
findCredential,
|
||||||
|
listCredentials,
|
||||||
|
touchCredential,
|
||||||
|
} from "./store.js";
|
||||||
|
|
||||||
|
/** Passkeys live for a month before the phone has to prove itself again. */
|
||||||
|
export const SESSION_TTL_MS = 30 * 24 * 60 * 60_000;
|
||||||
|
|
||||||
|
const CHALLENGE_TTL_MS = 5 * 60_000;
|
||||||
|
const MAX_CHALLENGES = 32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* There is exactly one logical user here — you. A stable handle means a second passkey
|
||||||
|
* enrolled later joins the same account instead of creating a parallel one.
|
||||||
|
*/
|
||||||
|
const USER_HANDLE = new TextEncoder().encode("grok-glance");
|
||||||
|
const USER_NAME = "grok-glance";
|
||||||
|
|
||||||
|
type Purpose = "register" | "authenticate";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Challenges are held server-side and consumed exactly once. SimpleWebAuthn lets us pass a
|
||||||
|
* predicate for `expectedChallenge`, so we never need to trust the client to tell us which
|
||||||
|
* challenge it was answering.
|
||||||
|
*/
|
||||||
|
class ChallengeStore {
|
||||||
|
private items = new Map<string, { purpose: Purpose; expiresAt: number }>();
|
||||||
|
|
||||||
|
issue(challenge: string, purpose: Purpose): void {
|
||||||
|
this.prune();
|
||||||
|
if (this.items.size >= MAX_CHALLENGES) {
|
||||||
|
// Drop the oldest rather than growing without bound.
|
||||||
|
const oldest = this.items.keys().next();
|
||||||
|
if (!oldest.done) this.items.delete(oldest.value);
|
||||||
|
}
|
||||||
|
this.items.set(challenge, { purpose, expiresAt: Date.now() + CHALLENGE_TTL_MS });
|
||||||
|
}
|
||||||
|
|
||||||
|
consume(challenge: string, purpose: Purpose): boolean {
|
||||||
|
this.prune();
|
||||||
|
const entry = this.items.get(challenge);
|
||||||
|
if (!entry || entry.purpose !== purpose) return false;
|
||||||
|
this.items.delete(challenge);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private prune(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, value] of this.items) {
|
||||||
|
if (value.expiresAt <= now) this.items.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthOutcome {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
token?: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebAuthnService {
|
||||||
|
private challenges = new ChallengeStore();
|
||||||
|
|
||||||
|
constructor(private readonly cfg: Config) {}
|
||||||
|
|
||||||
|
private get rpId(): string {
|
||||||
|
return this.cfg.rpId ?? "localhost";
|
||||||
|
}
|
||||||
|
|
||||||
|
get enrolled(): boolean {
|
||||||
|
return listCredentials().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async registrationOptions() {
|
||||||
|
const existing = listCredentials();
|
||||||
|
const options = await generateRegistrationOptions({
|
||||||
|
rpName: this.cfg.rpName,
|
||||||
|
rpID: this.rpId,
|
||||||
|
userID: USER_HANDLE,
|
||||||
|
userName: USER_NAME,
|
||||||
|
userDisplayName: this.cfg.rpName,
|
||||||
|
attestationType: "none",
|
||||||
|
// Don't let the same device enrol twice — it just confuses the device list.
|
||||||
|
excludeCredentials: existing.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
transports: c.transports,
|
||||||
|
})),
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: "required",
|
||||||
|
// "Strictly guarded" means a biometric or PIN every time, not merely possession.
|
||||||
|
userVerification: "required",
|
||||||
|
},
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
this.challenges.issue(options.challenge, "register");
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyRegistration(
|
||||||
|
response: RegistrationResponseJSON,
|
||||||
|
label: string,
|
||||||
|
): Promise<AuthOutcome> {
|
||||||
|
let verification;
|
||||||
|
try {
|
||||||
|
verification = await verifyRegistrationResponse({
|
||||||
|
response,
|
||||||
|
expectedChallenge: (challenge) => this.challenges.consume(challenge, "register"),
|
||||||
|
expectedOrigin: expectedOrigins(this.cfg),
|
||||||
|
expectedRPID: expectedRpIds(this.cfg),
|
||||||
|
requireUserVerification: true,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verification.verified || !verification.registrationInfo) {
|
||||||
|
return { ok: false, error: "registration could not be verified" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
||||||
|
addCredential({
|
||||||
|
id: credential.id,
|
||||||
|
publicKey: Buffer.from(credential.publicKey).toString("base64"),
|
||||||
|
counter: credential.counter,
|
||||||
|
transports: credential.transports,
|
||||||
|
label: label.trim().slice(0, 40) || "device",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
deviceType: credentialDeviceType,
|
||||||
|
backedUp: credentialBackedUp,
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = createAuthSession(credential.id, label, SESSION_TTL_MS);
|
||||||
|
return { ok: true, token: session.token, label };
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticationOptions() {
|
||||||
|
const options = await generateAuthenticationOptions({
|
||||||
|
rpID: this.rpId,
|
||||||
|
allowCredentials: listCredentials().map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
transports: c.transports,
|
||||||
|
})),
|
||||||
|
userVerification: "required",
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
this.challenges.issue(options.challenge, "authenticate");
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyAuthentication(response: AuthenticationResponseJSON): Promise<AuthOutcome> {
|
||||||
|
const stored = findCredential(response.id);
|
||||||
|
if (!stored) return { ok: false, error: "unknown device" };
|
||||||
|
|
||||||
|
let verification;
|
||||||
|
try {
|
||||||
|
verification = await verifyAuthenticationResponse({
|
||||||
|
response,
|
||||||
|
expectedChallenge: (challenge) => this.challenges.consume(challenge, "authenticate"),
|
||||||
|
expectedOrigin: expectedOrigins(this.cfg),
|
||||||
|
expectedRPID: expectedRpIds(this.cfg),
|
||||||
|
credential: {
|
||||||
|
id: stored.id,
|
||||||
|
publicKey: new Uint8Array(Buffer.from(stored.publicKey, "base64")),
|
||||||
|
counter: stored.counter,
|
||||||
|
transports: stored.transports,
|
||||||
|
},
|
||||||
|
requireUserVerification: true,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verification.verified) return { ok: false, error: "assertion rejected" };
|
||||||
|
|
||||||
|
touchCredential(stored.id, verification.authenticationInfo.newCounter);
|
||||||
|
const session = createAuthSession(stored.id, stored.label, SESSION_TTL_MS);
|
||||||
|
return { ok: true, token: session.token, label: stored.label };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
name: glance
|
||||||
|
description: Set up, inspect, or control grok-glance — the passkey-guarded phone dashboard for this Grok Build session. Use when the user asks to watch a session from their phone, enrol a device, expose the dashboard over Tailscale, or turn remote approve/deny on or off.
|
||||||
|
---
|
||||||
|
|
||||||
|
# grok-glance
|
||||||
|
|
||||||
|
A local daemon plus web dashboard that shows what Grok Build is doing, readable from a phone
|
||||||
|
behind a WebAuthn passkey. It can also pause risky tool calls until someone taps approve.
|
||||||
|
|
||||||
|
The daemon is started automatically by the `SessionStart` hook. Everything below is done through
|
||||||
|
the `glance` CLI at `$GROK_PLUGIN_ROOT/bin/glance`.
|
||||||
|
|
||||||
|
## First check whether it is even built
|
||||||
|
|
||||||
|
The plugin ships as TypeScript and must be built once:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd "$GROK_PLUGIN_ROOT" && npm install && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
`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
|
||||||
|
|
||||||
|
```sh
|
||||||
|
glance status # is it running, which origin, how many devices
|
||||||
|
glance up # start the daemon in the background
|
||||||
|
glance stop # stop it
|
||||||
|
glance logs # last 60 lines of the daemon log
|
||||||
|
glance enroll # mint a one-time code + URL for a new phone
|
||||||
|
glance set-origin <https-url> # set the public origin and WebAuthn RP ID
|
||||||
|
glance devices # list enrolled devices
|
||||||
|
glance revoke <id-prefix> # revoke one
|
||||||
|
glance approval <off|risky|all> # remote approve/deny policy
|
||||||
|
glance sync-hooks # rewrite hook URLs after changing the port
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting it onto a phone
|
||||||
|
|
||||||
|
The dashboard listens on `127.0.0.1` only. Passkeys need a real hostname with valid TLS — a bare
|
||||||
|
IP can never be a WebAuthn RP ID — so the supported path is Tailscale Serve:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tailscale serve --bg 127.0.0.1:8791
|
||||||
|
tailscale serve status # read the https://<box>.<tailnet>.ts.net URL
|
||||||
|
glance set-origin https://<box>.<tailnet>.ts.net
|
||||||
|
glance enroll
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open the printed URL on the phone, type the code, and create the passkey. The phone must be
|
||||||
|
on the same tailnet.
|
||||||
|
|
||||||
|
Changing the origin changes the RP ID, which invalidates existing passkeys. Say so before running
|
||||||
|
`set-origin` on a working setup.
|
||||||
|
|
||||||
|
## Remote approve/deny
|
||||||
|
|
||||||
|
`glance approval risky` makes `Bash`, `Write`, `Edit`, `MultiEdit` and `NotebookEdit` calls pause
|
||||||
|
and wait for a tap on the phone. Defaults that matter:
|
||||||
|
|
||||||
|
- Nothing waits unless a phone is actually watching the dashboard (`requireWatcher`).
|
||||||
|
- If nobody answers within 90s the call is **allowed**, not denied. Flip that on the phone's
|
||||||
|
settings panel if you want the opposite.
|
||||||
|
- Every failure path is fail-open: daemon down, timeout, bad JSON — the tool call proceeds. This
|
||||||
|
is a convenience gate, not a security boundary.
|
||||||
|
|
||||||
|
`glance approval off` (the default) means Grok Build never blocks on the phone.
|
||||||
|
|
||||||
|
## When something does not work
|
||||||
|
|
||||||
|
- **"not running"** → `glance up`, then `glance logs`.
|
||||||
|
- **Passkey prompt fails with a security error** → the phone is on a hostname the RP ID does not
|
||||||
|
cover. Compare `glance status`'s `rp id` with the hostname in the phone's address bar.
|
||||||
|
- **Dashboard loads but shows nothing** → hooks are not firing. Check that the port in
|
||||||
|
`hooks/hooks.json` matches `~/.grok/glance/config.json`; `glance sync-hooks` fixes it.
|
||||||
|
- **Page says "run npm install && npm run build"** → the web bundle is missing; build it.
|
||||||
|
|
||||||
|
## What it deliberately does not do
|
||||||
|
|
||||||
|
Read-only plus approve/deny. It cannot send prompts, edit files, run tools, or resume a session.
|
||||||
|
Do not tell the user otherwise.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"outDir": "dist/server",
|
||||||
|
"rootDir": "server/src",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["server/src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./web/src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["web/src", "web/vite-env.d.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
// The web app lives in web/ and is emitted into dist/web, which the daemon serves.
|
||||||
|
export default defineConfig({
|
||||||
|
root: "web",
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": fileURLToPath(new URL("./web/src", import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "../dist/web",
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// `npm run dev` serves the UI on 5173 and proxies the API to the daemon.
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://127.0.0.1:8791",
|
||||||
|
"/events": {
|
||||||
|
target: "http://127.0.0.1:8791",
|
||||||
|
changeOrigin: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<!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" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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 |
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+193
@@ -0,0 +1,193 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Alert, Button, Card, Spinner, useTheme } from "@heroui/react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useGlance, useNow } from "@/lib/useGlance";
|
||||||
|
import { Gate } from "@/components/Gate";
|
||||||
|
import { NowCard } from "@/components/NowCard";
|
||||||
|
import { PendingCard } from "@/components/PendingCard";
|
||||||
|
import { SessionsCard } from "@/components/SessionsCard";
|
||||||
|
import { SettingsPanel } from "@/components/SettingsPanel";
|
||||||
|
import { Timeline } from "@/components/Timeline";
|
||||||
|
import { GearIcon } from "@/components/icons";
|
||||||
|
import type { GateInfo } from "@/protocol";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [gate, setGate] = useState<GateInfo | null>(null);
|
||||||
|
const [fatal, setFatal] = useState<string | null>(null);
|
||||||
|
// HeroUI's own hook: it writes the `dark` class and `data-theme` in a layout effect (no
|
||||||
|
// flash), follows the OS while the intent is "system", and persists an explicit choice.
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
const [busyIds, setBusyIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const refreshGate = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setGate(await api.gate());
|
||||||
|
setFatal(null);
|
||||||
|
} catch (err) {
|
||||||
|
setFatal((err as Error).message);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshGate();
|
||||||
|
}, [refreshGate]);
|
||||||
|
|
||||||
|
const authenticated = !!gate?.authenticated;
|
||||||
|
const { snapshot, connection } = useGlance(authenticated);
|
||||||
|
const now = useNow(1000);
|
||||||
|
|
||||||
|
// A dropped stream is usually the network, but it is also how an expired session shows up.
|
||||||
|
// Re-checking the gate turns the second case back into the unlock screen instead of a spinner.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authenticated || connection !== "offline") return;
|
||||||
|
const id = window.setTimeout(() => void refreshGate(), 3000);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
|
}, [authenticated, connection, refreshGate]);
|
||||||
|
|
||||||
|
async function resolve(id: string, decision: "allow" | "deny") {
|
||||||
|
setBusyIds((ids) => [...ids, id]);
|
||||||
|
try {
|
||||||
|
await api.resolveApproval(id, decision);
|
||||||
|
} catch {
|
||||||
|
// Losing the race is normal: it may have timed out, or another device answered first.
|
||||||
|
} finally {
|
||||||
|
setBusyIds((ids) => ids.filter((x) => x !== id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fatal && !gate) {
|
||||||
|
return (
|
||||||
|
<Centered>
|
||||||
|
<Alert status="danger">
|
||||||
|
<Alert.Content>
|
||||||
|
<Alert.Title>Cannot reach the daemon</Alert.Title>
|
||||||
|
<Alert.Description>{fatal}</Alert.Description>
|
||||||
|
</Alert.Content>
|
||||||
|
</Alert>
|
||||||
|
<Button variant="outline" size="md" fullWidth onPress={() => void refreshGate()}>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</Centered>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gate) {
|
||||||
|
return (
|
||||||
|
<Centered>
|
||||||
|
<Spinner size="lg" color="current" />
|
||||||
|
</Centered>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authenticated) {
|
||||||
|
return <Gate gate={gate} onSignedIn={() => void refreshGate()} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = snapshot?.sessions ?? [];
|
||||||
|
const focus = sessions.find((s) => s.id === selected) ?? sessions[0];
|
||||||
|
const pending = snapshot?.pending ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh bg-background text-foreground">
|
||||||
|
<header className="sticky top-0 z-10 border-b border-separator bg-background/85 backdrop-blur-md">
|
||||||
|
<div className="mx-auto flex max-w-md items-center gap-3 px-4 pt-[max(0.75rem,env(safe-area-inset-top))] pb-3">
|
||||||
|
<span
|
||||||
|
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||||
|
connection === "live"
|
||||||
|
? "bg-success"
|
||||||
|
: connection === "connecting"
|
||||||
|
? "bg-warning"
|
||||||
|
: "bg-danger"
|
||||||
|
}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h1 className="truncate text-sm font-semibold tracking-tight">grok-glance</h1>
|
||||||
|
<p className="text-[11px] text-muted">
|
||||||
|
{connection === "live"
|
||||||
|
? `${sessions.length} session${sessions.length === 1 ? "" : "s"}`
|
||||||
|
: connection === "connecting"
|
||||||
|
? "connecting…"
|
||||||
|
: "offline — retrying"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={showSettings ? "primary" : "ghost"}
|
||||||
|
isIconOnly
|
||||||
|
aria-label="Settings"
|
||||||
|
onPress={() => setShowSettings((value) => !value)}
|
||||||
|
>
|
||||||
|
<GearIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto flex max-w-md flex-col gap-3 px-4 py-4 pb-[max(1.5rem,env(safe-area-inset-bottom))]">
|
||||||
|
{pending.map((approval) => (
|
||||||
|
<PendingCard
|
||||||
|
key={approval.id}
|
||||||
|
approval={approval}
|
||||||
|
now={now}
|
||||||
|
busy={busyIds.includes(approval.id)}
|
||||||
|
onResolve={resolve}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{showSettings && snapshot && (
|
||||||
|
<SettingsPanel
|
||||||
|
approval={snapshot.approval}
|
||||||
|
deviceLabel={gate.deviceLabel}
|
||||||
|
version={snapshot.version}
|
||||||
|
theme={resolvedTheme === "dark" ? "dark" : "light"}
|
||||||
|
onToggleTheme={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
|
||||||
|
onSignedOut={() => void refreshGate()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!snapshot ? (
|
||||||
|
<Centered inline>
|
||||||
|
<Spinner size="lg" color="current" />
|
||||||
|
</Centered>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title className="text-base">Nothing to show yet</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
Start Grok Build in a workspace and this page will fill in as it works.
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{focus && <NowCard session={focus} now={now} />}
|
||||||
|
{sessions.length > 1 && (
|
||||||
|
<SessionsCard
|
||||||
|
sessions={sessions}
|
||||||
|
selectedId={selected}
|
||||||
|
onSelect={setSelected}
|
||||||
|
now={now}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Timeline events={snapshot.events} sessionId={selected} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Centered({ children, inline }: { children: ReactNode; inline?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mx-auto flex w-full max-w-sm flex-col items-center justify-center gap-4 px-5 ${
|
||||||
|
inline ? "py-16" : "min-h-dvh"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Alert, Button, Card, Input, Spinner } from "@heroui/react";
|
||||||
|
import { browserSupportsWebAuthn } from "@simplewebauthn/browser";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { FingerprintIcon, LockIcon } from "@/components/icons";
|
||||||
|
import type { GateInfo } from "@/protocol";
|
||||||
|
|
||||||
|
/** A friendly default so most people never touch the label field. */
|
||||||
|
function guessDeviceName(): string {
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
if (/iPhone/.test(ua)) return "iPhone";
|
||||||
|
if (/iPad/.test(ua)) return "iPad";
|
||||||
|
if (/Android/.test(ua)) return "Android phone";
|
||||||
|
if (/Macintosh/.test(ua)) return "Mac";
|
||||||
|
if (/Windows/.test(ua)) return "Windows PC";
|
||||||
|
return "device";
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageFor(err: unknown): string {
|
||||||
|
const e = err as { name?: string; message?: string };
|
||||||
|
if (e?.name === "NotAllowedError") return "Cancelled, or the prompt timed out. Try again.";
|
||||||
|
if (e?.name === "InvalidStateError") return "This device is already enrolled — just sign in.";
|
||||||
|
if (e?.name === "SecurityError") {
|
||||||
|
return "The browser refused this origin. Passkeys need the exact https hostname the daemon was configured with.";
|
||||||
|
}
|
||||||
|
return e?.message ?? "Something went wrong.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Gate({ gate, onSignedIn }: { gate: GateInfo; onSignedIn: () => void }) {
|
||||||
|
const wantsEnroll = new URLSearchParams(location.search).has("enroll");
|
||||||
|
const [enrolling, setEnrolling] = useState(!gate.enrolled || wantsEnroll);
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [label, setLabel] = useState(guessDeviceName);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const supported = browserSupportsWebAuthn();
|
||||||
|
// The commonest setup failure by far: the page was opened on a hostname the RP ID does not
|
||||||
|
// cover, e.g. the LAN IP instead of the tailnet name. Say so before the prompt fails.
|
||||||
|
const hostMismatch =
|
||||||
|
!!gate.rpId &&
|
||||||
|
location.hostname !== gate.rpId &&
|
||||||
|
!location.hostname.endsWith(`.${gate.rpId}`) &&
|
||||||
|
location.hostname !== "localhost";
|
||||||
|
|
||||||
|
async function run(fn: () => Promise<unknown>) {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
onSignedIn();
|
||||||
|
} catch (err) {
|
||||||
|
setError(messageFor(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-dvh w-full max-w-sm flex-col justify-center gap-5 px-5 py-10">
|
||||||
|
<header className="flex flex-col items-center gap-3 text-center">
|
||||||
|
<span className="rounded-2xl border border-border p-3 text-foreground">
|
||||||
|
<LockIcon className="h-6 w-6" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight">grok-glance</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted">
|
||||||
|
Only enrolled devices get past this screen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{!supported && (
|
||||||
|
<Alert status="danger">
|
||||||
|
<Alert.Content>
|
||||||
|
<Alert.Title>This browser cannot do passkeys</Alert.Title>
|
||||||
|
<Alert.Description>
|
||||||
|
Open the dashboard in Safari or Chrome over https.
|
||||||
|
</Alert.Description>
|
||||||
|
</Alert.Content>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hostMismatch && (
|
||||||
|
<Alert status="warning">
|
||||||
|
<Alert.Content>
|
||||||
|
<Alert.Title>Wrong hostname for this passkey</Alert.Title>
|
||||||
|
<Alert.Description>
|
||||||
|
You are on {location.hostname}, but the daemon expects {gate.rpId}. Open that
|
||||||
|
hostname instead, or run{" "}
|
||||||
|
<code className="font-mono text-xs">glance set-origin</code>.
|
||||||
|
</Alert.Description>
|
||||||
|
</Alert.Content>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert status="danger">
|
||||||
|
<Alert.Content>
|
||||||
|
<Alert.Title>{error}</Alert.Title>
|
||||||
|
</Alert.Content>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{enrolling ? (
|
||||||
|
<Card>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Enrol this device</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
Run <code className="font-mono text-xs">glance enroll</code> on the machine running
|
||||||
|
Grok Build, then type the code it prints.
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content className="flex flex-col gap-3">
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-foreground">Enrolment code</span>
|
||||||
|
<Input
|
||||||
|
value={code}
|
||||||
|
onChange={(event) => setCode(event.target.value.toUpperCase())}
|
||||||
|
placeholder="ABCD2345"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="characters"
|
||||||
|
spellCheck={false}
|
||||||
|
inputMode="text"
|
||||||
|
maxLength={12}
|
||||||
|
aria-label="Enrolment code"
|
||||||
|
className="font-mono tracking-[0.25em]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-foreground">Name this device</span>
|
||||||
|
<Input
|
||||||
|
value={label}
|
||||||
|
onChange={(event) => setLabel(event.target.value)}
|
||||||
|
placeholder="iPhone"
|
||||||
|
maxLength={40}
|
||||||
|
aria-label="Device name"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</Card.Content>
|
||||||
|
<Card.Footer className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
fullWidth
|
||||||
|
isDisabled={busy || !supported || code.trim().length < 4}
|
||||||
|
onPress={() => run(() => api.enroll(code, label))}
|
||||||
|
>
|
||||||
|
{busy ? <Spinner size="sm" color="current" /> : <FingerprintIcon />}
|
||||||
|
Create passkey
|
||||||
|
</Button>
|
||||||
|
{gate.enrolled && (
|
||||||
|
<Button variant="ghost" size="md" fullWidth onPress={() => setEnrolling(false)}>
|
||||||
|
I already have a passkey
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Card.Footer>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Unlock</Card.Title>
|
||||||
|
<Card.Description>Use the passkey on this device.</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Footer className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
fullWidth
|
||||||
|
isDisabled={busy || !supported}
|
||||||
|
onPress={() => run(() => api.signIn())}
|
||||||
|
>
|
||||||
|
{busy ? <Spinner size="sm" color="current" /> : <FingerprintIcon />}
|
||||||
|
Unlock with passkey
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="md" fullWidth onPress={() => setEnrolling(true)}>
|
||||||
|
Enrol a new device
|
||||||
|
</Button>
|
||||||
|
</Card.Footer>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-muted">
|
||||||
|
grok-glance {gate.version}
|
||||||
|
{gate.rpId ? ` · ${gate.rpId}` : ""}
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Card, Spinner } from "@heroui/react";
|
||||||
|
import { StateChip, ToolChip } from "@/components/StatusChip";
|
||||||
|
import { duration, relTime } from "@/lib/format";
|
||||||
|
import type { SessionView } from "@/protocol";
|
||||||
|
|
||||||
|
export function NowCard({ session, now }: { session: SessionView; now: number }) {
|
||||||
|
const tool = session.currentTool;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Card.Header>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Card.Title className="truncate text-base">{session.label}</Card.Title>
|
||||||
|
<Card.Description className="truncate text-xs">{session.cwd}</Card.Description>
|
||||||
|
</div>
|
||||||
|
<StateChip state={session.state} />
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
|
||||||
|
<Card.Content className="flex flex-col gap-3">
|
||||||
|
{session.lastPrompt && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-medium tracking-wide text-muted uppercase">
|
||||||
|
Last asked
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-sm leading-snug break-words">{session.lastPrompt}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tool ? (
|
||||||
|
<div className="flex items-start gap-2.5 rounded-xl bg-surface-secondary p-3">
|
||||||
|
<Spinner size="sm" color="current" className="mt-0.5" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ToolChip tool={tool.name} />
|
||||||
|
<span className="text-xs tabular-nums text-muted">
|
||||||
|
{duration(Math.max(0, now - tool.startedAt))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm leading-snug break-words">{tool.title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Nothing running · last activity {relTime(session.lastActivity, now)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card.Content>
|
||||||
|
|
||||||
|
<Card.Footer>
|
||||||
|
<dl className="grid w-full grid-cols-3 gap-2 text-center">
|
||||||
|
<Stat label="tools" value={session.counts.tools} />
|
||||||
|
<Stat label="failed" value={session.counts.failures} tone={session.counts.failures > 0} />
|
||||||
|
<Stat label="denied" value={session.counts.denials} tone={session.counts.denials > 0} />
|
||||||
|
</dl>
|
||||||
|
</Card.Footer>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, tone }: { label: string; value: number; tone?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-surface-secondary py-2">
|
||||||
|
<dd className={`text-lg leading-none font-semibold tabular-nums ${tone ? "text-danger" : ""}`}>
|
||||||
|
{value}
|
||||||
|
</dd>
|
||||||
|
<dt className="mt-1 text-[11px] tracking-wide text-muted uppercase">{label}</dt>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Button, Card } from "@heroui/react";
|
||||||
|
import { BanIcon, CheckIcon } from "@/components/icons";
|
||||||
|
import { ToolChip } from "@/components/StatusChip";
|
||||||
|
import { secondsLeft } from "@/lib/format";
|
||||||
|
import type { PendingApproval } from "@/protocol";
|
||||||
|
|
||||||
|
export function PendingCard({
|
||||||
|
approval,
|
||||||
|
now,
|
||||||
|
busy,
|
||||||
|
onResolve,
|
||||||
|
}: {
|
||||||
|
approval: PendingApproval;
|
||||||
|
now: number;
|
||||||
|
busy: boolean;
|
||||||
|
onResolve: (id: string, decision: "allow" | "deny") => void;
|
||||||
|
}) {
|
||||||
|
const left = secondsLeft(approval.expiresAt, now);
|
||||||
|
const total = Math.max(1, approval.expiresAt - approval.createdAt);
|
||||||
|
const remaining = Math.max(0, Math.min(1, (approval.expiresAt - now) / total));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-warning/60">
|
||||||
|
<Card.Header>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Card.Title className="text-base">Waiting on you</Card.Title>
|
||||||
|
<span className="text-xs tabular-nums text-muted">{left}s</span>
|
||||||
|
</div>
|
||||||
|
<Card.Description className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<ToolChip tool={approval.tool} />
|
||||||
|
<span className="text-xs text-muted">in {approval.sessionLabel}</span>
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
|
||||||
|
<Card.Content className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm leading-snug break-words">{approval.title}</p>
|
||||||
|
{approval.detail && (
|
||||||
|
<pre className="max-h-32 overflow-auto rounded-lg bg-surface-secondary p-2.5 font-mono text-xs leading-relaxed whitespace-pre-wrap break-all text-surface-secondary-foreground">
|
||||||
|
{approval.detail}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{/* A bar rather than only a number: you can see at a glance how much time is left. */}
|
||||||
|
<div className="h-1 w-full overflow-hidden rounded-full bg-surface-secondary">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-warning transition-[width] duration-1000 ease-linear"
|
||||||
|
style={{ width: `${remaining * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
|
||||||
|
<Card.Footer className="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
variant="danger-soft"
|
||||||
|
size="lg"
|
||||||
|
isDisabled={busy}
|
||||||
|
onPress={() => onResolve(approval.id, "deny")}
|
||||||
|
>
|
||||||
|
<BanIcon />
|
||||||
|
Deny
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
isDisabled={busy}
|
||||||
|
onPress={() => onResolve(approval.id, "allow")}
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
</Card.Footer>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Card } from "@heroui/react";
|
||||||
|
import { StateChip } from "@/components/StatusChip";
|
||||||
|
import { relTime } from "@/lib/format";
|
||||||
|
import type { SessionView } from "@/protocol";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only rendered when more than one session is live. With a single workspace the Now card
|
||||||
|
* already says everything, and an extra list is just noise on a small screen.
|
||||||
|
*/
|
||||||
|
export function SessionsCard({
|
||||||
|
sessions,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
now,
|
||||||
|
}: {
|
||||||
|
sessions: SessionView[];
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string | null) => void;
|
||||||
|
now: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title className="text-base">Sessions</Card.Title>
|
||||||
|
<Card.Description className="text-xs">
|
||||||
|
Tap one to filter the activity list.
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content className="px-0">
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
<li className="border-b border-separator">
|
||||||
|
<Row active={selectedId === null} onPress={() => onSelect(null)}>
|
||||||
|
<span className="text-sm">All sessions</span>
|
||||||
|
<span className="text-xs text-muted">{sessions.length}</span>
|
||||||
|
</Row>
|
||||||
|
</li>
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<li key={session.id} className="border-b border-separator last:border-b-0">
|
||||||
|
<Row
|
||||||
|
active={selectedId === session.id}
|
||||||
|
onPress={() => onSelect(session.id === selectedId ? null : session.id)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm">{session.label}</span>
|
||||||
|
<span className="shrink-0 text-[11px] text-muted">
|
||||||
|
{relTime(session.lastActivity, now)}
|
||||||
|
</span>
|
||||||
|
<StateChip state={session.state} />
|
||||||
|
</Row>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
active,
|
||||||
|
onPress,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPress}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={`flex w-full items-center gap-2 px-4 py-3 text-left transition-colors ${
|
||||||
|
active ? "bg-surface-secondary" : "hover:bg-surface-hover"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Alert, Button, Card } from "@heroui/react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { MoonIcon, SunIcon } from "@/components/icons";
|
||||||
|
import type { ApprovalMode, ApprovalSettings, DeviceInfo } from "@/protocol";
|
||||||
|
|
||||||
|
type Patch = Parameters<typeof api.setApproval>[0];
|
||||||
|
|
||||||
|
const MODES: { value: ApprovalMode; label: string; hint: string }[] = [
|
||||||
|
{ value: "off", label: "Off", hint: "Grok Build never waits for you." },
|
||||||
|
{ value: "risky", label: "Risky", hint: "Shell commands and file writes need a tap." },
|
||||||
|
{ value: "all", label: "All", hint: "Every tool call needs a tap. Noisy." },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SettingsPanel({
|
||||||
|
approval,
|
||||||
|
deviceLabel,
|
||||||
|
version,
|
||||||
|
theme,
|
||||||
|
onToggleTheme,
|
||||||
|
onSignedOut,
|
||||||
|
}: {
|
||||||
|
approval: ApprovalSettings;
|
||||||
|
deviceLabel?: string;
|
||||||
|
version: string;
|
||||||
|
theme: "light" | "dark";
|
||||||
|
onToggleTheme: () => void;
|
||||||
|
onSignedOut: () => void;
|
||||||
|
}) {
|
||||||
|
const [local, setLocal] = useState(approval);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [devices, setDevices] = useState<DeviceInfo[] | null>(null);
|
||||||
|
const [currentId, setCurrentId] = useState<string>("");
|
||||||
|
|
||||||
|
// Keep in step with the stream: another device may have changed the policy.
|
||||||
|
useEffect(() => setLocal(approval), [approval]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.devices().then(
|
||||||
|
(out) => {
|
||||||
|
setDevices(out.devices);
|
||||||
|
setCurrentId(out.current);
|
||||||
|
},
|
||||||
|
() => setDevices([]),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function apply(patch: Patch) {
|
||||||
|
setError(null);
|
||||||
|
const previous = local;
|
||||||
|
setLocal({ ...local, ...patch });
|
||||||
|
try {
|
||||||
|
setLocal(await api.setApproval(patch));
|
||||||
|
} catch (err) {
|
||||||
|
setLocal(previous);
|
||||||
|
setError((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hint = MODES.find((m) => m.value === local.mode)?.hint;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{error && (
|
||||||
|
<Alert status="danger">
|
||||||
|
<Alert.Content>
|
||||||
|
<Alert.Title>{error}</Alert.Title>
|
||||||
|
</Alert.Content>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title className="text-base">Remote approval</Card.Title>
|
||||||
|
<Card.Description className="text-xs">
|
||||||
|
Which tool calls should pause and wait for a tap on this phone.
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content className="flex flex-col gap-3">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{MODES.map((mode) => (
|
||||||
|
<Button
|
||||||
|
key={mode.value}
|
||||||
|
size="md"
|
||||||
|
variant={local.mode === mode.value ? "primary" : "outline"}
|
||||||
|
onPress={() => apply({ mode: mode.value })}
|
||||||
|
>
|
||||||
|
{mode.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hint && <p className="text-xs text-muted">{hint}</p>}
|
||||||
|
|
||||||
|
<Toggle
|
||||||
|
label="Only when a phone is watching"
|
||||||
|
hint="Off means a tool call can wait even with nobody looking at this page."
|
||||||
|
value={local.requireWatcher}
|
||||||
|
onChange={(value) => apply({ requireWatcher: value })}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Deny if nobody answers"
|
||||||
|
hint={`Otherwise it is allowed after ${Math.round(local.timeoutMs / 1000)}s.`}
|
||||||
|
value={local.onTimeout === "deny"}
|
||||||
|
onChange={(value) => apply({ onTimeout: value ? "deny" : "allow" })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{local.mode !== "off" && (
|
||||||
|
<p className="font-mono text-[11px] break-all text-muted">
|
||||||
|
risky pattern: {local.riskyPattern}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title className="text-base">Devices</Card.Title>
|
||||||
|
<Card.Description className="text-xs">
|
||||||
|
Revoke from the terminal with <code className="font-mono">glance revoke <id></code>
|
||||||
|
.
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content className="px-0">
|
||||||
|
{devices === null ? (
|
||||||
|
<p className="px-4 text-sm text-muted">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{devices.map((device) => (
|
||||||
|
<li
|
||||||
|
key={device.id}
|
||||||
|
className="flex items-center justify-between gap-2 border-t border-separator px-4 py-2.5 first:border-t-0"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm">
|
||||||
|
{device.label}
|
||||||
|
{device.id === currentId && (
|
||||||
|
<span className="ml-1.5 text-[11px] text-muted">(this one)</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-[11px] text-muted">{device.id.slice(0, 16)}…</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-[11px] text-muted">
|
||||||
|
{new Date(device.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Content className="flex flex-col gap-2">
|
||||||
|
<Button variant="outline" size="md" fullWidth onPress={onToggleTheme}>
|
||||||
|
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||||
|
{theme === "dark" ? "Light mode" : "Dark mode"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger-soft"
|
||||||
|
size="md"
|
||||||
|
fullWidth
|
||||||
|
onPress={() => api.logout().then(onSignedOut, onSignedOut)}
|
||||||
|
>
|
||||||
|
Sign out {deviceLabel ? `(${deviceLabel})` : ""}
|
||||||
|
</Button>
|
||||||
|
<p className="pt-1 text-center text-xs text-muted">grok-glance {version}</p>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A two-state row built from a Button rather than a switch: the whole row is a large tap
|
||||||
|
* target, which matters more on a phone than the affordance of a sliding thumb.
|
||||||
|
*/
|
||||||
|
function Toggle({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
value: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm">{label}</p>
|
||||||
|
<p className="text-xs text-muted">{hint}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={value ? "primary" : "outline"}
|
||||||
|
onPress={() => onChange(!value)}
|
||||||
|
aria-pressed={value}
|
||||||
|
>
|
||||||
|
{value ? "On" : "Off"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Chip } from "@heroui/react";
|
||||||
|
import type { SessionState } from "@/protocol";
|
||||||
|
|
||||||
|
type ChipColor = "accent" | "danger" | "default" | "success" | "warning";
|
||||||
|
|
||||||
|
const STATE: Record<SessionState, { color: ChipColor; label: string }> = {
|
||||||
|
working: { color: "accent", label: "working" },
|
||||||
|
waiting: { color: "warning", label: "waiting on you" },
|
||||||
|
idle: { color: "success", label: "idle" },
|
||||||
|
error: { color: "danger", label: "error" },
|
||||||
|
ended: { color: "default", label: "ended" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StateChip({ state, size = "sm" }: { state: SessionState; size?: "sm" | "md" }) {
|
||||||
|
const { color, label } = STATE[state];
|
||||||
|
return (
|
||||||
|
<Chip color={color} size={size} variant="soft">
|
||||||
|
<Chip.Label>{label}</Chip.Label>
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolChip({ tool }: { tool: string }) {
|
||||||
|
return (
|
||||||
|
<Chip color="default" size="sm" variant="tertiary">
|
||||||
|
<Chip.Label>{tool}</Chip.Label>
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Card } from "@heroui/react";
|
||||||
|
import { clockTime, duration } from "@/lib/format";
|
||||||
|
import type { EventKind, GlanceEvent } from "@/protocol";
|
||||||
|
|
||||||
|
const DOT: Record<EventKind, string> = {
|
||||||
|
session_start: "bg-muted",
|
||||||
|
session_end: "bg-muted",
|
||||||
|
prompt: "bg-accent",
|
||||||
|
tool_start: "bg-muted",
|
||||||
|
tool_end: "bg-success",
|
||||||
|
tool_fail: "bg-danger",
|
||||||
|
permission_denied: "bg-danger",
|
||||||
|
turn_end: "bg-accent",
|
||||||
|
turn_error: "bg-danger",
|
||||||
|
notification: "bg-warning",
|
||||||
|
subagent_start: "bg-muted",
|
||||||
|
subagent_end: "bg-muted",
|
||||||
|
compact: "bg-muted",
|
||||||
|
approval_request: "bg-warning",
|
||||||
|
approval_allowed: "bg-success",
|
||||||
|
approval_denied: "bg-danger",
|
||||||
|
approval_expired: "bg-warning",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE = 40;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PreToolUse rows are hidden: PostToolUse reports the same call with a duration, and the
|
||||||
|
* running one is already the headline of the Now card. Showing both doubles every line.
|
||||||
|
*/
|
||||||
|
function visible(events: GlanceEvent[], sessionId: string | null): GlanceEvent[] {
|
||||||
|
return events.filter(
|
||||||
|
(event) =>
|
||||||
|
event.kind !== "tool_start" && (sessionId === null || event.sessionId === sessionId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Timeline({
|
||||||
|
events,
|
||||||
|
sessionId,
|
||||||
|
}: {
|
||||||
|
events: GlanceEvent[];
|
||||||
|
sessionId: string | null;
|
||||||
|
}) {
|
||||||
|
const [limit, setLimit] = useState(PAGE);
|
||||||
|
const rows = visible(events, sessionId);
|
||||||
|
const shown = rows.slice(0, limit);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title className="text-base">Activity</Card.Title>
|
||||||
|
<Card.Description className="text-xs">
|
||||||
|
{rows.length === 0 ? "Nothing yet." : `${rows.length} events`}
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
|
||||||
|
<Card.Content className="px-0">
|
||||||
|
<ol className="flex flex-col">
|
||||||
|
{shown.map((event) => (
|
||||||
|
<li
|
||||||
|
key={event.id}
|
||||||
|
className="flex gap-2.5 border-t border-separator px-4 py-2.5 first:border-t-0"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT[event.kind] ?? "bg-muted"}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<p className="min-w-0 text-sm leading-snug break-words">{event.title}</p>
|
||||||
|
<span className="shrink-0 text-[11px] tabular-nums text-muted">
|
||||||
|
{clockTime(event.ts)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{event.detail && (
|
||||||
|
<p className="mt-0.5 font-mono text-[11px] leading-relaxed break-all text-muted">
|
||||||
|
{event.detail}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{event.durationMs !== undefined && (
|
||||||
|
<p className="mt-0.5 text-[11px] tabular-nums text-muted">
|
||||||
|
took {duration(event.durationMs)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</Card.Content>
|
||||||
|
|
||||||
|
{rows.length > shown.length && (
|
||||||
|
<Card.Footer>
|
||||||
|
<Button variant="ghost" size="sm" fullWidth onPress={() => setLimit(limit + PAGE)}>
|
||||||
|
Show {Math.min(PAGE, rows.length - shown.length)} older
|
||||||
|
</Button>
|
||||||
|
</Card.Footer>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/** Hand-rolled icons: no icon package, so the bundle stays small and offline-safe. */
|
||||||
|
|
||||||
|
interface IconProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = "h-4 w-4 shrink-0";
|
||||||
|
|
||||||
|
export function LockIcon({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className ?? base}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="4" y="10.5" width="16" height="10.5" rx="2.5" />
|
||||||
|
<path d="M8 10.5V7.5a4 4 0 0 1 8 0v3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FingerprintIcon({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className ?? base}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12 3a9 9 0 0 0-9 9" />
|
||||||
|
<path d="M21 12a9 9 0 0 0-9-9" />
|
||||||
|
<path d="M12 7a5 5 0 0 0-5 5v3" />
|
||||||
|
<path d="M17 12a5 5 0 0 0-5-5" />
|
||||||
|
<path d="M12 11a1.5 1.5 0 0 0-1.5 1.5V19" />
|
||||||
|
<path d="M13.5 12.5A1.5 1.5 0 0 0 12 11" />
|
||||||
|
<path d="M16.5 15.5V12" />
|
||||||
|
<path d="M7 19.5v-1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GearIcon({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className ?? base}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M12 3v2.2M12 18.8V21M4.2 7.5l1.9 1.1M17.9 15.4l1.9 1.1M4.2 16.5l1.9-1.1M17.9 8.6l1.9-1.1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckIcon({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className ?? base}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M5 12.5l4.5 4.5L19 7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BanIcon({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className ?? base}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="8.5" />
|
||||||
|
<path d="M6.2 17.8 17.8 6.2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SunIcon({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className ?? base}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MoonIcon({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className ?? base}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a7 7 0 1 0 10.5 10.5Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type {
|
||||||
|
ApprovalMode,
|
||||||
|
ApprovalSettings,
|
||||||
|
DeviceInfo,
|
||||||
|
GateInfo,
|
||||||
|
Snapshot,
|
||||||
|
} from "@/protocol";
|
||||||
|
import type {
|
||||||
|
PublicKeyCredentialCreationOptionsJSON,
|
||||||
|
PublicKeyCredentialRequestOptionsJSON,
|
||||||
|
} from "@simplewebauthn/browser";
|
||||||
|
import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The daemon rejects any POST without this header. A cross-origin page cannot set it without
|
||||||
|
* a CORS preflight that we never answer, so it is a second barrier behind the SameSite cookie.
|
||||||
|
*/
|
||||||
|
const POST_HEADERS = {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-glance-csrf": "1",
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status: number,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(path, { credentials: "same-origin", ...init });
|
||||||
|
const text = await res.text();
|
||||||
|
let body: unknown = null;
|
||||||
|
try {
|
||||||
|
body = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
body = null;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const message =
|
||||||
|
(body as { error?: string } | null)?.error ?? `${res.status} ${res.statusText}`;
|
||||||
|
throw new ApiError(message, res.status);
|
||||||
|
}
|
||||||
|
return body as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function post<T>(path: string, body?: unknown): Promise<T> {
|
||||||
|
return request<T>(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: POST_HEADERS,
|
||||||
|
body: JSON.stringify(body ?? {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
gate: () => request<GateInfo>("/api/gate"),
|
||||||
|
snapshot: () => request<Snapshot>("/api/snapshot"),
|
||||||
|
devices: () => request<{ devices: DeviceInfo[]; current: string }>("/api/devices"),
|
||||||
|
logout: () => post<{ ok: true }>("/api/auth/logout"),
|
||||||
|
|
||||||
|
resolveApproval: (id: string, decision: "allow" | "deny") =>
|
||||||
|
post<{ ok: true }>("/api/approvals/resolve", { id, decision }),
|
||||||
|
|
||||||
|
setApproval: (patch: {
|
||||||
|
mode?: ApprovalMode;
|
||||||
|
requireWatcher?: boolean;
|
||||||
|
onTimeout?: "allow" | "deny";
|
||||||
|
}) => post<ApprovalSettings>("/api/approval", patch),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign in with an already-enrolled passkey. The device proves itself with a biometric or
|
||||||
|
* PIN; we never see or store anything the phone could not re-derive.
|
||||||
|
*/
|
||||||
|
async signIn(): Promise<string | undefined> {
|
||||||
|
const optionsJSON = await post<PublicKeyCredentialRequestOptionsJSON>(
|
||||||
|
"/api/auth/login/options",
|
||||||
|
);
|
||||||
|
const response = await startAuthentication({ optionsJSON });
|
||||||
|
const out = await post<{ ok: true; label?: string }>("/api/auth/login/verify", { response });
|
||||||
|
return out.label;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrol this device using a one-time code from `glance enroll`. The code is checked twice —
|
||||||
|
* once to get options, once to accept the attestation — and only consumed on success.
|
||||||
|
*/
|
||||||
|
async enroll(code: string, label: string): Promise<void> {
|
||||||
|
const optionsJSON = await post<PublicKeyCredentialCreationOptionsJSON>(
|
||||||
|
"/api/auth/register/options",
|
||||||
|
{ code },
|
||||||
|
);
|
||||||
|
const response = await startRegistration({ optionsJSON });
|
||||||
|
await post<{ ok: true }>("/api/auth/register/verify", { code, label, response });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/** Time formatting for a screen you look at for three seconds. */
|
||||||
|
|
||||||
|
export function relTime(ts: number, now: number): string {
|
||||||
|
const delta = Math.max(0, now - ts);
|
||||||
|
const s = Math.round(delta / 1000);
|
||||||
|
if (s < 5) return "now";
|
||||||
|
if (s < 60) return `${s}s ago`;
|
||||||
|
const m = Math.round(s / 60);
|
||||||
|
if (m < 60) return `${m}m ago`;
|
||||||
|
const h = Math.round(m / 60);
|
||||||
|
if (h < 24) return `${h}h ago`;
|
||||||
|
return `${Math.round(h / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clockTime(ts: number): string {
|
||||||
|
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function duration(ms: number): string {
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`;
|
||||||
|
const m = Math.floor(ms / 60_000);
|
||||||
|
const s = Math.round((ms % 60_000) / 1000);
|
||||||
|
return `${m}m ${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seconds left, floored at zero, for an approval countdown. */
|
||||||
|
export function secondsLeft(expiresAt: number, now: number): number {
|
||||||
|
return Math.max(0, Math.ceil((expiresAt - now) / 1000));
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Snapshot } from "@/protocol";
|
||||||
|
|
||||||
|
export type Connection = "connecting" | "live" | "offline";
|
||||||
|
|
||||||
|
/** Backoff caps out quickly: a phone coming out of sleep should reconnect, not sulk. */
|
||||||
|
const RETRY_MS = [500, 1000, 2000, 4000, 8000, 15_000];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to the daemon's event stream and keeps the latest full snapshot.
|
||||||
|
*
|
||||||
|
* The server sends whole snapshots rather than deltas, so a phone that slept through twenty
|
||||||
|
* events still lands on the truth with no reconciliation logic here.
|
||||||
|
*/
|
||||||
|
export function useGlance(enabled: boolean) {
|
||||||
|
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
|
||||||
|
const [connection, setConnection] = useState<Connection>("connecting");
|
||||||
|
const attempt = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setSnapshot(null);
|
||||||
|
setConnection("connecting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
let source: EventSource | null = null;
|
||||||
|
let timer: number | undefined;
|
||||||
|
|
||||||
|
const schedule = () => {
|
||||||
|
if (stopped) return;
|
||||||
|
const wait = RETRY_MS[Math.min(attempt.current, RETRY_MS.length - 1)];
|
||||||
|
attempt.current += 1;
|
||||||
|
timer = window.setTimeout(open, wait);
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
if (stopped) return;
|
||||||
|
// Fetch once alongside the stream so the first paint does not wait on the SSE handshake.
|
||||||
|
api.snapshot().then(
|
||||||
|
(snap) => {
|
||||||
|
if (!stopped) setSnapshot(snap);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
/* the stream will report the real problem */
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
source = new EventSource("/events", { withCredentials: true });
|
||||||
|
|
||||||
|
source.addEventListener("open", () => {
|
||||||
|
if (stopped) return;
|
||||||
|
attempt.current = 0;
|
||||||
|
setConnection("live");
|
||||||
|
});
|
||||||
|
|
||||||
|
source.addEventListener("snapshot", (event) => {
|
||||||
|
if (stopped) return;
|
||||||
|
try {
|
||||||
|
setSnapshot(JSON.parse((event as MessageEvent<string>).data) as Snapshot);
|
||||||
|
setConnection("live");
|
||||||
|
} catch {
|
||||||
|
/* ignore a malformed frame rather than tearing down the stream */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The daemon says goodbye on shutdown; reconnecting will pick it up when it returns.
|
||||||
|
source.addEventListener("bye", () => {
|
||||||
|
source?.close();
|
||||||
|
setConnection("offline");
|
||||||
|
schedule();
|
||||||
|
});
|
||||||
|
|
||||||
|
source.addEventListener("error", () => {
|
||||||
|
source?.close();
|
||||||
|
source = null;
|
||||||
|
if (stopped) return;
|
||||||
|
setConnection("offline");
|
||||||
|
schedule();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
open();
|
||||||
|
|
||||||
|
// iOS suspends the stream in the background; nudge it the moment the app is looked at.
|
||||||
|
const onVisible = () => {
|
||||||
|
if (document.visibilityState !== "visible") return;
|
||||||
|
if (source && source.readyState === EventSource.OPEN) {
|
||||||
|
api.snapshot().then(setSnapshot, () => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
source?.close();
|
||||||
|
source = null;
|
||||||
|
attempt.current = 0;
|
||||||
|
if (timer) window.clearTimeout(timer);
|
||||||
|
open();
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onVisible);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
document.removeEventListener("visibilitychange", onVisible);
|
||||||
|
if (timer) window.clearTimeout(timer);
|
||||||
|
source?.close();
|
||||||
|
};
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
return { snapshot, connection };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A ticking clock, for countdowns and "3s ago" labels. */
|
||||||
|
export function useNow(intervalMs = 1000): number {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(() => setNow(Date.now()), intervalMs);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [intervalMs]);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "@/App";
|
||||||
|
import "@/styles/globals.css";
|
||||||
|
|
||||||
|
// HeroUI v3 needs no provider — its components carry their own state. Theme handling lives in
|
||||||
|
// the library's own `useTheme` hook, which App calls.
|
||||||
|
const host = document.getElementById("root");
|
||||||
|
if (!host) throw new Error("missing #root");
|
||||||
|
|
||||||
|
createRoot(host).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Wire protocol shared between the daemon and the web app.
|
||||||
|
*
|
||||||
|
* NOTE: this is a copy of server/src/protocol.ts. Keep the two in sync — they are duplicated
|
||||||
|
* rather than shared because the server compiles under NodeNext while the web app compiles
|
||||||
|
* under a bundler resolution, and a single rootDir cannot span both.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type EventKind =
|
||||||
|
| "session_start"
|
||||||
|
| "session_end"
|
||||||
|
| "prompt"
|
||||||
|
| "tool_start"
|
||||||
|
| "tool_end"
|
||||||
|
| "tool_fail"
|
||||||
|
| "permission_denied"
|
||||||
|
| "turn_end"
|
||||||
|
| "turn_error"
|
||||||
|
| "notification"
|
||||||
|
| "subagent_start"
|
||||||
|
| "subagent_end"
|
||||||
|
| "compact"
|
||||||
|
| "approval_request"
|
||||||
|
| "approval_allowed"
|
||||||
|
| "approval_denied"
|
||||||
|
| "approval_expired";
|
||||||
|
|
||||||
|
export type SessionState = "working" | "idle" | "waiting" | "error" | "ended";
|
||||||
|
|
||||||
|
export interface GlanceEvent {
|
||||||
|
id: number;
|
||||||
|
ts: number;
|
||||||
|
sessionId: string;
|
||||||
|
kind: EventKind;
|
||||||
|
/** Tool name, for tool-shaped events. */
|
||||||
|
tool?: string;
|
||||||
|
/** One-line human summary, already truncated and redacted. */
|
||||||
|
title: string;
|
||||||
|
/** Optional second line, e.g. a file path or an error message. */
|
||||||
|
detail?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionView {
|
||||||
|
id: string;
|
||||||
|
/** Basename of the workspace root — what you actually recognise on a phone. */
|
||||||
|
label: string;
|
||||||
|
cwd: string;
|
||||||
|
state: SessionState;
|
||||||
|
startedAt: number;
|
||||||
|
lastActivity: number;
|
||||||
|
lastPrompt?: string;
|
||||||
|
currentTool?: { name: string; title: string; startedAt: number };
|
||||||
|
counts: { tools: number; failures: number; denials: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingApproval {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
sessionLabel: string;
|
||||||
|
tool: string;
|
||||||
|
title: string;
|
||||||
|
detail?: string;
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApprovalMode = "off" | "risky" | "all";
|
||||||
|
|
||||||
|
export interface ApprovalSettings {
|
||||||
|
mode: ApprovalMode;
|
||||||
|
riskyPattern: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
/** Skip gating entirely when no browser is streaming, so an unwatched agent never stalls. */
|
||||||
|
requireWatcher: boolean;
|
||||||
|
/** What to do when nobody answers in time. Allow keeps the agent moving; deny is stricter. */
|
||||||
|
onTimeout: "allow" | "deny";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Snapshot {
|
||||||
|
now: number;
|
||||||
|
version: string;
|
||||||
|
sessions: SessionView[];
|
||||||
|
events: GlanceEvent[];
|
||||||
|
pending: PendingApproval[];
|
||||||
|
approval: ApprovalSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceInfo {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
createdAt: number;
|
||||||
|
lastUsedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the app needs before it knows whether you are signed in. */
|
||||||
|
export interface GateInfo {
|
||||||
|
authenticated: boolean;
|
||||||
|
/** False when no passkey has been enrolled yet — the app then asks for an enrolment code. */
|
||||||
|
enrolled: boolean;
|
||||||
|
/** True while a one-time enrolment code minted by `glance enroll` is still valid. */
|
||||||
|
enrollmentOpen: boolean;
|
||||||
|
version: string;
|
||||||
|
deviceLabel?: string;
|
||||||
|
/** The WebAuthn RP ID in force. Shown so a hostname mismatch is diagnosable from the phone. */
|
||||||
|
rpId?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "@heroui/styles";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/* A dashboard you read one-handed: no rubber-band scroll surprises, no text inflation. */
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
/* useTheme sets the class and data-theme but not color-scheme, and without it the phone
|
||||||
|
paints native scrollbars and form controls light on a dark page. */
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100dvh;
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep the timeline scrollable without a visible scrollbar eating width on mobile. */
|
||||||
|
.glance-scroll {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.glance-scroll::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
.glance-scroll::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 3px;
|
||||||
|
background: color-mix(in oklab, currentColor 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Respect a user who has asked the OS to calm things down. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
Reference in New Issue
Block a user