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
+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")
}