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>
435 lines
12 KiB
Go
435 lines
12 KiB
Go
// 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
|
|
}
|