grok-glance: web control plane for grok's /rc remote control
A single Go binary that grok dials out to over a WebSocket, and a HeroUI web UI for driving the session it is attached to. The roles are inverted relative to the terminal: over the /rc link grok is the ACP Agent and glance is the Client. That makes glance a stock ACP client and the web Stop button a real session/cancel rather than a bespoke control message. Both notification rails are mirrored. The stable session/update rail carries correctness; x.ai/session_notification is presentation only and degrades rather than erroring, because its ~60 variants are grok internal and drift with every upstream sync. _meta is forwarded byte for byte so viewers can dedup and order. Permissions race: the terminal and any browser may answer, first responder wins, and the loser's UI retracts by itself. All three interaction methods go through that path, not just permissions. Auth is TOTP only, with no accounts to have. A bootstrap token printed at first start gates /setup, which is a 404 without it; state lives in one 0600 JSON file and history in an in-memory ring, so there is no database and no recovery story beyond deleting the file. ARCHITECTURE.md covers the topology and the limits of that auth model; CLAUDE.md covers building, the fakeagent loop, and the end-to-end checklist that unit tests cannot replace. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
bin/
|
||||
|
||||
# Vite output. dist/.gitkeep is tracked on purpose: web/embed.go needs a file
|
||||
# there for `go build` to succeed on a clone that has never run npm.
|
||||
web/dist/*
|
||||
!web/dist/.gitkeep
|
||||
web/node_modules/
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
# grok-glance — architecture
|
||||
|
||||
grok-glance is remote control for [`grok`](https://github.com/xai-org/grok-build): a
|
||||
self-hosted server plus web UI that mirrors a *running* terminal session and lets you
|
||||
drive it from a browser — send prompts, interrupt a turn, approve tool calls — without
|
||||
the terminal ceding anything.
|
||||
|
||||
It is one Go binary with the frontend embedded, one port, and no database.
|
||||
|
||||
```
|
||||
┌────────────── your machine ──────────────┐ ┌──── anywhere ────┐
|
||||
│ grok TUI │ │ │
|
||||
│ │ │ │ │
|
||||
│ ├── AcpClientRx ──▶ tee ──▶ TUI │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ ▼ │ │ │
|
||||
│ │ /rc bridge ═══ WSS ════╪══▶ glance ══ WSS ══▶ browser
|
||||
│ │ │ (ACP) │ (Go) (glance (HeroUI)
|
||||
│ └── AcpAgentTx ◀─────┘ │ envelope) │
|
||||
└──────────────────────────────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
Two links, two protocols, deliberately:
|
||||
|
||||
| Link | Protocol | Auth | Who dials |
|
||||
|---|---|---|---|
|
||||
| grok ↔ glance | ACP over WebSocket, **grok as the Agent** | `Authorization: Bearer <api key>` | grok dials out |
|
||||
| glance ↔ browser | glance's own JSON envelope | `__Host-glance` session cookie | browser dials in |
|
||||
|
||||
---
|
||||
|
||||
## Why it is shaped this way
|
||||
|
||||
### grok dials out
|
||||
|
||||
`grok` runs on a laptop, in a devcontainer, on a box behind NAT. glance runs where you can
|
||||
reach it. Making grok the dialer means remote control works without a port forward, a
|
||||
tunnel, or an inbound firewall rule on the machine that holds your source tree — the one
|
||||
place you least want an open port.
|
||||
|
||||
The consequence is that glance never initiates: an agent appears when it connects and
|
||||
disappears when it hangs up, and the session list is exactly the set of live sockets.
|
||||
|
||||
### The roles are inverted relative to the terminal
|
||||
|
||||
Inside grok, the TUI is an ACP **Client** talking to the agent runtime. Over the `/rc`
|
||||
link grok presents itself as the **Agent** and glance is the **Client**.
|
||||
|
||||
That inversion is the whole trick. It means glance is a stock ACP client — it receives
|
||||
`session/update` and `session/request_permission`, it sends `session/prompt` and
|
||||
`session/cancel` — and needs no knowledge of grok's internals to be a second head on the
|
||||
same session. It also means the browser's Stop button is a real `session/cancel`, not a
|
||||
simulated keypress.
|
||||
|
||||
### The browser is not an ACP peer
|
||||
|
||||
`/api/ws` speaks a small glance-specific envelope (see [Browser protocol](#browser-protocol)),
|
||||
not ACP. Translating once, server-side, keeps three things out of the frontend: JSON-RPC
|
||||
id correlation, the pending-interaction table, and the ring buffer. The browser sends
|
||||
`{"type":"prompt", ...}` and receives `{"type":"frame", ...}`; everything that must be
|
||||
exactly right about the ACP conversation is exactly right in one place, in Go, with tests.
|
||||
|
||||
### No database
|
||||
|
||||
Transcript history is a per-agent in-memory ring (4096 frames, `internal/hub/ring.go`).
|
||||
A reload replays it; a server restart does not. This was a deliberate choice: the
|
||||
alternative is a store of every prompt, file path, diff and command output from every
|
||||
session, on disk, forever, guarded by one TOTP secret. The ring is bounded, unbackupable
|
||||
by construction, and enough for what the UI is actually for — watching the turn that is
|
||||
happening now.
|
||||
|
||||
What *is* persisted is only what cannot be re-derived: the TOTP secret, the cookie signing
|
||||
key, and API key hashes, in `~/.grok/glance/state.json` (0600).
|
||||
|
||||
---
|
||||
|
||||
## The grok side (`/rc`)
|
||||
|
||||
Implemented as patches `0003`/`0004` in the sibling `newgrok/` tree. Summarised here
|
||||
because the two halves only make sense together; the authoritative comments are in
|
||||
`newgrok/work/crates/codegen/xai-grok-pager/src/rc/`.
|
||||
|
||||
**The tee.** The pager already holds an `AcpAgentTx` (to the agent) and an `AcpClientRx`
|
||||
(from it). Every inbound message passes through one function, so `/rc` inserts one call
|
||||
there: mirror a copy to the bridge, return the original untouched. Remote control needs no
|
||||
change to the agent runtime, the session actor, or the shell — which is what makes "does
|
||||
not disturb the session" true rather than aspirational.
|
||||
|
||||
**Both notification rails are mirrored.** Alongside the stable `session/update`, grok emits
|
||||
~60 grok-specific variants as `x.ai/session_notification` — tool-call deltas, subagents,
|
||||
retries, turn boundaries. Forwarding only the standard rail would give glance a transcript
|
||||
with the streaming taken out. `_meta` (`eventId`, `promptId`, `chunkId`, `isReplay`) is
|
||||
forwarded verbatim so a viewer can dedup and order the stream the same way the TUI does.
|
||||
|
||||
**Interactions are raced, not routed.** The three reverse-requests a human must answer —
|
||||
`session/request_permission`, `x.ai/ask_user_question`, `x.ai/exit_plan_mode` — carry their
|
||||
own oneshot reply channel. The tee lifts the real sender out, hands the TUI a substitute,
|
||||
forwards a copy to glance, and gives the answer to whichever side replies first. The loser
|
||||
is told to retract its dialog. Neither side is privileged, and no configuration decides who
|
||||
may answer.
|
||||
|
||||
**Failure is local-only.** The bridge is its own task with its own reconnect backoff. If
|
||||
glance is down, unreachable, or killed mid-turn, the terminal session is unaffected — the
|
||||
tee degrades to a plain move the moment the bridge's channel closes.
|
||||
|
||||
---
|
||||
|
||||
## The glance side
|
||||
|
||||
```
|
||||
cmd/glance/main.go serve | apikey add·list·rm | bootstrap | version
|
||||
internal/
|
||||
acp/ JSON-RPC framing, the ACP subset glance needs, method predicates
|
||||
hub/ the live system: agents, browsers, ring buffer, interaction table
|
||||
auth/ TOTP enrollment and login, bootstrap gate, cookie signing
|
||||
state/ ~/.grok/glance/state.json (0600)
|
||||
httpapi/ routing, middleware, the two upgrades, the embedded SPA
|
||||
web/ Vite + React 19 + Tailwind 4 + HeroUI 3
|
||||
```
|
||||
|
||||
### hub — where everything meets
|
||||
|
||||
`Hub` owns two maps: connected agents by API-key id, and connected browsers. Everything
|
||||
else is per-agent state on `Agent` (`internal/hub/agent.go`):
|
||||
|
||||
- `ring` — the replay buffer. Frames are stored **exactly as received**, bytes unchanged.
|
||||
- `interactions` — open reverse-requests, keyed by grok's JSON-RPC id.
|
||||
- `calls` — glance's own outstanding requests, keyed by id, each with a buffered reply
|
||||
channel so a late response never blocks the reader goroutine.
|
||||
- `outbound` — a bounded queue drained by a single writer goroutine, because
|
||||
`coder/websocket` permits one concurrent writer and prompts, replies and pings all
|
||||
originate on different goroutines.
|
||||
|
||||
**Fan-out is unfiltered.** Every browser receives every agent's frames. At this scale
|
||||
(a handful of sessions, a handful of viewers) per-browser filtering would be one more
|
||||
thing to get wrong for no measurable gain; the frontend ignores frames for the agent it is
|
||||
not showing.
|
||||
|
||||
**Backpressure is a disconnect, not a buffer.** A browser that cannot keep up fills its
|
||||
256-frame queue and is dropped; it reconnects into a fresh snapshot. An unbounded queue
|
||||
would turn one slow phone on hotel wifi into the server's memory problem, and a snapshot
|
||||
is cheaper than the backlog it would have replayed anyway.
|
||||
|
||||
**Turn state is derived, not declared.** `classifyUpdate` reads a single field —
|
||||
`update.sessionUpdate` — from either rail to decide whether a turn is running. Nothing
|
||||
else in the xAI rail's payload is interpreted, because those ~60 variants are internal to
|
||||
grok and drift with every upstream sync. The stable rail carries correctness; the xAI rail
|
||||
is presentation.
|
||||
|
||||
### The permission race, end to end
|
||||
|
||||
```
|
||||
grok ──▶ session/request_permission (id 7) ──▶ glance
|
||||
│ ring? no. interactions[7] = …
|
||||
└─▶ {"type":"interaction"} to all browsers
|
||||
|
||||
… whichever happens first:
|
||||
|
||||
browser answers ──▶ Agent.Answer(7) ──▶ JSON-RPC response id 7 ──▶ grok
|
||||
└──▶ {"type":"interaction_resolved", by:"browser"}
|
||||
|
||||
terminal answers ──▶ grok sends x.ai/rc/interaction_cancelled(7)
|
||||
└──▶ {"type":"interaction_resolved", by:"terminal"}
|
||||
```
|
||||
|
||||
`Agent.Answer` deletes from `interactions` under the lock and reports whether it was still
|
||||
there. A `false` return is not an error — it is the ordinary outcome of losing the race —
|
||||
and the browser that lost is sent `by:"elsewhere"` with *"already handled elsewhere"*.
|
||||
That is why exactly one JSON-RPC response ever reaches grok for a given id, even when two
|
||||
browsers and a terminal all click at once.
|
||||
|
||||
A browser may also **decline**, which replies to grok with a JSON-RPC *error*. grok reads
|
||||
that as "glance is not answering this", leaves the terminal's dialog up, and the turn
|
||||
proceeds when the user answers there. Declining is not denying — denying is an ordinary
|
||||
answer with a `reject` option.
|
||||
|
||||
### Requests glance will not serve
|
||||
|
||||
grok drives nothing on this link. An inbound request that is not one of the three
|
||||
interactions gets `-32601 Method not found`, including `fs/*` and `terminal/*`. glance has
|
||||
no filesystem to offer, and a fabricated success would leave grok acting on a lie. (In its
|
||||
default configuration the pager does not advertise those capabilities, so they should not
|
||||
arrive at all; the arm exists because "should not" is not "cannot".)
|
||||
|
||||
---
|
||||
|
||||
## Browser protocol
|
||||
|
||||
One JSON object per WebSocket message, in both directions.
|
||||
|
||||
**Browser → server** (`internal/hub/browser.go`, `command`):
|
||||
|
||||
| `type` | Fields | Meaning |
|
||||
|---|---|---|
|
||||
| `list` | — | resend the agent list |
|
||||
| `subscribe` | `agent` | send a full snapshot of one agent |
|
||||
| `prompt` | `agent`, `text` | start a turn |
|
||||
| `cancel` | `agent` | interrupt the running turn |
|
||||
| `answer` | `agent`, `id`, `result` | answer an interaction |
|
||||
| `decline` | `agent`, `id`, `reason` | hand it back to the terminal |
|
||||
|
||||
**Server → browser** (`Event`): `agents`, `snapshot`, `frame`, `interaction`,
|
||||
`interaction_resolved`, `notice`, `error`.
|
||||
|
||||
Two details that matter:
|
||||
|
||||
- **`prompt` and `cancel` run detached.** A prompt does not return until the turn ends,
|
||||
which can be many minutes. Waiting inline would stall the browser's whole command
|
||||
stream — including the Stop button it might need next. They run on their own goroutine
|
||||
under `context.WithoutCancel`, and progress arrives as mirrored frames like anything else.
|
||||
- **`snapshot` is authoritative.** It carries the ring, the open interactions, the session
|
||||
metadata and the turn state in one message. The frontend *replaces* state with it rather
|
||||
than merging, which is what makes a reload or a reconnect land on the server's view
|
||||
instead of a half-stale one.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
There is no username and no password. There is one authenticator app and one server.
|
||||
|
||||
**Bootstrap.** On first `serve`, glance mints a one-time token, prints it with a setup URL,
|
||||
and writes it to `~/.grok/glance/bootstrap.token` (0600) — stderr for the terminal case,
|
||||
the file for the systemd case. `/api/setup/*` answers **404** unless the request carries a
|
||||
valid, unused token, and 404 again once enrollment succeeds. Without that gate, the window
|
||||
between "server starts" and "operator opens the browser" is a race anyone who can reach
|
||||
the port may enter.
|
||||
|
||||
**Enrollment.** `/api/setup/begin` returns a candidate secret and `otpauth://` URI, which
|
||||
the page renders as a QR code. Nothing is persisted until `/api/setup/complete` verifies a
|
||||
code generated *from* that secret — a failed QR scan must not be able to lock the operator
|
||||
out of their own server. Success stores the secret, burns the bootstrap token, and issues a
|
||||
session cookie, because you have just proved you hold the authenticator and a login form
|
||||
one second later would ask for the same proof.
|
||||
|
||||
**Login.** Six digits, ±1 time step for clock skew. Failures are rate-limited (8 per 5
|
||||
minutes, counted globally — the limiter protects the secret, not a user account). Accepted
|
||||
time steps are burned, so a code observed over a shoulder cannot be replayed inside its
|
||||
30-second window.
|
||||
|
||||
**Session cookie.** `__Host-glance` — the prefix is a browser-enforced promise of Secure +
|
||||
`Path=/` + no `Domain`, so a sibling host cannot set or overwrite it. The value is
|
||||
`<expiry>.<hmac>`, signed with a key in `state.json`: no session table, so a restart does
|
||||
not sign everyone out, and deleting `state.json` invalidates every outstanding cookie at
|
||||
once. HttpOnly, SameSite=Strict, 12 hours.
|
||||
|
||||
**API keys.** `glance apikey add <name>` prints `glance_sk_…` once; only a SHA-256 hash is
|
||||
stored. The key identifies one grok instance, and its id is the agent id — which is why
|
||||
reconnecting with the same key *replaces* the previous connection instead of accumulating a
|
||||
ghost session in the list.
|
||||
|
||||
### What this model does not protect against
|
||||
|
||||
Stated plainly, because the threat model is small on purpose:
|
||||
|
||||
- **glance is a remote control for a shell agent.** Anyone who can authenticate can approve
|
||||
arbitrary tool calls. It listens on `127.0.0.1` by default; exposing it should be a
|
||||
deliberate act, ideally behind a reverse proxy that terminates TLS.
|
||||
- **No TLS of its own.** `--insecure-cookie` exists for plain-HTTP localhost and is refused
|
||||
on a non-loopback address, because a session cookie without `Secure` on a real network is
|
||||
a credential in cleartext.
|
||||
- **No account recovery.** Losing the authenticator means deleting `state.json` — which
|
||||
also revokes every API key and session. `glance bootstrap` refuses to mint a second token
|
||||
once enrolled, since a re-enrollment path is exactly the door the bootstrap gate exists
|
||||
to keep shut.
|
||||
- **One operator.** There are no roles, no audit log, and no per-key permissions.
|
||||
|
||||
### Content-Security-Policy
|
||||
|
||||
`default-src 'self'` with no `unsafe-inline` for scripts. glance renders agent output —
|
||||
file contents, command output, model text — and none of it is trusted markup. Plan content
|
||||
is rendered as preformatted text rather than parsed as Markdown for the same reason: it
|
||||
avoids shipping a parser and a sanitiser to display text an agent wrote.
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
React 19 + HeroUI 3 (react-aria-components) + Tailwind 4, built by Vite into `web/dist`
|
||||
and embedded with `//go:embed all:dist`.
|
||||
|
||||
- `App.tsx` gates on `/api/status` → **Setup** / **Login** / **Console**, and calls
|
||||
`useTheme` exactly once at the root (each call owns its own state — a second call would
|
||||
desync).
|
||||
- `Console` owns the single `GlanceSocket`, so moving between the session list and a
|
||||
session neither drops the connection nor re-requests a snapshot.
|
||||
- `lib/acp.ts` folds frames into a `Transcript` of seven item kinds (`user`, `assistant`,
|
||||
`thought`, `tool`, `plan`, `notice`, `turn-end`). Tool calls are updated in place by id,
|
||||
so a streaming `tool_call_update` refines the card that is already on screen.
|
||||
- `components/PermissionDialog.tsx` renders all three interaction types as **cards, not
|
||||
modals** — a modal that closes itself when the terminal wins the race is more jarring
|
||||
than a card doing the same, and cards stack when several are open.
|
||||
|
||||
The response payloads it builds are pinned to grok's Rust types, not guessed:
|
||||
`{"outcome":{"outcome":"selected","optionId":…}}` for permissions, `{"outcome":"approved"}`
|
||||
for plan mode, and for questions an `{"outcome":"accepted","answers":…}` envelope whose map
|
||||
is keyed by **question text**, in original order, values as arrays of labels, unanswered
|
||||
questions omitted — the exact construction `xai-grok-pager/src/views/question_view.rs`
|
||||
performs. Getting this wrong does not fail loudly; it fails as an agent that received an
|
||||
answer to a question nobody asked.
|
||||
|
||||
An unrecognised interaction method renders its raw params and offers only *decline*, which
|
||||
is the honest response to a request whose reply shape glance does not know.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
`internal/hub/hub_test.go` drives the hub over **real WebSocket connections** with a fake
|
||||
grok and fake browsers. The behaviour that matters — an interaction reaching a browser, an
|
||||
answer reaching grok, two sides racing — lives in the interleaving of three goroutines, and
|
||||
a mocked transport would test the mock. It covers the permission round trip, terminal-wins
|
||||
retraction, browser-vs-browser arbitration, decline, ring replay on subscribe, method-not-
|
||||
found, reconnect-replaces-ghost.
|
||||
|
||||
`internal/httpapi/server_test.go` covers the auth boundary: the bootstrap 404, enrollment,
|
||||
rate limiting, cookie forgery, both upgrades, and the SPA fallback's refusal to swallow
|
||||
`/api/*`.
|
||||
|
||||
What tests cannot cover is the race against a *real* terminal. The end-to-end checklist in
|
||||
`CLAUDE.md` is not optional.
|
||||
@@ -0,0 +1,287 @@
|
||||
# CLAUDE.md — working on grok-glance
|
||||
|
||||
grok-glance is the web control plane for grok's `/rc` remote control. Read
|
||||
[ARCHITECTURE.md](ARCHITECTURE.md) first — it explains *why* the pieces are shaped the way
|
||||
they are. This file is the operational half: how to build it, how to test it, and the
|
||||
handful of things that will waste an afternoon if you learn them the hard way.
|
||||
|
||||
The Rust half lives in the sibling `newgrok/` tree as patches `0003`/`0004`. **The two are
|
||||
one feature.** A change to the wire format is a change to both.
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
cmd/glance/main.go serve | apikey add·list·rm | bootstrap | version
|
||||
cmd/fakeagent/main.go a fake grok, for UI work without rebuilding Rust
|
||||
internal/
|
||||
acp/ JSON-RPC framing + the ACP subset glance needs ~280 lines
|
||||
hub/ agents, browsers, ring buffer, interaction table the live system
|
||||
auth/ TOTP, bootstrap gate, signed cookies
|
||||
state/ ~/.grok/glance/state.json (0600)
|
||||
httpapi/ chi router, both WS upgrades, embedded SPA
|
||||
web/src/
|
||||
App.tsx status gate: Setup / Login / Console
|
||||
pages/ Setup, Login, Sessions, Session
|
||||
components/ Transcript, ToolCall, PermissionDialog, PromptBox, TurnStatus
|
||||
lib/ api.ts (REST), ws.ts (GlanceSocket), acp.ts (frames → transcript)
|
||||
```
|
||||
|
||||
If you are looking for where a decision lives:
|
||||
|
||||
| Question | File |
|
||||
|---|---|
|
||||
| What does the browser send/receive? | `internal/hub/browser.go` |
|
||||
| What happens when two people click Allow? | `internal/hub/agent.go` → `Answer`, `retract` |
|
||||
| Which methods does glance refuse? | `internal/hub/agent.go` → `handleFrame` |
|
||||
| Is this frame part of the transcript? | `internal/acp/jsonrpc.go` → `IsTranscript` |
|
||||
| Why is `/setup` a 404? | `internal/auth/auth.go` → `BootstrapToken` |
|
||||
| How does a frame become a bubble? | `web/src/lib/acp.ts` → `applyFrame` |
|
||||
|
||||
---
|
||||
|
||||
## Build and run
|
||||
|
||||
```sh
|
||||
make build # frontend, then binary → bin/glance
|
||||
make server # binary only, keeping the last frontend build (fast server loop)
|
||||
make web # frontend only
|
||||
make check # go vet + go test + tsc --noEmit
|
||||
make dev # Go server + Vite with hot reload → http://localhost:5173
|
||||
```
|
||||
|
||||
`//go:embed all:dist` resolves at compile time, so **`go build` ships whatever `make web`
|
||||
last produced**. If a UI change does not appear in `bin/glance`, that is why.
|
||||
|
||||
Use `make dev` for frontend work: Vite proxies `/api` to the Go server, which is what keeps
|
||||
the `__Host-glance` cookie working — it is `SameSite=Strict` and would never survive a
|
||||
cross-origin request. Hitting the Go port directly on 7717 with the Vite UI will look like
|
||||
a mysterious auth failure.
|
||||
|
||||
### First run
|
||||
|
||||
```sh
|
||||
make build
|
||||
bin/glance serve --addr 127.0.0.1:7717 --insecure-cookie
|
||||
# → prints a bootstrap URL; open it, scan the QR, enter one code
|
||||
bin/glance apikey add dev
|
||||
# → prints glance_sk_… once, plus a [remote_control] block for ~/.grok/config.toml
|
||||
```
|
||||
|
||||
`--insecure-cookie` drops the `Secure` attribute so a plain-HTTP localhost session works.
|
||||
It is refused on a non-loopback address, deliberately. In production put glance behind a
|
||||
TLS-terminating proxy and leave the flag off.
|
||||
|
||||
Lost the authenticator? Delete `~/.grok/glance/state.json` and start over. That is the
|
||||
whole recovery story, on purpose — see ARCHITECTURE.md. **`~/.grok/glance/` also contains
|
||||
`secret.key` and `hook.secret` that belong to something else entirely. Do not touch them.**
|
||||
|
||||
---
|
||||
|
||||
## Testing without rebuilding grok
|
||||
|
||||
`cmd/fakeagent` dials the agent socket exactly as the real bridge does and plays a scripted
|
||||
turn: streamed text, a thought, a plan, a tool call, and a **real**
|
||||
`session/request_permission` that waits for a real answer.
|
||||
|
||||
```sh
|
||||
bin/glance serve --insecure-cookie &
|
||||
make fakeagent KEY=glance_sk_… # or: go run ./cmd/fakeagent --key …
|
||||
```
|
||||
|
||||
Then in the browser: prompt it, watch it stream, answer the permission. Useful flags:
|
||||
|
||||
- `--terminal-after 3s` — the "terminal" answers the permission first, so you can watch the
|
||||
browser's card retract by itself. This is the path a browser alone cannot exercise.
|
||||
- `--speed 1s` — slow the stream down to catch layout problems mid-turn.
|
||||
- Send the prompt and hit **Stop**: the fake agent honours `session/cancel` and finishes the
|
||||
turn with `stopReason: cancelled`.
|
||||
|
||||
Run several at once with different `--title` to test the session list.
|
||||
|
||||
It emits `turn_completed` on the **xAI rail** (`x.ai/session_notification`) on purpose: if
|
||||
the turn stops showing as finished in the UI, rail mirroring has regressed. That is the
|
||||
most likely thing to break silently after an upstream sync.
|
||||
|
||||
What fakeagent does *not* prove is that the real bridge speaks this dialect. Only the
|
||||
end-to-end checklist does.
|
||||
|
||||
---
|
||||
|
||||
## Where the ACP types come from
|
||||
|
||||
There is no Go SDK for ACP. `internal/acp` is hand-written against
|
||||
[`agentclientprotocol/agent-client-protocol`](https://github.com/agentclientprotocol/agent-client-protocol)
|
||||
(`schema/v1/schema.json`), and it is thin on purpose — glance correlates ids, recognises a
|
||||
dozen methods, and passes payloads through to the browser as `json.RawMessage`.
|
||||
|
||||
**Do not "finish" it by modelling every update variant.** The stable `session/update` rail
|
||||
is a small closed set; the xAI rail has ~60 grok-internal variants that drift with every
|
||||
upstream sync. Typed structs for those would be a large amount of code whose only effect is
|
||||
to turn an upstream rename into a parse error that kills a live connection. The contract is:
|
||||
|
||||
- **The stable rail carries correctness.** Turn state comes from `update.sessionUpdate`,
|
||||
read by `classifyUpdate` in `internal/hub/agent.go` — one field, both rails.
|
||||
- **The xAI rail is presentation.** Unrecognised variants are stored, forwarded, and
|
||||
skipped by the renderer. Never an error.
|
||||
- **`_meta` is forwarded byte-for-byte.** `eventId`, `promptId`, `chunkId` and `isReplay`
|
||||
are how a viewer dedups and orders; rewriting the envelope would break replay.
|
||||
|
||||
To regenerate anything, clone the spec repo and read `schema/v1/schema.json`. There is no
|
||||
codegen step and adding one would be a mistake at this size.
|
||||
|
||||
### Response shapes are pinned to grok's Rust types, not to the spec
|
||||
|
||||
The three interaction replies are built in `web/src/components/PermissionDialog.tsx`, and
|
||||
their exact shapes were read off grok's source, not guessed:
|
||||
|
||||
| Interaction | Reply |
|
||||
|---|---|
|
||||
| `session/request_permission` | `{"outcome":{"outcome":"selected","optionId":"…"}}`, or `{"outcome":{"outcome":"cancelled"}}` |
|
||||
| `x.ai/exit_plan_mode` | `{"outcome":"approved"}`, `{"outcome":"cancelled","feedback":"…"}`, `{"outcome":"abandoned"}` |
|
||||
| `x.ai/ask_user_question` | `{"outcome":"accepted","answers":{"<question text>":["<label>"]}}`, plus optional `annotations` |
|
||||
|
||||
`ask_user_question` is the fiddly one, and `buildAccepted` reproduces
|
||||
`xai-grok-pager/src/views/question_view.rs` rule for rule: values are **arrays** of labels;
|
||||
the map is keyed by question **text** in the original order; unanswered questions are
|
||||
**omitted** rather than sent empty; a freeform-only answer is the literal `["Other"]` with
|
||||
the typed text in `annotations[q].notes`; `preview` rides along only for single-select.
|
||||
|
||||
Getting these wrong does not fail loudly. It produces an agent that acts on an answer to a
|
||||
question nobody asked. If you change one, change it in `newgrok/` too and re-run the
|
||||
end-to-end checklist.
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
|
||||
**Go.** Standard library plus chi, coder/websocket, pquerna/otp — that is the whole
|
||||
dependency list and it should stay that way. `slog` for logging, never `fmt.Println`.
|
||||
Comments explain *why*; the code already says what. Errors reaching a browser go through
|
||||
`Event{Type: EventError}` so the UI can show them rather than the socket dying quietly.
|
||||
|
||||
**One writer per socket.** `coder/websocket` allows a single concurrent writer, so every
|
||||
connection has exactly one writer goroutine draining a bounded queue. If you need to send
|
||||
from a new place, push to `outbound`/`send`; do not call `conn.Write` directly.
|
||||
|
||||
**Backpressure is a disconnect.** A full queue drops the connection and the client
|
||||
reconnects into a fresh snapshot. Do not "fix" this by growing the buffer — the snapshot is
|
||||
cheaper than the backlog it would replay.
|
||||
|
||||
**Nothing blocks the reader goroutine.** Anything slow (a prompt, a cancel) runs detached
|
||||
under `context.WithoutCancel`. A prompt can take minutes; waiting inline would freeze the
|
||||
Stop button that is meant to end it.
|
||||
|
||||
**Frontend.** HeroUI 3 sits on react-aria-components: buttons take `onPress`, not
|
||||
`onClick`, and there is no provider component to wrap. Tailwind 4 is CSS-first — there is
|
||||
**no `tailwind.config.js`**; theme tokens and `@source "./"` live in `web/src/index.css`.
|
||||
Use the semantic token classes already in use rather than arbitrary `bg-[var(--x)]` values.
|
||||
|
||||
**Never render agent output as markup.** Plans, tool output and model text are printed as
|
||||
text. The CSP is `default-src 'self'` with no `unsafe-inline` for scripts; keep it that way.
|
||||
|
||||
**`useTheme` is called exactly once,** at the root in `App.tsx`. Each call owns its own
|
||||
state, so a second call silently desyncs the toggle.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
go test ./... # all five internal packages
|
||||
go test -race ./internal/hub -run TestOnlyTheFirst -count=3
|
||||
```
|
||||
|
||||
`internal/hub/hub_test.go` drives the hub over **real WebSocket connections** with a fake
|
||||
grok and fake browsers, because the behaviour under test is the interleaving of three
|
||||
goroutines and a mocked transport would test the mock. If you add a command or an event,
|
||||
add it there.
|
||||
|
||||
Two things to know before you write a hub test:
|
||||
|
||||
- `interaction_resolved` is **broadcast to every browser**. A test with two browsers must
|
||||
consume the broadcast on the second one before asserting anything about its own reply, or
|
||||
it will read the first browser's resolution and report a confusing failure.
|
||||
- Agent-list churn arrives whenever a connection comes or goes. `fakeBrowser.expect` skips
|
||||
it; assert on the event kind you care about, not on message order.
|
||||
|
||||
`internal/httpapi/server_test.go` covers the auth boundary: the bootstrap 404, enrollment,
|
||||
rate limiting, cookie forgery, both upgrades, and the SPA fallback's refusal to swallow
|
||||
`/api/*`.
|
||||
|
||||
---
|
||||
|
||||
## End-to-end checklist
|
||||
|
||||
Unit tests cannot cover the race against a real terminal. Run this against a real grok with
|
||||
`/rc` on before shipping any change to the bridge, the interaction path, or the wire format.
|
||||
|
||||
1. `glance serve` → bootstrap → enroll TOTP → `glance apikey add laptop`.
|
||||
2. Put the printed `[remote_control]` block in `~/.grok/config.toml`.
|
||||
3. Start grok, type `/rc` → a system block confirms connected, **and the TUI stays fully
|
||||
usable**. This is the whole premise of the feature.
|
||||
4. Send a prompt from the browser → it appears in the terminal and streams to both.
|
||||
5. Trigger a tool needing approval → approve in the **browser** → the terminal's modal
|
||||
closes by itself.
|
||||
6. Same again, approving in the **terminal** → the browser's card closes by itself.
|
||||
7. Repeat 5–6 for `x.ai/ask_user_question` and `x.ai/exit_plan_mode`. All three race through
|
||||
the same path, but only permissions get exercised by accident.
|
||||
8. Start a long turn, press **Stop** in the browser → the turn aborts, and the session log
|
||||
attributes it to `client:glance`, not to Esc.
|
||||
9. Confirm tool-call deltas and `turn_completed` reach the browser — proof the xAI rail is
|
||||
mirrored and not just `session/update`.
|
||||
10. **Kill glance mid-turn.** The TUI must keep working; the bridge reconnects with backoff
|
||||
and replays. Remote control must never be able to take the local session down with it.
|
||||
|
||||
Step 10 is the one that matters most.
|
||||
|
||||
---
|
||||
|
||||
## Working on the Rust half
|
||||
|
||||
In `newgrok/`, and read its `CLAUDE.md` first. The short version:
|
||||
|
||||
- `patches/` is the source of truth and is tracked; `work/` is disposable build output.
|
||||
**Edit in `work/`, commit there, then `make rebuild` to export patches.** Never edit a
|
||||
`.patch` by hand.
|
||||
- **`make apply` does `git reset --hard` + `git clean -fdx` in `work/`.** Running it with
|
||||
uncommitted work destroys that work with no warning.
|
||||
- The root `Cargo.toml` is generated. Treat it as read-only; per-crate manifests take
|
||||
`workspace = true` deps.
|
||||
- Cargo on this box is I/O-heavy enough to disturb the machine. Build throttled and in the
|
||||
foreground:
|
||||
```sh
|
||||
nice -n 19 env CARGO_CMD=test PKG=xai-grok-pager ./scripts/build.sh --lib -j 2
|
||||
```
|
||||
`cargo` is only on `PATH` via `scripts/lib.sh`, so go through `scripts/build.sh`. Use
|
||||
module-qualified test filters (`rc::`, `slash::commands::rc`) — a bare `rc` matches
|
||||
hundreds of unrelated tests.
|
||||
- `doctor_cmd::tests::fake_standalone_facts_compose_through_shared_view` fails on a clean
|
||||
upstream tree. It is not yours.
|
||||
- Adding a pager slash command requires listing it in `xai-grok-shell`'s
|
||||
`PAGER_COMMAND_KEYS`, or `pager_builtin_triggers_are_reserved_in_shell` fails.
|
||||
|
||||
The RC code is `xai-grok-pager/src/rc/` (`mod`, `protocol`, `ring`, `tee`, `transport`) plus
|
||||
`slash/commands/rc.rs` and `app/dispatch/rc.rs`. `tee.rs` is the interesting one: it is a
|
||||
single interception point on the pager's ACP channel, and keeping it that way is what keeps
|
||||
upstream conflicts survivable.
|
||||
|
||||
---
|
||||
|
||||
## Things that will bite you
|
||||
|
||||
- **`vite build` empties `web/dist/`,** taking `.gitkeep` with it — and `web/embed.go` needs
|
||||
a file there or `go build` fails on a clean clone with an unhelpful embed error. The
|
||||
`web` and `clean` targets restore it; a bare `npm run build` does not.
|
||||
- **`__Host-` is a browser-enforced contract**: Secure, `Path=/`, no `Domain`. Change any of
|
||||
those and the browser silently discards the cookie, which looks exactly like a broken
|
||||
login.
|
||||
- **A used TOTP step is burned.** Logging in twice inside one 30-second window fails the
|
||||
second time. That is the replay defence, not a bug.
|
||||
- **The rate limiter is global, not per-user** — there are no users. Eight failures in five
|
||||
minutes locks out *everyone*, including a correct code. Tests must account for it.
|
||||
- **Reconnecting with the same API key replaces the previous connection.** Two grok
|
||||
instances sharing one key will fight over the slot; give each its own.
|
||||
- **A `false` return from `Answer`/`Decline` is not an error.** It is the ordinary outcome
|
||||
of losing the race, and it must produce `by:"elsewhere"`, not a 500.
|
||||
@@ -0,0 +1,115 @@
|
||||
# grok-glance
|
||||
#
|
||||
# Two build systems, one binary: the frontend is compiled by Vite into web/dist
|
||||
# and then embedded by `go build`. That ordering is not optional — `//go:embed`
|
||||
# resolves at compile time, so a Go build always ships whatever `make web` last
|
||||
# produced.
|
||||
|
||||
GO ?= go
|
||||
NPM ?= npm
|
||||
ADDR ?= :7717
|
||||
BIN := bin/glance
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
LDFLAGS := -X main.version=$(VERSION)
|
||||
|
||||
# Vite's dev server. Its proxy sends /api to the Go server, which is what keeps
|
||||
# the `__Host-` session cookie working in development: the cookie is
|
||||
# SameSite=Strict and would never be sent cross-origin.
|
||||
WEB_PORT ?= 5173
|
||||
|
||||
.PHONY: all
|
||||
all: build
|
||||
|
||||
# ── build ────────────────────────────────────────────────────────────────────
|
||||
|
||||
.PHONY: build
|
||||
build: web
|
||||
@mkdir -p bin
|
||||
$(GO) build -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/glance
|
||||
@echo "built $(BIN) ($(VERSION))"
|
||||
|
||||
## Go binary without rebuilding the frontend — fast loop for server work.
|
||||
.PHONY: server
|
||||
server:
|
||||
@mkdir -p bin
|
||||
$(GO) build -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/glance
|
||||
|
||||
.PHONY: web
|
||||
web: web/node_modules
|
||||
cd web && $(NPM) run build
|
||||
# Vite empties dist/ on every build, taking the .gitkeep with it. The file is
|
||||
# what lets `go build` succeed on a clean clone that has never run npm, so it
|
||||
# has to come back or the next contributor gets an unexplainable embed error.
|
||||
@touch web/dist/.gitkeep
|
||||
|
||||
web/node_modules: web/package.json
|
||||
cd web && $(NPM) install
|
||||
@touch web/node_modules
|
||||
|
||||
# ── development ──────────────────────────────────────────────────────────────
|
||||
|
||||
## Go server + Vite with hot reload. Open http://localhost:$(WEB_PORT).
|
||||
.PHONY: dev
|
||||
dev: server web/node_modules
|
||||
@echo "glance on $(ADDR), UI on http://localhost:$(WEB_PORT)"
|
||||
@trap 'kill 0' EXIT INT TERM; \
|
||||
$(BIN) serve --addr $(ADDR) & \
|
||||
cd web && $(NPM) run dev -- --port $(WEB_PORT); \
|
||||
wait
|
||||
|
||||
## Run the built binary against the embedded UI (production shape).
|
||||
.PHONY: run
|
||||
run: build
|
||||
$(BIN) serve --addr $(ADDR)
|
||||
|
||||
## A fake grok on the agent socket -- UI work without rebuilding Rust.
|
||||
## KEY comes from `glance apikey add dev`, or $$GLANCE_API_KEY.
|
||||
KEY ?= $(GLANCE_API_KEY)
|
||||
.PHONY: fakeagent
|
||||
fakeagent:
|
||||
$(GO) run ./cmd/fakeagent --key "$(KEY)"
|
||||
|
||||
# ── checks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
$(GO) test ./...
|
||||
|
||||
.PHONY: vet
|
||||
vet:
|
||||
$(GO) vet ./...
|
||||
|
||||
.PHONY: typecheck
|
||||
typecheck: web/node_modules
|
||||
cd web && $(NPM) run typecheck
|
||||
|
||||
.PHONY: fmt
|
||||
fmt:
|
||||
$(GO) fmt ./...
|
||||
|
||||
## Everything CI would run.
|
||||
.PHONY: check
|
||||
check: vet test typecheck
|
||||
|
||||
# ── housekeeping ─────────────────────────────────────────────────────────────
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -rf bin web/dist
|
||||
@mkdir -p web/dist && touch web/dist/.gitkeep
|
||||
|
||||
.PHONY: distclean
|
||||
distclean: clean
|
||||
rm -rf web/node_modules
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "grok-glance targets:"
|
||||
@echo " make build frontend + binary -> $(BIN)"
|
||||
@echo " make dev Go server + Vite dev server (hot reload)"
|
||||
@echo " make run build, then serve on $(ADDR)"
|
||||
@echo " make fakeagent connect a fake grok (KEY=glance_sk_...)"
|
||||
@echo " make web frontend only"
|
||||
@echo " make server binary only (keeps the last frontend build)"
|
||||
@echo " make check vet + go test + tsc"
|
||||
@echo " make clean drop bin/ and web/dist"
|
||||
@@ -0,0 +1,434 @@
|
||||
// Command fakeagent impersonates grok's `/rc` bridge so the glance server and
|
||||
// web UI can be developed without rebuilding grok.
|
||||
//
|
||||
// Rebuilding the Rust side is a multi-minute cargo run through a patch tree,
|
||||
// which is a poor inner loop for "does this tool card wrap correctly". This
|
||||
// dials the agent socket exactly as the real bridge does, answers `initialize`
|
||||
// with session metadata, and runs a scripted turn on every prompt: streamed
|
||||
// text on both notification rails, a thought, a plan, a tool call, and a real
|
||||
// `session/request_permission` that waits for a genuine answer.
|
||||
//
|
||||
// glance apikey add dev # prints glance_sk_...
|
||||
// go run ./cmd/fakeagent --key glance_sk_...
|
||||
//
|
||||
// It is a development aid, not a test fixture -- the tests in internal/hub have
|
||||
// their own in-process fake. What this adds is a browser you can click.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/user/grok-glance/internal/acp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
url := flag.String("url", "ws://127.0.0.1:7717/api/acp/agent", "glance agent socket")
|
||||
key := flag.String("key", os.Getenv("GLANCE_API_KEY"), "API key from `glance apikey add` (or $GLANCE_API_KEY)")
|
||||
title := flag.String("title", "fake session", "session title shown in the UI")
|
||||
model := flag.String("model", "grok-4-fake", "model name shown in the UI")
|
||||
cwd := flag.String("cwd", mustCwd(), "working directory shown in the UI")
|
||||
// Simulates the terminal answering a permission first, which is the one
|
||||
// path a browser alone cannot exercise: the card must retract by itself.
|
||||
terminalAfter := flag.Duration("terminal-after", 0, "answer permissions from the `terminal` after this delay (0 = never)")
|
||||
speed := flag.Duration("speed", 220*time.Millisecond, "delay between streamed chunks")
|
||||
flag.Parse()
|
||||
|
||||
if *key == "" {
|
||||
fmt.Fprintln(os.Stderr, "fakeagent: --key is required (run `glance apikey add dev`)")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
a := &agent{
|
||||
out: make(chan acp.Frame, 64),
|
||||
pending: make(map[string]chan acp.Frame),
|
||||
meta: acp.SessionMeta{
|
||||
SessionID: "fake-session-1",
|
||||
CWD: *cwd,
|
||||
Title: *title,
|
||||
Model: *model,
|
||||
Hostname: hostname(),
|
||||
Version: "fakeagent",
|
||||
},
|
||||
terminalAfter: *terminalAfter,
|
||||
speed: *speed,
|
||||
}
|
||||
|
||||
if err := a.run(ctx, *url, *key); err != nil && !errors.Is(err, context.Canceled) {
|
||||
log.Fatalf("fakeagent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type agent struct {
|
||||
conn *websocket.Conn
|
||||
out chan acp.Frame
|
||||
|
||||
mu sync.Mutex
|
||||
nextID uint64
|
||||
pending map[string]chan acp.Frame // our requests, awaiting glance's answer
|
||||
cancel context.CancelFunc // cancels the turn in flight, if any
|
||||
|
||||
meta acp.SessionMeta
|
||||
terminalAfter time.Duration
|
||||
speed time.Duration
|
||||
}
|
||||
|
||||
func (a *agent) run(ctx context.Context, url, key string) error {
|
||||
conn, resp, err := websocket.Dial(ctx, url, &websocket.DialOptions{
|
||||
HTTPHeader: http.Header{"Authorization": {"Bearer " + key}},
|
||||
})
|
||||
if err != nil {
|
||||
if resp != nil && resp.StatusCode == http.StatusUnauthorized {
|
||||
return fmt.Errorf("glance rejected the API key (%s)", resp.Status)
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
conn.SetReadLimit(8 << 20)
|
||||
a.conn = conn
|
||||
|
||||
log.Printf("connected to %s as %q", url, a.meta.Label())
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
go a.writeLoop(ctx)
|
||||
|
||||
for {
|
||||
typ, data, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
continue
|
||||
}
|
||||
var frame acp.Frame
|
||||
if err := json.Unmarshal(data, &frame); err != nil {
|
||||
log.Printf("undecodable frame: %v", err)
|
||||
continue
|
||||
}
|
||||
a.handle(ctx, frame)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agent) writeLoop(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case frame := <-a.out:
|
||||
data, err := json.Marshal(frame)
|
||||
if err != nil {
|
||||
log.Printf("unencodable frame: %v", err)
|
||||
continue
|
||||
}
|
||||
if err := a.conn.Write(ctx, websocket.MessageText, data); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agent) handle(ctx context.Context, frame acp.Frame) {
|
||||
switch frame.Kind() {
|
||||
case acp.KindResponse:
|
||||
a.mu.Lock()
|
||||
ch := a.pending[string(frame.ID)]
|
||||
delete(a.pending, string(frame.ID))
|
||||
a.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- frame
|
||||
}
|
||||
return
|
||||
|
||||
case acp.KindNotification:
|
||||
if frame.Method == acp.MethodRCViewers {
|
||||
var p acp.ViewersParams
|
||||
_ = json.Unmarshal(frame.Params, &p)
|
||||
log.Printf("viewers: %d", p.Count)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch frame.Method {
|
||||
case acp.MethodInitialize:
|
||||
a.reply(frame.ID, acp.InitializeResult{
|
||||
ProtocolVersion: 1,
|
||||
Meta: &acp.InitializeMeta{
|
||||
Session: a.meta,
|
||||
RemoteControl: &acp.RemoteControlStatus{ReplayBuffer: 2048},
|
||||
},
|
||||
})
|
||||
|
||||
case acp.MethodSessionList:
|
||||
a.reply(frame.ID, map[string]any{"sessions": []acp.SessionMeta{a.meta}})
|
||||
|
||||
case acp.MethodSessionPrompt:
|
||||
var p acp.PromptParams
|
||||
_ = json.Unmarshal(frame.Params, &p)
|
||||
log.Printf("prompt: %q", p.Text)
|
||||
go a.turn(ctx, frame.ID, p.Text)
|
||||
|
||||
case acp.MethodSessionCancel:
|
||||
a.mu.Lock()
|
||||
cancel := a.cancel
|
||||
a.mu.Unlock()
|
||||
if cancel != nil {
|
||||
log.Print("cancelled by glance")
|
||||
cancel()
|
||||
}
|
||||
a.reply(frame.ID, map[string]any{})
|
||||
|
||||
default:
|
||||
a.send(acp.NewErrorResponse(frame.ID, acp.CodeMethodNotFound, "fakeagent: "+frame.Method))
|
||||
}
|
||||
}
|
||||
|
||||
// turn plays a scripted turn. The response to the prompt request is sent last,
|
||||
// which is what the real bridge does: `session/prompt` does not return until the
|
||||
// turn is over.
|
||||
func (a *agent) turn(parent context.Context, promptID json.RawMessage, text string) {
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
a.mu.Lock()
|
||||
if a.cancel != nil {
|
||||
a.cancel() // a second prompt supersedes the turn in flight
|
||||
}
|
||||
a.cancel = cancel
|
||||
a.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
cancel()
|
||||
a.mu.Lock()
|
||||
a.cancel = nil
|
||||
a.mu.Unlock()
|
||||
}()
|
||||
|
||||
stopped := func() bool {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return true
|
||||
case <-time.After(a.speed):
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
a.update("user_message_chunk", map[string]any{"content": textBlock(text)})
|
||||
a.update("agent_thought_chunk", map[string]any{"content": textBlock("Considering how to answer that.")})
|
||||
if stopped() {
|
||||
a.finish(promptID, "cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
a.update("plan", map[string]any{"entries": []map[string]any{
|
||||
{"content": "Read the file", "status": "in_progress", "priority": "high"},
|
||||
{"content": "Report back", "status": "pending", "priority": "medium"},
|
||||
}})
|
||||
|
||||
for _, chunk := range []string{"Sure — ", "let me look at ", "that file.\n\n"} {
|
||||
if stopped() {
|
||||
a.finish(promptID, "cancelled")
|
||||
return
|
||||
}
|
||||
a.update("agent_message_chunk", map[string]any{"content": textBlock(chunk)})
|
||||
}
|
||||
|
||||
const toolCallID = "call-1"
|
||||
a.update("tool_call", map[string]any{
|
||||
"toolCallId": toolCallID,
|
||||
"title": "Read src/main.rs",
|
||||
"kind": "read",
|
||||
"status": "pending",
|
||||
"rawInput": map[string]any{"path": "src/main.rs"},
|
||||
})
|
||||
|
||||
granted, err := a.requestPermission(ctx, toolCallID)
|
||||
if err != nil {
|
||||
a.update("tool_call_update", map[string]any{"toolCallId": toolCallID, "status": "failed"})
|
||||
a.finish(promptID, "cancelled")
|
||||
return
|
||||
}
|
||||
if !granted {
|
||||
a.update("tool_call_update", map[string]any{"toolCallId": toolCallID, "status": "failed"})
|
||||
a.update("agent_message_chunk", map[string]any{"content": textBlock("Understood, leaving it alone.")})
|
||||
a.turnCompleted()
|
||||
a.finish(promptID, "end_turn")
|
||||
return
|
||||
}
|
||||
|
||||
a.update("tool_call_update", map[string]any{
|
||||
"toolCallId": toolCallID,
|
||||
"status": "completed",
|
||||
"content": []map[string]any{
|
||||
{"type": "content", "content": textBlock("fn main() {\n println!(\"hello\");\n}\n")},
|
||||
},
|
||||
})
|
||||
if stopped() {
|
||||
a.finish(promptID, "cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
a.update("plan", map[string]any{"entries": []map[string]any{
|
||||
{"content": "Read the file", "status": "completed", "priority": "high"},
|
||||
{"content": "Report back", "status": "completed", "priority": "medium"},
|
||||
}})
|
||||
a.update("agent_message_chunk", map[string]any{"content": textBlock("It prints `hello`. Nothing else in there.")})
|
||||
a.turnCompleted()
|
||||
a.finish(promptID, "end_turn")
|
||||
}
|
||||
|
||||
// requestPermission raises a real interaction and waits for a real answer, so
|
||||
// the browser's dialog is exercised end to end rather than mocked.
|
||||
func (a *agent) requestPermission(ctx context.Context, toolCallID string) (bool, error) {
|
||||
id, reply := a.request(acp.MethodRequestPermission, map[string]any{
|
||||
"sessionId": a.meta.SessionID,
|
||||
"toolCall": map[string]any{
|
||||
"toolCallId": toolCallID,
|
||||
"title": "Read src/main.rs",
|
||||
"kind": "read",
|
||||
},
|
||||
"options": []map[string]any{
|
||||
{"optionId": "allow", "name": "Allow", "kind": "allow_once"},
|
||||
{"optionId": "reject", "name": "Reject", "kind": "reject_once"},
|
||||
},
|
||||
})
|
||||
|
||||
// The terminal racing the browser for the same answer.
|
||||
var terminal <-chan time.Time
|
||||
if a.terminalAfter > 0 {
|
||||
t := time.NewTimer(a.terminalAfter)
|
||||
defer t.Stop()
|
||||
terminal = t.C
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false, ctx.Err()
|
||||
|
||||
case <-terminal:
|
||||
log.Print("terminal answered first; retracting the browser's dialog")
|
||||
a.notify(acp.MethodRCInteractionCancelled, acp.InteractionCancelledParams{ID: id, ToolCallID: toolCallID})
|
||||
a.mu.Lock()
|
||||
delete(a.pending, fmt.Sprint(id))
|
||||
a.mu.Unlock()
|
||||
return true, nil
|
||||
|
||||
case frame := <-reply:
|
||||
if frame.Error != nil {
|
||||
// A decline: glance is not answering, so in the real bridge the
|
||||
// terminal's dialog stays up. Here, the terminal shrugs and allows.
|
||||
log.Printf("declined by glance (%s); falling back to the terminal", frame.Error.Message)
|
||||
return true, nil
|
||||
}
|
||||
var out struct {
|
||||
Outcome struct {
|
||||
Outcome string `json:"outcome"`
|
||||
OptionID string `json:"optionId"`
|
||||
} `json:"outcome"`
|
||||
}
|
||||
_ = json.Unmarshal(frame.Result, &out)
|
||||
log.Printf("answered: %s/%s", out.Outcome.Outcome, out.Outcome.OptionID)
|
||||
return out.Outcome.OptionID != "reject" && out.Outcome.Outcome != "cancelled", nil
|
||||
}
|
||||
}
|
||||
|
||||
// turnCompleted is emitted on the xAI rail on purpose: if it shows up in the UI,
|
||||
// both rails are being mirrored, which is the thing most likely to silently
|
||||
// regress.
|
||||
func (a *agent) turnCompleted() {
|
||||
a.notify(acp.MethodXAINotification, map[string]any{
|
||||
"sessionId": a.meta.SessionID,
|
||||
"update": map[string]any{"sessionUpdate": "turn_completed", "stopReason": "end_turn"},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *agent) finish(promptID json.RawMessage, stopReason string) {
|
||||
a.reply(promptID, map[string]any{"stopReason": stopReason})
|
||||
}
|
||||
|
||||
func (a *agent) update(kind string, fields map[string]any) {
|
||||
update := map[string]any{"sessionUpdate": kind}
|
||||
for k, v := range fields {
|
||||
update[k] = v
|
||||
}
|
||||
a.notify(acp.MethodSessionUpdate, map[string]any{
|
||||
"sessionId": a.meta.SessionID,
|
||||
"update": update,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *agent) notify(method string, params any) {
|
||||
frame, err := acp.NewNotification(method, params)
|
||||
if err != nil {
|
||||
log.Printf("notify %s: %v", method, err)
|
||||
return
|
||||
}
|
||||
a.send(frame)
|
||||
}
|
||||
|
||||
func (a *agent) request(method string, params any) (uint64, <-chan acp.Frame) {
|
||||
a.mu.Lock()
|
||||
a.nextID++
|
||||
id := a.nextID
|
||||
ch := make(chan acp.Frame, 1)
|
||||
a.pending[fmt.Sprint(id)] = ch
|
||||
a.mu.Unlock()
|
||||
|
||||
frame, err := acp.NewRequest(id, method, params)
|
||||
if err != nil {
|
||||
log.Printf("request %s: %v", method, err)
|
||||
return id, ch
|
||||
}
|
||||
a.send(frame)
|
||||
return id, ch
|
||||
}
|
||||
|
||||
func (a *agent) reply(id json.RawMessage, result any) {
|
||||
frame, err := acp.NewResponse(id, result)
|
||||
if err != nil {
|
||||
log.Printf("reply: %v", err)
|
||||
return
|
||||
}
|
||||
a.send(frame)
|
||||
}
|
||||
|
||||
func (a *agent) send(frame acp.Frame) {
|
||||
select {
|
||||
case a.out <- frame:
|
||||
default:
|
||||
log.Print("send queue full, dropping frame")
|
||||
}
|
||||
}
|
||||
|
||||
func textBlock(text string) map[string]any {
|
||||
return map[string]any{"type": "text", "text": text}
|
||||
}
|
||||
|
||||
func mustCwd() string {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "/"
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func hostname() string {
|
||||
name, err := os.Hostname()
|
||||
if err != nil {
|
||||
return "localhost"
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
// Command glance is the grok-glance server and its administrative CLI.
|
||||
//
|
||||
// glance serve [--addr :7717] [--dir ~/.grok/glance] [--insecure-cookie]
|
||||
// glance apikey add <name> | list | rm <id-or-name>
|
||||
// glance bootstrap # mint a fresh setup token
|
||||
// glance version
|
||||
//
|
||||
// One binary, one port, no database. See ARCHITECTURE.md for why.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/user/grok-glance/internal/auth"
|
||||
"github.com/user/grok-glance/internal/httpapi"
|
||||
"github.com/user/grok-glance/internal/hub"
|
||||
"github.com/user/grok-glance/internal/state"
|
||||
"github.com/user/grok-glance/web"
|
||||
)
|
||||
|
||||
// version is stamped at build time: `-ldflags "-X main.version=$(git describe)"`.
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "glance:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
if len(args) == 0 {
|
||||
usage()
|
||||
return errors.New("no command given")
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "serve":
|
||||
return serve(args[1:])
|
||||
case "apikey":
|
||||
return apikey(args[1:])
|
||||
case "bootstrap":
|
||||
return bootstrap(args[1:])
|
||||
case "version", "--version", "-v":
|
||||
fmt.Println("grok-glance", version)
|
||||
return nil
|
||||
case "help", "--help", "-h":
|
||||
usage()
|
||||
return nil
|
||||
default:
|
||||
usage()
|
||||
return fmt.Errorf("unknown command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprint(os.Stderr, `grok-glance -- remote control for grok build
|
||||
|
||||
glance serve [flags] run the server
|
||||
glance apikey add <name> mint a key for one grok instance
|
||||
glance apikey list list keys
|
||||
glance apikey rm <id-or-name> revoke a key
|
||||
glance bootstrap mint a fresh setup token
|
||||
glance version
|
||||
|
||||
serve flags:
|
||||
--addr <host:port> listen address (default 127.0.0.1:7717)
|
||||
--dir <path> state directory (default ~/.grok/glance)
|
||||
--insecure-cookie omit the cookie's Secure flag, for plain-HTTP localhost
|
||||
`)
|
||||
}
|
||||
|
||||
// openStore resolves the state directory and opens it.
|
||||
func openStore(dir string) (*state.Store, error) {
|
||||
if dir == "" {
|
||||
var err error
|
||||
dir, err = state.DefaultDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("locate state directory: %w", err)
|
||||
}
|
||||
}
|
||||
return state.Open(dir)
|
||||
}
|
||||
|
||||
func serve(args []string) error {
|
||||
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||
addr := fs.String("addr", "127.0.0.1:7717", "listen address")
|
||||
dir := fs.String("dir", "", "state directory (default ~/.grok/glance)")
|
||||
insecureCookie := fs.Bool("insecure-cookie", false, "omit the Secure cookie flag (plain-HTTP localhost only)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store, err := openStore(*dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
|
||||
// Binding to a loopback address is the default because this server is a
|
||||
// remote control for a shell agent. Exposing it means exposing that, so it
|
||||
// should be a deliberate act -- ideally behind a reverse proxy or a tunnel
|
||||
// that terminates TLS, which the cookie's Secure flag assumes.
|
||||
if !isLoopback(*addr) && *insecureCookie {
|
||||
return errors.New("--insecure-cookie is for plain-HTTP localhost only; " +
|
||||
"on a non-loopback address the session cookie must be Secure")
|
||||
}
|
||||
|
||||
if !store.Enrolled() {
|
||||
if err := announceBootstrap(store, *addr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
assets, err := web.Assets()
|
||||
if err != nil {
|
||||
log.Warn("no built web UI in this binary; serving a placeholder", "err", err)
|
||||
assets = nil
|
||||
}
|
||||
|
||||
server := httpapi.New(httpapi.Options{
|
||||
Store: store,
|
||||
Auth: auth.NewManager(store, !*insecureCookie),
|
||||
Hub: hub.New(log),
|
||||
Log: log,
|
||||
Web: assets,
|
||||
})
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: server,
|
||||
// No WriteTimeout: WebSocket connections are long-lived by design and a
|
||||
// write deadline would sever them mid-session. Per-write deadlines are
|
||||
// applied inside the hub instead, where they can be scoped to one frame.
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
errc := make(chan error, 1)
|
||||
go func() {
|
||||
log.Info("listening", "addr", *addr, "state", store.Path())
|
||||
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errc <- err
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errc:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
log.Info("shutting down")
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return httpServer.Shutdown(shutdownCtx)
|
||||
}
|
||||
|
||||
// announceBootstrap mints a setup token and puts it where the operator will
|
||||
// actually see it.
|
||||
//
|
||||
// It goes to stderr *and* to a 0600 file, because the two failure modes are
|
||||
// different: a server started under systemd has no terminal to print to, and a
|
||||
// server started in a scrollback that has since been cleared has no file to
|
||||
// recover from unless one was written.
|
||||
func announceBootstrap(store *state.Store, addr string) error {
|
||||
token, err := store.NewBootstrapToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("mint bootstrap token: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(filepath.Dir(store.Path()), "bootstrap.token")
|
||||
if err := os.WriteFile(path, []byte(token+"\n"), 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("http://%s/setup?token=%s", displayAddr(addr), token)
|
||||
fmt.Fprintf(os.Stderr, `
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
grok-glance is not set up yet.
|
||||
|
||||
Open this once to enroll your authenticator:
|
||||
|
||||
%s
|
||||
|
||||
The token is also in %s.
|
||||
It stops working the moment enrollment succeeds.
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
|
||||
`, url, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func bootstrap(args []string) error {
|
||||
fs := flag.NewFlagSet("bootstrap", flag.ContinueOnError)
|
||||
dir := fs.String("dir", "", "state directory (default ~/.grok/glance)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
store, err := openStore(*dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if store.Enrolled() {
|
||||
// Minting a token here would be a way to re-enroll around a lost phone,
|
||||
// which is exactly the door the bootstrap gate exists to keep shut. The
|
||||
// recovery path is deliberately physical: delete the state file.
|
||||
return errors.New("an authenticator is already enrolled; " +
|
||||
"to start over, delete " + store.Path() + " (this also revokes every API key and session)")
|
||||
}
|
||||
token, err := store.NewBootstrapToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(filepath.Dir(store.Path()), "bootstrap.token")
|
||||
if err := os.WriteFile(path, []byte(token+"\n"), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(token)
|
||||
return nil
|
||||
}
|
||||
|
||||
func apikey(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: glance apikey add <name> | list | rm <id-or-name>")
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("apikey", flag.ContinueOnError)
|
||||
dir := fs.String("dir", "", "state directory (default ~/.grok/glance)")
|
||||
sub := args[0]
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
rest := fs.Args()
|
||||
|
||||
// Go's flag package stops at the first non-flag argument, so `apikey add foo
|
||||
// --dir X` silently leaves --dir unparsed. Saying which mistake was made
|
||||
// beats a bare usage line, and checking before openStore keeps a typo from
|
||||
// creating a state file in the default directory.
|
||||
for _, arg := range rest {
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
return fmt.Errorf("flags must come before the name: glance apikey %s %s <name>", sub, arg)
|
||||
}
|
||||
}
|
||||
if (sub == "add" || sub == "rm") && len(rest) != 1 {
|
||||
return fmt.Errorf("usage: glance apikey %s <%s>", sub, map[string]string{"add": "name", "rm": "id-or-name"}[sub])
|
||||
}
|
||||
|
||||
store, err := openStore(*dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch sub {
|
||||
case "add":
|
||||
plaintext, key, err := store.AddAPIKey(rest[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Printed once and never again: only the hash is stored, so there is no
|
||||
// way to recover it later. Say so, rather than let someone discover it.
|
||||
fmt.Printf(`Key %q created (id %s).
|
||||
|
||||
Add it to ~/.grok/config.toml on the machine running grok:
|
||||
|
||||
[remote_control]
|
||||
url = "ws://127.0.0.1:7717/api/acp/agent"
|
||||
api_key = "%s"
|
||||
|
||||
Then run /rc in grok.
|
||||
|
||||
This is the only time the key is shown.
|
||||
`, key.Name, key.ID, plaintext)
|
||||
return nil
|
||||
|
||||
case "list":
|
||||
keys := store.ListAPIKeys()
|
||||
if len(keys) == 0 {
|
||||
fmt.Println("No API keys. Create one with: glance apikey add <name>")
|
||||
return nil
|
||||
}
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tNAME\tCREATED\tLAST SEEN")
|
||||
for _, k := range keys {
|
||||
seen := "never"
|
||||
if k.LastSeen != nil {
|
||||
seen = k.LastSeen.Local().Format(time.RFC3339)
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
|
||||
k.ID, k.Name, k.CreatedAt.Local().Format(time.RFC3339), seen)
|
||||
}
|
||||
return w.Flush()
|
||||
|
||||
case "rm":
|
||||
removed, err := store.RemoveAPIKey(rest[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !removed {
|
||||
return fmt.Errorf("no API key matches %q", rest[0])
|
||||
}
|
||||
fmt.Printf("Removed %q. Any grok using it will be rejected on its next reconnect.\n", rest[0])
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown apikey command %q", sub)
|
||||
}
|
||||
}
|
||||
|
||||
func isLoopback(addr string) bool {
|
||||
host, _, found := strings.Cut(addr, ":")
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
switch host {
|
||||
case "127.0.0.1", "localhost", "::1", "[::1]":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// displayAddr turns a listen address into something clickable.
|
||||
func displayAddr(addr string) string {
|
||||
if strings.HasPrefix(addr, ":") {
|
||||
return "127.0.0.1" + addr
|
||||
}
|
||||
return addr
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
module github.com/user/grok-glance
|
||||
|
||||
go 1.24.4
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/pquerna/otp v1.5.0
|
||||
)
|
||||
|
||||
require github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
@@ -0,0 +1,15 @@
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
@@ -0,0 +1,294 @@
|
||||
// Package acp speaks the Agent Client Protocol dialect that grok's `/rc` bridge
|
||||
// exposes.
|
||||
//
|
||||
// There is no Go SDK for ACP upstream, so this is hand-written -- but
|
||||
// deliberately thin. Glance is a control plane, not a second agent: it needs to
|
||||
// correlate requests with responses, recognise the handful of methods it acts
|
||||
// on, and pass everything else through to the browser untouched. Modelling all
|
||||
// ~60 xAI notification variants as Go structs would be a large amount of code
|
||||
// that breaks on every upstream sync and buys nothing, since the browser renders
|
||||
// from the JSON either way.
|
||||
//
|
||||
// The roles are inverted relative to the terminal: over this link *grok is the
|
||||
// Agent* and glance is the Client. That is what lets glance drive a session it
|
||||
// did not create -- it sends `session/prompt` and `session/cancel`, and receives
|
||||
// `session/update` plus permission requests.
|
||||
package acp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Methods glance sends to grok.
|
||||
const (
|
||||
MethodInitialize = "initialize"
|
||||
MethodSessionList = "session/list"
|
||||
MethodSessionPrompt = "session/prompt"
|
||||
MethodSessionCancel = "session/cancel"
|
||||
MethodRCReplay = "x.ai/rc/replay"
|
||||
MethodRCViewers = "x.ai/rc/viewers"
|
||||
)
|
||||
|
||||
// Methods grok sends to glance.
|
||||
const (
|
||||
MethodSessionUpdate = "session/update"
|
||||
// The xAI rail: tool-call deltas, subagent activity, turn boundaries. Not
|
||||
// in the upstream schema and not stable -- treated as opaque presentation
|
||||
// data, never as something correctness depends on.
|
||||
MethodXAINotification = "x.ai/session_notification"
|
||||
|
||||
MethodRequestPermission = "session/request_permission"
|
||||
MethodAskUserQuestion = "x.ai/ask_user_question"
|
||||
MethodExitPlanMode = "x.ai/exit_plan_mode"
|
||||
|
||||
MethodRCStatus = "x.ai/rc/status"
|
||||
// The terminal answered an interaction first: retract the browser's dialog.
|
||||
MethodRCInteractionCancelled = "x.ai/rc/interaction_cancelled"
|
||||
)
|
||||
|
||||
// JSON-RPC error codes used on this link.
|
||||
const (
|
||||
CodeMethodNotFound = -32601
|
||||
CodeInvalidParams = -32602
|
||||
CodeInternal = -32603
|
||||
)
|
||||
|
||||
// Frame is one JSON-RPC 2.0 message in either direction.
|
||||
//
|
||||
// Every field is optional because the same struct decodes requests,
|
||||
// notifications, and responses; which one it is follows from which fields are
|
||||
// set, per Kind.
|
||||
type Frame struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *Error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Error is a JSON-RPC error object.
|
||||
type Error struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return fmt.Sprintf("jsonrpc %d: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// Kind classifies a decoded frame.
|
||||
type Kind int
|
||||
|
||||
const (
|
||||
// KindRequest expects a response: it has both a method and an id.
|
||||
KindRequest Kind = iota
|
||||
// KindNotification is fire-and-forget: method, no id.
|
||||
KindNotification
|
||||
// KindResponse answers a request we sent: id, no method.
|
||||
KindResponse
|
||||
// KindInvalid is none of the above.
|
||||
KindInvalid
|
||||
)
|
||||
|
||||
// Kind reports what f is.
|
||||
func (f *Frame) Kind() Kind {
|
||||
hasID := len(f.ID) > 0 && string(f.ID) != "null"
|
||||
switch {
|
||||
case f.Method != "" && hasID:
|
||||
return KindRequest
|
||||
case f.Method != "":
|
||||
return KindNotification
|
||||
case hasID && (len(f.Result) > 0 || f.Error != nil):
|
||||
return KindResponse
|
||||
default:
|
||||
return KindInvalid
|
||||
}
|
||||
}
|
||||
|
||||
// IsInteraction reports whether a request from grok is one the user must answer.
|
||||
//
|
||||
// These three are the set grok's own leader broadcasts for first-answer-wins
|
||||
// arbitration. Handling only permissions would strand a browser user the moment
|
||||
// the agent asked a question instead of requesting a tool.
|
||||
func IsInteraction(method string) bool {
|
||||
switch method {
|
||||
case MethodRequestPermission, MethodAskUserQuestion, MethodExitPlanMode:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsTranscript reports whether a notification from grok belongs in the
|
||||
// transcript ring and should be forwarded to browsers.
|
||||
//
|
||||
// Both rails qualify: the stable `session/update` carries correctness, and the
|
||||
// xAI rail carries the streaming detail that makes the transcript readable.
|
||||
func IsTranscript(method string) bool {
|
||||
switch method {
|
||||
case MethodSessionUpdate, MethodXAINotification:
|
||||
return true
|
||||
}
|
||||
// grok's own predicate accepts an `x.ai/session/update` spelling too;
|
||||
// mirroring that keeps glance working if the bridge starts emitting it.
|
||||
return method == "x.ai/session/update"
|
||||
}
|
||||
|
||||
// NewRequest builds a request frame.
|
||||
func NewRequest(id uint64, method string, params any) (Frame, error) {
|
||||
raw, err := marshalParams(params)
|
||||
if err != nil {
|
||||
return Frame{}, err
|
||||
}
|
||||
return Frame{JSONRPC: "2.0", ID: encodeID(id), Method: method, Params: raw}, nil
|
||||
}
|
||||
|
||||
// NewNotification builds a notification frame.
|
||||
func NewNotification(method string, params any) (Frame, error) {
|
||||
raw, err := marshalParams(params)
|
||||
if err != nil {
|
||||
return Frame{}, err
|
||||
}
|
||||
return Frame{JSONRPC: "2.0", Method: method, Params: raw}, nil
|
||||
}
|
||||
|
||||
// NewResponse builds a success response to id.
|
||||
func NewResponse(id json.RawMessage, result any) (Frame, error) {
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return Frame{}, err
|
||||
}
|
||||
return Frame{JSONRPC: "2.0", ID: id, Result: raw}, nil
|
||||
}
|
||||
|
||||
// NewErrorResponse builds a failure response to id.
|
||||
func NewErrorResponse(id json.RawMessage, code int, message string) Frame {
|
||||
return Frame{JSONRPC: "2.0", ID: id, Error: &Error{Code: code, Message: message}}
|
||||
}
|
||||
|
||||
func encodeID(id uint64) json.RawMessage {
|
||||
return json.RawMessage(fmt.Sprintf("%d", id))
|
||||
}
|
||||
|
||||
// marshalParams keeps `params` absent rather than null when there is nothing to
|
||||
// send: some JSON-RPC peers distinguish the two, and absent is the safer of the
|
||||
// two to emit.
|
||||
func marshalParams(params any) (json.RawMessage, error) {
|
||||
if params == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if raw, ok := params.(json.RawMessage); ok {
|
||||
return raw, nil
|
||||
}
|
||||
return json.Marshal(params)
|
||||
}
|
||||
|
||||
// SessionMeta labels a mirrored session in the UI. grok sends it in the
|
||||
// `initialize` result and again in every `x.ai/rc/status` notification, so a
|
||||
// reconnecting browser can label the session without another round trip.
|
||||
type SessionMeta struct {
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
CWD string `json:"cwd,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
// Label is the best human-readable name available for this session.
|
||||
func (m SessionMeta) Label() string {
|
||||
switch {
|
||||
case m.Title != "":
|
||||
return m.Title
|
||||
case m.CWD != "":
|
||||
return m.CWD
|
||||
case m.SessionID != "":
|
||||
return m.SessionID
|
||||
default:
|
||||
return "session"
|
||||
}
|
||||
}
|
||||
|
||||
// InitializeResult is grok's reply to `initialize`.
|
||||
type InitializeResult struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
Meta *InitializeMeta `json:"_meta,omitempty"`
|
||||
}
|
||||
|
||||
// InitializeMeta carries the session identity in `initialize`'s `_meta`.
|
||||
type InitializeMeta struct {
|
||||
Session SessionMeta `json:"session"`
|
||||
RemoteControl *RemoteControlStatus `json:"remoteControl,omitempty"`
|
||||
}
|
||||
|
||||
// RemoteControlStatus describes the bridge's replay ring.
|
||||
type RemoteControlStatus struct {
|
||||
ReplayBuffer int `json:"replayBuffer"`
|
||||
Frames int `json:"frames"`
|
||||
Dropped int `json:"dropped"`
|
||||
}
|
||||
|
||||
// StatusParams is the payload of `x.ai/rc/status`.
|
||||
type StatusParams struct {
|
||||
Session SessionMeta `json:"session"`
|
||||
Replay struct {
|
||||
Frames int `json:"frames"`
|
||||
Dropped int `json:"dropped"`
|
||||
} `json:"replay"`
|
||||
}
|
||||
|
||||
// InteractionCancelledParams is the payload of `x.ai/rc/interaction_cancelled`:
|
||||
// the terminal answered first, so the browser's dialog must close.
|
||||
type InteractionCancelledParams struct {
|
||||
ID uint64 `json:"id"`
|
||||
ToolCallID string `json:"toolCallId,omitempty"`
|
||||
}
|
||||
|
||||
// PromptParams asks grok to run a turn. The bridge accepts either ACP content
|
||||
// blocks or a plain string; glance sends the string, which is all a browser
|
||||
// prompt box produces.
|
||||
type PromptParams struct {
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// CancelParams interrupts the running turn.
|
||||
type CancelParams struct {
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
}
|
||||
|
||||
// ViewersParams tells the bridge how many browsers are watching, so `/rc status`
|
||||
// in the terminal can say so. Cosmetic.
|
||||
type ViewersParams struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// ToolCallID digs the tool call id out of an interaction's params.
|
||||
//
|
||||
// It is what both sides key a retraction by: when one side answers, the other's
|
||||
// dialog is closed by tool call id rather than by JSON-RPC id, because the
|
||||
// browser never sees the terminal's ids. The three interaction shapes spell it
|
||||
// differently, hence the two probes.
|
||||
func ToolCallID(params json.RawMessage) string {
|
||||
if len(params) == 0 {
|
||||
return ""
|
||||
}
|
||||
var probe struct {
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
ToolCall struct {
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
} `json:"toolCall"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &probe); err != nil {
|
||||
return ""
|
||||
}
|
||||
if probe.ToolCallID != "" {
|
||||
return probe.ToolCallID
|
||||
}
|
||||
return probe.ToolCall.ToolCallID
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func decode(t *testing.T, raw string) Frame {
|
||||
t.Helper()
|
||||
var f Frame
|
||||
if err := json.Unmarshal([]byte(raw), &f); err != nil {
|
||||
t.Fatalf("decode %s: %v", raw, err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func TestKindClassifiesEveryShape(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want Kind
|
||||
}{
|
||||
{"request", `{"jsonrpc":"2.0","id":7,"method":"session/request_permission","params":{}}`, KindRequest},
|
||||
{"notification", `{"jsonrpc":"2.0","method":"session/update","params":{}}`, KindNotification},
|
||||
{"response", `{"jsonrpc":"2.0","id":7,"result":{"ok":true}}`, KindResponse},
|
||||
{"error response", `{"jsonrpc":"2.0","id":7,"error":{"code":-32601,"message":"nope"}}`, KindResponse},
|
||||
{"string id request", `{"jsonrpc":"2.0","id":"abc","method":"x.ai/ask_user_question"}`, KindRequest},
|
||||
// A null id is JSON-RPC's "no id", not id zero: treating it as a request
|
||||
// would have glance reply to something nothing is listening for.
|
||||
{"null id", `{"jsonrpc":"2.0","id":null,"method":"session/update"}`, KindNotification},
|
||||
{"empty", `{"jsonrpc":"2.0"}`, KindInvalid},
|
||||
{"id only", `{"jsonrpc":"2.0","id":7}`, KindInvalid},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
frame := decode(t, tc.raw)
|
||||
if got := frame.Kind(); got != tc.want {
|
||||
t.Fatalf("Kind() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInteractionAndTranscriptMethods(t *testing.T) {
|
||||
// These three are the set that races between the terminal and the browser.
|
||||
// Dropping one would strand a remote user whenever the agent used it.
|
||||
for _, m := range []string{MethodRequestPermission, MethodAskUserQuestion, MethodExitPlanMode} {
|
||||
if !IsInteraction(m) {
|
||||
t.Fatalf("IsInteraction(%q) = false", m)
|
||||
}
|
||||
}
|
||||
for _, m := range []string{MethodSessionUpdate, "session/list", "fs/read_text_file", ""} {
|
||||
if IsInteraction(m) {
|
||||
t.Fatalf("IsInteraction(%q) = true", m)
|
||||
}
|
||||
}
|
||||
|
||||
// Both rails must reach the browser: the stable one carries correctness, the
|
||||
// xAI one carries the streaming deltas that make a transcript readable.
|
||||
for _, m := range []string{MethodSessionUpdate, MethodXAINotification, "x.ai/session/update"} {
|
||||
if !IsTranscript(m) {
|
||||
t.Fatalf("IsTranscript(%q) = false", m)
|
||||
}
|
||||
}
|
||||
for _, m := range []string{MethodRCStatus, MethodRCInteractionCancelled, ""} {
|
||||
if IsTranscript(m) {
|
||||
t.Fatalf("IsTranscript(%q) = true", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameBuilders(t *testing.T) {
|
||||
req, err := NewRequest(42, MethodSessionPrompt, PromptParams{SessionID: "s1", Text: "hi"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.JSONRPC != "2.0" || string(req.ID) != "42" || req.Kind() != KindRequest {
|
||||
t.Fatalf("bad request frame: %+v", req)
|
||||
}
|
||||
|
||||
// Absent beats null: some JSON-RPC peers distinguish the two.
|
||||
note, err := NewNotification(MethodRCViewers, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if note.Params != nil {
|
||||
t.Fatalf("nil params encoded as %s, want absent", note.Params)
|
||||
}
|
||||
if note.Kind() != KindNotification {
|
||||
t.Fatalf("notification classified as %v", note.Kind())
|
||||
}
|
||||
|
||||
resp, err := NewResponse(json.RawMessage(`7`), map[string]string{"outcome": "allow"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Kind() != KindResponse || string(resp.ID) != "7" {
|
||||
t.Fatalf("bad response frame: %+v", resp)
|
||||
}
|
||||
|
||||
fail := NewErrorResponse(json.RawMessage(`7`), CodeMethodNotFound, "Method not found")
|
||||
if fail.Error == nil || fail.Error.Code != CodeMethodNotFound {
|
||||
t.Fatalf("bad error frame: %+v", fail)
|
||||
}
|
||||
if fail.Kind() != KindResponse {
|
||||
t.Fatalf("error response classified as %v", fail.Kind())
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallIDProbesBothShapes(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
`{"toolCallId":"tc_1"}`: "tc_1",
|
||||
`{"toolCall":{"toolCallId":"tc_2"}}`: "tc_2",
|
||||
`{"toolCallId":"","toolCall":{}}`: "",
|
||||
`{"sessionId":"s1"}`: "",
|
||||
`not json`: "",
|
||||
``: "",
|
||||
`{"toolCallId":"tc_3","toolCall":{"toolCallId":"tc_other"}}`: "tc_3",
|
||||
}
|
||||
for params, want := range cases {
|
||||
if got := ToolCallID(json.RawMessage(params)); got != want {
|
||||
t.Fatalf("ToolCallID(%s) = %q, want %q", params, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionMetaLabelDegradesGracefully(t *testing.T) {
|
||||
cases := []struct {
|
||||
meta SessionMeta
|
||||
want string
|
||||
}{
|
||||
{SessionMeta{Title: "fix the parser", CWD: "/src", SessionID: "s1"}, "fix the parser"},
|
||||
{SessionMeta{CWD: "/src", SessionID: "s1"}, "/src"},
|
||||
{SessionMeta{SessionID: "s1"}, "s1"},
|
||||
// A session that connected but has not yet reported anything still needs
|
||||
// a row in the UI rather than a blank one.
|
||||
{SessionMeta{}, "session"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := tc.meta.Label(); got != tc.want {
|
||||
t.Fatalf("Label(%+v) = %q, want %q", tc.meta, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
// Package auth is grok-glance's whole access-control story.
|
||||
//
|
||||
// There is no username and no password. A single TOTP authenticator, enrolled
|
||||
// once, is the only credential — and enrolling it requires a token the server
|
||||
// printed on its own stdout. That combination is what makes it safe to expose
|
||||
// this port at all: without the bootstrap gate, whoever loaded /setup first
|
||||
// would become the admin, and on a machine reachable from a network that is not
|
||||
// necessarily the operator.
|
||||
//
|
||||
// What this does not defend against, stated plainly because it shapes how the
|
||||
// server should be deployed: anyone who can read `~/.grok/glance/state.json` has
|
||||
// the TOTP secret and the cookie-signing key, and anyone who can watch the
|
||||
// network without TLS has the session cookie. Glance is meant to run behind TLS
|
||||
// (a reverse proxy or a tunnel), on a machine whose home directory the operator
|
||||
// controls.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
|
||||
"github.com/user/grok-glance/internal/state"
|
||||
)
|
||||
|
||||
const (
|
||||
// CookieName is the session cookie. The `__Host-` prefix is a browser-
|
||||
// enforced promise: Secure, path=/, and no Domain attribute, so it cannot be
|
||||
// set or overwritten by a sibling host.
|
||||
CookieName = "__Host-glance"
|
||||
|
||||
// SessionTTL is how long a login lasts. Long enough not to nag someone
|
||||
// checking on an agent through the day; short enough that a forgotten open
|
||||
// tab is not indefinite access.
|
||||
SessionTTL = 12 * time.Hour
|
||||
|
||||
// Issuer labels the entry in the authenticator app.
|
||||
Issuer = "grok-glance"
|
||||
|
||||
// loginWindow is how many 30-second steps either side of now are accepted,
|
||||
// covering ordinary clock skew between the phone and the server.
|
||||
loginWindow = 1
|
||||
|
||||
// maxAttempts is the number of failed codes allowed per window before the
|
||||
// endpoint stops answering. A 6-digit code is 10^6 possibilities; unlimited
|
||||
// guessing would exhaust that in minutes.
|
||||
maxAttempts = 8
|
||||
attemptWindow = 5 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotEnrolled means setup has not run.
|
||||
ErrNotEnrolled = errors.New("no authenticator is enrolled")
|
||||
// ErrBadCode means the TOTP code did not verify.
|
||||
ErrBadCode = errors.New("that code is not valid")
|
||||
// ErrReplay means the code was already used. TOTP codes stay valid for a
|
||||
// whole step, so accepting one twice would let an observer reuse it.
|
||||
ErrReplay = errors.New("that code has already been used")
|
||||
// ErrRateLimited means too many failures too fast.
|
||||
ErrRateLimited = errors.New("too many attempts; wait a minute and try again")
|
||||
)
|
||||
|
||||
// Manager verifies codes and mints session cookies.
|
||||
type Manager struct {
|
||||
store *state.Store
|
||||
secure bool
|
||||
|
||||
mu sync.Mutex
|
||||
usedStep map[int64]time.Time
|
||||
failures []time.Time
|
||||
}
|
||||
|
||||
// NewManager builds the verifier.
|
||||
//
|
||||
// secure controls the cookie's Secure attribute. It is false only for plain-HTTP
|
||||
// localhost development, where browsers would otherwise refuse the cookie
|
||||
// outright; any real deployment sets it.
|
||||
func NewManager(store *state.Store, secure bool) *Manager {
|
||||
return &Manager{
|
||||
store: store,
|
||||
secure: secure,
|
||||
usedStep: make(map[int64]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// Enrolled reports whether an authenticator exists.
|
||||
func (m *Manager) Enrolled() bool { return m.store.Enrolled() }
|
||||
|
||||
// BootstrapValid reports whether token unlocks /setup.
|
||||
func (m *Manager) BootstrapValid(token string) bool { return m.store.BootstrapValid(token) }
|
||||
|
||||
// Enrollment is a proposed authenticator, not yet persisted.
|
||||
type Enrollment struct {
|
||||
Secret string `json:"secret"`
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
|
||||
// BeginEnrollment generates a candidate secret and its `otpauth://` URI.
|
||||
//
|
||||
// Nothing is persisted here: the secret only becomes the server's credential
|
||||
// once the user proves they can generate a code from it. Persisting first would
|
||||
// lock the operator out whenever a QR scan silently failed.
|
||||
func (m *Manager) BeginEnrollment(account string) (Enrollment, error) {
|
||||
if m.store.Enrolled() {
|
||||
return Enrollment{}, errors.New("an authenticator is already enrolled")
|
||||
}
|
||||
if account == "" {
|
||||
account = "operator"
|
||||
}
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: Issuer,
|
||||
AccountName: account,
|
||||
Period: 30,
|
||||
Digits: otp.DigitsSix,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
})
|
||||
if err != nil {
|
||||
return Enrollment{}, err
|
||||
}
|
||||
return Enrollment{Secret: key.Secret(), URI: key.URL()}, nil
|
||||
}
|
||||
|
||||
// CompleteEnrollment verifies one code against the candidate secret and, on
|
||||
// success, stores it and burns the bootstrap token.
|
||||
func (m *Manager) CompleteEnrollment(secret, code, account string) error {
|
||||
if m.store.Enrolled() {
|
||||
return errors.New("an authenticator is already enrolled")
|
||||
}
|
||||
if !verify(code, secret) {
|
||||
return ErrBadCode
|
||||
}
|
||||
if account == "" {
|
||||
account = "operator"
|
||||
}
|
||||
return m.store.EnrollTOTP(secret, Issuer, account)
|
||||
}
|
||||
|
||||
// Login verifies a code against the enrolled authenticator.
|
||||
func (m *Manager) Login(code string) error {
|
||||
secret := m.store.TOTPSecret()
|
||||
if secret == "" {
|
||||
return ErrNotEnrolled
|
||||
}
|
||||
|
||||
if err := m.checkRate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
code = strings.TrimSpace(code)
|
||||
if !verify(code, secret) {
|
||||
m.recordFailure()
|
||||
return ErrBadCode
|
||||
}
|
||||
|
||||
// A TOTP code is valid for its whole 30-second step, so a code observed on
|
||||
// the wire or over a shoulder can be replayed within that window. Burning
|
||||
// the step closes it.
|
||||
step := time.Now().Unix() / 30
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.pruneStepsLocked()
|
||||
if _, used := m.usedStep[step]; used {
|
||||
return ErrReplay
|
||||
}
|
||||
m.usedStep[step] = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func verify(code, secret string) bool {
|
||||
ok, err := totp.ValidateCustom(code, secret, time.Now(), totp.ValidateOpts{
|
||||
Period: 30,
|
||||
Skew: loginWindow,
|
||||
Digits: otp.DigitsSix,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
})
|
||||
return err == nil && ok
|
||||
}
|
||||
|
||||
func (m *Manager) checkRate() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cutoff := time.Now().Add(-attemptWindow)
|
||||
kept := m.failures[:0]
|
||||
for _, at := range m.failures {
|
||||
if at.After(cutoff) {
|
||||
kept = append(kept, at)
|
||||
}
|
||||
}
|
||||
m.failures = kept
|
||||
if len(m.failures) >= maxAttempts {
|
||||
return ErrRateLimited
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) recordFailure() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.failures = append(m.failures, time.Now())
|
||||
}
|
||||
|
||||
// pruneStepsLocked drops burnt steps that can no longer be replayed, so the map
|
||||
// does not grow for the life of the process.
|
||||
func (m *Manager) pruneStepsLocked() {
|
||||
cutoff := time.Now().Add(-2 * time.Minute)
|
||||
for step, at := range m.usedStep {
|
||||
if at.Before(cutoff) {
|
||||
delete(m.usedStep, step)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IssueCookie writes a signed session cookie.
|
||||
//
|
||||
// The cookie is `<expiry>.<hmac>` — self-contained, so the server keeps no
|
||||
// session table and a restart does not log everyone out (the signing key
|
||||
// survives in state.json). Deleting state.json is the panic button: it rotates
|
||||
// the key and invalidates every outstanding cookie.
|
||||
func (m *Manager) IssueCookie(w http.ResponseWriter) {
|
||||
expiry := time.Now().Add(SessionTTL).Unix()
|
||||
value := m.signSession(expiry)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: m.secure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Expires: time.Unix(expiry, 0),
|
||||
})
|
||||
}
|
||||
|
||||
// ClearCookie logs the browser out.
|
||||
func (m *Manager) ClearCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: m.secure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
// Authenticated reports whether r carries a valid, unexpired session.
|
||||
func (m *Manager) Authenticated(r *http.Request) bool {
|
||||
cookie, err := r.Cookie(CookieName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return m.validSession(cookie.Value)
|
||||
}
|
||||
|
||||
func (m *Manager) signSession(expiry int64) string {
|
||||
payload := strconv.FormatInt(expiry, 10)
|
||||
mac := hmac.New(sha256.New, m.store.SessionKey())
|
||||
mac.Write([]byte(payload))
|
||||
return payload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (m *Manager) validSession(value string) bool {
|
||||
payload, sig, ok := strings.Cut(value, ".")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
expiry, err := strconv.ParseInt(payload, 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, m.store.SessionKey())
|
||||
mac.Write([]byte(payload))
|
||||
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
if subtle.ConstantTimeCompare([]byte(sig), []byte(want)) != 1 {
|
||||
return false
|
||||
}
|
||||
// Signature first, expiry second: checking expiry on an unverified payload
|
||||
// would be reading attacker-controlled data as though it meant something.
|
||||
return time.Now().Unix() < expiry
|
||||
}
|
||||
|
||||
// BearerToken pulls the API key out of an Authorization header.
|
||||
func BearerToken(r *http.Request) string {
|
||||
header := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(header[len(prefix):])
|
||||
}
|
||||
|
||||
// BootstrapToken pulls the setup token from the query string or a header.
|
||||
func BootstrapToken(r *http.Request) string {
|
||||
if token := r.URL.Query().Get("token"); token != "" {
|
||||
return token
|
||||
}
|
||||
return r.Header.Get("X-Glance-Bootstrap")
|
||||
}
|
||||
|
||||
// DescribeEnrollment renders the account label for an otpauth URI.
|
||||
func DescribeEnrollment(host string) string {
|
||||
if host == "" {
|
||||
return "operator"
|
||||
}
|
||||
return fmt.Sprintf("operator@%s", host)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
|
||||
"github.com/user/grok-glance/internal/state"
|
||||
)
|
||||
|
||||
func newManager(t *testing.T) (*Manager, *state.Store) {
|
||||
t.Helper()
|
||||
store, err := state.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
return NewManager(store, true), store
|
||||
}
|
||||
|
||||
func code(t *testing.T, secret string) string {
|
||||
t.Helper()
|
||||
c, err := totp.GenerateCode(secret, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestEnrollmentRequiresAWorkingCode(t *testing.T) {
|
||||
m, store := newManager(t)
|
||||
|
||||
enrollment, err := m.BeginEnrollment("operator@localhost")
|
||||
if err != nil {
|
||||
t.Fatalf("BeginEnrollment: %v", err)
|
||||
}
|
||||
if enrollment.Secret == "" || !strings.HasPrefix(enrollment.URI, "otpauth://totp/") {
|
||||
t.Fatalf("unusable enrollment: %+v", enrollment)
|
||||
}
|
||||
|
||||
// Nothing is persisted until the user proves the secret reached their phone.
|
||||
// Otherwise a failed QR scan would lock the operator out of their own server.
|
||||
if store.Enrolled() {
|
||||
t.Fatal("BeginEnrollment persisted the secret before it was confirmed")
|
||||
}
|
||||
|
||||
if err := m.CompleteEnrollment(enrollment.Secret, "000000", ""); !errors.Is(err, ErrBadCode) {
|
||||
t.Fatalf("CompleteEnrollment with a wrong code: %v, want ErrBadCode", err)
|
||||
}
|
||||
if store.Enrolled() {
|
||||
t.Fatal("a failed confirmation still enrolled")
|
||||
}
|
||||
|
||||
if err := m.CompleteEnrollment(enrollment.Secret, code(t, enrollment.Secret), ""); err != nil {
|
||||
t.Fatalf("CompleteEnrollment: %v", err)
|
||||
}
|
||||
if !store.Enrolled() {
|
||||
t.Fatal("enrollment did not persist")
|
||||
}
|
||||
|
||||
// A second enrollment would be a password reset with no authentication on it.
|
||||
if _, err := m.BeginEnrollment(""); err == nil {
|
||||
t.Fatal("a second enrollment was allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRejectsReplayAndRateLimits(t *testing.T) {
|
||||
m, _ := newManager(t)
|
||||
|
||||
if err := m.Login("123456"); !errors.Is(err, ErrNotEnrolled) {
|
||||
t.Fatalf("login before enrollment: %v, want ErrNotEnrolled", err)
|
||||
}
|
||||
|
||||
enrollment, err := m.BeginEnrollment("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.CompleteEnrollment(enrollment.Secret, code(t, enrollment.Secret), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
valid := code(t, enrollment.Secret)
|
||||
if err := m.Login(valid); err != nil {
|
||||
t.Fatalf("Login with a fresh code: %v", err)
|
||||
}
|
||||
// A TOTP code stays valid for its whole 30-second step, so anyone who saw it
|
||||
// could use it again inside that window.
|
||||
if err := m.Login(valid); !errors.Is(err, ErrReplay) {
|
||||
t.Fatalf("replayed code: %v, want ErrReplay", err)
|
||||
}
|
||||
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
if err := m.Login("000000"); errors.Is(err, ErrRateLimited) {
|
||||
t.Fatalf("rate limit tripped early, after %d attempts", i)
|
||||
}
|
||||
}
|
||||
// 10^6 codes is a short brute force at network speed; the limiter is what
|
||||
// makes a 6-digit secret adequate.
|
||||
if err := m.Login("000000"); !errors.Is(err, ErrRateLimited) {
|
||||
t.Fatalf("after %d failures: %v, want ErrRateLimited", maxAttempts, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCookieRoundTrip(t *testing.T) {
|
||||
m, _ := newManager(t)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
m.IssueCookie(rec)
|
||||
cookies := rec.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("IssueCookie wrote %d cookies, want 1", len(cookies))
|
||||
}
|
||||
cookie := cookies[0]
|
||||
if cookie.Name != CookieName {
|
||||
t.Fatalf("cookie name = %q, want %q", cookie.Name, CookieName)
|
||||
}
|
||||
// The `__Host-` prefix is only honoured by browsers when all three hold.
|
||||
if !cookie.HttpOnly || !cookie.Secure || cookie.Path != "/" {
|
||||
t.Fatalf("cookie does not satisfy the __Host- prefix rules: %+v", cookie)
|
||||
}
|
||||
if cookie.SameSite != http.SameSiteStrictMode {
|
||||
t.Fatal("cookie is not SameSite=Strict")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/agents", nil)
|
||||
req.AddCookie(cookie)
|
||||
if !m.Authenticated(req) {
|
||||
t.Fatal("a freshly issued cookie did not authenticate")
|
||||
}
|
||||
|
||||
// No cookie at all.
|
||||
if m.Authenticated(httptest.NewRequest(http.MethodGet, "/api/agents", nil)) {
|
||||
t.Fatal("an unauthenticated request was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgedCookiesAreRejected(t *testing.T) {
|
||||
m, _ := newManager(t)
|
||||
|
||||
far := strconv.FormatInt(time.Now().Add(100*time.Hour).Unix(), 10)
|
||||
cases := map[string]string{
|
||||
"no signature": far,
|
||||
"empty": "",
|
||||
"garbage signature": far + ".not-a-signature",
|
||||
"unsigned future": far + ".",
|
||||
"expired": m.signSession(time.Now().Add(-time.Minute).Unix()),
|
||||
}
|
||||
for name, value := range cases {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/agents", nil)
|
||||
req.AddCookie(&http.Cookie{Name: CookieName, Value: value})
|
||||
if m.Authenticated(req) {
|
||||
t.Fatalf("%s: forged cookie accepted", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Extending an otherwise-valid cookie's expiry must invalidate the signature.
|
||||
valid := m.signSession(time.Now().Add(time.Hour).Unix())
|
||||
_, sig, _ := strings.Cut(valid, ".")
|
||||
tampered := strconv.FormatInt(time.Now().Add(1000*time.Hour).Unix(), 10) + "." + sig
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/agents", nil)
|
||||
req.AddCookie(&http.Cookie{Name: CookieName, Value: tampered})
|
||||
if m.Authenticated(req) {
|
||||
t.Fatal("a cookie with an extended expiry was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerAndBootstrapExtraction(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/acp/agent", nil)
|
||||
if got := BearerToken(req); got != "" {
|
||||
t.Fatalf("BearerToken with no header = %q", got)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer glance_sk_abc")
|
||||
if got := BearerToken(req); got != "glance_sk_abc" {
|
||||
t.Fatalf("BearerToken = %q", got)
|
||||
}
|
||||
// Some proxies and clients normalise the scheme's case.
|
||||
req.Header.Set("Authorization", "bearer glance_sk_abc")
|
||||
if got := BearerToken(req); got != "glance_sk_abc" {
|
||||
t.Fatalf("BearerToken with a lowercase scheme = %q", got)
|
||||
}
|
||||
req.Header.Set("Authorization", "Basic glance_sk_abc")
|
||||
if got := BearerToken(req); got != "" {
|
||||
t.Fatalf("BearerToken accepted a Basic header: %q", got)
|
||||
}
|
||||
|
||||
// The token arrives in the URL the operator pastes from the terminal, and in
|
||||
// a header once the SPA takes over.
|
||||
q := httptest.NewRequest(http.MethodPost, "/api/setup/begin?token=abc", nil)
|
||||
if got := BootstrapToken(q); got != "abc" {
|
||||
t.Fatalf("BootstrapToken from query = %q", got)
|
||||
}
|
||||
h := httptest.NewRequest(http.MethodPost, "/api/setup/begin", nil)
|
||||
h.Header.Set("X-Glance-Bootstrap", "abc")
|
||||
if got := BootstrapToken(h); got != "abc" {
|
||||
t.Fatalf("BootstrapToken from header = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// Package httpapi is the server's outer edge: routing, authentication
|
||||
// middleware, the two WebSocket upgrades, and the embedded web UI.
|
||||
//
|
||||
// Everything is one origin and one port. The frontend is served from the same
|
||||
// binary, so there is no CORS story to get wrong and no second thing to deploy.
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/user/grok-glance/internal/auth"
|
||||
"github.com/user/grok-glance/internal/hub"
|
||||
"github.com/user/grok-glance/internal/state"
|
||||
)
|
||||
|
||||
// Options configures the server.
|
||||
type Options struct {
|
||||
Store *state.Store
|
||||
Auth *auth.Manager
|
||||
Hub *hub.Hub
|
||||
Log *slog.Logger
|
||||
// Web is the built frontend, rooted at index.html. Nil serves a plain
|
||||
// placeholder page instead, so `go run ./cmd/glance` works before `npm run
|
||||
// build` has ever been run.
|
||||
Web fs.FS
|
||||
}
|
||||
|
||||
// Server is the HTTP handler tree.
|
||||
type Server struct {
|
||||
opts Options
|
||||
router chi.Router
|
||||
}
|
||||
|
||||
// New wires the routes.
|
||||
func New(opts Options) *Server {
|
||||
s := &Server{opts: opts}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(securityHeaders)
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
// Open: tells an unauthenticated browser which page to render. It leaks
|
||||
// only whether setup has happened, which the /setup 404 reveals anyway.
|
||||
r.Get("/status", s.handleStatus)
|
||||
|
||||
// Bootstrap-gated: the token is the only thing standing between a fresh
|
||||
// server and whoever reaches the port first.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireBootstrap)
|
||||
r.Post("/setup/begin", s.handleSetupBegin)
|
||||
r.Post("/setup/complete", s.handleSetupComplete)
|
||||
})
|
||||
|
||||
r.Post("/login", s.handleLogin)
|
||||
r.Post("/logout", s.handleLogout)
|
||||
|
||||
// The agent link authenticates with an API key, not a cookie: it is a
|
||||
// program on another machine, not a browser.
|
||||
r.Get("/acp/agent", s.handleAgentSocket)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireSession)
|
||||
r.Get("/agents", s.handleAgents)
|
||||
r.Get("/ws", s.handleBrowserSocket)
|
||||
})
|
||||
})
|
||||
|
||||
r.NotFound(s.serveWeb)
|
||||
s.router = r
|
||||
return s
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.router.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// securityHeaders keeps the UI from being framed or sniffed.
|
||||
//
|
||||
// The CSP is strict because glance renders agent output — file contents, command
|
||||
// output, model text — and none of that is trusted markup. `default-src 'self'`
|
||||
// with no `unsafe-inline` means an injected <script> in a diff cannot execute.
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("Content-Security-Policy",
|
||||
"default-src 'self'; "+
|
||||
"img-src 'self' data:; "+
|
||||
"style-src 'self' 'unsafe-inline'; "+
|
||||
"connect-src 'self' ws: wss:; "+
|
||||
"frame-ancestors 'none'; "+
|
||||
"base-uri 'none'; "+
|
||||
"form-action 'none'")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) requireSession(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.opts.Auth.Authenticated(r) {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not signed in"})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// requireBootstrap gates enrollment.
|
||||
//
|
||||
// It answers 404 rather than 403 once enrollment is done or the token is wrong:
|
||||
// a probe should not be able to tell a glance server with a pending setup from
|
||||
// one that is already configured.
|
||||
func (s *Server) requireBootstrap(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.opts.Auth.Enrolled() || !s.opts.Auth.BootstrapValid(auth.BootstrapToken(r)) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type statusResponse struct {
|
||||
Enrolled bool `json:"enrolled"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, statusResponse{
|
||||
Enrolled: s.opts.Auth.Enrolled(),
|
||||
Authenticated: s.opts.Auth.Authenticated(r),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSetupBegin(w http.ResponseWriter, r *http.Request) {
|
||||
enrollment, err := s.opts.Auth.BeginEnrollment(auth.DescribeEnrollment(r.Host))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, enrollment)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Secret string `json:"secret"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.opts.Auth.CompleteEnrollment(body.Secret, body.Code, auth.DescribeEnrollment(r.Host)); err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, auth.ErrBadCode) {
|
||||
status = http.StatusUnauthorized
|
||||
}
|
||||
writeJSON(w, status, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Enrolling logs you in: you have just proved you hold the authenticator,
|
||||
// and a login form immediately afterwards would ask for the same proof.
|
||||
s.opts.Auth.IssueCookie(w)
|
||||
s.opts.Log.Info("authenticator enrolled; bootstrap token is now spent")
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.opts.Auth.Login(body.Code); err != nil {
|
||||
status := http.StatusUnauthorized
|
||||
if errors.Is(err, auth.ErrRateLimited) {
|
||||
status = http.StatusTooManyRequests
|
||||
}
|
||||
writeJSON(w, status, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.opts.Auth.IssueCookie(w)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
s.opts.Auth.ClearCookie(w)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAgents(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"agents": s.opts.Hub.Summaries()})
|
||||
}
|
||||
|
||||
// handleAgentSocket accepts a grok bridge.
|
||||
func (s *Server) handleAgentSocket(w http.ResponseWriter, r *http.Request) {
|
||||
key := s.opts.Store.LookupAPIKey(auth.BearerToken(r))
|
||||
if key == nil {
|
||||
// 401 before the upgrade is what makes the bridge give up rather than
|
||||
// reconnect forever: it treats a 4xx at upgrade time as a verdict on its
|
||||
// credentials, and a retry loop against a rejected key helps nobody.
|
||||
s.opts.Log.Warn("rejected agent connection", "remote", r.RemoteAddr)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
// Same-origin is meaningless here: the peer is grok, not a browser, and
|
||||
// it authenticates with a bearer token that a cross-site request could
|
||||
// not forge.
|
||||
InsecureSkipVerify: true,
|
||||
CompressionMode: websocket.CompressionContextTakeover,
|
||||
})
|
||||
if err != nil {
|
||||
s.opts.Log.Warn("agent upgrade failed", "err", err)
|
||||
return
|
||||
}
|
||||
// A busy turn produces large frames (file contents, diffs). The default
|
||||
// limit would kill the connection on the first big tool result.
|
||||
conn.SetReadLimit(8 << 20)
|
||||
|
||||
s.opts.Store.TouchAPIKey(key.ID)
|
||||
s.opts.Hub.ServeAgent(r.Context(), conn, key.ID, key.Name)
|
||||
}
|
||||
|
||||
// handleBrowserSocket accepts a web client.
|
||||
func (s *Server) handleBrowserSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
// Browsers do not send Origin on same-origin WebSocket handshakes from
|
||||
// the page we served, and the cookie is SameSite=Strict, so a cross-site
|
||||
// page cannot open this socket with credentials in the first place.
|
||||
OriginPatterns: []string{r.Host},
|
||||
CompressionMode: websocket.CompressionContextTakeover,
|
||||
})
|
||||
if err != nil {
|
||||
s.opts.Log.Warn("browser upgrade failed", "err", err)
|
||||
return
|
||||
}
|
||||
conn.SetReadLimit(1 << 20)
|
||||
s.opts.Hub.ServeBrowser(r.Context(), conn)
|
||||
}
|
||||
|
||||
// serveWeb serves the embedded SPA.
|
||||
//
|
||||
// Unknown paths fall back to index.html so client-side routes survive a reload,
|
||||
// but /api/* never does: a mistyped API path must 404 as an API path, not return
|
||||
// HTML that the caller will fail to parse.
|
||||
func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"})
|
||||
return
|
||||
}
|
||||
if s.opts.Web == nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(placeholderPage))
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if path == "" {
|
||||
path = "index.html"
|
||||
}
|
||||
file, err := s.opts.Web.Open(path)
|
||||
if err != nil {
|
||||
path = "index.html"
|
||||
file, err = s.opts.Web.Open(path)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
seeker, ok := file.(interface {
|
||||
Read([]byte) (int, error)
|
||||
Seek(int64, int) (int64, error)
|
||||
})
|
||||
if !ok {
|
||||
http.Error(w, "unreadable asset", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Hashed asset filenames are immutable; index.html is not and must be
|
||||
// revalidated or a deploy would leave browsers on the old bundle.
|
||||
if strings.HasPrefix(path, "assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
http.ServeContent(w, r, path, time.Time{}, seeker)
|
||||
}
|
||||
|
||||
const placeholderPage = `<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>grok-glance</title>
|
||||
<style>
|
||||
body { font: 16px/1.6 ui-sans-serif, system-ui, sans-serif; max-width: 40rem;
|
||||
margin: 4rem auto; padding: 0 1.5rem; color: #18181b; background: #fafafa; }
|
||||
code { background: #f4f4f5; padding: .15em .4em; border-radius: .25rem; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { color: #fafafa; background: #18181b; }
|
||||
code { background: #27272a; }
|
||||
}
|
||||
</style>
|
||||
<h1>grok-glance</h1>
|
||||
<p>The server is running, but the web UI has not been built into this binary.</p>
|
||||
<p>Run <code>make web</code> (or <code>npm --prefix web install && npm --prefix web run build</code>),
|
||||
then rebuild with <code>make build</code>.</p>
|
||||
<p>For frontend development, run <code>make dev</code> instead: Vite serves the UI on
|
||||
port 5173 and proxies the API here.</p>
|
||||
`
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
// The status line is already out; nothing useful is left to do.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, dst any) error {
|
||||
// A bounded reader keeps an unauthenticated POST from being a memory
|
||||
// allocation primitive.
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<16))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(dst); err != nil {
|
||||
return errors.New("could not read request body")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/pquerna/otp/totp"
|
||||
|
||||
"github.com/user/grok-glance/internal/auth"
|
||||
"github.com/user/grok-glance/internal/hub"
|
||||
"github.com/user/grok-glance/internal/state"
|
||||
)
|
||||
|
||||
// harness is one server with its own state directory, plus the pieces a test
|
||||
// needs to forge credentials for it.
|
||||
type harness struct {
|
||||
server *Server
|
||||
store *state.Store
|
||||
auth *auth.Manager
|
||||
hub *hub.Hub
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
store, err := state.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
// secure=false so the session cookie is usable over the plain-HTTP test
|
||||
// server; every other property of the cookie is unchanged.
|
||||
manager := auth.NewManager(store, false)
|
||||
h := hub.New(slog.New(slog.DiscardHandler))
|
||||
|
||||
return &harness{
|
||||
server: New(Options{Store: store, Auth: manager, Hub: h, Log: slog.New(slog.DiscardHandler)}),
|
||||
store: store,
|
||||
auth: manager,
|
||||
hub: h,
|
||||
}
|
||||
}
|
||||
|
||||
// enroll puts the harness in the post-setup state and returns the TOTP secret.
|
||||
func (h *harness) enroll(t *testing.T) string {
|
||||
t.Helper()
|
||||
enrollment, err := h.auth.BeginEnrollment("operator@test")
|
||||
if err != nil {
|
||||
t.Fatalf("BeginEnrollment: %v", err)
|
||||
}
|
||||
if err := h.store.EnrollTOTP(enrollment.Secret, auth.Issuer, "operator@test"); err != nil {
|
||||
t.Fatalf("EnrollTOTP: %v", err)
|
||||
}
|
||||
return enrollment.Secret
|
||||
}
|
||||
|
||||
func (h *harness) do(t *testing.T, req *http.Request) *http.Response {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
h.server.ServeHTTP(rec, req)
|
||||
return rec.Result()
|
||||
}
|
||||
|
||||
func (h *harness) get(t *testing.T, path string, cookies ...*http.Cookie) *http.Response {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
return h.do(t, req)
|
||||
}
|
||||
|
||||
func (h *harness) post(t *testing.T, path string, body any, cookies ...*http.Cookie) *http.Response {
|
||||
t.Helper()
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
reader = strings.NewReader(string(encoded))
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, path, reader)
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
return h.do(t, req)
|
||||
}
|
||||
|
||||
func decodeBody[T any](t *testing.T, resp *http.Response) T {
|
||||
t.Helper()
|
||||
defer resp.Body.Close()
|
||||
var out T
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sessionCookie(t *testing.T, resp *http.Response) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == auth.CookieName {
|
||||
return c
|
||||
}
|
||||
}
|
||||
t.Fatalf("no %s cookie on the response", auth.CookieName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func code(t *testing.T, secret string) string {
|
||||
t.Helper()
|
||||
c, err := totp.GenerateCode(secret, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestStatusIsOpenAndCarriesSecurityHeaders(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
resp := h.get(t, "/api/status")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The frontend has to be able to ask this before it has any credentials --
|
||||
// it is what decides between the setup, login and console screens.
|
||||
got := decodeBody[statusResponse](t, resp)
|
||||
if got.Enrolled || got.Authenticated {
|
||||
t.Fatalf("fresh server reports %+v, want both false", got)
|
||||
}
|
||||
|
||||
// Agent output is rendered on this origin, so a missing CSP is a real hole,
|
||||
// not a lint failure.
|
||||
if csp := resp.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "default-src 'self'") {
|
||||
t.Fatalf("Content-Security-Policy = %q", csp)
|
||||
}
|
||||
for header, want := range map[string]string{
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
} {
|
||||
if got := resp.Header.Get(header); got != want {
|
||||
t.Errorf("%s = %q, want %q", header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupIsInvisibleWithoutTheBootstrapToken(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
token, err := h.store.NewBootstrapToken()
|
||||
if err != nil {
|
||||
t.Fatalf("NewBootstrapToken: %v", err)
|
||||
}
|
||||
|
||||
// 404 rather than 403: a probe must not learn that a glance server is
|
||||
// sitting here with setup still pending.
|
||||
if resp := h.post(t, "/api/setup/begin", nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("no token: status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
if resp := h.post(t, "/api/setup/begin?token=wrong", nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("wrong token: status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp := h.post(t, "/api/setup/begin?token="+token, nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("with token: status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
enrollment := decodeBody[auth.Enrollment](t, resp)
|
||||
if enrollment.Secret == "" || !strings.HasPrefix(enrollment.URI, "otpauth://totp/") {
|
||||
t.Fatalf("unusable enrollment: %+v", enrollment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupCompleteVerifiesTheCodeThenSpendsTheToken(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
token, err := h.store.NewBootstrapToken()
|
||||
if err != nil {
|
||||
t.Fatalf("NewBootstrapToken: %v", err)
|
||||
}
|
||||
enrollment := decodeBody[auth.Enrollment](t, h.post(t, "/api/setup/begin?token="+token, nil))
|
||||
|
||||
bad := h.post(t, "/api/setup/complete?token="+token,
|
||||
map[string]string{"secret": enrollment.Secret, "code": "000000"})
|
||||
if bad.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("bad code: status = %d, want 401", bad.StatusCode)
|
||||
}
|
||||
if h.auth.Enrolled() {
|
||||
t.Fatal("a rejected code still enrolled the authenticator")
|
||||
}
|
||||
|
||||
good := h.post(t, "/api/setup/complete?token="+token,
|
||||
map[string]string{"secret": enrollment.Secret, "code": code(t, enrollment.Secret)})
|
||||
if good.StatusCode != http.StatusOK {
|
||||
t.Fatalf("good code: status = %d, want 200", good.StatusCode)
|
||||
}
|
||||
|
||||
// Enrolling signs you in: you have just proved you hold the authenticator.
|
||||
cookie := sessionCookie(t, good)
|
||||
status := decodeBody[statusResponse](t, h.get(t, "/api/status", cookie))
|
||||
if !status.Enrolled || !status.Authenticated {
|
||||
t.Fatalf("after setup, status = %+v, want both true", status)
|
||||
}
|
||||
|
||||
// The token is single-use, so the setup route closes behind it. Reopening it
|
||||
// would give anyone who saw the startup banner a second admin.
|
||||
if resp := h.post(t, "/api/setup/begin?token="+token, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("setup after enrollment: status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupCompleteRejectsUnknownFields(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
token, err := h.store.NewBootstrapToken()
|
||||
if err != nil {
|
||||
t.Fatalf("NewBootstrapToken: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/setup/complete?token="+token,
|
||||
strings.NewReader(`{"secret":"X","code":"000000","admin":true}`))
|
||||
if resp := h.do(t, req); resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginIssuesACookieAndRateLimits(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
secret := h.enroll(t)
|
||||
|
||||
resp := h.post(t, "/api/login", map[string]string{"code": code(t, secret)})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("good code: status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
cookie := sessionCookie(t, resp)
|
||||
if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode || cookie.Path != "/" {
|
||||
t.Fatalf("weak session cookie: %+v", cookie)
|
||||
}
|
||||
|
||||
// Eight wrong codes are allowed, then the endpoint stops answering. Six
|
||||
// digits is 10^6 possibilities; unlimited guessing exhausts that in minutes.
|
||||
for attempt := range 8 {
|
||||
got := h.post(t, "/api/login", map[string]string{"code": "000000"})
|
||||
if got.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("attempt %d: status = %d, want 401", attempt, got.StatusCode)
|
||||
}
|
||||
}
|
||||
limited := h.post(t, "/api/login", map[string]string{"code": "000000"})
|
||||
if limited.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("after 8 failures: status = %d, want 429", limited.StatusCode)
|
||||
}
|
||||
|
||||
// The limiter counts failures, not identities, so a correct code is also
|
||||
// held off until the window drains. That is deliberate: otherwise the
|
||||
// attacker's guesses would be free as long as the operator kept logging in.
|
||||
if got := h.post(t, "/api/login", map[string]string{"code": code(t, secret)}); got.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("good code while limited: status = %d, want 429", got.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRoutesRequireACookie(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
secret := h.enroll(t)
|
||||
|
||||
for _, path := range []string{"/api/agents", "/api/ws"} {
|
||||
resp := h.get(t, path)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("%s without a cookie: status = %d, want 401", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
cookie := sessionCookie(t, h.post(t, "/api/login", map[string]string{"code": code(t, secret)}))
|
||||
resp := h.get(t, "/api/agents", cookie)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("/api/agents with a cookie: status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
listing := decodeBody[struct {
|
||||
Agents []hub.AgentSummary `json:"agents"`
|
||||
}](t, resp)
|
||||
// Empty, not null: the frontend maps over this without a guard.
|
||||
if listing.Agents == nil {
|
||||
t.Fatal("agents = null, want []")
|
||||
}
|
||||
if len(listing.Agents) != 0 {
|
||||
t.Fatalf("agents = %d, want 0", len(listing.Agents))
|
||||
}
|
||||
|
||||
// A forged cookie must not be enough -- the value is HMAC-signed.
|
||||
forged := &http.Cookie{Name: auth.CookieName, Value: "9999999999.notasignature"}
|
||||
if got := h.get(t, "/api/agents", forged); got.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("forged cookie: status = %d, want 401", got.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutClearsTheSession(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
secret := h.enroll(t)
|
||||
cookie := sessionCookie(t, h.post(t, "/api/login", map[string]string{"code": code(t, secret)}))
|
||||
|
||||
cleared := sessionCookie(t, h.post(t, "/api/logout", nil, cookie))
|
||||
if cleared.Value != "" || cleared.MaxAge >= 0 {
|
||||
t.Fatalf("logout cookie = %+v, want an expiring empty value", cleared)
|
||||
}
|
||||
|
||||
// The browser now holds the cleared cookie; the server must treat it as
|
||||
// anonymous rather than as a malformed session.
|
||||
status := decodeBody[statusResponse](t, h.get(t, "/api/status", cleared))
|
||||
if status.Authenticated {
|
||||
t.Fatal("still authenticated after logout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentSocketRejectsBadKeysBeforeUpgrading(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
if _, _, err := h.store.AddAPIKey("laptop"); err != nil {
|
||||
t.Fatalf("AddAPIKey: %v", err)
|
||||
}
|
||||
|
||||
for name, header := range map[string]string{
|
||||
"no header": "",
|
||||
"not bearer": "Basic abc",
|
||||
"unknown key": "Bearer glance_sk_nope",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/acp/agent", nil)
|
||||
if header != "" {
|
||||
req.Header.Set("Authorization", header)
|
||||
}
|
||||
resp := h.do(t, req)
|
||||
// 401 rather than a failed upgrade: the bridge reads a 4xx here as a
|
||||
// verdict on its credentials and stops retrying.
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("%s: status = %d, want 401", name, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBothSocketsCarryTheirTraffic is the one test that exercises the real
|
||||
// upgrade path: an agent dials in with an API key, a browser dials in with a
|
||||
// cookie, and the browser is told the agent is there.
|
||||
func TestBothSocketsCarryTheirTraffic(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
secret := h.enroll(t)
|
||||
plaintext, key, err := h.store.AddAPIKey("laptop")
|
||||
if err != nil {
|
||||
t.Fatalf("AddAPIKey: %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(h.server)
|
||||
defer server.Close()
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
agentConn, _, err := websocket.Dial(ctx, wsURL+"/api/acp/agent", &websocket.DialOptions{
|
||||
HTTPHeader: http.Header{"Authorization": {"Bearer " + plaintext}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("agent dial: %v", err)
|
||||
}
|
||||
defer agentConn.CloseNow()
|
||||
|
||||
// The hub asks who just connected. Reading it proves the connection is a
|
||||
// live ACP channel and not merely an accepted upgrade.
|
||||
_, hello, err := agentConn.Read(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("read initialize: %v", err)
|
||||
}
|
||||
var request struct {
|
||||
Method string `json:"method"`
|
||||
}
|
||||
if err := json.Unmarshal(hello, &request); err != nil {
|
||||
t.Fatalf("decode initialize: %v", err)
|
||||
}
|
||||
if request.Method != "initialize" {
|
||||
t.Fatalf("first frame from the hub = %q, want initialize", request.Method)
|
||||
}
|
||||
|
||||
cookie := sessionCookie(t, h.post(t, "/api/login", map[string]string{"code": code(t, secret)}))
|
||||
browserConn, _, err := websocket.Dial(ctx, wsURL+"/api/ws", &websocket.DialOptions{
|
||||
HTTPHeader: http.Header{"Cookie": {cookie.Name + "=" + cookie.Value}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("browser dial: %v", err)
|
||||
}
|
||||
defer browserConn.CloseNow()
|
||||
|
||||
// A browser is handed the current agent list the moment it connects, so the
|
||||
// sessions page is populated without asking for anything.
|
||||
_, greeting, err := browserConn.Read(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("read greeting: %v", err)
|
||||
}
|
||||
var event struct {
|
||||
Type string `json:"type"`
|
||||
Agents []hub.AgentSummary `json:"agents"`
|
||||
}
|
||||
if err := json.Unmarshal(greeting, &event); err != nil {
|
||||
t.Fatalf("decode greeting: %v", err)
|
||||
}
|
||||
if event.Type != "agents" {
|
||||
t.Fatalf("greeting type = %q, want agents", event.Type)
|
||||
}
|
||||
if len(event.Agents) != 1 || event.Agents[0].ID != key.ID {
|
||||
t.Fatalf("greeting agents = %+v, want the one that just connected", event.Agents)
|
||||
}
|
||||
if event.Agents[0].KeyName != "laptop" {
|
||||
t.Fatalf("keyName = %q, want laptop", event.Agents[0].KeyName)
|
||||
}
|
||||
|
||||
// Hanging up deregisters: a stale entry would show in the UI as a session
|
||||
// that never updates again.
|
||||
agentConn.Close(websocket.StatusNormalClosure, "done")
|
||||
waitFor(t, func() bool { return len(h.hub.Summaries()) == 0 })
|
||||
}
|
||||
|
||||
func TestBrowserSocketRefusesAnUnauthenticatedUpgrade(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
server := httptest.NewServer(h.server)
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, resp, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http")+"/api/ws", nil)
|
||||
if err == nil {
|
||||
conn.CloseNow()
|
||||
t.Fatal("dialed /api/ws without a session cookie")
|
||||
}
|
||||
if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("upgrade response = %v, want 401", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownAPIPathsStayJSON(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
resp := h.get(t, "/api/nope")
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
// The SPA fallback must not swallow API paths: a client that asked for JSON
|
||||
// and got index.html fails with a parse error miles from the real mistake.
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Fatalf("Content-Type = %q, want JSON", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceholderPageWhenTheFrontendIsNotBuilt(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
resp := h.get(t, "/")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
// `go run ./cmd/glance` before `npm run build` should explain itself rather
|
||||
// than 404.
|
||||
if !strings.Contains(string(body), "make web") {
|
||||
t.Fatalf("placeholder does not mention how to build the UI: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPAFallbackAndAssetCaching(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.server = New(Options{
|
||||
Store: h.store,
|
||||
Auth: h.auth,
|
||||
Hub: h.hub,
|
||||
Log: slog.New(slog.DiscardHandler),
|
||||
Web: fstest.MapFS{
|
||||
"index.html": {Data: []byte("<!doctype html><title>glance</title>")},
|
||||
"assets/index-abc12.js": {Data: []byte("console.log(1)")},
|
||||
},
|
||||
})
|
||||
|
||||
for _, path := range []string{"/", "/index.html", "/a/agent-1", "/deep/unknown/route"} {
|
||||
resp := h.get(t, path)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s: status = %d, want 200", path, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: read body: %v", path, err)
|
||||
}
|
||||
// Client-side routes have to survive a reload, so anything unclaimed
|
||||
// resolves to the shell.
|
||||
if !strings.Contains(string(body), "<title>glance</title>") {
|
||||
t.Fatalf("%s served %q, want index.html", path, body)
|
||||
}
|
||||
// index.html names the hashed bundles, so caching it would strand
|
||||
// browsers on the previous deploy.
|
||||
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
|
||||
t.Fatalf("%s: Cache-Control = %q, want no-cache", path, cc)
|
||||
}
|
||||
}
|
||||
|
||||
asset := h.get(t, "/assets/index-abc12.js")
|
||||
if asset.StatusCode != http.StatusOK {
|
||||
t.Fatalf("asset: status = %d, want 200", asset.StatusCode)
|
||||
}
|
||||
asset.Body.Close()
|
||||
// Hashed filenames change when the content does, so the response is
|
||||
// immutable by construction.
|
||||
if cc := asset.Header.Get("Cache-Control"); !strings.Contains(cc, "immutable") {
|
||||
t.Fatalf("asset Cache-Control = %q, want immutable", cc)
|
||||
}
|
||||
|
||||
// A missing asset falls back to the shell like any other path, but the API
|
||||
// namespace still refuses to.
|
||||
if resp := h.get(t, "/api/nope"); resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("/api/nope with a frontend present: status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// waitFor polls until cond holds, because hub bookkeeping happens on the
|
||||
// connection's own goroutine and is not synchronous with the close.
|
||||
func waitFor(t *testing.T, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition never held")
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/user/grok-glance/internal/acp"
|
||||
)
|
||||
|
||||
const (
|
||||
// One turn of a busy session produces thousands of streaming deltas. This
|
||||
// holds a few minutes of that -- enough that a browser opened mid-turn sees
|
||||
// the turn, not so much that an idle server holds megabytes per agent.
|
||||
defaultRingCapacity = 4096
|
||||
|
||||
// Depth of the per-agent outbound queue. Large enough to absorb a burst of
|
||||
// browser input; a full queue means the socket is wedged, and dropping the
|
||||
// connection is better than blocking a request handler on it.
|
||||
agentSendQueue = 64
|
||||
|
||||
// How long a call to grok waits before giving up. `session/prompt` is the
|
||||
// exception: it blocks for the whole turn and gets no deadline of its own.
|
||||
callTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// ErrAgentGone means the grok instance disconnected before answering.
|
||||
var ErrAgentGone = errors.New("agent disconnected")
|
||||
|
||||
// Interaction is a reverse-request from grok that a human must answer:
|
||||
// a tool permission, a question, or a plan approval.
|
||||
//
|
||||
// Both the terminal and any browser can answer. Whoever answers first wins and
|
||||
// the other side's dialog is retracted -- from glance's perspective that means
|
||||
// either a browser answers (and glance replies to grok), or grok sends
|
||||
// `x.ai/rc/interaction_cancelled` because the terminal got there first.
|
||||
type Interaction struct {
|
||||
// ID is grok's JSON-RPC id, echoed back in the response.
|
||||
ID json.RawMessage `json:"id"`
|
||||
// Method is the ACP method, so the UI knows which dialog to render.
|
||||
Method string `json:"method"`
|
||||
// Params is passed through untouched: the browser renders from it, and
|
||||
// glance has no reason to understand its every field.
|
||||
Params json.RawMessage `json:"params"`
|
||||
// ToolCallID keys the retraction on both sides.
|
||||
ToolCallID string `json:"toolCallId,omitempty"`
|
||||
OpenedAt time.Time `json:"openedAt"`
|
||||
}
|
||||
|
||||
// Agent is one connected grok instance and the single session it mirrors.
|
||||
type Agent struct {
|
||||
ID string
|
||||
KeyName string
|
||||
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
log *slog.Logger
|
||||
|
||||
// outbound is drained by the writer goroutine. coder/websocket permits one
|
||||
// concurrent writer, and prompts, replies and heartbeats all originate on
|
||||
// different goroutines.
|
||||
outbound chan acp.Frame
|
||||
|
||||
// done closes when the connection is torn down, unblocking everything
|
||||
// waiting on this agent.
|
||||
done chan struct{}
|
||||
closeOne sync.Once
|
||||
|
||||
nextID atomic.Uint64
|
||||
|
||||
mu sync.RWMutex
|
||||
meta acp.SessionMeta
|
||||
connectedAt time.Time
|
||||
lastActivity time.Time
|
||||
turnActive bool
|
||||
ring *ring
|
||||
interactions map[string]*Interaction
|
||||
// calls correlates responses to requests glance sent. Each channel is
|
||||
// buffered so a reply never blocks the reader goroutine, even if the caller
|
||||
// timed out and stopped listening.
|
||||
calls map[uint64]chan acp.Frame
|
||||
}
|
||||
|
||||
// AgentSummary is the JSON view of an agent for the session list.
|
||||
type AgentSummary struct {
|
||||
ID string `json:"id"`
|
||||
KeyName string `json:"keyName"`
|
||||
Session acp.SessionMeta `json:"session"`
|
||||
Label string `json:"label"`
|
||||
ConnectedAt time.Time `json:"connectedAt"`
|
||||
LastActivity time.Time `json:"lastActivity"`
|
||||
TurnActive bool `json:"turnActive"`
|
||||
Pending int `json:"pending"`
|
||||
Frames int `json:"frames"`
|
||||
Dropped int `json:"dropped"`
|
||||
}
|
||||
|
||||
// Summary snapshots the agent for the UI.
|
||||
func (a *Agent) Summary() AgentSummary {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return AgentSummary{
|
||||
ID: a.ID,
|
||||
KeyName: a.KeyName,
|
||||
Session: a.meta,
|
||||
Label: a.meta.Label(),
|
||||
ConnectedAt: a.connectedAt,
|
||||
LastActivity: a.lastActivity,
|
||||
TurnActive: a.turnActive,
|
||||
Pending: len(a.interactions),
|
||||
Frames: a.ring.size,
|
||||
Dropped: a.ring.dropped,
|
||||
}
|
||||
}
|
||||
|
||||
// Transcript returns the buffered frames, oldest first, plus how many were
|
||||
// dropped off the front.
|
||||
func (a *Agent) Transcript() ([]json.RawMessage, int) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.ring.snapshot(), a.ring.dropped
|
||||
}
|
||||
|
||||
// OpenInteractions lists what is currently waiting on a human.
|
||||
func (a *Agent) OpenInteractions() []*Interaction {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
out := make([]*Interaction, 0, len(a.interactions))
|
||||
for _, in := range a.interactions {
|
||||
out = append(out, in)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Prompt runs a turn. It returns when the turn ends, which can be minutes --
|
||||
// callers that only want the turn *started* should not wait on it.
|
||||
func (a *Agent) Prompt(ctx context.Context, text string) (json.RawMessage, error) {
|
||||
a.mu.RLock()
|
||||
sessionID := a.meta.SessionID
|
||||
a.mu.RUnlock()
|
||||
|
||||
a.markTurn(true)
|
||||
frame, err := a.call(ctx, acp.MethodSessionPrompt, acp.PromptParams{
|
||||
SessionID: sessionID,
|
||||
Text: text,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if frame.Error != nil {
|
||||
return nil, frame.Error
|
||||
}
|
||||
return frame.Result, nil
|
||||
}
|
||||
|
||||
// Cancel interrupts the running turn.
|
||||
//
|
||||
// grok classifies this the same way it classifies Esc, but attributes it to
|
||||
// glance rather than to the terminal, so the session log records who stopped it.
|
||||
func (a *Agent) Cancel(ctx context.Context) error {
|
||||
a.mu.RLock()
|
||||
sessionID := a.meta.SessionID
|
||||
a.mu.RUnlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
frame, err := a.call(ctx, acp.MethodSessionCancel, acp.CancelParams{SessionID: sessionID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if frame.Error != nil {
|
||||
return frame.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Answer resolves an open interaction with a result from the browser.
|
||||
//
|
||||
// It reports whether the interaction was still open: a false return is the
|
||||
// normal outcome of losing the race to the terminal, not an error, and the UI
|
||||
// shows "already handled elsewhere" rather than a failure.
|
||||
func (a *Agent) Answer(id string, result json.RawMessage) bool {
|
||||
a.mu.Lock()
|
||||
interaction, ok := a.interactions[id]
|
||||
if ok {
|
||||
delete(a.interactions, id)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
frame, err := acp.NewResponse(interaction.ID, json.RawMessage(result))
|
||||
if err != nil {
|
||||
a.log.Warn("could not encode interaction answer", "err", err)
|
||||
return false
|
||||
}
|
||||
a.send(frame)
|
||||
a.hub.broadcast(interactionResolvedEvent(a.ID, interaction, "browser"))
|
||||
return true
|
||||
}
|
||||
|
||||
// Decline hands an interaction back to the terminal without answering it.
|
||||
//
|
||||
// grok treats a JSON-RPC error on an interaction as "glance is not answering
|
||||
// this", leaves the terminal's dialog up, and the turn proceeds normally once
|
||||
// the user answers there.
|
||||
func (a *Agent) Decline(id, reason string) bool {
|
||||
a.mu.Lock()
|
||||
interaction, ok := a.interactions[id]
|
||||
if ok {
|
||||
delete(a.interactions, id)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
a.send(acp.NewErrorResponse(interaction.ID, acp.CodeInternal, reason))
|
||||
a.hub.broadcast(interactionResolvedEvent(a.ID, interaction, "declined"))
|
||||
return true
|
||||
}
|
||||
|
||||
// NotifyViewers tells the bridge how many browsers are watching, so the
|
||||
// terminal's `/rc status` can say so. Cosmetic and best-effort.
|
||||
func (a *Agent) NotifyViewers(n int) {
|
||||
frame, err := acp.NewNotification(acp.MethodRCViewers, acp.ViewersParams{Count: n})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
a.send(frame)
|
||||
}
|
||||
|
||||
// call sends a request and waits for its response.
|
||||
func (a *Agent) call(ctx context.Context, method string, params any) (acp.Frame, error) {
|
||||
id := a.nextID.Add(1)
|
||||
frame, err := acp.NewRequest(id, method, params)
|
||||
if err != nil {
|
||||
return acp.Frame{}, err
|
||||
}
|
||||
|
||||
reply := make(chan acp.Frame, 1)
|
||||
a.mu.Lock()
|
||||
a.calls[id] = reply
|
||||
a.mu.Unlock()
|
||||
defer func() {
|
||||
a.mu.Lock()
|
||||
delete(a.calls, id)
|
||||
a.mu.Unlock()
|
||||
}()
|
||||
|
||||
if !a.trySend(frame) {
|
||||
return acp.Frame{}, ErrAgentGone
|
||||
}
|
||||
|
||||
select {
|
||||
case f := <-reply:
|
||||
return f, nil
|
||||
case <-a.done:
|
||||
return acp.Frame{}, ErrAgentGone
|
||||
case <-ctx.Done():
|
||||
return acp.Frame{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) send(frame acp.Frame) {
|
||||
a.trySend(frame)
|
||||
}
|
||||
|
||||
// trySend queues a frame, reporting whether it was accepted. A full queue means
|
||||
// the socket is not draining; killing the connection turns a silent stall into a
|
||||
// reconnect, which the bridge handles by design.
|
||||
func (a *Agent) trySend(frame acp.Frame) bool {
|
||||
select {
|
||||
case a.outbound <- frame:
|
||||
return true
|
||||
case <-a.done:
|
||||
return false
|
||||
default:
|
||||
a.log.Warn("agent send queue full; dropping connection", "agent", a.ID)
|
||||
a.close()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) close() {
|
||||
a.closeOne.Do(func() {
|
||||
close(a.done)
|
||||
a.conn.CloseNow()
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Agent) markTurn(active bool) {
|
||||
a.mu.Lock()
|
||||
a.turnActive = active
|
||||
a.lastActivity = time.Now()
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// serve runs the agent's read and write loops until the socket dies.
|
||||
func (a *Agent) serve(ctx context.Context) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
go a.writeLoop(ctx)
|
||||
|
||||
for {
|
||||
typ, data, err := a.conn.Read(ctx)
|
||||
if err != nil {
|
||||
a.log.Info("agent disconnected", "agent", a.ID, "err", err)
|
||||
return
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
continue
|
||||
}
|
||||
a.handleFrame(data)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) writeLoop(ctx context.Context) {
|
||||
// A periodic ping is what turns a silently dead TCP connection (laptop
|
||||
// asleep, NAT entry evicted) into a normal disconnect the bridge reconnects
|
||||
// from, instead of an agent that shows as connected forever.
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-a.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
err := a.conn.Ping(pingCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
a.close()
|
||||
return
|
||||
}
|
||||
case frame := <-a.outbound:
|
||||
raw, err := json.Marshal(frame)
|
||||
if err != nil {
|
||||
a.log.Warn("could not encode frame for agent", "err", err)
|
||||
continue
|
||||
}
|
||||
writeCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
err = a.conn.Write(writeCtx, websocket.MessageText, raw)
|
||||
cancel()
|
||||
if err != nil {
|
||||
a.log.Info("agent write failed", "agent", a.ID, "err", err)
|
||||
a.close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleFrame dispatches one inbound frame from grok.
|
||||
func (a *Agent) handleFrame(raw []byte) {
|
||||
var frame acp.Frame
|
||||
if err := json.Unmarshal(raw, &frame); err != nil {
|
||||
a.log.Warn("unparseable frame from agent", "agent", a.ID, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
switch frame.Kind() {
|
||||
case acp.KindResponse:
|
||||
a.resolveCall(frame)
|
||||
|
||||
case acp.KindRequest:
|
||||
if acp.IsInteraction(frame.Method) {
|
||||
a.openInteraction(frame)
|
||||
return
|
||||
}
|
||||
// grok drives nothing else on this link; saying so beats a fabricated
|
||||
// success that would leave it waiting for behaviour glance does not have.
|
||||
a.send(acp.NewErrorResponse(frame.ID, acp.CodeMethodNotFound,
|
||||
fmt.Sprintf("Method not found: %s", frame.Method)))
|
||||
|
||||
case acp.KindNotification:
|
||||
a.handleNotification(frame, raw)
|
||||
|
||||
default:
|
||||
a.log.Warn("frame from agent is neither request, response nor notification", "agent", a.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) resolveCall(frame acp.Frame) {
|
||||
var id uint64
|
||||
if err := json.Unmarshal(frame.ID, &id); err != nil {
|
||||
a.log.Warn("response from agent with unusable id", "agent", a.ID)
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
reply, ok := a.calls[id]
|
||||
delete(a.calls, id)
|
||||
a.mu.Unlock()
|
||||
if !ok {
|
||||
// The caller timed out, or this is a duplicate. Neither is worth more
|
||||
// than a debug line.
|
||||
a.log.Debug("response for an unknown call", "agent", a.ID, "id", id)
|
||||
return
|
||||
}
|
||||
reply <- frame
|
||||
}
|
||||
|
||||
func (a *Agent) openInteraction(frame acp.Frame) {
|
||||
interaction := &Interaction{
|
||||
ID: frame.ID,
|
||||
Method: frame.Method,
|
||||
Params: frame.Params,
|
||||
ToolCallID: acp.ToolCallID(frame.Params),
|
||||
OpenedAt: time.Now(),
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.interactions[string(frame.ID)] = interaction
|
||||
a.lastActivity = interaction.OpenedAt
|
||||
a.mu.Unlock()
|
||||
|
||||
a.hub.broadcast(Event{
|
||||
Type: EventInteraction,
|
||||
Agent: a.ID,
|
||||
Interaction: interaction,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Agent) handleNotification(frame acp.Frame, raw []byte) {
|
||||
switch {
|
||||
case frame.Method == acp.MethodRCStatus:
|
||||
var params acp.StatusParams
|
||||
if err := json.Unmarshal(frame.Params, ¶ms); err == nil {
|
||||
a.mu.Lock()
|
||||
a.meta = params.Session
|
||||
a.mu.Unlock()
|
||||
a.hub.broadcast(Event{Type: EventAgents, Agents: a.hub.Summaries()})
|
||||
}
|
||||
|
||||
case frame.Method == acp.MethodRCInteractionCancelled:
|
||||
// The terminal answered first. Close the browser's dialog with the same
|
||||
// wording it would get if another browser had answered.
|
||||
var params acp.InteractionCancelledParams
|
||||
if err := json.Unmarshal(frame.Params, ¶ms); err != nil {
|
||||
return
|
||||
}
|
||||
a.retract(fmt.Sprintf("%d", params.ID), params.ToolCallID, "terminal")
|
||||
|
||||
case acp.IsTranscript(frame.Method):
|
||||
a.recordTranscript(frame, raw)
|
||||
|
||||
default:
|
||||
a.log.Debug("unhandled notification from agent", "agent", a.ID, "method", frame.Method)
|
||||
}
|
||||
}
|
||||
|
||||
// recordTranscript rings the frame and fans it out.
|
||||
//
|
||||
// The frame is stored and forwarded exactly as it arrived: `_meta` carries
|
||||
// `eventId`, `promptId`, `chunkId` and `isReplay`, which is what lets a viewer
|
||||
// dedup and order the stream the same way the terminal does. Rewriting it here
|
||||
// would quietly break that.
|
||||
func (a *Agent) recordTranscript(frame acp.Frame, raw []byte) {
|
||||
kind, toolCallID := classifyUpdate(frame.Params)
|
||||
|
||||
a.mu.Lock()
|
||||
a.ring.push(json.RawMessage(raw))
|
||||
a.lastActivity = time.Now()
|
||||
switch kind {
|
||||
case updateTurnEnd:
|
||||
a.turnActive = false
|
||||
case updateInTurn:
|
||||
a.turnActive = true
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
// `interaction_resolved` is grok's own signal that a reverse-request was
|
||||
// answered somewhere. It reaches glance for interactions the bridge never
|
||||
// raced, so it is handled alongside `x.ai/rc/interaction_cancelled` rather
|
||||
// than instead of it.
|
||||
if kind == updateInteractionResolved && toolCallID != "" {
|
||||
a.retractByToolCall(toolCallID, "terminal")
|
||||
}
|
||||
|
||||
a.hub.broadcast(Event{Type: EventFrame, Agent: a.ID, Frame: json.RawMessage(raw)})
|
||||
}
|
||||
|
||||
// retract closes an interaction that was answered elsewhere.
|
||||
func (a *Agent) retract(id, toolCallID, by string) {
|
||||
a.mu.Lock()
|
||||
interaction, ok := a.interactions[id]
|
||||
if ok {
|
||||
delete(a.interactions, id)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if !ok {
|
||||
if toolCallID != "" {
|
||||
a.retractByToolCall(toolCallID, by)
|
||||
}
|
||||
return
|
||||
}
|
||||
a.hub.broadcast(interactionResolvedEvent(a.ID, interaction, by))
|
||||
}
|
||||
|
||||
func (a *Agent) retractByToolCall(toolCallID, by string) {
|
||||
a.mu.Lock()
|
||||
var found *Interaction
|
||||
for key, in := range a.interactions {
|
||||
if in.ToolCallID == toolCallID {
|
||||
found = in
|
||||
delete(a.interactions, key)
|
||||
break
|
||||
}
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if found == nil {
|
||||
return
|
||||
}
|
||||
a.hub.broadcast(interactionResolvedEvent(a.ID, found, by))
|
||||
}
|
||||
|
||||
func interactionResolvedEvent(agentID string, in *Interaction, by string) Event {
|
||||
return Event{
|
||||
Type: EventInteractionResolved,
|
||||
Agent: agentID,
|
||||
ID: string(in.ID),
|
||||
ToolCallID: in.ToolCallID,
|
||||
By: by,
|
||||
}
|
||||
}
|
||||
|
||||
type updateKind int
|
||||
|
||||
const (
|
||||
updateOther updateKind = iota
|
||||
updateInTurn
|
||||
updateTurnEnd
|
||||
updateInteractionResolved
|
||||
)
|
||||
|
||||
// classifyUpdate reads just enough of a notification to keep the UI's turn
|
||||
// indicator honest.
|
||||
//
|
||||
// Both rails share the `{sessionId, update: {sessionUpdate: "...", ...}}` shape,
|
||||
// so one probe covers them. Everything else in the payload stays opaque: the
|
||||
// xAI rail's ~60 variants are internal to grok and drift with every upstream
|
||||
// sync, so nothing load-bearing is keyed off them.
|
||||
func classifyUpdate(params json.RawMessage) (updateKind, string) {
|
||||
if len(params) == 0 {
|
||||
return updateOther, ""
|
||||
}
|
||||
var probe struct {
|
||||
Update struct {
|
||||
SessionUpdate string `json:"sessionUpdate"`
|
||||
// The stable rail spells it camelCase; the xAI rail keeps Rust's
|
||||
// snake_case field names. Accept both rather than guess.
|
||||
ToolCallIDCamel string `json:"toolCallId"`
|
||||
ToolCallIDSnake string `json:"tool_call_id"`
|
||||
} `json:"update"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &probe); err != nil {
|
||||
return updateOther, ""
|
||||
}
|
||||
toolCallID := probe.Update.ToolCallIDCamel
|
||||
if toolCallID == "" {
|
||||
toolCallID = probe.Update.ToolCallIDSnake
|
||||
}
|
||||
|
||||
switch probe.Update.SessionUpdate {
|
||||
case "turn_completed":
|
||||
return updateTurnEnd, toolCallID
|
||||
case "interaction_resolved":
|
||||
return updateInteractionResolved, toolCallID
|
||||
case "agent_message_chunk", "agent_thought_chunk", "tool_call", "tool_call_update",
|
||||
"user_message_chunk", "plan", "pending_interaction":
|
||||
return updateInTurn, toolCallID
|
||||
}
|
||||
return updateOther, toolCallID
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// browserSendQueue bounds how far behind a viewer may fall.
|
||||
//
|
||||
// A turn can emit thousands of frames faster than a slow link drains them. Once
|
||||
// this fills, the viewer is dropped and reconnects into a fresh snapshot --
|
||||
// which is strictly better than an unbounded queue that turns one slow browser
|
||||
// into the server's memory problem.
|
||||
const browserSendQueue = 256
|
||||
|
||||
// Browser is one connected web client.
|
||||
type Browser struct {
|
||||
hub *Hub
|
||||
log *slog.Logger
|
||||
|
||||
conn *websocket.Conn
|
||||
outbound chan []byte
|
||||
|
||||
done chan struct{}
|
||||
closeOne sync.Once
|
||||
}
|
||||
|
||||
// command is what a browser sends.
|
||||
type command struct {
|
||||
Type string `json:"type"`
|
||||
Agent string `json:"agent,omitempty"`
|
||||
|
||||
// Prompt.
|
||||
Text string `json:"text,omitempty"`
|
||||
|
||||
// Interaction answer.
|
||||
ID string `json:"id,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// ServeBrowser runs a browser connection to completion.
|
||||
func (h *Hub) ServeBrowser(ctx context.Context, conn *websocket.Conn) {
|
||||
b := &Browser{
|
||||
hub: h,
|
||||
log: h.log,
|
||||
conn: conn,
|
||||
outbound: make(chan []byte, browserSendQueue),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
h.addBrowser(b)
|
||||
defer func() {
|
||||
h.removeBrowser(b)
|
||||
b.close()
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
go b.writeLoop(ctx)
|
||||
|
||||
b.send(Event{Type: EventAgents, Agents: h.Summaries()})
|
||||
|
||||
for {
|
||||
typ, data, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
continue
|
||||
}
|
||||
var cmd command
|
||||
if err := json.Unmarshal(data, &cmd); err != nil {
|
||||
b.send(Event{Type: EventError, Message: "could not parse command"})
|
||||
continue
|
||||
}
|
||||
b.handle(ctx, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Browser) handle(ctx context.Context, cmd command) {
|
||||
switch cmd.Type {
|
||||
case "list":
|
||||
b.send(Event{Type: EventAgents, Agents: b.hub.Summaries()})
|
||||
|
||||
case "subscribe":
|
||||
b.subscribe(cmd.Agent)
|
||||
|
||||
case "prompt":
|
||||
b.prompt(ctx, cmd)
|
||||
|
||||
case "cancel":
|
||||
b.cancel(ctx, cmd)
|
||||
|
||||
case "answer":
|
||||
b.answer(cmd)
|
||||
|
||||
case "decline":
|
||||
b.decline(cmd)
|
||||
|
||||
default:
|
||||
b.send(Event{Type: EventError, Message: "unknown command: " + cmd.Type})
|
||||
}
|
||||
}
|
||||
|
||||
// subscribe sends the full current state of one agent.
|
||||
//
|
||||
// Every browser receives every agent's frames regardless -- fan-out is cheap at
|
||||
// this scale and per-browser filtering would be one more thing to get wrong.
|
||||
// Subscribing is how a browser gets *history*: the ring, the open interactions
|
||||
// and the session metadata, in one message, so a reload or a mid-session open
|
||||
// renders immediately instead of waiting for the next frame.
|
||||
func (b *Browser) subscribe(agentID string) {
|
||||
agent, ok := b.hub.Agent(agentID)
|
||||
if !ok {
|
||||
b.send(Event{Type: EventError, Agent: agentID, Message: "no such agent connected"})
|
||||
return
|
||||
}
|
||||
frames, dropped := agent.Transcript()
|
||||
summary := agent.Summary()
|
||||
session := summary.Session
|
||||
|
||||
b.send(Event{
|
||||
Type: EventSnapshot,
|
||||
Agent: agentID,
|
||||
Agents: b.hub.Summaries(),
|
||||
Frames: frames,
|
||||
Dropped: dropped,
|
||||
Open: agent.OpenInteractions(),
|
||||
Session: &session,
|
||||
TurnActive: summary.TurnActive,
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Browser) prompt(ctx context.Context, cmd command) {
|
||||
agent, ok := b.hub.Agent(cmd.Agent)
|
||||
if !ok {
|
||||
b.send(Event{Type: EventError, Agent: cmd.Agent, Message: "no such agent connected"})
|
||||
return
|
||||
}
|
||||
// Whitespace counts as empty: the UI trims before sending, but the server is
|
||||
// the boundary, and a stray Enter in a box holding a space should not start a
|
||||
// turn. The text itself goes on unmodified -- refusing an accident is the
|
||||
// server's business, editing someone's prompt is not.
|
||||
if strings.TrimSpace(cmd.Text) == "" {
|
||||
b.send(Event{Type: EventError, Agent: cmd.Agent, Message: "prompt is empty"})
|
||||
return
|
||||
}
|
||||
|
||||
// A prompt does not return until the turn ends, which can be many minutes.
|
||||
// Waiting here would stall this browser's whole command stream -- including
|
||||
// the Stop button it might need next -- so the turn runs detached and its
|
||||
// progress arrives as mirrored frames like any other.
|
||||
go func() {
|
||||
if _, err := agent.Prompt(context.WithoutCancel(ctx), cmd.Text); err != nil {
|
||||
if errors.Is(err, ErrAgentGone) {
|
||||
b.hub.Notice(cmd.Agent, "the session disconnected before the turn finished")
|
||||
return
|
||||
}
|
||||
b.hub.Notice(cmd.Agent, "prompt failed: "+err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (b *Browser) cancel(ctx context.Context, cmd command) {
|
||||
agent, ok := b.hub.Agent(cmd.Agent)
|
||||
if !ok {
|
||||
b.send(Event{Type: EventError, Agent: cmd.Agent, Message: "no such agent connected"})
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if err := agent.Cancel(context.WithoutCancel(ctx)); err != nil {
|
||||
b.hub.Notice(cmd.Agent, "interrupt failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
b.hub.Notice(cmd.Agent, "turn interrupted from the web UI")
|
||||
}()
|
||||
}
|
||||
|
||||
func (b *Browser) answer(cmd command) {
|
||||
agent, ok := b.hub.Agent(cmd.Agent)
|
||||
if !ok {
|
||||
b.send(Event{Type: EventError, Agent: cmd.Agent, Message: "no such agent connected"})
|
||||
return
|
||||
}
|
||||
if len(cmd.Result) == 0 {
|
||||
b.send(Event{Type: EventError, Agent: cmd.Agent, Message: "answer has no result"})
|
||||
return
|
||||
}
|
||||
if !agent.Answer(cmd.ID, cmd.Result) {
|
||||
// Losing the race is the expected outcome half the time, not an error:
|
||||
// the terminal answered first, or another browser did.
|
||||
b.send(Event{
|
||||
Type: EventInteractionResolved,
|
||||
Agent: cmd.Agent,
|
||||
ID: cmd.ID,
|
||||
By: "elsewhere",
|
||||
Message: "already handled elsewhere",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Browser) decline(cmd command) {
|
||||
agent, ok := b.hub.Agent(cmd.Agent)
|
||||
if !ok {
|
||||
b.send(Event{Type: EventError, Agent: cmd.Agent, Message: "no such agent connected"})
|
||||
return
|
||||
}
|
||||
reason := cmd.Reason
|
||||
if reason == "" {
|
||||
reason = "declined in the web UI; answer in the terminal"
|
||||
}
|
||||
if !agent.Decline(cmd.ID, reason) {
|
||||
b.send(Event{
|
||||
Type: EventInteractionResolved,
|
||||
Agent: cmd.Agent,
|
||||
ID: cmd.ID,
|
||||
By: "elsewhere",
|
||||
Message: "already handled elsewhere",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Browser) send(ev Event) {
|
||||
raw, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
b.log.Warn("could not encode event for browser", "err", err)
|
||||
return
|
||||
}
|
||||
b.deliver(raw)
|
||||
}
|
||||
|
||||
func (b *Browser) deliver(raw []byte) {
|
||||
select {
|
||||
case b.outbound <- raw:
|
||||
case <-b.done:
|
||||
default:
|
||||
b.log.Info("browser too slow; dropping it to reconnect")
|
||||
b.close()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Browser) writeLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-b.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
err := b.conn.Ping(pingCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
b.close()
|
||||
return
|
||||
}
|
||||
case raw := <-b.outbound:
|
||||
writeCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
err := b.conn.Write(writeCtx, websocket.MessageText, raw)
|
||||
cancel()
|
||||
if err != nil {
|
||||
b.close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Browser) close() {
|
||||
b.closeOne.Do(func() {
|
||||
close(b.done)
|
||||
b.conn.CloseNow()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Package hub is the live half of grok-glance: connected grok instances,
|
||||
// connected browsers, and the routing between them.
|
||||
//
|
||||
// Nothing here is persisted. A restart drops every connection; the bridges
|
||||
// reconnect on their own and browsers reconnect on their own, so the cost of a
|
||||
// restart is the transcript history and nothing else.
|
||||
//
|
||||
// # Two protocols, deliberately
|
||||
//
|
||||
// grok speaks ACP over `/api/acp/agent`. Browsers speak a small glance envelope
|
||||
// over `/api/ws`. Making the browser a real ACP peer was possible and rejected:
|
||||
// it would push JSON-RPC correlation, request ids and the agent/client role
|
||||
// inversion into the frontend, in exchange for nothing the UI actually needs.
|
||||
// So the hub translates, and the frontend sees events with a `type`.
|
||||
package hub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/user/grok-glance/internal/acp"
|
||||
)
|
||||
|
||||
// EventType tags a message from glance to a browser.
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
// EventSnapshot is the first message on a browser socket: everything needed
|
||||
// to render without further round trips.
|
||||
EventSnapshot EventType = "snapshot"
|
||||
// EventAgents means the set of connected agents (or their metadata) changed.
|
||||
EventAgents EventType = "agents"
|
||||
// EventFrame is one mirrored ACP notification, passed through verbatim.
|
||||
EventFrame EventType = "frame"
|
||||
// EventInteraction is a request awaiting a human answer.
|
||||
EventInteraction EventType = "interaction"
|
||||
// EventInteractionResolved means it was answered -- possibly elsewhere.
|
||||
EventInteractionResolved EventType = "interaction_resolved"
|
||||
// EventNotice is a human-readable line for the UI to surface.
|
||||
EventNotice EventType = "notice"
|
||||
// EventError reports that a browser's own action failed.
|
||||
EventError EventType = "error"
|
||||
)
|
||||
|
||||
// Event is what a browser receives.
|
||||
type Event struct {
|
||||
Type EventType `json:"type"`
|
||||
Agent string `json:"agent,omitempty"`
|
||||
|
||||
// Snapshot payload.
|
||||
Agents []AgentSummary `json:"agents,omitempty"`
|
||||
Frames []json.RawMessage `json:"frames,omitempty"`
|
||||
Dropped int `json:"dropped,omitempty"`
|
||||
Open []*Interaction `json:"open,omitempty"`
|
||||
Session *acp.SessionMeta `json:"session,omitempty"`
|
||||
TurnActive bool `json:"turnActive,omitempty"`
|
||||
|
||||
// Streaming payload.
|
||||
Frame json.RawMessage `json:"frame,omitempty"`
|
||||
Interaction *Interaction `json:"interaction,omitempty"`
|
||||
|
||||
// Resolution payload.
|
||||
ID string `json:"id,omitempty"`
|
||||
ToolCallID string `json:"toolCallId,omitempty"`
|
||||
// By is "browser", "terminal", or "declined" -- what the UI shows when a
|
||||
// dialog closes without this user having answered it.
|
||||
By string `json:"by,omitempty"`
|
||||
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// Hub owns every live connection.
|
||||
type Hub struct {
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
agents map[string]*Agent
|
||||
browsers map[*Browser]struct{}
|
||||
}
|
||||
|
||||
// New builds an empty hub.
|
||||
func New(log *slog.Logger) *Hub {
|
||||
return &Hub{
|
||||
log: log,
|
||||
agents: make(map[string]*Agent),
|
||||
browsers: make(map[*Browser]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// ServeAgent runs a grok connection to completion.
|
||||
//
|
||||
// agentID identifies the connection for the lifetime of the socket; keyName is
|
||||
// the API key's label, shown in the UI so several machines can be told apart.
|
||||
func (h *Hub) ServeAgent(ctx context.Context, conn *websocket.Conn, agentID, keyName string) {
|
||||
now := time.Now()
|
||||
agent := &Agent{
|
||||
ID: agentID,
|
||||
KeyName: keyName,
|
||||
hub: h,
|
||||
conn: conn,
|
||||
log: h.log.With("agent", agentID),
|
||||
outbound: make(chan acp.Frame, agentSendQueue),
|
||||
done: make(chan struct{}),
|
||||
connectedAt: now,
|
||||
lastActivity: now,
|
||||
ring: newRing(defaultRingCapacity),
|
||||
interactions: make(map[string]*Interaction),
|
||||
calls: make(map[uint64]chan acp.Frame),
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
// A reconnect from the same key replaces the old connection rather than
|
||||
// accumulating a ghost: the bridge reconnects after every network blip, and
|
||||
// a stale entry would show as a second session that never updates.
|
||||
if old, ok := h.agents[agentID]; ok {
|
||||
go old.close()
|
||||
}
|
||||
h.agents[agentID] = agent
|
||||
h.mu.Unlock()
|
||||
|
||||
h.log.Info("agent connected", "agent", agentID, "key", keyName)
|
||||
h.broadcast(Event{Type: EventAgents, Agents: h.Summaries()})
|
||||
|
||||
// Ask who this is. The bridge also volunteers it in `x.ai/rc/status` on
|
||||
// connect, but asking means the UI is correct even if that frame is missed.
|
||||
go h.initialize(ctx, agent)
|
||||
|
||||
agent.serve(ctx)
|
||||
|
||||
agent.close()
|
||||
h.mu.Lock()
|
||||
if h.agents[agentID] == agent {
|
||||
delete(h.agents, agentID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
h.log.Info("agent gone", "agent", agentID)
|
||||
h.broadcast(Event{Type: EventAgents, Agents: h.Summaries()})
|
||||
}
|
||||
|
||||
func (h *Hub) initialize(ctx context.Context, agent *Agent) {
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
|
||||
frame, err := agent.call(ctx, acp.MethodInitialize, map[string]any{
|
||||
"protocolVersion": 1,
|
||||
"clientCapabilities": map[string]any{
|
||||
// glance is a viewer and a controller, not a workspace: it does not
|
||||
// offer grok a filesystem or a terminal, and says so up front.
|
||||
"fs": map[string]any{"readTextFile": false, "writeTextFile": false},
|
||||
"terminal": false,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
agent.log.Info("initialize failed", "err", err)
|
||||
return
|
||||
}
|
||||
if frame.Error != nil {
|
||||
agent.log.Warn("agent refused initialize", "err", frame.Error)
|
||||
return
|
||||
}
|
||||
|
||||
var result acp.InitializeResult
|
||||
if err := json.Unmarshal(frame.Result, &result); err != nil {
|
||||
agent.log.Warn("could not read initialize result", "err", err)
|
||||
return
|
||||
}
|
||||
if result.Meta != nil {
|
||||
agent.mu.Lock()
|
||||
agent.meta = result.Meta.Session
|
||||
agent.mu.Unlock()
|
||||
}
|
||||
h.broadcast(Event{Type: EventAgents, Agents: h.Summaries()})
|
||||
h.syncViewers(agent)
|
||||
}
|
||||
|
||||
// Agent looks up a connected instance.
|
||||
func (h *Hub) Agent(id string) (*Agent, bool) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
agent, ok := h.agents[id]
|
||||
return agent, ok
|
||||
}
|
||||
|
||||
// Summaries lists connected agents, for the sessions page.
|
||||
func (h *Hub) Summaries() []AgentSummary {
|
||||
h.mu.RLock()
|
||||
agents := make([]*Agent, 0, len(h.agents))
|
||||
for _, a := range h.agents {
|
||||
agents = append(agents, a)
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
out := make([]AgentSummary, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
out = append(out, a.Summary())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// broadcast fans an event out to every browser.
|
||||
//
|
||||
// Delivery is best-effort per browser: a viewer that cannot keep up is
|
||||
// disconnected and reconnects into a fresh snapshot, which is both simpler and
|
||||
// more correct than letting it fall arbitrarily far behind.
|
||||
func (h *Hub) broadcast(ev Event) {
|
||||
raw, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
h.log.Warn("could not encode event", "type", ev.Type, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
browsers := make([]*Browser, 0, len(h.browsers))
|
||||
for b := range h.browsers {
|
||||
browsers = append(browsers, b)
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
for _, b := range browsers {
|
||||
b.deliver(raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Notice pushes a human-readable line to every browser.
|
||||
func (h *Hub) Notice(agentID, message string) {
|
||||
h.broadcast(Event{Type: EventNotice, Agent: agentID, Message: message})
|
||||
}
|
||||
|
||||
func (h *Hub) addBrowser(b *Browser) {
|
||||
h.mu.Lock()
|
||||
h.browsers[b] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
h.syncAllViewers()
|
||||
}
|
||||
|
||||
func (h *Hub) removeBrowser(b *Browser) {
|
||||
h.mu.Lock()
|
||||
delete(h.browsers, b)
|
||||
h.mu.Unlock()
|
||||
h.syncAllViewers()
|
||||
}
|
||||
|
||||
// syncAllViewers tells every bridge how many browsers are attached, so `/rc
|
||||
// status` in the terminal reflects reality.
|
||||
func (h *Hub) syncAllViewers() {
|
||||
h.mu.RLock()
|
||||
count := len(h.browsers)
|
||||
agents := make([]*Agent, 0, len(h.agents))
|
||||
for _, a := range h.agents {
|
||||
agents = append(agents, a)
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
for _, a := range agents {
|
||||
a.NotifyViewers(count)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) syncViewers(agent *Agent) {
|
||||
h.mu.RLock()
|
||||
count := len(h.browsers)
|
||||
h.mu.RUnlock()
|
||||
agent.NotifyViewers(count)
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/user/grok-glance/internal/acp"
|
||||
)
|
||||
|
||||
// These tests drive the hub over real WebSocket connections rather than mocking
|
||||
// the transport. The behaviour that matters here -- an interaction reaching a
|
||||
// browser, an answer reaching grok, and the two sides racing -- lives in the
|
||||
// interleaving of three goroutines, and a mocked conn would test the mock.
|
||||
|
||||
const testTimeout = 5 * time.Second
|
||||
|
||||
// link is a hub with an HTTP front door for both socket kinds.
|
||||
type link struct {
|
||||
hub *Hub
|
||||
server *httptest.Server
|
||||
url string
|
||||
}
|
||||
|
||||
func newLink(t *testing.T) *link {
|
||||
t.Helper()
|
||||
l := &link{hub: New(slog.New(slog.DiscardHandler))}
|
||||
|
||||
l.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.SetReadLimit(8 << 20)
|
||||
if strings.HasPrefix(r.URL.Path, "/agent") {
|
||||
l.hub.ServeAgent(r.Context(), conn, r.URL.Query().Get("id"), "laptop")
|
||||
return
|
||||
}
|
||||
l.hub.ServeBrowser(r.Context(), conn)
|
||||
}))
|
||||
t.Cleanup(l.server.Close)
|
||||
l.url = "ws" + strings.TrimPrefix(l.server.URL, "http")
|
||||
return l
|
||||
}
|
||||
|
||||
// fakeAgent stands in for the grok bridge.
|
||||
//
|
||||
// Its reader answers `initialize` by itself, the way the real bridge does, so
|
||||
// tests do not have to step through a handshake they are not about.
|
||||
type fakeAgent struct {
|
||||
t *testing.T
|
||||
conn *websocket.Conn
|
||||
frames chan acp.Frame
|
||||
ready chan struct{}
|
||||
}
|
||||
|
||||
func (l *link) dialAgent(t *testing.T, id string, meta acp.SessionMeta) *fakeAgent {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
|
||||
defer cancel()
|
||||
|
||||
conn, _, err := websocket.Dial(ctx, l.url+"/agent?id="+id, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial agent: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { conn.CloseNow() })
|
||||
|
||||
a := &fakeAgent{t: t, conn: conn, frames: make(chan acp.Frame, 64), ready: make(chan struct{})}
|
||||
go a.read(meta)
|
||||
|
||||
// Wait for the handshake so that tests which read the agent list do not race
|
||||
// the metadata that names the session.
|
||||
select {
|
||||
case <-a.ready:
|
||||
case <-time.After(testTimeout):
|
||||
t.Fatal("hub never sent initialize")
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *fakeAgent) read(meta acp.SessionMeta) {
|
||||
ctx := context.Background()
|
||||
handshake := false
|
||||
for {
|
||||
_, data, err := a.conn.Read(ctx)
|
||||
if err != nil {
|
||||
close(a.frames)
|
||||
return
|
||||
}
|
||||
var frame acp.Frame
|
||||
if err := json.Unmarshal(data, &frame); err != nil {
|
||||
continue
|
||||
}
|
||||
if frame.Method == acp.MethodInitialize {
|
||||
reply, err := acp.NewResponse(frame.ID, acp.InitializeResult{
|
||||
ProtocolVersion: 1,
|
||||
Meta: &acp.InitializeMeta{Session: meta},
|
||||
})
|
||||
if err == nil {
|
||||
a.write(reply)
|
||||
}
|
||||
if !handshake {
|
||||
handshake = true
|
||||
close(a.ready)
|
||||
}
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case a.frames <- frame:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *fakeAgent) write(frame any) {
|
||||
a.t.Helper()
|
||||
raw, err := json.Marshal(frame)
|
||||
if err != nil {
|
||||
a.t.Errorf("encode frame: %v", err)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
|
||||
defer cancel()
|
||||
if err := a.conn.Write(ctx, websocket.MessageText, raw); err != nil {
|
||||
a.t.Errorf("write frame: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// raw writes a frame written out as JSON, for shapes the Go types do not model.
|
||||
func (a *fakeAgent) raw(body string) {
|
||||
a.t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
|
||||
defer cancel()
|
||||
if err := a.conn.Write(ctx, websocket.MessageText, []byte(body)); err != nil {
|
||||
a.t.Errorf("write raw: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// expect waits for the next frame satisfying match.
|
||||
func (a *fakeAgent) expect(match func(acp.Frame) bool, what string) acp.Frame {
|
||||
a.t.Helper()
|
||||
deadline := time.After(testTimeout)
|
||||
for {
|
||||
select {
|
||||
case frame, ok := <-a.frames:
|
||||
if !ok {
|
||||
a.t.Fatalf("agent socket closed while waiting for %s", what)
|
||||
}
|
||||
if match(frame) {
|
||||
return frame
|
||||
}
|
||||
case <-deadline:
|
||||
a.t.Fatalf("timed out waiting for %s", what)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fakeBrowser stands in for the web UI.
|
||||
type fakeBrowser struct {
|
||||
t *testing.T
|
||||
conn *websocket.Conn
|
||||
events chan Event
|
||||
}
|
||||
|
||||
func (l *link) dialBrowser(t *testing.T) *fakeBrowser {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
|
||||
defer cancel()
|
||||
|
||||
conn, _, err := websocket.Dial(ctx, l.url+"/browser", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial browser: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { conn.CloseNow() })
|
||||
|
||||
b := &fakeBrowser{t: t, conn: conn, events: make(chan Event, 256)}
|
||||
go b.read()
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *fakeBrowser) read() {
|
||||
ctx := context.Background()
|
||||
for {
|
||||
_, data, err := b.conn.Read(ctx)
|
||||
if err != nil {
|
||||
close(b.events)
|
||||
return
|
||||
}
|
||||
var ev Event
|
||||
if err := json.Unmarshal(data, &ev); err != nil {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case b.events <- ev:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *fakeBrowser) send(cmd command) {
|
||||
b.t.Helper()
|
||||
raw, err := json.Marshal(cmd)
|
||||
if err != nil {
|
||||
b.t.Fatalf("encode command: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
|
||||
defer cancel()
|
||||
if err := b.conn.Write(ctx, websocket.MessageText, raw); err != nil {
|
||||
b.t.Fatalf("write command: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// expect waits for the next event of the given type, skipping the agent-list
|
||||
// churn that connects and disconnects produce.
|
||||
func (b *fakeBrowser) expect(kind EventType) Event {
|
||||
b.t.Helper()
|
||||
deadline := time.After(testTimeout)
|
||||
for {
|
||||
select {
|
||||
case ev, ok := <-b.events:
|
||||
if !ok {
|
||||
b.t.Fatalf("browser socket closed while waiting for %s", kind)
|
||||
}
|
||||
if ev.Type == kind {
|
||||
return ev
|
||||
}
|
||||
case <-deadline:
|
||||
b.t.Fatalf("timed out waiting for a %s event", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// permissionRequest is what grok asks when a tool needs approval.
|
||||
func permissionRequest(id int, toolCallID string) map[string]any {
|
||||
return map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": acp.MethodRequestPermission,
|
||||
"params": map[string]any{
|
||||
"sessionId": "s-1",
|
||||
"toolCallId": toolCallID,
|
||||
"options": []map[string]string{
|
||||
{"optionId": "allow", "name": "Allow", "kind": "allow_once"},
|
||||
{"optionId": "deny", "name": "Deny", "kind": "reject_once"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentConnectAnnouncesItselfWithSessionMetadata(t *testing.T) {
|
||||
l := newLink(t)
|
||||
browser := l.dialBrowser(t)
|
||||
browser.expect(EventAgents) // the empty greeting
|
||||
|
||||
l.dialAgent(t, "key-1", acp.SessionMeta{
|
||||
SessionID: "s-1",
|
||||
CWD: "/home/user/project",
|
||||
Model: "grok-4",
|
||||
Hostname: "workstation",
|
||||
})
|
||||
|
||||
// The list is broadcast on connect and again when initialize answers, so the
|
||||
// named entry may be the second event, not the first.
|
||||
deadline := time.After(testTimeout)
|
||||
for {
|
||||
ev := browser.expect(EventAgents)
|
||||
if len(ev.Agents) == 1 && ev.Agents[0].Session.Model == "grok-4" {
|
||||
if got := ev.Agents[0].Label; got != "/home/user/project" {
|
||||
t.Fatalf("label = %q, want the cwd when there is no title", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("agent never appeared with its session metadata")
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserAnswerReachesGrokAndClosesEveryDialog(t *testing.T) {
|
||||
l := newLink(t)
|
||||
agent := l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-1"})
|
||||
browser := l.dialBrowser(t)
|
||||
browser.expect(EventAgents)
|
||||
|
||||
agent.write(permissionRequest(7, "call-1"))
|
||||
|
||||
opened := browser.expect(EventInteraction)
|
||||
if opened.Interaction == nil || opened.Interaction.Method != acp.MethodRequestPermission {
|
||||
t.Fatalf("interaction = %+v, want a permission request", opened.Interaction)
|
||||
}
|
||||
if opened.Interaction.ToolCallID != "call-1" {
|
||||
t.Fatalf("toolCallId = %q, want call-1", opened.Interaction.ToolCallID)
|
||||
}
|
||||
// The params are passed through untouched: glance renders nothing from them
|
||||
// itself, so anything it rewrote would be a bug the UI inherits.
|
||||
if !strings.Contains(string(opened.Interaction.Params), `"allow_once"`) {
|
||||
t.Fatalf("params were not passed through: %s", opened.Interaction.Params)
|
||||
}
|
||||
|
||||
id := string(opened.Interaction.ID)
|
||||
browser.send(command{
|
||||
Type: "answer",
|
||||
Agent: "key-1",
|
||||
ID: id,
|
||||
Result: json.RawMessage(`{"outcome":{"outcome":"selected","optionId":"allow"}}`),
|
||||
})
|
||||
|
||||
// grok gets a JSON-RPC response on the id it asked with.
|
||||
reply := agent.expect(func(f acp.Frame) bool {
|
||||
return f.Kind() == acp.KindResponse && string(f.ID) == "7"
|
||||
}, "the permission response")
|
||||
if !strings.Contains(string(reply.Result), `"optionId":"allow"`) {
|
||||
t.Fatalf("result = %s, want the browser's choice", reply.Result)
|
||||
}
|
||||
|
||||
resolved := browser.expect(EventInteractionResolved)
|
||||
if resolved.ID != id || resolved.By != "browser" {
|
||||
t.Fatalf("resolution = %+v, want id %s by browser", resolved, id)
|
||||
}
|
||||
|
||||
// The interaction is gone, so a reload does not show a dialog grok is no
|
||||
// longer waiting on.
|
||||
if pending := l.hub.Summaries()[0].Pending; pending != 0 {
|
||||
t.Fatalf("pending = %d after answering, want 0", pending)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalWinningRetractsTheBrowserDialog(t *testing.T) {
|
||||
l := newLink(t)
|
||||
agent := l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-1"})
|
||||
browser := l.dialBrowser(t)
|
||||
browser.expect(EventAgents)
|
||||
|
||||
agent.write(permissionRequest(11, "call-2"))
|
||||
opened := browser.expect(EventInteraction)
|
||||
|
||||
// The user approved in the terminal, so the bridge tells glance to take the
|
||||
// card down. glance must not answer grok afterwards: grok already has it.
|
||||
agent.write(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"method": acp.MethodRCInteractionCancelled,
|
||||
"params": acp.InteractionCancelledParams{ID: 11, ToolCallID: "call-2"},
|
||||
})
|
||||
|
||||
resolved := browser.expect(EventInteractionResolved)
|
||||
if resolved.By != "terminal" {
|
||||
t.Fatalf("resolved by %q, want terminal", resolved.By)
|
||||
}
|
||||
if resolved.ID != string(opened.Interaction.ID) {
|
||||
t.Fatalf("resolved id = %q, want %q", resolved.ID, opened.Interaction.ID)
|
||||
}
|
||||
|
||||
// Answering now is the losing half of the race and must say so rather than
|
||||
// fail: the browser had the card open when the terminal won.
|
||||
browser.send(command{
|
||||
Type: "answer",
|
||||
Agent: "key-1",
|
||||
ID: resolved.ID,
|
||||
Result: json.RawMessage(`{"outcome":{"outcome":"selected","optionId":"allow"}}`),
|
||||
})
|
||||
late := browser.expect(EventInteractionResolved)
|
||||
if late.By != "elsewhere" || late.Message == "" {
|
||||
t.Fatalf("late answer = %+v, want by=elsewhere with an explanation", late)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyTheFirstBrowserToAnswerWins(t *testing.T) {
|
||||
l := newLink(t)
|
||||
agent := l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-1"})
|
||||
first := l.dialBrowser(t)
|
||||
second := l.dialBrowser(t)
|
||||
first.expect(EventAgents)
|
||||
second.expect(EventAgents)
|
||||
|
||||
agent.write(permissionRequest(13, "call-3"))
|
||||
opened := first.expect(EventInteraction)
|
||||
second.expect(EventInteraction)
|
||||
id := string(opened.Interaction.ID)
|
||||
|
||||
answer := command{
|
||||
Type: "answer",
|
||||
Agent: "key-1",
|
||||
ID: id,
|
||||
Result: json.RawMessage(`{"outcome":{"outcome":"selected","optionId":"allow"}}`),
|
||||
}
|
||||
first.send(answer)
|
||||
if got := first.expect(EventInteractionResolved); got.By != "browser" {
|
||||
t.Fatalf("first answer resolved by %q, want browser", got.By)
|
||||
}
|
||||
|
||||
// The resolution is broadcast, so the other browser's card closes on its own
|
||||
// without anyone touching it.
|
||||
if got := second.expect(EventInteractionResolved); got.By != "browser" || got.ID != id {
|
||||
t.Fatalf("second browser saw %+v, want the first browser's resolution", got)
|
||||
}
|
||||
|
||||
// Answering anyway -- the click that was already in flight -- is told what
|
||||
// happened rather than failing.
|
||||
second.send(answer)
|
||||
if got := second.expect(EventInteractionResolved); got.By != "elsewhere" {
|
||||
t.Fatalf("second answer resolved by %q, want elsewhere", got.By)
|
||||
}
|
||||
|
||||
// One response, not two: grok is waiting on a single id and a duplicate
|
||||
// would be an unsolicited frame.
|
||||
agent.expect(func(f acp.Frame) bool {
|
||||
return f.Kind() == acp.KindResponse && string(f.ID) == "13"
|
||||
}, "the permission response")
|
||||
select {
|
||||
case frame, ok := <-agent.frames:
|
||||
if ok && frame.Kind() == acp.KindResponse && string(frame.ID) == "13" {
|
||||
t.Fatal("the losing browser's answer was also sent to grok")
|
||||
}
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclineHandsTheInteractionBackToTheTerminal(t *testing.T) {
|
||||
l := newLink(t)
|
||||
agent := l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-1"})
|
||||
browser := l.dialBrowser(t)
|
||||
browser.expect(EventAgents)
|
||||
|
||||
agent.write(permissionRequest(17, "call-4"))
|
||||
opened := browser.expect(EventInteraction)
|
||||
|
||||
browser.send(command{
|
||||
Type: "decline",
|
||||
Agent: "key-1",
|
||||
ID: string(opened.Interaction.ID),
|
||||
Reason: "answering in the terminal",
|
||||
})
|
||||
|
||||
// A JSON-RPC error, not a fabricated outcome: grok reads it as "glance is
|
||||
// not answering this" and leaves the terminal's dialog up.
|
||||
reply := agent.expect(func(f acp.Frame) bool {
|
||||
return f.Kind() == acp.KindResponse && string(f.ID) == "17"
|
||||
}, "the decline")
|
||||
if reply.Error == nil {
|
||||
t.Fatalf("decline produced result %s, want a JSON-RPC error", reply.Result)
|
||||
}
|
||||
if !strings.Contains(reply.Error.Message, "answering in the terminal") {
|
||||
t.Fatalf("error message = %q, want the browser's reason", reply.Error.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscriptIsRingedAndReplayedOnSubscribe(t *testing.T) {
|
||||
l := newLink(t)
|
||||
agent := l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-1"})
|
||||
|
||||
// One frame from each rail. The xAI rail is opaque to glance but must still
|
||||
// reach the browser, or the transcript loses its streaming detail.
|
||||
agent.raw(`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s-1",
|
||||
"update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}},
|
||||
"_meta":{"eventId":"e-1"}}}`)
|
||||
agent.raw(`{"jsonrpc":"2.0","method":"x.ai/session_notification","params":{"sessionId":"s-1",
|
||||
"update":{"sessionUpdate":"tool_call_update","tool_call_id":"call-9","status":"in_progress"}}}`)
|
||||
|
||||
browser := l.dialBrowser(t)
|
||||
browser.expect(EventAgents)
|
||||
|
||||
// Subscribing late still renders the turn so far -- that is what the ring is
|
||||
// for, and what makes a page reload mid-turn survivable.
|
||||
waitFor(t, func() bool { return l.hub.Summaries()[0].Frames == 2 })
|
||||
browser.send(command{Type: "subscribe", Agent: "key-1"})
|
||||
|
||||
snapshot := browser.expect(EventSnapshot)
|
||||
if len(snapshot.Frames) != 2 {
|
||||
t.Fatalf("snapshot has %d frames, want 2", len(snapshot.Frames))
|
||||
}
|
||||
if snapshot.Dropped != 0 {
|
||||
t.Fatalf("dropped = %d, want 0", snapshot.Dropped)
|
||||
}
|
||||
// A chunk means a turn is running; the UI's spinner is driven by this.
|
||||
if !snapshot.TurnActive {
|
||||
t.Fatal("turnActive = false after a message chunk")
|
||||
}
|
||||
// `_meta` is what lets a viewer dedup and order the stream, so it has to
|
||||
// survive the round trip verbatim.
|
||||
if !strings.Contains(string(snapshot.Frames[0]), `"eventId":"e-1"`) {
|
||||
t.Fatalf("frame lost its _meta: %s", snapshot.Frames[0])
|
||||
}
|
||||
|
||||
// A live frame arrives on the same socket after the snapshot.
|
||||
agent.raw(`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s-1",
|
||||
"update":{"sessionUpdate":"turn_completed"}}}`)
|
||||
live := browser.expect(EventFrame)
|
||||
if !strings.Contains(string(live.Frame), "turn_completed") {
|
||||
t.Fatalf("live frame = %s, want the turn_completed update", live.Frame)
|
||||
}
|
||||
waitFor(t, func() bool { return !l.hub.Summaries()[0].TurnActive })
|
||||
}
|
||||
|
||||
func TestUnsupportedRequestsGetMethodNotFound(t *testing.T) {
|
||||
l := newLink(t)
|
||||
agent := l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-1"})
|
||||
|
||||
// glance drives grok, not the other way round: it has no filesystem to
|
||||
// offer. Saying so beats a fabricated success grok would then act on.
|
||||
agent.write(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 21,
|
||||
"method": "fs/read_text_file",
|
||||
"params": map[string]any{"path": "/etc/passwd"},
|
||||
})
|
||||
|
||||
reply := agent.expect(func(f acp.Frame) bool {
|
||||
return f.Kind() == acp.KindResponse && string(f.ID) == "21"
|
||||
}, "the method-not-found reply")
|
||||
if reply.Error == nil || reply.Error.Code != acp.CodeMethodNotFound {
|
||||
t.Fatalf("reply = %+v, want a method-not-found error", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandsForAMissingAgentAreReportedNotDropped(t *testing.T) {
|
||||
l := newLink(t)
|
||||
browser := l.dialBrowser(t)
|
||||
browser.expect(EventAgents)
|
||||
|
||||
for _, cmd := range []command{
|
||||
{Type: "subscribe", Agent: "ghost"},
|
||||
{Type: "prompt", Agent: "ghost", Text: "hi"},
|
||||
{Type: "cancel", Agent: "ghost"},
|
||||
{Type: "answer", Agent: "ghost", ID: "1", Result: json.RawMessage(`{}`)},
|
||||
} {
|
||||
browser.send(cmd)
|
||||
if ev := browser.expect(EventError); !strings.Contains(ev.Message, "no such agent") {
|
||||
t.Fatalf("%s: message = %q, want a missing-agent error", cmd.Type, ev.Message)
|
||||
}
|
||||
}
|
||||
|
||||
browser.send(command{Type: "nonsense"})
|
||||
if ev := browser.expect(EventError); !strings.Contains(ev.Message, "unknown command") {
|
||||
t.Fatalf("message = %q, want an unknown-command error", ev.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentDisconnectLeavesNoGhost(t *testing.T) {
|
||||
l := newLink(t)
|
||||
agent := l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-1"})
|
||||
waitFor(t, func() bool { return len(l.hub.Summaries()) == 1 })
|
||||
|
||||
agent.conn.Close(websocket.StatusNormalClosure, "bye")
|
||||
|
||||
// A stale entry would show in the UI as a session that never updates again.
|
||||
waitFor(t, func() bool { return len(l.hub.Summaries()) == 0 })
|
||||
}
|
||||
|
||||
func TestReconnectWithTheSameKeyReplacesTheOldConnection(t *testing.T) {
|
||||
l := newLink(t)
|
||||
l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-1"})
|
||||
waitFor(t, func() bool { return len(l.hub.Summaries()) == 1 })
|
||||
|
||||
// The bridge reconnects after every network blip. Accumulating a second
|
||||
// entry per blip would fill the session list with dead sessions.
|
||||
l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-2"})
|
||||
waitFor(t, func() bool {
|
||||
summaries := l.hub.Summaries()
|
||||
return len(summaries) == 1 && summaries[0].Session.SessionID == "s-2"
|
||||
})
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(testTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition never held")
|
||||
}
|
||||
|
||||
// The prompt box and the Stop button, which are the two things the browser can
|
||||
// do to a turn. Both are dispatched on their own goroutine -- a prompt does not
|
||||
// return until the turn ends -- so this also checks that a browser can still be
|
||||
// heard while one is outstanding.
|
||||
func TestPromptAndStopReachGrok(t *testing.T) {
|
||||
l := newLink(t)
|
||||
agent := l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-1", CWD: "/repo"})
|
||||
browser := l.dialBrowser(t)
|
||||
|
||||
browser.send(command{Type: "prompt", Agent: "key-1", Text: "summarise the diff"})
|
||||
|
||||
prompt := agent.expect(func(f acp.Frame) bool {
|
||||
return f.Kind() == acp.KindRequest && f.Method == acp.MethodSessionPrompt
|
||||
}, "the prompt")
|
||||
var got acp.PromptParams
|
||||
if err := json.Unmarshal(prompt.Params, &got); err != nil {
|
||||
t.Fatalf("decode prompt params: %v", err)
|
||||
}
|
||||
if got.Text != "summarise the diff" {
|
||||
t.Fatalf("prompt text = %q, want the browser's text", got.Text)
|
||||
}
|
||||
if got.SessionID != "s-1" {
|
||||
t.Fatalf("prompt sessionId = %q, want the mirrored session", got.SessionID)
|
||||
}
|
||||
|
||||
// The turn is now running and grok has not answered the prompt. Stop must
|
||||
// still get through rather than queueing behind it.
|
||||
browser.send(command{Type: "cancel", Agent: "key-1"})
|
||||
|
||||
cancel := agent.expect(func(f acp.Frame) bool {
|
||||
return f.Kind() == acp.KindRequest && f.Method == acp.MethodSessionCancel
|
||||
}, "the cancel")
|
||||
var cancelled acp.CancelParams
|
||||
if err := json.Unmarshal(cancel.Params, &cancelled); err != nil {
|
||||
t.Fatalf("decode cancel params: %v", err)
|
||||
}
|
||||
if cancelled.SessionID != "s-1" {
|
||||
t.Fatalf("cancel sessionId = %q, want the mirrored session", cancelled.SessionID)
|
||||
}
|
||||
|
||||
// Answering both keeps the agent's call table clean, which is what the next
|
||||
// prompt depends on.
|
||||
agent.write(map[string]any{"jsonrpc": "2.0", "id": cancel.ID, "result": map[string]any{}})
|
||||
agent.write(map[string]any{
|
||||
"jsonrpc": "2.0", "id": prompt.ID,
|
||||
"result": map[string]any{"stopReason": "cancelled"},
|
||||
})
|
||||
|
||||
browser.send(command{Type: "prompt", Agent: "key-1", Text: "again"})
|
||||
agent.expect(func(f acp.Frame) bool {
|
||||
return f.Kind() == acp.KindRequest && f.Method == acp.MethodSessionPrompt &&
|
||||
string(f.ID) != string(prompt.ID)
|
||||
}, "a second prompt")
|
||||
}
|
||||
|
||||
// An empty prompt is the accidental Enter in an empty box. It must not reach
|
||||
// grok and start a turn nobody asked for.
|
||||
func TestEmptyPromptsAreRefusedLocally(t *testing.T) {
|
||||
l := newLink(t)
|
||||
agent := l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-1"})
|
||||
browser := l.dialBrowser(t)
|
||||
|
||||
browser.send(command{Type: "prompt", Agent: "key-1", Text: " "})
|
||||
if ev := browser.expect(EventError); ev.Message == "" {
|
||||
t.Fatal("an empty prompt should be reported to the browser")
|
||||
}
|
||||
|
||||
// Nothing reached grok: a real prompt afterwards is the first one it sees.
|
||||
browser.send(command{Type: "prompt", Agent: "key-1", Text: "real"})
|
||||
prompt := agent.expect(func(f acp.Frame) bool {
|
||||
return f.Kind() == acp.KindRequest && f.Method == acp.MethodSessionPrompt
|
||||
}, "the prompt")
|
||||
var got acp.PromptParams
|
||||
if err := json.Unmarshal(prompt.Params, &got); err != nil {
|
||||
t.Fatalf("decode prompt params: %v", err)
|
||||
}
|
||||
if got.Text != "real" {
|
||||
t.Fatalf("prompt text = %q, want the first prompt grok sees to be the real one", got.Text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package hub
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// ring is a bounded transcript buffer.
|
||||
//
|
||||
// The whole persistence story of glance is this type: a browser that attaches
|
||||
// mid-session, or reloads, sees the last `capacity` frames and nothing older.
|
||||
// Frames are kept as the raw bytes that arrived, so replay is byte-identical to
|
||||
// what the terminal saw and costs no re-encoding.
|
||||
//
|
||||
// Bounded and in-memory is a deliberate choice, not a shortcut. A control plane
|
||||
// that durably recorded everything an agent ever said -- file contents, diffs,
|
||||
// command output -- would be a far larger secret to keep than the one this
|
||||
// server is built to keep.
|
||||
type ring struct {
|
||||
frames []json.RawMessage
|
||||
start int
|
||||
size int
|
||||
dropped int
|
||||
}
|
||||
|
||||
func newRing(capacity int) *ring {
|
||||
if capacity < 1 {
|
||||
capacity = 1
|
||||
}
|
||||
return &ring{frames: make([]json.RawMessage, capacity)}
|
||||
}
|
||||
|
||||
func (r *ring) push(frame json.RawMessage) {
|
||||
n := len(r.frames)
|
||||
if r.size < n {
|
||||
r.frames[(r.start+r.size)%n] = frame
|
||||
r.size++
|
||||
return
|
||||
}
|
||||
// Full: overwrite the oldest and remember that history was lost, so the UI
|
||||
// can say "history truncated" rather than implying the session began here.
|
||||
r.frames[r.start] = frame
|
||||
r.start = (r.start + 1) % n
|
||||
r.dropped++
|
||||
}
|
||||
|
||||
// snapshot returns the buffered frames oldest-first.
|
||||
//
|
||||
// The slice is fresh but the frames are shared: they are never mutated after
|
||||
// being pushed, so readers may hold them without copying.
|
||||
func (r *ring) snapshot() []json.RawMessage {
|
||||
out := make([]json.RawMessage, 0, r.size)
|
||||
n := len(r.frames)
|
||||
for i := 0; i < r.size; i++ {
|
||||
out = append(out, r.frames[(r.start+i)%n])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *ring) reset() {
|
||||
r.start = 0
|
||||
r.size = 0
|
||||
r.dropped = 0
|
||||
for i := range r.frames {
|
||||
r.frames[i] = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func frames(r *ring) []string {
|
||||
out := make([]string, 0, r.size)
|
||||
for _, f := range r.snapshot() {
|
||||
out = append(out, string(f))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestRingKeepsTheMostRecentFrames(t *testing.T) {
|
||||
r := newRing(3)
|
||||
if got := r.snapshot(); len(got) != 0 {
|
||||
t.Fatalf("empty ring snapshot = %v", got)
|
||||
}
|
||||
|
||||
for i := 1; i <= 5; i++ {
|
||||
r.push(json.RawMessage(fmt.Sprintf(`{"n":%d}`, i)))
|
||||
}
|
||||
|
||||
// A browser opening mid-session wants the end of the transcript, not the
|
||||
// beginning, so the oldest frames are what fall off.
|
||||
want := []string{`{"n":3}`, `{"n":4}`, `{"n":5}`}
|
||||
got := frames(r)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("snapshot = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("snapshot = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The count of dropped frames is shown in the UI, so a viewer knows the
|
||||
// transcript starts mid-stream rather than at the beginning of the session.
|
||||
if r.dropped != 2 {
|
||||
t.Fatalf("dropped = %d, want 2", r.dropped)
|
||||
}
|
||||
if r.size != 3 {
|
||||
t.Fatalf("size = %d, want 3", r.size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRingSnapshotIsOrderedAcrossTheWrap(t *testing.T) {
|
||||
r := newRing(4)
|
||||
for i := 1; i <= 4; i++ {
|
||||
r.push(json.RawMessage(fmt.Sprintf(`%d`, i)))
|
||||
}
|
||||
// Exactly full: no wrap yet.
|
||||
if got := frames(r); got[0] != "1" || got[3] != "4" {
|
||||
t.Fatalf("full ring = %v", got)
|
||||
}
|
||||
|
||||
r.push(json.RawMessage(`5`))
|
||||
got := frames(r)
|
||||
// Replay is only useful if it is in order; an off-by-one at the wrap point
|
||||
// would show the transcript scrambled rather than truncated.
|
||||
for i, want := range []string{"2", "3", "4", "5"} {
|
||||
if got[i] != want {
|
||||
t.Fatalf("after wrap = %v, want [2 3 4 5]", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRingReset(t *testing.T) {
|
||||
r := newRing(2)
|
||||
r.push(json.RawMessage(`1`))
|
||||
r.push(json.RawMessage(`2`))
|
||||
r.push(json.RawMessage(`3`))
|
||||
r.reset()
|
||||
if r.size != 0 || r.dropped != 0 || len(r.snapshot()) != 0 {
|
||||
t.Fatalf("reset left size=%d dropped=%d len=%d", r.size, r.dropped, len(r.snapshot()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
// Package state owns everything grok-glance keeps across restarts.
|
||||
//
|
||||
// That is deliberately very little: the TOTP secret, the hashes of issued API
|
||||
// keys, the bootstrap token's hash, and the key used to sign session cookies.
|
||||
// Transcripts are not here and never will be -- they live in a bounded
|
||||
// in-memory ring per connected agent and are gone when the process exits. A
|
||||
// control plane that records everything an agent ever said is a much larger
|
||||
// security promise than this one is prepared to keep.
|
||||
//
|
||||
// The whole file is rewritten atomically under a mutex on every change. It is a
|
||||
// few kilobytes at most and changes a handful of times per install, so a
|
||||
// database would buy nothing and cost a migration story.
|
||||
package state
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Version of the on-disk format. Bump only for incompatible changes; unknown
|
||||
// higher versions are refused rather than silently reinterpreted.
|
||||
const Version = 1
|
||||
|
||||
// ErrFutureVersion means the state file was written by a newer glance.
|
||||
var ErrFutureVersion = errors.New("state file was written by a newer grok-glance")
|
||||
|
||||
// TOTP is the enrolled authenticator. Exactly one exists once setup completes.
|
||||
type TOTP struct {
|
||||
Secret string `json:"secret"`
|
||||
Issuer string `json:"issuer"`
|
||||
Account string `json:"account"`
|
||||
EnrolledAt time.Time `json:"enrolled_at"`
|
||||
}
|
||||
|
||||
// Bootstrap is the one-time token that gates /setup.
|
||||
//
|
||||
// Only its hash is stored. Without this gate, whoever loads /setup first
|
||||
// becomes the admin -- including anyone who finds the port before the operator
|
||||
// does. Requiring a token printed on the server's own stdout closes that
|
||||
// window.
|
||||
type Bootstrap struct {
|
||||
Hash string `json:"hash"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UsedAt *time.Time `json:"used_at,omitempty"`
|
||||
}
|
||||
|
||||
// Used reports whether enrollment has already consumed this token.
|
||||
func (b *Bootstrap) Used() bool { return b != nil && b.UsedAt != nil }
|
||||
|
||||
// APIKey is one credential a grok instance uses to dial in. Only the hash is
|
||||
// stored: a leaked state file must not yield working keys.
|
||||
type APIKey struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Hash string `json:"hash"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastSeen *time.Time `json:"last_seen,omitempty"`
|
||||
}
|
||||
|
||||
type data struct {
|
||||
Version int `json:"version"`
|
||||
TOTP *TOTP `json:"totp,omitempty"`
|
||||
Bootstrap *Bootstrap `json:"bootstrap,omitempty"`
|
||||
SessionKey string `json:"session_key"`
|
||||
APIKeys []APIKey `json:"api_keys"`
|
||||
}
|
||||
|
||||
// Store is the process-wide handle on the state file.
|
||||
//
|
||||
// The file is shared with a second process more often than it looks: `glance
|
||||
// apikey add` and `glance bootstrap` run against the state directory of a server
|
||||
// that is already up. So the in-memory copy is a cache of the file, not the
|
||||
// authority — see refreshLocked.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
d data
|
||||
stamp fileStamp
|
||||
}
|
||||
|
||||
// fileStamp is how the store notices someone else wrote the file. Modtime and
|
||||
// size are not a strong identity, but the alternative — re-reading and parsing
|
||||
// on every cookie check — costs more than it is worth for a file that changes a
|
||||
// handful of times per install.
|
||||
type fileStamp struct {
|
||||
mod time.Time
|
||||
size int64
|
||||
}
|
||||
|
||||
// DefaultDir is where glance keeps its files. It sits alongside grok's own
|
||||
// config so an operator has one directory to back up and one to lock down.
|
||||
func DefaultDir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".grok", "glance"), nil
|
||||
}
|
||||
|
||||
// Open loads the store at dir, creating a fresh one if absent.
|
||||
//
|
||||
// Pre-existing files in the directory (grok's own `secret.key`, `hook.secret`)
|
||||
// are neither read nor touched: glance owns exactly `state.json` and
|
||||
// `bootstrap.token`.
|
||||
func Open(dir string) (*Store, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create %s: %w", dir, err)
|
||||
}
|
||||
s := &Store{path: filepath.Join(dir, "state.json")}
|
||||
|
||||
raw, err := os.ReadFile(s.path)
|
||||
switch {
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
key, err := randomBytes(32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.d = data{
|
||||
Version: Version,
|
||||
SessionKey: base64.StdEncoding.EncodeToString(key),
|
||||
APIKeys: []APIKey{},
|
||||
}
|
||||
if err := s.persistLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("read %s: %w", s.path, err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(raw, &s.d); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", s.path, err)
|
||||
}
|
||||
if s.d.Version > Version {
|
||||
return nil, fmt.Errorf("%w: found v%d, this build understands v%d",
|
||||
ErrFutureVersion, s.d.Version, Version)
|
||||
}
|
||||
if s.d.SessionKey == "" {
|
||||
key, err := randomBytes(32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.d.SessionKey = base64.StdEncoding.EncodeToString(key)
|
||||
if err := s.persistLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
s.stampLocked()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Path is the state file's location, for error messages and `glance version`.
|
||||
func (s *Store) Path() string { return s.path }
|
||||
|
||||
// SessionKey is the HMAC key for session cookies. Rotating it (by deleting the
|
||||
// state file) invalidates every outstanding cookie, which is the intended
|
||||
// panic button.
|
||||
//
|
||||
// This is the one accessor that does not consult the file first: it runs on
|
||||
// every authenticated request, and the key is written once at Open and never
|
||||
// again by any command. A refresh from a neighbouring call picks up a
|
||||
// hand-replaced file soon enough.
|
||||
func (s *Store) SessionKey() []byte {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
key, _ := base64.StdEncoding.DecodeString(s.d.SessionKey)
|
||||
return key
|
||||
}
|
||||
|
||||
// Enrolled reports whether a TOTP authenticator exists. Until it does, the
|
||||
// whole UI is closed except /setup.
|
||||
func (s *Store) Enrolled() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.refreshLocked()
|
||||
return s.d.TOTP != nil
|
||||
}
|
||||
|
||||
// TOTPSecret returns the enrolled secret, or "" if setup has not run.
|
||||
func (s *Store) TOTPSecret() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.refreshLocked()
|
||||
if s.d.TOTP == nil {
|
||||
return ""
|
||||
}
|
||||
return s.d.TOTP.Secret
|
||||
}
|
||||
|
||||
// EnrollTOTP persists the authenticator and burns the bootstrap token in one
|
||||
// write, so a crash cannot leave a usable token behind an enrolled server.
|
||||
func (s *Store) EnrollTOTP(secret, issuer, account string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.refreshLocked()
|
||||
if s.d.TOTP != nil {
|
||||
return errors.New("an authenticator is already enrolled")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
s.d.TOTP = &TOTP{Secret: secret, Issuer: issuer, Account: account, EnrolledAt: now}
|
||||
if s.d.Bootstrap != nil {
|
||||
s.d.Bootstrap.UsedAt = &now
|
||||
}
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
// NewBootstrapToken mints a token, stores its hash, and returns the plaintext
|
||||
// exactly once. Calling it again replaces any unused token.
|
||||
func (s *Store) NewBootstrapToken() (string, error) {
|
||||
raw, err := randomBytes(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(raw)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.refreshLocked()
|
||||
s.d.Bootstrap = &Bootstrap{Hash: hashString(token), CreatedAt: time.Now().UTC()}
|
||||
if err := s.persistLocked(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// BootstrapValid reports whether token matches the live, unused bootstrap
|
||||
// token. Comparison is constant-time; an unset or already-used token is never
|
||||
// valid, which is what makes /setup 404 after enrollment.
|
||||
func (s *Store) BootstrapValid(token string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.refreshLocked()
|
||||
if token == "" || s.d.Bootstrap == nil || s.d.Bootstrap.Used() {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(hashString(token)), []byte(s.d.Bootstrap.Hash)) == 1
|
||||
}
|
||||
|
||||
// BootstrapPending reports whether an unused token exists, for the CLI's
|
||||
// startup banner.
|
||||
func (s *Store) BootstrapPending() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.refreshLocked()
|
||||
return s.d.Bootstrap != nil && !s.d.Bootstrap.Used()
|
||||
}
|
||||
|
||||
// AddAPIKey mints a key for one grok instance and returns the plaintext once.
|
||||
func (s *Store) AddAPIKey(name string) (string, APIKey, error) {
|
||||
raw, err := randomBytes(32)
|
||||
if err != nil {
|
||||
return "", APIKey{}, err
|
||||
}
|
||||
id, err := randomBytes(8)
|
||||
if err != nil {
|
||||
return "", APIKey{}, err
|
||||
}
|
||||
plaintext := "glance_sk_" + base64.RawURLEncoding.EncodeToString(raw)
|
||||
key := APIKey{
|
||||
ID: hex.EncodeToString(id),
|
||||
Name: name,
|
||||
Hash: hashString(plaintext),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.d.APIKeys = append(s.d.APIKeys, key)
|
||||
if err := s.persistLocked(); err != nil {
|
||||
return "", APIKey{}, err
|
||||
}
|
||||
return plaintext, key, nil
|
||||
}
|
||||
|
||||
// LookupAPIKey resolves a presented key to its record, or nil.
|
||||
//
|
||||
// Every stored hash is compared even after a match, so the time taken does not
|
||||
// reveal which key matched or how many are configured.
|
||||
func (s *Store) LookupAPIKey(plaintext string) *APIKey {
|
||||
if plaintext == "" {
|
||||
return nil
|
||||
}
|
||||
want := []byte(hashString(plaintext))
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.refreshLocked()
|
||||
var found *APIKey
|
||||
for i := range s.d.APIKeys {
|
||||
if subtle.ConstantTimeCompare(want, []byte(s.d.APIKeys[i].Hash)) == 1 {
|
||||
key := s.d.APIKeys[i]
|
||||
found = &key
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// TouchAPIKey records a successful connection. Best-effort: a failed write
|
||||
// must not reject an otherwise-valid agent.
|
||||
func (s *Store) TouchAPIKey(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.refreshLocked()
|
||||
now := time.Now().UTC()
|
||||
for i := range s.d.APIKeys {
|
||||
if s.d.APIKeys[i].ID == id {
|
||||
s.d.APIKeys[i].LastSeen = &now
|
||||
_ = s.persistLocked()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ListAPIKeys returns the key records for display, with hashes stripped.
|
||||
//
|
||||
// Callers only ever print names and dates, so handing them the hash would be
|
||||
// giving away material for an offline guess in exchange for nothing. Stripping
|
||||
// it here makes that a property of the API rather than a rule callers must know.
|
||||
func (s *Store) ListAPIKeys() []APIKey {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.refreshLocked()
|
||||
out := make([]APIKey, len(s.d.APIKeys))
|
||||
copy(out, s.d.APIKeys)
|
||||
for i := range out {
|
||||
out[i].Hash = ""
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RemoveAPIKey deletes by id or exact name. Returns whether anything matched.
|
||||
func (s *Store) RemoveAPIKey(idOrName string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.refreshLocked()
|
||||
kept := s.d.APIKeys[:0:0]
|
||||
removed := false
|
||||
for _, k := range s.d.APIKeys {
|
||||
if k.ID == idOrName || k.Name == idOrName {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, k)
|
||||
}
|
||||
if !removed {
|
||||
return false, nil
|
||||
}
|
||||
s.d.APIKeys = kept
|
||||
return true, s.persistLocked()
|
||||
}
|
||||
|
||||
// refreshLocked re-reads the file when another process has written it.
|
||||
//
|
||||
// `glance apikey add` runs while the server is up, and without this the new key
|
||||
// would be invisible twice over: the server would keep serving its startup
|
||||
// snapshot, and its next write would persist that snapshot back over the CLI's
|
||||
// addition. Treating the file as the source of truth whenever its stamp moves
|
||||
// fixes both directions, and reduces the remaining race to two processes writing
|
||||
// in the same instant — which for a one-operator control plane is not a race
|
||||
// worth a lock file.
|
||||
//
|
||||
// A read failure is deliberately silent: the in-memory copy is still the best
|
||||
// answer available, and refusing to authenticate an agent because a stat failed
|
||||
// would be a worse outcome than serving slightly stale keys.
|
||||
func (s *Store) refreshLocked() {
|
||||
info, err := os.Stat(s.path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if info.ModTime().Equal(s.stamp.mod) && info.Size() == s.stamp.size {
|
||||
return
|
||||
}
|
||||
raw, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var fresh data
|
||||
if err := json.Unmarshal(raw, &fresh); err != nil {
|
||||
return
|
||||
}
|
||||
if fresh.Version > Version || fresh.SessionKey == "" {
|
||||
// A file we do not understand, or one still being written. Keep what we
|
||||
// have rather than signing cookies with a half-read key.
|
||||
return
|
||||
}
|
||||
s.d = fresh
|
||||
s.stamp = fileStamp{mod: info.ModTime(), size: info.Size()}
|
||||
}
|
||||
|
||||
// stampLocked records the file as we last left it, so our own writes do not look
|
||||
// like somebody else's.
|
||||
func (s *Store) stampLocked() {
|
||||
if info, err := os.Stat(s.path); err == nil {
|
||||
s.stamp = fileStamp{mod: info.ModTime(), size: info.Size()}
|
||||
}
|
||||
}
|
||||
|
||||
// persistLocked writes via a temp file + rename, so a crash mid-write leaves
|
||||
// the previous state intact rather than a truncated file that would lock the
|
||||
// operator out of their own server.
|
||||
func (s *Store) persistLocked() error {
|
||||
s.d.Version = Version
|
||||
raw, err := json.MarshalIndent(s.d, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(s.path)
|
||||
tmp, err := os.CreateTemp(dir, ".state-*.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.Write(raw); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, s.path); err != nil {
|
||||
return err
|
||||
}
|
||||
s.stampLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
func hashString(v string) string {
|
||||
sum := sha256.Sum256([]byte(v))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func randomBytes(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, fmt.Errorf("read random bytes: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func open(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
store, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func TestOpenCreatesPrivateStateAndSurvivesReopen(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(store.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("state file not written: %v", err)
|
||||
}
|
||||
// The file holds the TOTP secret and the cookie-signing key. Group- or
|
||||
// world-readable would make every other precaution in this package pointless.
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Fatalf("state file mode = %o, want 600", perm)
|
||||
}
|
||||
|
||||
key := append([]byte(nil), store.SessionKey()...)
|
||||
|
||||
reopened, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
if string(reopened.SessionKey()) != string(key) {
|
||||
t.Fatal("session key changed across reopen; every cookie would be invalidated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRefusesAFutureVersion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "state.json")
|
||||
blob, _ := json.Marshal(map[string]any{"version": Version + 1})
|
||||
if err := os.WriteFile(path, blob, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Silently "upgrading" a file we do not understand would drop fields a newer
|
||||
// build wrote. Refusing keeps a downgrade from being destructive.
|
||||
if _, err := Open(dir); err == nil {
|
||||
t.Fatal("expected a refusal for a newer state file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrollmentBurnsTheBootstrapToken(t *testing.T) {
|
||||
store := open(t)
|
||||
|
||||
if store.Enrolled() {
|
||||
t.Fatal("a fresh store should not be enrolled")
|
||||
}
|
||||
token, err := store.NewBootstrapToken()
|
||||
if err != nil {
|
||||
t.Fatalf("NewBootstrapToken: %v", err)
|
||||
}
|
||||
if !store.BootstrapValid(token) {
|
||||
t.Fatal("a freshly minted token should be valid")
|
||||
}
|
||||
if store.BootstrapValid(token + "x") {
|
||||
t.Fatal("a wrong token was accepted")
|
||||
}
|
||||
|
||||
if err := store.EnrollTOTP("SECRET", "grok-glance", "operator"); err != nil {
|
||||
t.Fatalf("EnrollTOTP: %v", err)
|
||||
}
|
||||
if !store.Enrolled() {
|
||||
t.Fatal("Enrolled should report true after enrollment")
|
||||
}
|
||||
// This is the whole point of the gate: once setup succeeds, the token that
|
||||
// opened it must never open it again.
|
||||
if store.BootstrapValid(token) {
|
||||
t.Fatal("the bootstrap token still works after enrollment")
|
||||
}
|
||||
if store.BootstrapPending() {
|
||||
t.Fatal("BootstrapPending should be false after enrollment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapStateOutlivesTheProcess(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := store.NewBootstrapToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.EnrollTOTP("SECRET", "grok-glance", "operator"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A restart must not reopen the enrollment window.
|
||||
reopened, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reopened.BootstrapValid(token) {
|
||||
t.Fatal("a spent bootstrap token came back after a restart")
|
||||
}
|
||||
if !reopened.Enrolled() {
|
||||
t.Fatal("enrollment did not persist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeyLifecycle(t *testing.T) {
|
||||
store := open(t)
|
||||
|
||||
plaintext, key, err := store.AddAPIKey("laptop")
|
||||
if err != nil {
|
||||
t.Fatalf("AddAPIKey: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(plaintext, "glance_sk_") {
|
||||
t.Fatalf("key %q lacks the glance_sk_ prefix that makes it greppable in a leak", plaintext)
|
||||
}
|
||||
|
||||
// Only the hash is kept, so a stolen state.json does not yield usable keys.
|
||||
blob, err := os.ReadFile(store.Path())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(blob), plaintext) {
|
||||
t.Fatal("the plaintext API key was written to disk")
|
||||
}
|
||||
|
||||
found := store.LookupAPIKey(plaintext)
|
||||
if found == nil || found.ID != key.ID {
|
||||
t.Fatalf("LookupAPIKey did not find the key it just minted")
|
||||
}
|
||||
if store.LookupAPIKey("glance_sk_nonsense") != nil {
|
||||
t.Fatal("an unknown key was accepted")
|
||||
}
|
||||
if store.LookupAPIKey("") != nil {
|
||||
t.Fatal("an empty key was accepted")
|
||||
}
|
||||
|
||||
store.TouchAPIKey(key.ID)
|
||||
keys := store.ListAPIKeys()
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("ListAPIKeys returned %d keys, want 1", len(keys))
|
||||
}
|
||||
if keys[0].LastSeen == nil {
|
||||
t.Fatal("TouchAPIKey did not record a last-seen time")
|
||||
}
|
||||
if keys[0].Hash != "" {
|
||||
t.Fatal("ListAPIKeys leaked the stored hash to its caller")
|
||||
}
|
||||
|
||||
removed, err := store.RemoveAPIKey("laptop")
|
||||
if err != nil || !removed {
|
||||
t.Fatalf("RemoveAPIKey by name: removed=%v err=%v", removed, err)
|
||||
}
|
||||
if store.LookupAPIKey(plaintext) != nil {
|
||||
t.Fatal("a revoked key still authenticates")
|
||||
}
|
||||
removed, err = store.RemoveAPIKey("laptop")
|
||||
if err != nil || removed {
|
||||
t.Fatalf("removing a missing key should be a no-op: removed=%v err=%v", removed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeysAreDistinct(t *testing.T) {
|
||||
store := open(t)
|
||||
seen := make(map[string]bool)
|
||||
for i := 0; i < 16; i++ {
|
||||
plaintext, _, err := store.AddAPIKey("k")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen[plaintext] {
|
||||
t.Fatal("AddAPIKey repeated a key")
|
||||
}
|
||||
seen[plaintext] = true
|
||||
}
|
||||
}
|
||||
|
||||
// `glance apikey add` runs in a second process against the state directory of a
|
||||
// server that is already up. The server has to see that key without a restart,
|
||||
// and must not persist its own older snapshot over it afterwards -- so this
|
||||
// exercises both directions with two Stores on one file.
|
||||
func TestASecondProcessCanAddKeysToARunningServer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
server, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Open server: %v", err)
|
||||
}
|
||||
existing, _, err := server.AddAPIKey("first")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cli, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Open cli: %v", err)
|
||||
}
|
||||
added, _, err := cli.AddAPIKey("added-while-running")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if server.LookupAPIKey(added) == nil {
|
||||
t.Fatal("the running server does not see a key added by the CLI")
|
||||
}
|
||||
|
||||
// The server writing afterwards must not resurrect its startup snapshot.
|
||||
server.TouchAPIKey(server.LookupAPIKey(added).ID)
|
||||
reopened, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
if reopened.LookupAPIKey(added) == nil {
|
||||
t.Fatal("a later server write dropped the CLI's key")
|
||||
}
|
||||
if reopened.LookupAPIKey(existing) == nil {
|
||||
t.Fatal("the CLI's write dropped the server's earlier key")
|
||||
}
|
||||
if got := len(reopened.ListAPIKeys()); got != 2 {
|
||||
t.Fatalf("keys = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The same hazard for `glance bootstrap`: a token minted while the server is up
|
||||
// has to be accepted by that server.
|
||||
func TestASecondProcessCanMintABootstrapToken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
server, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if server.BootstrapPending() {
|
||||
t.Fatal("a fresh store should have no pending token")
|
||||
}
|
||||
|
||||
cli, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := cli.NewBootstrapToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !server.BootstrapPending() {
|
||||
t.Fatal("the running server does not see the new token as pending")
|
||||
}
|
||||
if !server.BootstrapValid(token) {
|
||||
t.Fatal("the running server rejects a token the CLI just minted")
|
||||
}
|
||||
}
|
||||
Vendored
@@ -0,0 +1,33 @@
|
||||
// Package web carries the built frontend into the binary.
|
||||
//
|
||||
// The embed directive points at `dist/`, which is Vite's output. That directory
|
||||
// is checked in with only a `.gitkeep` so a clean clone still compiles: Go
|
||||
// resolves `//go:embed` at build time and would fail outright on a missing path,
|
||||
// which would mean `go build ./...` could not run until someone had installed
|
||||
// npm. Assets returns an error in that state instead, and the server falls back
|
||||
// to a placeholder page telling the operator to run `make web`.
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var embedded embed.FS
|
||||
|
||||
// ErrNotBuilt means the binary was built without running the frontend build.
|
||||
var ErrNotBuilt = errors.New("web UI not built; run `make web`")
|
||||
|
||||
// Assets returns the frontend rooted at index.html.
|
||||
func Assets() (fs.FS, error) {
|
||||
dist, err := fs.Sub(embedded, "dist")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := fs.Stat(dist, "index.html"); err != nil {
|
||||
return nil, ErrNotBuilt
|
||||
}
|
||||
return dist, nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<!--
|
||||
No favicon link: the server serves only what Vite emits, and a missing
|
||||
/favicon.ico would fall through to index.html and be logged as a failed
|
||||
image decode on every load.
|
||||
-->
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>grok-glance</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2440
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "grok-glance-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroui/react": "3.2.4",
|
||||
"@react-aria/i18n": "^3.13.1",
|
||||
"@react-aria/ssr": "^3.10.1",
|
||||
"@react-aria/utils": "^3.34.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.0",
|
||||
"react-aria": "^3.51.0",
|
||||
"react-aria-components": "^1.20.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^8.2.1"
|
||||
}
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Alert, Button, Spinner, useTheme } from "@heroui/react";
|
||||
import { ApiError, api, type AgentSummary, type Status } from "./lib/api";
|
||||
import {
|
||||
applyFrame,
|
||||
applyFrames,
|
||||
appendNotice,
|
||||
emptyTranscript,
|
||||
type Transcript,
|
||||
} from "./lib/acp";
|
||||
import {
|
||||
GlanceSocket,
|
||||
interactionKey,
|
||||
type ConnectionState,
|
||||
type Interaction,
|
||||
type ServerEvent,
|
||||
} from "./lib/ws";
|
||||
import { Login } from "./pages/Login";
|
||||
import { Session } from "./pages/Session";
|
||||
import { Sessions } from "./pages/Sessions";
|
||||
import { Setup } from "./pages/Setup";
|
||||
|
||||
/** `#/a/<agent-id>` selects a session; anything else is the list. */
|
||||
function agentFromHash(): string | null {
|
||||
const match = /^#\/a\/(.+)$/.exec(window.location.hash);
|
||||
const id = match?.[1];
|
||||
return id ? decodeURIComponent(id) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in application: one socket, one agent list, one open session.
|
||||
*
|
||||
* The socket is owned here rather than in the session page so that navigating
|
||||
* between the list and a session neither drops the connection nor re-requests a
|
||||
* snapshot — and so the agent list keeps updating while a session is open.
|
||||
*/
|
||||
function Console({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
const [connection, setConnection] = useState<ConnectionState>("connecting");
|
||||
const [agents, setAgents] = useState<AgentSummary[]>([]);
|
||||
const [selected, setSelected] = useState<string | null>(() => agentFromHash());
|
||||
const [transcript, setTranscript] = useState<Transcript>(() => emptyTranscript());
|
||||
const [interactions, setInteractions] = useState<Interaction[]>([]);
|
||||
|
||||
const socket = useRef<GlanceSocket | null>(null);
|
||||
|
||||
// Latest-value ref: the socket's handlers are installed once, but they have to
|
||||
// test events against whichever session is open *now*.
|
||||
const selectedRef = useRef(selected);
|
||||
selectedRef.current = selected;
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => setSelected(agentFromHash());
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
return () => window.removeEventListener("hashchange", onHashChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onEvent = (event: ServerEvent) => {
|
||||
switch (event.type) {
|
||||
case "agents":
|
||||
setAgents(event.agents ?? []);
|
||||
break;
|
||||
|
||||
case "snapshot": {
|
||||
if (event.agents) setAgents(event.agents);
|
||||
if (event.agent !== selectedRef.current) break;
|
||||
// A snapshot is authoritative: it replaces local state rather than
|
||||
// merging into it, which is what makes a reload or a reconnect land
|
||||
// on exactly the server's view instead of a half-stale one.
|
||||
setTranscript(
|
||||
applyFrames(
|
||||
emptyTranscript(event.dropped ?? 0, event.turnActive ?? false),
|
||||
event.frames ?? [],
|
||||
),
|
||||
);
|
||||
setInteractions(event.open ?? []);
|
||||
break;
|
||||
}
|
||||
|
||||
case "frame":
|
||||
if (event.agent !== selectedRef.current) break;
|
||||
setTranscript((current) => applyFrame(current, event.frame));
|
||||
break;
|
||||
|
||||
case "interaction": {
|
||||
if (event.agent !== selectedRef.current) break;
|
||||
const key = interactionKey(event.interaction.id);
|
||||
setInteractions((current) =>
|
||||
current.some((item) => interactionKey(item.id) === key)
|
||||
? current
|
||||
: [...current, event.interaction],
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "interaction_resolved": {
|
||||
if (event.agent !== selectedRef.current) break;
|
||||
const resolved = event.id;
|
||||
setInteractions((current) =>
|
||||
current.filter((item) => interactionKey(item.id) !== resolved),
|
||||
);
|
||||
// Losing the race is normal, not an error — but it must be visible,
|
||||
// or a card vanishing under your finger looks like a bug.
|
||||
const message = event.message;
|
||||
if (event.by === "elsewhere" && message) {
|
||||
setTranscript((current) => appendNotice(current, message));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "notice":
|
||||
if (event.agent && event.agent !== selectedRef.current) break;
|
||||
setTranscript((current) => appendNotice(current, event.message));
|
||||
break;
|
||||
|
||||
case "error":
|
||||
setTranscript((current) => appendNotice(current, event.message));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const onState = (state: ConnectionState) => {
|
||||
setConnection(state);
|
||||
if (state !== "closed") return;
|
||||
// A rejected upgrade and an unreachable server close identically, so the
|
||||
// only honest way to tell an expired session from a restart is to ask.
|
||||
api
|
||||
.status()
|
||||
.then((status) => {
|
||||
if (!status.authenticated) onSignedOut();
|
||||
})
|
||||
.catch(() => {
|
||||
/* server is down; the socket's own backoff handles it */
|
||||
});
|
||||
};
|
||||
|
||||
const instance = new GlanceSocket({ onEvent, onState });
|
||||
socket.current = instance;
|
||||
instance.start();
|
||||
return () => {
|
||||
instance.stop();
|
||||
socket.current = null;
|
||||
};
|
||||
}, [onSignedOut]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) {
|
||||
socket.current?.clearSubscription();
|
||||
return;
|
||||
}
|
||||
setTranscript(emptyTranscript());
|
||||
setInteractions([]);
|
||||
socket.current?.subscribe(selected);
|
||||
}, [selected]);
|
||||
|
||||
const signOut = useCallback(() => {
|
||||
void api.logout().finally(onSignedOut);
|
||||
}, [onSignedOut]);
|
||||
|
||||
if (!selected) {
|
||||
return (
|
||||
<Sessions
|
||||
agents={agents}
|
||||
connection={connection}
|
||||
onOpen={(id) => {
|
||||
window.location.hash = `#/a/${encodeURIComponent(id)}`;
|
||||
}}
|
||||
onSignOut={signOut}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const agent = agents.find((candidate) => candidate.id === selected);
|
||||
|
||||
return (
|
||||
<Session
|
||||
agent={agent}
|
||||
transcript={transcript}
|
||||
interactions={interactions}
|
||||
connection={connection}
|
||||
onBack={() => {
|
||||
window.location.hash = "#/";
|
||||
}}
|
||||
onPrompt={(text) => socket.current?.send({ type: "prompt", agent: selected, text })}
|
||||
onCancel={() => socket.current?.send({ type: "cancel", agent: selected })}
|
||||
onAnswer={(interaction, result) =>
|
||||
socket.current?.send({
|
||||
type: "answer",
|
||||
agent: selected,
|
||||
id: interactionKey(interaction.id),
|
||||
result,
|
||||
})
|
||||
}
|
||||
onDecline={(interaction, reason) =>
|
||||
socket.current?.send({
|
||||
type: "decline",
|
||||
agent: selected,
|
||||
id: interactionKey(interaction.id),
|
||||
reason,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
// Called once, at the root: each call owns its own state, so a second call
|
||||
// elsewhere would give a toggle that only half the app agrees with. "system"
|
||||
// means the page follows the OS, which is the right default for something
|
||||
// read on a phone at night.
|
||||
useTheme("system");
|
||||
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [fatal, setFatal] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
api
|
||||
.status()
|
||||
.then((next) => {
|
||||
setStatus(next);
|
||||
setFatal(null);
|
||||
})
|
||||
.catch((cause: unknown) => {
|
||||
setFatal(cause instanceof ApiError ? cause.message : "could not reach the glance server");
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(refresh, [refresh]);
|
||||
|
||||
if (fatal) {
|
||||
return (
|
||||
<div className="min-h-full flex items-center justify-center p-4">
|
||||
<div className="max-w-sm w-full space-y-3">
|
||||
<Alert status="danger">
|
||||
<Alert.Content>
|
||||
<Alert.Title>Cannot reach glance</Alert.Title>
|
||||
<Alert.Description>{fatal}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
<Button fullWidth variant="outline" onPress={refresh}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="min-h-full flex items-center justify-center">
|
||||
<Spinner color="accent" aria-label="loading" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status.enrolled) return <Setup onDone={refresh} />;
|
||||
if (!status.authenticated) return <Login onDone={refresh} />;
|
||||
return <Console onSignedOut={refresh} />;
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* The three interactions grok can block a turn on, rendered for the browser.
|
||||
*
|
||||
* These are *cards*, not modals. An interaction can be answered in the terminal
|
||||
* at any moment and will then vanish from here mid-read; a modal that steals
|
||||
* focus and then closes itself is far more jarring than a card that quietly
|
||||
* disappears from a tray. Cards also stack, which matters because more than one
|
||||
* can be open at once.
|
||||
*
|
||||
* Every response shape below is verbatim from grok's own wire types — the
|
||||
* agent deserializes into a typed struct and a near-miss is a hard error, so
|
||||
* these are not places to improvise:
|
||||
*
|
||||
* session/request_permission -> acp::RequestPermissionResponse
|
||||
* x.ai/ask_user_question -> AskUserQuestionExtResponse (tagged "outcome")
|
||||
* x.ai/exit_plan_mode -> ExitPlanModeExtResponse ({outcome, feedback?})
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
Description,
|
||||
Label,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
TextArea,
|
||||
TextField,
|
||||
} from "@heroui/react";
|
||||
import {
|
||||
METHOD_ASK_USER_QUESTION,
|
||||
METHOD_EXIT_PLAN_MODE,
|
||||
METHOD_REQUEST_PERMISSION,
|
||||
type AskUserQuestionParams,
|
||||
type ExitPlanModeParams,
|
||||
type Question,
|
||||
type RequestPermissionParams,
|
||||
} from "../lib/acp";
|
||||
import type { Interaction } from "../lib/ws";
|
||||
import { ToolCall } from "./ToolCall";
|
||||
|
||||
const OPTION_VARIANT: Record<string, "primary" | "secondary" | "danger" | "danger-soft"> = {
|
||||
allow_once: "primary",
|
||||
allow_always: "secondary",
|
||||
reject_once: "danger-soft",
|
||||
reject_always: "danger",
|
||||
};
|
||||
|
||||
function isMulti(question: Question): boolean {
|
||||
return question.multiSelect ?? question.multi_select ?? false;
|
||||
}
|
||||
|
||||
function Shell({
|
||||
title,
|
||||
hint,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string;
|
||||
children?: React.ReactNode;
|
||||
footer: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="border border-warning">
|
||||
<Card.Header>
|
||||
<Card.Title className="text-sm">{title}</Card.Title>
|
||||
{hint ? <Card.Description className="text-xs">{hint}</Card.Description> : null}
|
||||
</Card.Header>
|
||||
{children ? <Card.Content className="space-y-3">{children}</Card.Content> : null}
|
||||
<Card.Footer className="flex flex-wrap gap-2 justify-end">{footer}</Card.Footer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// session/request_permission
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PermissionRequest({
|
||||
params,
|
||||
onAnswer,
|
||||
disabled,
|
||||
}: {
|
||||
params: RequestPermissionParams;
|
||||
onAnswer: (result: unknown) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const options = params.options ?? [];
|
||||
return (
|
||||
<Shell
|
||||
title="Permission needed"
|
||||
hint="answering here also closes the prompt in the terminal"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={disabled}
|
||||
onPress={() => onAnswer({ outcome: { outcome: "cancelled" } })}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.optionId}
|
||||
size="sm"
|
||||
variant={OPTION_VARIANT[option.kind ?? ""] ?? "outline"}
|
||||
isDisabled={disabled}
|
||||
onPress={() =>
|
||||
onAnswer({ outcome: { outcome: "selected", optionId: option.optionId } })
|
||||
}
|
||||
>
|
||||
{option.name}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{params.toolCall ? <ToolCall call={params.toolCall} /> : null}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// x.ai/exit_plan_mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ExitPlanMode({
|
||||
params,
|
||||
onAnswer,
|
||||
disabled,
|
||||
}: {
|
||||
params: ExitPlanModeParams;
|
||||
onAnswer: (result: unknown) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const trimmed = feedback.trim();
|
||||
|
||||
return (
|
||||
<Shell
|
||||
title="Plan ready for approval"
|
||||
hint="approve to let the agent start work, or send it back with notes"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger-soft"
|
||||
isDisabled={disabled}
|
||||
onPress={() => onAnswer({ outcome: "abandoned" })}
|
||||
>
|
||||
Abandon
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={disabled}
|
||||
onPress={() =>
|
||||
// `feedback` is only meaningful on "cancelled" — that is the one
|
||||
// path where the plan comes back for another round.
|
||||
onAnswer(trimmed ? { outcome: "cancelled", feedback: trimmed } : { outcome: "cancelled" })
|
||||
}
|
||||
>
|
||||
Keep planning
|
||||
</Button>
|
||||
<Button size="sm" isDisabled={disabled} onPress={() => onAnswer({ outcome: "approved" })}>
|
||||
Approve
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{params.planContent ? (
|
||||
// The plan is markdown. Rendering it properly would mean shipping a
|
||||
// markdown parser and sanitiser for text an agent wrote; showing the
|
||||
// source keeps it faithful and keeps the attack surface at zero.
|
||||
<div className="glance-prose text-sm max-h-96 overflow-auto rounded-md bg-surface-secondary p-3">
|
||||
{params.planContent}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted">the agent sent no plan text</div>
|
||||
)}
|
||||
|
||||
<TextField value={feedback} onChange={setFeedback} isDisabled={disabled} aria-label="feedback">
|
||||
<TextArea rows={2} placeholder="feedback (sent with “Keep planning”)" fullWidth />
|
||||
</TextField>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// x.ai/ask_user_question
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface QuestionState {
|
||||
labels: string[];
|
||||
other: boolean;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const EMPTY_ANSWER: QuestionState = { labels: [], other: false, notes: "" };
|
||||
|
||||
/**
|
||||
* Build the `accepted` payload exactly as the TUI does
|
||||
* (xai-grok-pager/src/views/question_view.rs).
|
||||
*
|
||||
* The rules that are easy to get wrong and that grok's formatter depends on:
|
||||
* unanswered questions are *omitted* rather than sent empty; the map is keyed by
|
||||
* the question text, in the original order; a freeform-only answer is the literal
|
||||
* `["Other"]` with the typed text in `annotations[q].notes`; and `preview` is
|
||||
* carried only for single-select questions.
|
||||
*/
|
||||
function buildAccepted(questions: Question[], states: QuestionState[]) {
|
||||
const answers: Record<string, string[]> = {};
|
||||
const annotations: Record<string, { preview?: string; notes?: string }> = {};
|
||||
|
||||
questions.forEach((question, index) => {
|
||||
const state = states[index] ?? EMPTY_ANSWER;
|
||||
const notes = state.other ? state.notes.trim() : "";
|
||||
const hasFreeform = state.other && notes !== "";
|
||||
if (state.labels.length === 0 && !hasFreeform) return;
|
||||
|
||||
answers[question.question] = state.labels.length > 0 ? state.labels : ["Other"];
|
||||
|
||||
const single = !isMulti(question);
|
||||
const selected = state.labels[0];
|
||||
const preview =
|
||||
single && state.labels.length === 1
|
||||
? question.options.find((option) => option.label === selected)?.preview
|
||||
: undefined;
|
||||
|
||||
if (preview || hasFreeform) {
|
||||
annotations[question.question] = {
|
||||
...(preview ? { preview } : {}),
|
||||
...(hasFreeform ? { notes } : {}),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return Object.keys(annotations).length > 0
|
||||
? { outcome: "accepted", answers, annotations }
|
||||
: { outcome: "accepted", answers };
|
||||
}
|
||||
|
||||
/** Plan-mode paths carry label-only partials; notes are dropped by design. */
|
||||
function buildPartial(questions: Question[], states: QuestionState[]) {
|
||||
const partial: Record<string, string> = {};
|
||||
questions.forEach((question, index) => {
|
||||
const state = states[index] ?? EMPTY_ANSWER;
|
||||
const first = state.labels[0];
|
||||
if (first) partial[question.question] = first;
|
||||
else if (state.other && state.notes.trim() !== "") partial[question.question] = "Other";
|
||||
});
|
||||
return partial;
|
||||
}
|
||||
|
||||
function QuestionCard({
|
||||
question,
|
||||
state,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
question: Question;
|
||||
state: QuestionState;
|
||||
onChange: (next: QuestionState) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const multi = isMulti(question);
|
||||
const preview =
|
||||
!multi && state.labels.length === 1
|
||||
? question.options.find((option) => option.label === state.labels[0])?.preview
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">{question.question}</div>
|
||||
|
||||
{multi ? (
|
||||
<CheckboxGroup
|
||||
aria-label={question.question}
|
||||
value={state.labels}
|
||||
onChange={(labels) => onChange({ ...state, labels })}
|
||||
isDisabled={disabled}
|
||||
className="gap-1"
|
||||
>
|
||||
{question.options.map((option) => (
|
||||
<Checkbox key={option.label} value={option.label}>
|
||||
<Checkbox.Content>
|
||||
<Checkbox.Control>
|
||||
<Checkbox.Indicator />
|
||||
</Checkbox.Control>
|
||||
<Label>{option.label}</Label>
|
||||
</Checkbox.Content>
|
||||
<Description>{option.description}</Description>
|
||||
</Checkbox>
|
||||
))}
|
||||
</CheckboxGroup>
|
||||
) : (
|
||||
<RadioGroup
|
||||
aria-label={question.question}
|
||||
value={state.labels[0] ?? ""}
|
||||
onChange={(label) => onChange({ ...state, labels: [label], other: false })}
|
||||
isDisabled={disabled}
|
||||
className="gap-1"
|
||||
>
|
||||
{question.options.map((option) => (
|
||||
<Radio key={option.label} value={option.label}>
|
||||
<Radio.Content>
|
||||
<Radio.Control>
|
||||
<Radio.Indicator />
|
||||
</Radio.Control>
|
||||
<Label>{option.label}</Label>
|
||||
</Radio.Content>
|
||||
<Description>{option.description}</Description>
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
|
||||
{preview ? <pre className="glance-pre p-3 rounded-md bg-surface-secondary overflow-auto max-h-64">{preview}</pre> : null}
|
||||
|
||||
{/* "Other" is not one of the model's options — it is the escape hatch the
|
||||
TUI also offers, and grok understands it by that exact spelling. */}
|
||||
<div className="space-y-1">
|
||||
<Checkbox
|
||||
isSelected={state.other}
|
||||
onChange={(other) =>
|
||||
onChange(multi ? { ...state, other } : { ...state, other, labels: other ? [] : state.labels })
|
||||
}
|
||||
isDisabled={disabled}
|
||||
>
|
||||
<Checkbox.Content>
|
||||
<Checkbox.Control>
|
||||
<Checkbox.Indicator />
|
||||
</Checkbox.Control>
|
||||
<Label>Other</Label>
|
||||
</Checkbox.Content>
|
||||
</Checkbox>
|
||||
|
||||
{state.other ? (
|
||||
<TextField
|
||||
value={state.notes}
|
||||
onChange={(notes) => onChange({ ...state, notes })}
|
||||
isDisabled={disabled}
|
||||
aria-label="other"
|
||||
fullWidth
|
||||
>
|
||||
<TextArea rows={2} placeholder="your answer…" fullWidth />
|
||||
</TextField>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AskUserQuestion({
|
||||
params,
|
||||
onAnswer,
|
||||
disabled,
|
||||
}: {
|
||||
params: AskUserQuestionParams;
|
||||
onAnswer: (result: unknown) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const questions = params.questions ?? [];
|
||||
const [states, setStates] = useState<QuestionState[]>(() => questions.map(() => EMPTY_ANSWER));
|
||||
const planMode = params.mode === "plan";
|
||||
|
||||
const answered = states.some(
|
||||
(state) => state.labels.length > 0 || (state.other && state.notes.trim() !== ""),
|
||||
);
|
||||
|
||||
const update = (index: number, next: QuestionState) =>
|
||||
setStates((current) => current.map((state, i) => (i === index ? next : state)));
|
||||
|
||||
return (
|
||||
<Shell
|
||||
title={questions.length > 1 ? `${questions.length} questions` : "A question for you"}
|
||||
hint="the terminal is showing this too — whoever answers first wins"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={disabled}
|
||||
onPress={() => onAnswer({ outcome: "cancelled" })}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{planMode ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isDisabled={disabled}
|
||||
onPress={() =>
|
||||
onAnswer({
|
||||
outcome: "skip_interview",
|
||||
partial_answers: buildPartial(questions, states),
|
||||
})
|
||||
}
|
||||
>
|
||||
Skip & plan
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isDisabled={disabled}
|
||||
onPress={() =>
|
||||
onAnswer({
|
||||
outcome: "chat_about_this",
|
||||
partial_answers: buildPartial(questions, states),
|
||||
})
|
||||
}
|
||||
>
|
||||
Chat about this
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
isDisabled={disabled || !answered}
|
||||
onPress={() => onAnswer(buildAccepted(questions, states))}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{questions.map((question, index) => (
|
||||
<QuestionCard
|
||||
key={question.id ?? question.question}
|
||||
question={question}
|
||||
state={states[index] ?? EMPTY_ANSWER}
|
||||
onChange={(next) => update(index, next)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function PermissionDialog({
|
||||
interaction,
|
||||
onAnswer,
|
||||
onDecline,
|
||||
}: {
|
||||
interaction: Interaction;
|
||||
onAnswer: (result: unknown) => void;
|
||||
/** For requests this build cannot render — replies with a JSON-RPC error. */
|
||||
onDecline: (reason: string) => void;
|
||||
}) {
|
||||
// One answer per card. The card normally disappears when the server confirms,
|
||||
// but a lost race can take a moment, and a double-answer would be sent as a
|
||||
// second reply to a JSON-RPC id that is already resolved.
|
||||
const [sent, setSent] = useState(false);
|
||||
const answer = (result: unknown) => {
|
||||
if (sent) return;
|
||||
setSent(true);
|
||||
onAnswer(result);
|
||||
};
|
||||
|
||||
switch (interaction.method) {
|
||||
case METHOD_REQUEST_PERMISSION:
|
||||
return (
|
||||
<PermissionRequest
|
||||
params={(interaction.params ?? {}) as RequestPermissionParams}
|
||||
onAnswer={answer}
|
||||
disabled={sent}
|
||||
/>
|
||||
);
|
||||
|
||||
case METHOD_EXIT_PLAN_MODE:
|
||||
return (
|
||||
<ExitPlanMode
|
||||
params={(interaction.params ?? {}) as ExitPlanModeParams}
|
||||
onAnswer={answer}
|
||||
disabled={sent}
|
||||
/>
|
||||
);
|
||||
|
||||
case METHOD_ASK_USER_QUESTION:
|
||||
return (
|
||||
<AskUserQuestion
|
||||
params={(interaction.params ?? {}) as AskUserQuestionParams}
|
||||
onAnswer={answer}
|
||||
disabled={sent}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
// grok's extension surface grows upstream. Guessing a response shape for
|
||||
// an unknown method would deserialize into garbage or hang the turn, so
|
||||
// this says so plainly and lets the terminal handle it.
|
||||
return (
|
||||
<Shell
|
||||
title={`Unsupported request: ${interaction.method}`}
|
||||
hint="answer this one in the terminal — this build does not know its response shape"
|
||||
footer={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={sent}
|
||||
onPress={() => {
|
||||
if (sent) return;
|
||||
setSent(true);
|
||||
onDecline("no browser UI for " + interaction.method);
|
||||
}}
|
||||
>
|
||||
Decline here
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<pre className="glance-pre p-3 rounded-md bg-surface-secondary overflow-auto max-h-64">
|
||||
{JSON.stringify(interaction.params, null, 2)}
|
||||
</pre>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
import { Button, TextArea, TextField } from "@heroui/react";
|
||||
|
||||
/**
|
||||
* Prompt entry and the Stop button.
|
||||
*
|
||||
* Stop is deliberately always available while a turn runs, and is not merged
|
||||
* into the send control: interrupting is the one action a remote viewer most
|
||||
* urgently needs, and hunting for it inside a disabled composer is the wrong
|
||||
* experience at exactly the wrong moment.
|
||||
*/
|
||||
export function PromptBox({
|
||||
disabled,
|
||||
turnActive,
|
||||
onSend,
|
||||
onStop,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
turnActive: boolean;
|
||||
onSend: (text: string) => void;
|
||||
onStop: () => void;
|
||||
}) {
|
||||
const [text, setText] = useState("");
|
||||
|
||||
const send = () => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || disabled) return;
|
||||
onSend(trimmed);
|
||||
setText("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-border bg-background">
|
||||
<div className="mx-auto max-w-3xl px-4 py-3 flex items-end gap-2">
|
||||
<TextField
|
||||
className="grow"
|
||||
value={text}
|
||||
onChange={setText}
|
||||
isDisabled={disabled}
|
||||
aria-label="prompt"
|
||||
>
|
||||
<TextArea
|
||||
rows={1}
|
||||
placeholder={disabled ? "not connected" : "send a prompt…"}
|
||||
className="max-h-40 resize-none"
|
||||
onKeyDown={(event) => {
|
||||
// Enter sends, Shift+Enter breaks the line. `isComposing` guards
|
||||
// IME input: without it, committing a Chinese or Japanese
|
||||
// candidate with Enter would fire the prompt half-typed.
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
{turnActive ? (
|
||||
<Button variant="danger-soft" onPress={onStop} isDisabled={disabled}>
|
||||
Stop
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button onPress={send} isDisabled={disabled || text.trim() === ""}>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Chip, Disclosure, Spinner } from "@heroui/react";
|
||||
import type { ToolCallContent, ToolCallPayload, ToolCallStatus } from "../lib/acp";
|
||||
|
||||
const STATUS_COLOR: Record<string, "default" | "accent" | "success" | "warning" | "danger"> = {
|
||||
pending: "default",
|
||||
in_progress: "accent",
|
||||
completed: "success",
|
||||
failed: "danger",
|
||||
};
|
||||
|
||||
/**
|
||||
* Tool kinds get a glyph rather than a colour: status already owns colour in
|
||||
* this row, and two colour dimensions on one line stop reading as either.
|
||||
*/
|
||||
const KIND_GLYPH: Record<string, string> = {
|
||||
read: "▤",
|
||||
edit: "✎",
|
||||
delete: "␡",
|
||||
move: "→",
|
||||
search: "⌕",
|
||||
execute: "❯",
|
||||
think: "◇",
|
||||
fetch: "↓",
|
||||
switch_mode: "⇄",
|
||||
other: "•",
|
||||
};
|
||||
|
||||
function DiffBlock({ item }: { item: ToolCallContent }) {
|
||||
const oldText = item.oldText ?? "";
|
||||
const newText = item.newText ?? "";
|
||||
return (
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<div className="px-3 py-1.5 text-xs font-medium bg-surface-secondary truncate">
|
||||
{item.path ?? "(unnamed file)"}
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-border">
|
||||
{oldText ? (
|
||||
<pre className="glance-pre p-3 overflow-x-auto bg-danger-soft text-danger-soft-foreground">
|
||||
{oldText}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="p-3 text-xs text-muted">new file</div>
|
||||
)}
|
||||
<pre className="glance-pre p-3 overflow-x-auto bg-success-soft text-success-soft-foreground">
|
||||
{newText}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentItem({ item }: { item: ToolCallContent }) {
|
||||
if (item.type === "diff") return <DiffBlock item={item} />;
|
||||
|
||||
if (item.type === "terminal") {
|
||||
// Terminal content is a live handle, not data: the bridge does not mirror
|
||||
// the pty, so claiming to show output would be a lie.
|
||||
return (
|
||||
<div className="text-xs text-muted italic">
|
||||
live terminal {item.terminalId ?? ""} — output stays in the session
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const block = item.content;
|
||||
const text =
|
||||
block?.text ??
|
||||
block?.resource?.text ??
|
||||
(block?.type === "image"
|
||||
? "[image]"
|
||||
: block?.type === "resource_link"
|
||||
? `[${block.name ?? block.uri ?? "resource"}]`
|
||||
: "");
|
||||
if (!text) return null;
|
||||
return (
|
||||
<pre className="glance-pre p-3 rounded-md bg-surface-secondary overflow-auto max-h-96">
|
||||
{text}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolCall({ call }: { call: ToolCallPayload }) {
|
||||
const status: ToolCallStatus = call.status ?? "pending";
|
||||
const running = status === "pending" || status === "in_progress";
|
||||
const content = call.content ?? [];
|
||||
const glyph = KIND_GLYPH[call.kind ?? "other"] ?? KIND_GLYPH.other;
|
||||
|
||||
const header = (
|
||||
<div className="flex items-center gap-2 min-w-0 w-full text-left">
|
||||
<span aria-hidden className="text-muted shrink-0 w-4 text-center">
|
||||
{glyph}
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium">{call.title ?? call.kind ?? "tool call"}</span>
|
||||
<span className="grow" />
|
||||
{running ? (
|
||||
<Spinner size="sm" color="accent" aria-label="running" />
|
||||
) : (
|
||||
<Chip size="sm" color={STATUS_COLOR[status] ?? "default"} variant="soft">
|
||||
<Chip.Label>{status.replace("_", " ")}</Chip.Label>
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const hasDetail = content.length > 0 || (call.locations?.length ?? 0) > 0;
|
||||
|
||||
// A tool call with nothing to show should not pretend to be expandable: an
|
||||
// empty disclosure opening onto blank space reads as a loading bug.
|
||||
if (!hasDetail) {
|
||||
return (
|
||||
<div className="px-3 py-2 rounded-lg border border-border bg-surface">{header}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Disclosure className="rounded-lg border border-border bg-surface">
|
||||
<Disclosure.Heading>
|
||||
<Disclosure.Trigger className="px-3 py-2 w-full flex items-center gap-2">
|
||||
{header}
|
||||
<Disclosure.Indicator className="shrink-0" />
|
||||
</Disclosure.Trigger>
|
||||
</Disclosure.Heading>
|
||||
<Disclosure.Content>
|
||||
<Disclosure.Body className="px-3 pb-3 space-y-2">
|
||||
{call.locations?.length ? (
|
||||
<div className="text-xs text-muted truncate">
|
||||
{call.locations.map((l) => (l.line ? `${l.path}:${l.line}` : l.path)).join(" · ")}
|
||||
</div>
|
||||
) : null}
|
||||
{content.map((item, i) => (
|
||||
<ContentItem key={i} item={item} />
|
||||
))}
|
||||
</Disclosure.Body>
|
||||
</Disclosure.Content>
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Alert } from "@heroui/react";
|
||||
import type { PlanEntry, Transcript as TranscriptModel, TranscriptItem } from "../lib/acp";
|
||||
import { ToolCall } from "./ToolCall";
|
||||
|
||||
const PLAN_GLYPH: Record<string, string> = {
|
||||
pending: "○",
|
||||
in_progress: "◐",
|
||||
completed: "●",
|
||||
};
|
||||
|
||||
function PlanList({ entries }: { entries: PlanEntry[] }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-3">
|
||||
<div className="text-xs font-medium text-muted mb-2">plan</div>
|
||||
<ul className="space-y-1">
|
||||
{entries.map((entry, i) => {
|
||||
const done = entry.status === "completed";
|
||||
return (
|
||||
<li key={i} className="flex gap-2 text-sm">
|
||||
<span aria-hidden className="text-muted shrink-0">
|
||||
{PLAN_GLYPH[entry.status ?? "pending"] ?? "○"}
|
||||
</span>
|
||||
<span className={done ? "line-through text-muted" : undefined}>{entry.content}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Item({ item }: { item: TranscriptItem }) {
|
||||
switch (item.kind) {
|
||||
case "user":
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] rounded-lg bg-accent-soft text-accent-soft-foreground px-3 py-2 glance-prose text-sm">
|
||||
{item.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "assistant":
|
||||
return <div className="glance-prose text-sm">{item.text}</div>;
|
||||
|
||||
case "thought":
|
||||
// Thinking is dimmed rather than hidden: on a phone it is often the only
|
||||
// sign of life during a long tool-free stretch, but it must never compete
|
||||
// with the answer for attention.
|
||||
return (
|
||||
<div className="border-l-2 border-border pl-3 text-sm text-muted glance-prose italic">
|
||||
{item.text}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "tool":
|
||||
return <ToolCall call={item.call} />;
|
||||
|
||||
case "plan":
|
||||
return <PlanList entries={item.entries} />;
|
||||
|
||||
case "notice":
|
||||
return (
|
||||
<Alert status="default" className="text-sm">
|
||||
<Alert.Content>
|
||||
<Alert.Description>{item.text}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
case "turn-end":
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1" aria-label="turn complete">
|
||||
<span className="h-px grow bg-separator" />
|
||||
<span className="text-[0.6875rem] uppercase tracking-wider text-muted">turn complete</span>
|
||||
<span className="h-px grow bg-separator" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The mirrored session, rendered.
|
||||
*
|
||||
* Scrolling sticks to the bottom only while the reader is already there. The
|
||||
* alternative — always scrolling — yanks the view away mid-sentence every time
|
||||
* a chunk lands, which makes reading back through a long turn impossible on the
|
||||
* device this is most likely to be read on.
|
||||
*/
|
||||
export function Transcript({
|
||||
transcript,
|
||||
className,
|
||||
}: {
|
||||
transcript: TranscriptModel;
|
||||
className?: string;
|
||||
}) {
|
||||
const viewport = useRef<HTMLDivElement | null>(null);
|
||||
const pinned = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
const el = viewport.current;
|
||||
if (el && pinned.current) el.scrollTop = el.scrollHeight;
|
||||
}, [transcript]);
|
||||
|
||||
const onScroll = () => {
|
||||
const el = viewport.current;
|
||||
if (!el) return;
|
||||
pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={viewport} onScroll={onScroll} className={className}>
|
||||
<div className="mx-auto max-w-3xl px-4 py-4 space-y-3">
|
||||
{transcript.dropped > 0 ? (
|
||||
<div className="text-xs text-muted text-center">
|
||||
{transcript.dropped} earlier {transcript.dropped === 1 ? "event" : "events"} fell out of
|
||||
the buffer
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{transcript.items.length === 0 ? (
|
||||
<div className="text-sm text-muted text-center py-12">
|
||||
nothing mirrored yet — this fills in as the session runs
|
||||
</div>
|
||||
) : (
|
||||
transcript.items.map((item) => <Item key={item.id} item={item} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Chip, Spinner } from "@heroui/react";
|
||||
import type { ConnectionState } from "../lib/ws";
|
||||
|
||||
const CONNECTION: Record<
|
||||
ConnectionState,
|
||||
{ label: string; color: "default" | "accent" | "success" | "warning" | "danger" }
|
||||
> = {
|
||||
connecting: { label: "connecting", color: "warning" },
|
||||
open: { label: "live", color: "success" },
|
||||
closed: { label: "offline", color: "danger" },
|
||||
};
|
||||
|
||||
/**
|
||||
* The two facts a remote viewer needs before trusting anything on screen: is
|
||||
* this stream still live, and is the agent working right now.
|
||||
*
|
||||
* They are shown together because a stalled transcript is ambiguous otherwise —
|
||||
* "no new output" looks identical whether the agent is thinking or the socket
|
||||
* died ten minutes ago.
|
||||
*/
|
||||
export function TurnStatus({
|
||||
connection,
|
||||
turnActive,
|
||||
pending = 0,
|
||||
}: {
|
||||
connection: ConnectionState;
|
||||
turnActive: boolean;
|
||||
pending?: number;
|
||||
}) {
|
||||
const conn = CONNECTION[connection];
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip size="sm" color={conn.color} variant="soft">
|
||||
<Chip.Label>{conn.label}</Chip.Label>
|
||||
</Chip>
|
||||
|
||||
{turnActive ? (
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<Spinner size="sm" color="accent" aria-label="turn in progress" />
|
||||
working
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted">idle</span>
|
||||
)}
|
||||
|
||||
{pending > 0 ? (
|
||||
<Chip size="sm" color="warning" variant="soft">
|
||||
<Chip.Label>
|
||||
{pending} waiting on you
|
||||
</Chip.Label>
|
||||
</Chip>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* HeroUI v3 brings its own `@import "tailwindcss"`, the default theme's CSS
|
||||
* variables and the `dark` custom variant, so importing it is the whole Tailwind
|
||||
* setup — there is no tailwind.config.js in a v4 CSS-first project.
|
||||
*/
|
||||
@import "@heroui/react/styles";
|
||||
|
||||
/*
|
||||
* Tailwind v4 discovers utility classes by scanning from the CSS file that
|
||||
* imports it. That file lives in node_modules here, so automatic detection would
|
||||
* scan the wrong tree and emit a stylesheet with none of this app's utilities.
|
||||
* Pointing @source at src/ is what makes the build correct rather than empty.
|
||||
*/
|
||||
@source "./";
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family:
|
||||
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||
/* iOS zooms the page when a focused input is under 16px; the prompt box is
|
||||
the one control a phone user reaches for most. */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/*
|
||||
* Transcript text is model output and command output: it must wrap, preserve
|
||||
* its own whitespace, and never widen the page. `overflow-wrap: anywhere` is
|
||||
* what keeps a 200-character path or a base64 blob from introducing a
|
||||
* horizontal scrollbar on the whole layout.
|
||||
*/
|
||||
.glance-pre {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.glance-prose {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.65;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* ACP frame shapes and the reducer that turns a stream of them into a
|
||||
* transcript.
|
||||
*
|
||||
* Two rails arrive here and both are handled by the same code:
|
||||
*
|
||||
* - `session/update` — the stable ACP rail. Correctness is keyed off this.
|
||||
* - `x.ai/session_notification` — grok's own rail, ~60 internal variants
|
||||
* carrying the streaming deltas that make a transcript readable.
|
||||
*
|
||||
* They share the `{sessionId, update: {sessionUpdate, ...}}` envelope, which is
|
||||
* why one reducer covers them. The xAI rail is *not* a stable interface: its
|
||||
* variants drift with every upstream sync, so an unrecognised `sessionUpdate`
|
||||
* is dropped from the rendering rather than treated as an error. Nothing
|
||||
* load-bearing is keyed off it.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Frame {
|
||||
jsonrpc?: string;
|
||||
id?: unknown;
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string };
|
||||
}
|
||||
|
||||
export interface ContentBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
uri?: string;
|
||||
name?: string;
|
||||
mimeType?: string;
|
||||
data?: string;
|
||||
annotations?: unknown;
|
||||
resource?: { text?: string; uri?: string; mimeType?: string };
|
||||
}
|
||||
|
||||
export interface ToolCallContent {
|
||||
type: "content" | "diff" | "terminal" | string;
|
||||
content?: ContentBlock;
|
||||
path?: string;
|
||||
oldText?: string | null;
|
||||
newText?: string;
|
||||
terminalId?: string;
|
||||
}
|
||||
|
||||
export type ToolCallStatus = "pending" | "in_progress" | "completed" | "failed";
|
||||
|
||||
export interface ToolCallLocation {
|
||||
path: string;
|
||||
line?: number;
|
||||
}
|
||||
|
||||
export interface ToolCallPayload {
|
||||
toolCallId?: string;
|
||||
title?: string;
|
||||
kind?: string;
|
||||
status?: ToolCallStatus;
|
||||
content?: ToolCallContent[];
|
||||
locations?: ToolCallLocation[];
|
||||
rawInput?: unknown;
|
||||
rawOutput?: unknown;
|
||||
}
|
||||
|
||||
export interface PlanEntry {
|
||||
content: string;
|
||||
priority?: string;
|
||||
status?: "pending" | "in_progress" | "completed" | string;
|
||||
}
|
||||
|
||||
/** The `{sessionId, update}` envelope both rails share. */
|
||||
export interface UpdateParams {
|
||||
sessionId?: string;
|
||||
update?: Record<string, unknown>;
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interaction methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const METHOD_REQUEST_PERMISSION = "session/request_permission";
|
||||
export const METHOD_ASK_USER_QUESTION = "x.ai/ask_user_question";
|
||||
export const METHOD_EXIT_PLAN_MODE = "x.ai/exit_plan_mode";
|
||||
|
||||
export interface PermissionOption {
|
||||
optionId: string;
|
||||
name: string;
|
||||
/** `allow_once` | `allow_always` | `reject_once` | `reject_always` */
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
export interface RequestPermissionParams {
|
||||
sessionId?: string;
|
||||
toolCall?: ToolCallPayload;
|
||||
options?: PermissionOption[];
|
||||
}
|
||||
|
||||
export interface QuestionOption {
|
||||
label: string;
|
||||
description: string;
|
||||
preview?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
question: string;
|
||||
options: QuestionOption[];
|
||||
multiSelect?: boolean;
|
||||
multi_select?: boolean;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface AskUserQuestionParams {
|
||||
sessionId?: string;
|
||||
toolCallId?: string;
|
||||
questions?: Question[];
|
||||
/** `default` | `plan` — plan mode unlocks two extra outcomes. */
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
export interface ExitPlanModeParams {
|
||||
sessionId?: string;
|
||||
toolCallId?: string;
|
||||
planContent?: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Turn tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whether an update starts, continues or ends a turn.
|
||||
*
|
||||
* This mirrors `classifyUpdate` in internal/hub/agent.go deliberately. The
|
||||
* server sends `turnActive` only in the snapshot, so a browser that did not
|
||||
* derive it from the frame stream would show a stale spinner for the rest of
|
||||
* the session. Keeping the two in step matters: if upstream renames
|
||||
* `turn_completed`, both sides go wrong the same way rather than disagreeing.
|
||||
*/
|
||||
export function turnEffect(kind: string | undefined): "start" | "end" | null {
|
||||
switch (kind) {
|
||||
case "turn_completed":
|
||||
return "end";
|
||||
case "agent_message_chunk":
|
||||
case "agent_thought_chunk":
|
||||
case "tool_call":
|
||||
case "tool_call_update":
|
||||
case "user_message_chunk":
|
||||
case "plan":
|
||||
case "pending_interaction":
|
||||
return "start";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transcript model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TranscriptItem =
|
||||
| { kind: "user"; id: string; text: string }
|
||||
| { kind: "assistant"; id: string; text: string }
|
||||
| { kind: "thought"; id: string; text: string }
|
||||
| { kind: "tool"; id: string; call: ToolCallPayload }
|
||||
| { kind: "plan"; id: string; entries: PlanEntry[] }
|
||||
| { kind: "notice"; id: string; text: string }
|
||||
| { kind: "turn-end"; id: string };
|
||||
|
||||
export interface Transcript {
|
||||
items: TranscriptItem[];
|
||||
/** Frames the server's ring buffer discarded before this browser attached. */
|
||||
dropped: number;
|
||||
turnActive: boolean;
|
||||
}
|
||||
|
||||
export function emptyTranscript(dropped = 0, turnActive = false): Transcript {
|
||||
return { items: [], dropped, turnActive };
|
||||
}
|
||||
|
||||
function textOf(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!content || typeof content !== "object") return "";
|
||||
if (Array.isArray(content)) return content.map(textOf).join("");
|
||||
const block = content as ContentBlock;
|
||||
if (typeof block.text === "string") return block.text;
|
||||
if (block.resource && typeof block.resource.text === "string") return block.resource.text;
|
||||
// Images and audio have no textual form; naming them beats rendering nothing
|
||||
// and leaving a silent gap in the transcript.
|
||||
if (block.type === "image") return "[image]";
|
||||
if (block.type === "audio") return "[audio]";
|
||||
if (block.type === "resource_link") return `[${block.name ?? block.uri ?? "resource"}]`;
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge one tool-call update into an existing call.
|
||||
*
|
||||
* `tool_call_update` is a partial: fields it omits keep their previous value,
|
||||
* and `content` arrives as replacement snapshots rather than appends. Treating
|
||||
* an update as a whole new call — the obvious shortcut — makes a long edit
|
||||
* flicker between titled and untitled as deltas arrive.
|
||||
*/
|
||||
function mergeToolCall(previous: ToolCallPayload, next: ToolCallPayload): ToolCallPayload {
|
||||
return {
|
||||
...previous,
|
||||
...Object.fromEntries(Object.entries(next).filter(([, v]) => v !== undefined && v !== null)),
|
||||
content: next.content ?? previous.content,
|
||||
};
|
||||
}
|
||||
|
||||
let syntheticId = 0;
|
||||
|
||||
/**
|
||||
* Fold one mirrored frame into the transcript, returning a new value.
|
||||
*
|
||||
* Unknown methods and unknown update kinds are no-ops: this is the code path
|
||||
* that has to survive an upstream sync it was not written against.
|
||||
*/
|
||||
export function applyFrame(transcript: Transcript, raw: unknown): Transcript {
|
||||
const frame = raw as Frame;
|
||||
if (!frame || typeof frame !== "object" || typeof frame.method !== "string") {
|
||||
return transcript;
|
||||
}
|
||||
|
||||
const params = frame.params as UpdateParams | undefined;
|
||||
const update = params?.update;
|
||||
if (!update || typeof update !== "object") return transcript;
|
||||
|
||||
const kind = typeof update.sessionUpdate === "string" ? update.sessionUpdate : undefined;
|
||||
const effect = turnEffect(kind);
|
||||
const turnActive =
|
||||
effect === "start" ? true : effect === "end" ? false : transcript.turnActive;
|
||||
|
||||
const items = transcript.items;
|
||||
const last = items[items.length - 1];
|
||||
|
||||
switch (kind) {
|
||||
case "user_message_chunk": {
|
||||
const text = textOf(update.content);
|
||||
if (!text) break;
|
||||
// Chunks of the same kind coalesce into one bubble; without this a
|
||||
// streamed reply renders as one paragraph per token.
|
||||
if (last?.kind === "user") {
|
||||
return {
|
||||
...transcript,
|
||||
turnActive,
|
||||
items: [...items.slice(0, -1), { ...last, text: last.text + text }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...transcript,
|
||||
turnActive,
|
||||
items: [...items, { kind: "user", id: `u${syntheticId++}`, text }],
|
||||
};
|
||||
}
|
||||
|
||||
case "agent_message_chunk": {
|
||||
const text = textOf(update.content);
|
||||
if (!text) break;
|
||||
if (last?.kind === "assistant") {
|
||||
return {
|
||||
...transcript,
|
||||
turnActive,
|
||||
items: [...items.slice(0, -1), { ...last, text: last.text + text }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...transcript,
|
||||
turnActive,
|
||||
items: [...items, { kind: "assistant", id: `a${syntheticId++}`, text }],
|
||||
};
|
||||
}
|
||||
|
||||
case "agent_thought_chunk": {
|
||||
const text = textOf(update.content);
|
||||
if (!text) break;
|
||||
if (last?.kind === "thought") {
|
||||
return {
|
||||
...transcript,
|
||||
turnActive,
|
||||
items: [...items.slice(0, -1), { ...last, text: last.text + text }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...transcript,
|
||||
turnActive,
|
||||
items: [...items, { kind: "thought", id: `t${syntheticId++}`, text }],
|
||||
};
|
||||
}
|
||||
|
||||
case "tool_call":
|
||||
case "tool_call_update": {
|
||||
const call = update as ToolCallPayload;
|
||||
const id = call.toolCallId;
|
||||
if (!id) break;
|
||||
const index = items.findIndex((item) => item.kind === "tool" && item.call.toolCallId === id);
|
||||
if (index === -1) {
|
||||
// An update for a call whose `tool_call` was dropped from the ring is
|
||||
// normal on reattach, so it opens a new entry rather than being lost.
|
||||
return {
|
||||
...transcript,
|
||||
turnActive,
|
||||
items: [...items, { kind: "tool", id: `tc:${id}`, call }],
|
||||
};
|
||||
}
|
||||
const existing = items[index] as Extract<TranscriptItem, { kind: "tool" }>;
|
||||
const merged = [...items];
|
||||
merged[index] = { ...existing, call: mergeToolCall(existing.call, call) };
|
||||
return { ...transcript, turnActive, items: merged };
|
||||
}
|
||||
|
||||
case "plan": {
|
||||
const entries = (update.entries ?? update.plan) as PlanEntry[] | undefined;
|
||||
if (!Array.isArray(entries)) break;
|
||||
// The plan is a running snapshot, not an append: replacing the previous
|
||||
// one keeps the checklist a checklist instead of a pile of revisions.
|
||||
const index = items.findIndex((item) => item.kind === "plan");
|
||||
const entry: TranscriptItem = { kind: "plan", id: "plan", entries };
|
||||
if (index === -1) return { ...transcript, turnActive, items: [...items, entry] };
|
||||
const merged = [...items];
|
||||
merged[index] = entry;
|
||||
return { ...transcript, turnActive, items: merged };
|
||||
}
|
||||
|
||||
case "turn_completed": {
|
||||
if (last?.kind === "turn-end") return { ...transcript, turnActive };
|
||||
return {
|
||||
...transcript,
|
||||
turnActive,
|
||||
items: [...items, { kind: "turn-end", id: `end${syntheticId++}` }],
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
// An unrecognised variant still counts toward turn state if
|
||||
// `turnEffect` claimed it; otherwise it is deliberately invisible.
|
||||
break;
|
||||
}
|
||||
|
||||
return turnActive === transcript.turnActive ? transcript : { ...transcript, turnActive };
|
||||
}
|
||||
|
||||
/** Replay a snapshot's worth of frames in one pass. */
|
||||
export function applyFrames(transcript: Transcript, frames: unknown[]): Transcript {
|
||||
return frames.reduce<Transcript>((acc, frame) => applyFrame(acc, frame), transcript);
|
||||
}
|
||||
|
||||
export function appendNotice(transcript: Transcript, text: string): Transcript {
|
||||
return {
|
||||
...transcript,
|
||||
items: [...transcript.items, { kind: "notice", id: `n${syntheticId++}`, text }],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* REST client for the glance API.
|
||||
*
|
||||
* Every call is same-origin and relies on the `__Host-` session cookie, so
|
||||
* nothing here handles tokens — except the bootstrap token, which is a
|
||||
* query-string credential by design (it has to work before any cookie exists).
|
||||
*/
|
||||
|
||||
export interface Status {
|
||||
enrolled: boolean;
|
||||
authenticated: boolean;
|
||||
}
|
||||
|
||||
export interface Enrollment {
|
||||
/** Base32 TOTP secret, shown so an authenticator can be set up by hand. */
|
||||
secret: string;
|
||||
/** `otpauth://` URI to render as a QR code. */
|
||||
uri: string;
|
||||
}
|
||||
|
||||
export interface SessionMeta {
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
title?: string;
|
||||
model?: string;
|
||||
hostname?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface AgentSummary {
|
||||
id: string;
|
||||
keyName: string;
|
||||
session: SessionMeta;
|
||||
label: string;
|
||||
connectedAt: string;
|
||||
lastActivity: string;
|
||||
turnActive: boolean;
|
||||
pending: number;
|
||||
frames: number;
|
||||
dropped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* An API call that failed with a status the caller may want to branch on.
|
||||
*
|
||||
* The status matters more than the message here: 404 from the setup endpoints
|
||||
* means "the bootstrap gate rejected you" (deliberately indistinguishable from
|
||||
* "already enrolled"), and 429 from login means rate-limited rather than wrong.
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
// The cookie is same-origin anyway, but being explicit means a future
|
||||
// change to the dev proxy cannot silently drop credentials.
|
||||
credentials: "same-origin",
|
||||
});
|
||||
} catch (cause) {
|
||||
// fetch only rejects on network-level failure, which here means the server
|
||||
// is down or the dev proxy has no target. Saying so beats "Failed to fetch".
|
||||
throw new ApiError(0, `cannot reach the glance server (${String(cause)})`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
let body: unknown = null;
|
||||
if (text) {
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
// A non-JSON body from an API path means something upstream of the
|
||||
// handler answered — a proxy error page, most likely.
|
||||
body = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
body && typeof body === "object" && "error" in body && typeof body.error === "string"
|
||||
? body.error
|
||||
: `request failed with ${response.status}`;
|
||||
throw new ApiError(response.status, message);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
status: () => request<Status>("/api/status"),
|
||||
|
||||
/**
|
||||
* Both setup calls carry the bootstrap token in the query string, matching
|
||||
* `auth.BootstrapToken`, which reads `?token=` before the header.
|
||||
*/
|
||||
setupBegin: (token: string) =>
|
||||
request<Enrollment>(`/api/setup/begin?token=${encodeURIComponent(token)}`, {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
setupComplete: (token: string, secret: string, code: string) =>
|
||||
request<{ ok: boolean }>(`/api/setup/complete?token=${encodeURIComponent(token)}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ secret, code }),
|
||||
}),
|
||||
|
||||
login: (code: string) =>
|
||||
request<{ ok: boolean }>("/api/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code }),
|
||||
}),
|
||||
|
||||
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST" }),
|
||||
|
||||
agents: () => request<{ agents: AgentSummary[] | null }>("/api/agents"),
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Coarse relative time, for "last seen" style labels.
|
||||
*
|
||||
* Deliberately low-resolution: these timestamps come from a server whose clock
|
||||
* may differ from the browser's by seconds, so "12s ago" would imply a precision
|
||||
* that does not exist. Anything under a minute is just "now".
|
||||
*/
|
||||
export function ago(iso: string | undefined): string {
|
||||
if (!iso) return "";
|
||||
const then = Date.parse(iso);
|
||||
if (Number.isNaN(then)) return "";
|
||||
|
||||
const seconds = Math.max(0, Math.round((Date.now() - then) / 1000));
|
||||
if (seconds < 60) return "just now";
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.round(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
/** Shorten a path for a header line, keeping the end — the part that identifies it. */
|
||||
export function shortPath(path: string | undefined, keep = 3): string {
|
||||
if (!path) return "";
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
if (parts.length <= keep) return path;
|
||||
return "…/" + parts.slice(-keep).join("/");
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* The browser's socket to glance.
|
||||
*
|
||||
* This is deliberately *not* an ACP peer. glance translates the ACP link into a
|
||||
* small envelope so the frontend never has to correlate JSON-RPC ids — with one
|
||||
* exception: an interaction's `id` is the agent's own JSON-RPC id, echoed back
|
||||
* verbatim, because that is what the answer has to be addressed to.
|
||||
*/
|
||||
|
||||
import type { AgentSummary, SessionMeta } from "./api";
|
||||
|
||||
export interface Interaction {
|
||||
/** grok's JSON-RPC id, as a JSON value. Echoed back untouched when answering. */
|
||||
id: unknown;
|
||||
method: string;
|
||||
params: unknown;
|
||||
toolCallId?: string;
|
||||
openedAt: string;
|
||||
}
|
||||
|
||||
export type ServerEvent =
|
||||
| {
|
||||
type: "snapshot";
|
||||
agent: string;
|
||||
agents?: AgentSummary[];
|
||||
frames?: unknown[];
|
||||
dropped?: number;
|
||||
open?: Interaction[];
|
||||
session?: SessionMeta;
|
||||
turnActive?: boolean;
|
||||
}
|
||||
| { type: "agents"; agents?: AgentSummary[] }
|
||||
| { type: "frame"; agent: string; frame: unknown }
|
||||
| { type: "interaction"; agent: string; interaction: Interaction }
|
||||
| {
|
||||
type: "interaction_resolved";
|
||||
agent: string;
|
||||
id?: string;
|
||||
toolCallId?: string;
|
||||
by?: string;
|
||||
message?: string;
|
||||
}
|
||||
| { type: "notice"; agent?: string; message: string }
|
||||
| { type: "error"; agent?: string; message: string };
|
||||
|
||||
export type ClientCommand =
|
||||
| { type: "list" }
|
||||
| { type: "subscribe"; agent: string }
|
||||
| { type: "prompt"; agent: string; text: string }
|
||||
| { type: "cancel"; agent: string }
|
||||
| { type: "answer"; agent: string; id: string; result: unknown }
|
||||
| { type: "decline"; agent: string; id: string; reason?: string };
|
||||
|
||||
export type ConnectionState = "connecting" | "open" | "closed";
|
||||
|
||||
/**
|
||||
* `Interaction.id` arrives as a parsed JSON value but is *keyed* server-side by
|
||||
* its raw JSON text (`string(frame.ID)` in internal/hub/agent.go). Re-encoding
|
||||
* it is what makes an answer land on the right pending entry: an id of `7`
|
||||
* becomes `"7"` and an id of `"abc"` becomes `"\"abc\""`, matching Go's
|
||||
* `json.RawMessage` bytes on both sides.
|
||||
*/
|
||||
export function interactionKey(id: unknown): string {
|
||||
return JSON.stringify(id ?? null);
|
||||
}
|
||||
|
||||
export interface GlanceSocketHandlers {
|
||||
onEvent: (event: ServerEvent) => void;
|
||||
/**
|
||||
* Connection state changes.
|
||||
*
|
||||
* A rejected upgrade (401, cookie expired) and an unreachable server both
|
||||
* surface as an abnormal close with no close frame, so this reports "closed"
|
||||
* either way and leaves the app to ask `/api/status` which one it was.
|
||||
* Guessing from the close code would send people to a login page whenever the
|
||||
* server restarted.
|
||||
*/
|
||||
onState: (state: ConnectionState) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reconnecting socket.
|
||||
*
|
||||
* Reconnection is not optional here: laptops sleep, phones switch networks, and
|
||||
* a viewer that silently stops updating is worse than one that says it is
|
||||
* offline — it looks like an idle agent.
|
||||
*/
|
||||
export class GlanceSocket {
|
||||
private socket: WebSocket | null = null;
|
||||
private handlers: GlanceSocketHandlers;
|
||||
private attempt = 0;
|
||||
private timer: number | null = null;
|
||||
private stopped = false;
|
||||
/** Re-sent on every reconnect so a resumed socket refills its transcript. */
|
||||
private subscription: string | null = null;
|
||||
|
||||
constructor(handlers: GlanceSocketHandlers) {
|
||||
this.handlers = handlers;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.stopped = false;
|
||||
this.open();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
if (this.timer !== null) {
|
||||
window.clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.socket?.close();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
send(command: ClientCommand): boolean {
|
||||
if (this.socket?.readyState !== WebSocket.OPEN) return false;
|
||||
this.socket.send(JSON.stringify(command));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Subscribe, and remember it so a reconnect restores the same view. */
|
||||
subscribe(agent: string): void {
|
||||
this.subscription = agent;
|
||||
this.send({ type: "subscribe", agent });
|
||||
}
|
||||
|
||||
clearSubscription(): void {
|
||||
this.subscription = null;
|
||||
}
|
||||
|
||||
private open(): void {
|
||||
if (this.stopped) return;
|
||||
this.handlers.onState("connecting");
|
||||
|
||||
const url = new URL("/api/ws", window.location.href);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
const socket = new WebSocket(url);
|
||||
this.socket = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
this.attempt = 0;
|
||||
this.handlers.onState("open");
|
||||
if (this.subscription) this.send({ type: "subscribe", agent: this.subscription });
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data !== "string") return;
|
||||
let parsed: ServerEvent;
|
||||
try {
|
||||
parsed = JSON.parse(event.data) as ServerEvent;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
this.handlers.onEvent(parsed);
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
this.socket = null;
|
||||
this.handlers.onState("closed");
|
||||
if (this.stopped) return;
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
// `onclose` always follows; handling both would double the backoff.
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
// Capped exponential backoff with jitter. The cap is low because this is a
|
||||
// local-network tool and a viewer that takes 30s to come back after a
|
||||
// laptop wakes up reads as broken.
|
||||
const base = Math.min(500 * 2 ** this.attempt, 5000);
|
||||
const delay = base + Math.random() * 250;
|
||||
this.attempt += 1;
|
||||
this.timer = window.setTimeout(() => {
|
||||
this.timer = null;
|
||||
this.open();
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./index.css";
|
||||
|
||||
const container = document.getElementById("root");
|
||||
if (!container) throw new Error("#root is missing from index.html");
|
||||
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, InputOTP } from "@heroui/react";
|
||||
import { ApiError, api } from "../lib/api";
|
||||
import { Centered } from "./Setup";
|
||||
|
||||
export function Login({ onDone }: { onDone: () => void }) {
|
||||
const [code, setCode] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (value: string) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.login(value);
|
||||
onDone();
|
||||
} catch (cause) {
|
||||
setCode("");
|
||||
// 429 is worth naming separately: retrying immediately is exactly the
|
||||
// wrong response to it, and "wrong code" would invite precisely that.
|
||||
setError(
|
||||
cause instanceof ApiError && cause.status === 429
|
||||
? "too many attempts — wait a moment before trying again"
|
||||
: cause instanceof ApiError && cause.status === 401
|
||||
? "that code did not match"
|
||||
: cause instanceof Error
|
||||
? cause.message
|
||||
: "sign-in failed",
|
||||
);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Centered title="grok glance">
|
||||
{error ? (
|
||||
<Alert status="danger">
|
||||
<Alert.Content>
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<p className="text-sm text-muted">Enter the current code from your authenticator.</p>
|
||||
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
onComplete={submit}
|
||||
isDisabled={busy}
|
||||
isInvalid={error !== null}
|
||||
pattern="^\d*$"
|
||||
inputMode="numeric"
|
||||
aria-label="verification code"
|
||||
autoFocus
|
||||
className="self-center"
|
||||
>
|
||||
<InputOTP.Group>
|
||||
{[0, 1, 2, 3, 4, 5].map((index) => (
|
||||
<InputOTP.Slot key={index} index={index} />
|
||||
))}
|
||||
</InputOTP.Group>
|
||||
</InputOTP>
|
||||
|
||||
<Button fullWidth isDisabled={busy || code.length < 6} onPress={() => void submit(code)}>
|
||||
{busy ? "Checking…" : "Sign in"}
|
||||
</Button>
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Alert, Button } from "@heroui/react";
|
||||
import type { Transcript as TranscriptModel } from "../lib/acp";
|
||||
import type { AgentSummary } from "../lib/api";
|
||||
import { shortPath } from "../lib/format";
|
||||
import { interactionKey, type ConnectionState, type Interaction } from "../lib/ws";
|
||||
import { PermissionDialog } from "../components/PermissionDialog";
|
||||
import { PromptBox } from "../components/PromptBox";
|
||||
import { Transcript } from "../components/Transcript";
|
||||
import { TurnStatus } from "../components/TurnStatus";
|
||||
|
||||
export function Session({
|
||||
agent,
|
||||
transcript,
|
||||
interactions,
|
||||
connection,
|
||||
onBack,
|
||||
onPrompt,
|
||||
onCancel,
|
||||
onAnswer,
|
||||
onDecline,
|
||||
}: {
|
||||
/** Undefined once the session disconnects — the transcript stays readable. */
|
||||
agent: AgentSummary | undefined;
|
||||
transcript: TranscriptModel;
|
||||
interactions: Interaction[];
|
||||
connection: ConnectionState;
|
||||
onBack: () => void;
|
||||
onPrompt: (text: string) => void;
|
||||
onCancel: () => void;
|
||||
onAnswer: (interaction: Interaction, result: unknown) => void;
|
||||
onDecline: (interaction: Interaction, reason: string) => void;
|
||||
}) {
|
||||
const meta = agent?.session ?? {};
|
||||
const live = connection === "open" && agent !== undefined;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<header className="border-b border-border shrink-0">
|
||||
<div className="mx-auto max-w-3xl px-4 py-3 flex items-center gap-3">
|
||||
<Button size="sm" variant="ghost" onPress={onBack} aria-label="back to sessions">
|
||||
←
|
||||
</Button>
|
||||
<div className="min-w-0 grow">
|
||||
<div className="text-sm font-semibold truncate">{agent?.label ?? "session"}</div>
|
||||
<div className="text-xs text-muted truncate">
|
||||
{[meta.model, meta.hostname, shortPath(meta.cwd)].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
<TurnStatus
|
||||
connection={connection}
|
||||
turnActive={transcript.turnActive}
|
||||
pending={interactions.length}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{agent === undefined ? (
|
||||
<div className="mx-auto max-w-3xl w-full px-4 pt-3 shrink-0">
|
||||
<Alert status="warning">
|
||||
<Alert.Content>
|
||||
<Alert.Title>This session has disconnected</Alert.Title>
|
||||
<Alert.Description>
|
||||
What was mirrored is still here to read. It will reappear if the session runs
|
||||
<code className="glance-pre"> /rc </code>again.
|
||||
</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Transcript transcript={transcript} className="grow overflow-y-auto" />
|
||||
|
||||
{interactions.length > 0 ? (
|
||||
<div className="shrink-0 max-h-[60vh] overflow-y-auto border-t border-border bg-surface-secondary">
|
||||
<div className="mx-auto max-w-3xl px-4 py-3 space-y-3">
|
||||
{interactions.map((interaction) => (
|
||||
<PermissionDialog
|
||||
// Keying on the interaction id is what resets the form state
|
||||
// when one card replaces another: React would otherwise reuse
|
||||
// the mounted component and carry the previous answer over.
|
||||
key={interactionKey(interaction.id)}
|
||||
interaction={interaction}
|
||||
onAnswer={(result) => onAnswer(interaction, result)}
|
||||
onDecline={(reason) => onDecline(interaction, reason)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="shrink-0">
|
||||
<PromptBox
|
||||
disabled={!live}
|
||||
turnActive={transcript.turnActive}
|
||||
onSend={onPrompt}
|
||||
onStop={onCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Button, Card, Chip, EmptyState, Spinner } from "@heroui/react";
|
||||
import type { AgentSummary } from "../lib/api";
|
||||
import { ago, shortPath } from "../lib/format";
|
||||
import type { ConnectionState } from "../lib/ws";
|
||||
|
||||
function AgentCard({ agent, onOpen }: { agent: AgentSummary; onOpen: () => void }) {
|
||||
const meta = agent.session ?? {};
|
||||
return (
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<Card.Title className="text-base truncate">{agent.label}</Card.Title>
|
||||
<Card.Description className="text-xs truncate">
|
||||
{[meta.model, meta.hostname, shortPath(meta.cwd)].filter(Boolean).join(" · ")}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content className="flex flex-wrap items-center gap-2">
|
||||
{agent.turnActive ? (
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<Spinner size="sm" color="accent" aria-label="turn in progress" />
|
||||
working
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted">idle · {ago(agent.lastActivity)}</span>
|
||||
)}
|
||||
|
||||
{agent.pending > 0 ? (
|
||||
<Chip size="sm" color="warning" variant="soft">
|
||||
<Chip.Label>{agent.pending} waiting</Chip.Label>
|
||||
</Chip>
|
||||
) : null}
|
||||
|
||||
{agent.dropped > 0 ? (
|
||||
<Chip size="sm" color="default" variant="soft">
|
||||
<Chip.Label>{agent.dropped} dropped</Chip.Label>
|
||||
</Chip>
|
||||
) : null}
|
||||
</Card.Content>
|
||||
|
||||
<Card.Footer className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted truncate">key: {agent.keyName}</span>
|
||||
<Button size="sm" onPress={onOpen}>
|
||||
Open
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sessions({
|
||||
agents,
|
||||
connection,
|
||||
onOpen,
|
||||
onSignOut,
|
||||
}: {
|
||||
agents: AgentSummary[];
|
||||
connection: ConnectionState;
|
||||
onOpen: (id: string) => void;
|
||||
onSignOut: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
<header className="border-b border-border">
|
||||
<div className="mx-auto max-w-3xl px-4 py-3 flex items-center gap-3">
|
||||
<h1 className="text-sm font-semibold grow">grok glance</h1>
|
||||
<Chip
|
||||
size="sm"
|
||||
variant="soft"
|
||||
color={connection === "open" ? "success" : connection === "connecting" ? "warning" : "danger"}
|
||||
>
|
||||
<Chip.Label>{connection === "open" ? "live" : connection}</Chip.Label>
|
||||
</Chip>
|
||||
<Button size="sm" variant="ghost" onPress={onSignOut}>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-3xl px-4 py-6 space-y-3">
|
||||
{agents.length === 0 ? (
|
||||
<EmptyState className="py-16 text-center">
|
||||
<p className="text-sm font-medium">No sessions connected</p>
|
||||
<p className="text-sm text-muted mt-2">
|
||||
Run <code className="glance-pre">/rc</code> in a grok session to mirror it here.
|
||||
</p>
|
||||
</EmptyState>
|
||||
) : (
|
||||
agents.map((agent) => (
|
||||
<AgentCard key={agent.id} agent={agent} onOpen={() => onOpen(agent.id)} />
|
||||
))
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import QRCode from "qrcode";
|
||||
import { Alert, Button, Card, InputOTP, Spinner } from "@heroui/react";
|
||||
import { ApiError, api, type Enrollment } from "../lib/api";
|
||||
|
||||
/**
|
||||
* First-run TOTP enrolment.
|
||||
*
|
||||
* The bootstrap token in the query string is the whole access-control story
|
||||
* here: without it the server answers 404, which is what closes the window where
|
||||
* anyone who reaches the port first could enrol themselves as the owner. It is
|
||||
* spent the moment enrolment completes.
|
||||
*/
|
||||
export function Setup({ onDone }: { onDone: () => void }) {
|
||||
const token = new URLSearchParams(window.location.search).get("token") ?? "";
|
||||
|
||||
const [enrollment, setEnrollment] = useState<Enrollment | null>(null);
|
||||
const [qr, setQr] = useState<string | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
let cancelled = false;
|
||||
|
||||
api
|
||||
.setupBegin(token)
|
||||
.then(async (result) => {
|
||||
if (cancelled) return;
|
||||
setEnrollment(result);
|
||||
// A data: URI, not a remote image — the CSP allows `img-src 'self' data:`
|
||||
// precisely so this works without loosening anything for the rest of the
|
||||
// page. The secret never leaves the browser as a URL either way.
|
||||
const url = await QRCode.toDataURL(result.uri, { margin: 1, width: 220 });
|
||||
if (!cancelled) setQr(url);
|
||||
})
|
||||
.catch((cause: unknown) => {
|
||||
if (cancelled) return;
|
||||
setError(
|
||||
cause instanceof ApiError && cause.status === 404
|
||||
? "that bootstrap token is not valid — it may already have been used"
|
||||
: cause instanceof Error
|
||||
? cause.message
|
||||
: "enrolment could not be started",
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
const submit = async (value: string) => {
|
||||
if (!enrollment || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.setupComplete(token, enrollment.secret, value);
|
||||
onDone();
|
||||
} catch (cause) {
|
||||
setCode("");
|
||||
setError(
|
||||
cause instanceof ApiError && cause.status === 401
|
||||
? "that code did not match — check your device's clock and try the next one"
|
||||
: cause instanceof Error
|
||||
? cause.message
|
||||
: "enrolment failed",
|
||||
);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<Centered title="Bootstrap token required">
|
||||
<p className="text-sm text-muted">
|
||||
glance printed a one-time setup link when it first started. Open that link, or read the
|
||||
token from <code className="glance-pre">~/.grok/glance/bootstrap.token</code> and visit{" "}
|
||||
<code className="glance-pre">/?token=…</code>.
|
||||
</p>
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Centered title="Set up two-factor sign-in">
|
||||
{error ? (
|
||||
<Alert status="danger">
|
||||
<Alert.Content>
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!enrollment ? (
|
||||
!error ? <Spinner color="accent" aria-label="preparing enrolment" /> : null
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted">
|
||||
Scan this with your authenticator, then type the six digits it shows.
|
||||
</p>
|
||||
|
||||
{qr ? (
|
||||
<img
|
||||
src={qr}
|
||||
alt="TOTP enrolment QR code"
|
||||
width={220}
|
||||
height={220}
|
||||
className="rounded-md bg-white p-2 self-center"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<details className="text-xs text-muted">
|
||||
<summary className="cursor-pointer">Can't scan it?</summary>
|
||||
<p className="mt-2">Enter this secret by hand:</p>
|
||||
<code className="glance-pre block mt-1 break-all">{enrollment.secret}</code>
|
||||
</details>
|
||||
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
onComplete={submit}
|
||||
isDisabled={busy}
|
||||
pattern="^\d*$"
|
||||
inputMode="numeric"
|
||||
aria-label="verification code"
|
||||
className="self-center"
|
||||
>
|
||||
<InputOTP.Group>
|
||||
{[0, 1, 2, 3, 4, 5].map((index) => (
|
||||
<InputOTP.Slot key={index} index={index} />
|
||||
))}
|
||||
</InputOTP.Group>
|
||||
</InputOTP>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
isDisabled={busy || code.length < 6}
|
||||
onPress={() => void submit(code)}
|
||||
>
|
||||
{busy ? "Confirming…" : "Confirm"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
|
||||
export function Centered({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-full flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-sm">
|
||||
<Card.Header>
|
||||
<Card.Title>{title}</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content className="flex flex-col gap-4">{children}</Card.Content>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"],
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// The dev server proxies to a `glance serve` running on its default address.
|
||||
//
|
||||
// Going through the proxy rather than pointing the browser straight at :7717
|
||||
// keeps the app same-origin in development, which is not cosmetic: the session
|
||||
// cookie is `__Host-` prefixed and `SameSite=Strict`, so a cross-origin dev
|
||||
// setup would never send it and every authenticated call would 401.
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// `ws: true` covers /api/ws and /api/acp/agent; both are upgrades, and a
|
||||
// proxy that only forwards plain HTTP would fail the handshake.
|
||||
"/api": { target: "http://127.0.0.1:7717", ws: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
// Emptying is what keeps a stale bundle from being embedded after a rename;
|
||||
// the Makefile restores web/dist/.gitkeep afterwards so `go build` still has
|
||||
// a directory to embed.
|
||||
emptyOutDir: true,
|
||||
// The server sets `Cache-Control: immutable` on hashed assets only, so
|
||||
// leaving the default hashed names in place is load-bearing.
|
||||
chunkSizeWarningLimit: 900,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user