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,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
|
||||
}
|
||||
Reference in New Issue
Block a user