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
+294
View File
@@ -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
}
+144
View File
@@ -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)
}
}
}
+318
View File
@@ -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)
}
+202
View File
@@ -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)
}
}
+349
View File
@@ -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 &amp;&amp; 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
}
+541
View File
@@ -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")
}
+585
View File
@@ -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, &params); 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, &params); 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
}
+285
View File
@@ -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()
})
}
+270
View File
@@ -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)
}
+663
View File
@@ -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)
}
}
+64
View File
@@ -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
}
}
+80
View File
@@ -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()))
}
}
+459
View File
@@ -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
}
+267
View File
@@ -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")
}
}