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:
iceBear67
2026-08-15 10:07:33 +00:00
co-authored by Claude Opus 5
commit 051efe8fec
45 changed files with 11006 additions and 0 deletions
+434
View File
@@ -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
}
+344
View File
@@ -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
}