diff --git a/README.md b/README.md index 51ab3d0..fc64e8c 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,8 @@ This repository's own `config.json` is also **compiled into the binary** at buil }, "server": { "addr": "127.0.0.1:8787", - "ui": true + "ui": true, + "password": "" }, "store": { "path": "~/.agent-loop/state.db" @@ -430,6 +431,7 @@ This repository's own `config.json` is also **compiled into the binary** at buil | `verify.env` | extra environment for the test command — mainly `PATH`, so the daemon can find language toolchains (see [Verification](#verification)) | | `server.addr` | control API bind address; keep loopback-only | | `server.ui` | mount the web interface at `/ui` (and redirect `/` to it); see [Web interface](#web-interface) | +| `server.password` | optional password for the web console and control API; empty disables authentication; stored in plaintext, so keep `config.json` readable only by the daemon user | | `store.path` | SQLite database path | | `discord.enabled` | turn on Discord status notifications (see [Discord notifications](#discord-notifications)) | | `discord.webhook_url` | the channel's incoming webhook URL; **required** if `discord.enabled` is true | @@ -546,12 +548,15 @@ Loopback-only by default. It can pause and cancel work, so do not expose it. | Route | Purpose | | ---------------------------- | ------------------------------------------------------------------- | | `GET /healthz` | liveness | +| `GET /auth` | whether `server.password` is set, and whether the caller's credential is currently valid | +| `POST /login` | exchange the configured password for a session token | +| `POST /logout` | revoke the presented session token | | `GET /status` | gate state, in-flight runs, claims, model cooldowns, usage snapshot | | `GET /runs?limit=&repo=` | recent runs with outcome, model, cost, PR link, created/started/ended timestamps; `kind` distinguishes an issue run from a PR-comment run | | `GET /runs/{id}` | one run plus its event timeline | | `GET /runs/{id}/log` | the raw JSONL transcript of the Claude run | | `GET /sessions?repo=&issue=&limit=` | Claude session IDs recorded per repo/issue, newest first | -| `GET /config` | current configuration, with the Discord webhook URL redacted | +| `GET /config` | current configuration, with the Discord webhook URL and web console password redacted | | `GET /models` | the model registry plus the plan/implement ladders and which models are cooled down | | `POST /pause` `POST /resume` | stop / resume claiming new work | | `POST /runs/{id}/cancel` | cancel an in-flight run | @@ -564,6 +569,16 @@ host, so a page loaded from another site cannot drive this API from a visitor's with neither header — `curl`, scripts, anything hitting the API directly — is unaffected; that remains the documented way to use it. +When `server.password` is set, every route requires a credential **except** `GET /healthz`, +`GET /auth`, `POST /login`, `GET /`, and everything under `GET /ui/*` (those must stay open for the +lock screen itself to load). Send it as `Authorization: Bearer `, where `` is either +the configured password itself or a token obtained from `POST /login` — both are accepted, so +existing scripts keep working unchanged: + +```sh +curl -H "Authorization: Bearer $PASSWORD" localhost:8787/status +``` + ## Web interface A small browser console for the control API above, compiled into the binary via `go:embed` (see @@ -579,10 +594,16 @@ read-only **config** view (current settings and the model ladders, with cooled-d through). It polls `/status` every few seconds (configurable, pauses automatically when the tab is hidden) rather than holding a persistent connection. -It shares the same posture as the rest of the control API: **loopback-only and unauthenticated by +It shares the same posture as the rest of the control API: **loopback-first, and unauthenticated by default.** Anything with local access to the port has full control, same as `curl`. Set `"server": {"ui": false}` to disable the mount entirely and keep only the JSON API. +Setting `server.password` puts a lock screen in front of the console. Unlocking stores a session +token in the browser's `sessionStorage`, not a cookie and not `localStorage`, so closing the tab or +browser logs you out; a daemon restart invalidates every outstanding session too, since tokens are +only ever kept in memory. The password is not a substitute for binding to loopback — there is no +TLS here, so a password sent over a non-loopback connection is sent in the clear. + ## Discord notifications Optional, **one-way** status updates posted to a Discord channel (e.g. `#coding-agent-loop`) via an diff --git a/config.example.json b/config.example.json index 834ba89..c993a18 100644 --- a/config.example.json +++ b/config.example.json @@ -61,7 +61,8 @@ }, "server": { "addr": "127.0.0.1:8787", - "ui": true + "ui": true, + "password": "" }, "store": { "path": "~/.agent-loop/state.db" diff --git a/internal/config/config.go b/internal/config/config.go index dcc333c..ab7ec5a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -175,9 +175,13 @@ type ServerConfig struct { // Addr should stay on loopback: this API can pause and cancel work. Addr string `json:"addr"` // UI mounts the browser console at /ui (and redirects / to it). It shares - // the same loopback-only, no-authentication posture as the rest of the - // control API. + // the same loopback-only posture as the rest of the control API. UI bool `json:"ui"` + // Password, when non-blank, requires it (or a session token obtained by + // submitting it) to use the control API and web console. Empty disables + // authentication, matching pre-#13 behaviour. Stored in plaintext here, + // so keep this file readable only by the daemon user. + Password string `json:"password"` } type StoreConfig struct { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7d404f3..6994902 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -261,3 +261,21 @@ func TestPRCommentsRejectsMentionWithoutAt(t *testing.T) { t.Fatalf("want a mention validation error, got %v", err) } } + +// The password field must exist on ServerConfig before DisallowUnknownFields +// will let config.example.json carry it. +func TestServerPasswordLoads(t *testing.T) { + cfg, err := Load(writeConfig(t, `{"github":{"owners":["acme"]},"server":{"password":"s3cret"}}`), false) + if err != nil { + t.Fatal(err) + } + if cfg.Server.Password != "s3cret" { + t.Fatalf("server.password = %q, want %q", cfg.Server.Password, "s3cret") + } +} + +func TestDefaultServerPasswordIsEmpty(t *testing.T) { + if got := Default().Server.Password; got != "" { + t.Fatalf("Default().Server.Password = %q, want empty (auth disabled by default)", got) + } +} diff --git a/internal/server/auth.go b/internal/server/auth.go new file mode 100644 index 0000000..637bb68 --- /dev/null +++ b/internal/server/auth.go @@ -0,0 +1,135 @@ +package server + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "strings" + "sync" + "time" + + "github.com/gofiber/fiber/v3" +) + +const ( + // sessionTTL is how long an issued token stays valid without use; a hit + // slides the expiry forward, so an active tab never gets logged out. + sessionTTL = 12 * time.Hour + // throttleWindow/throttleMax bound login attempts: once throttleMax + // failures land inside throttleWindow, further attempts are rejected + // with 429 until the window rolls over. + throttleWindow = time.Minute + throttleMax = 10 +) + +// sessionStore tracks issued session tokens and throttles failed logins. A +// zero-value store (Password unset) is never consulted for anything but +// stays inert either way. +type sessionStore struct { + mu sync.Mutex + tokens map[string]time.Time + + failWindowStart time.Time + failCount int +} + +func newSessionStore() *sessionStore { + return &sessionStore{tokens: map[string]time.Time{}} +} + +// issue mints a new session token. +func (s *sessionStore) issue() string { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + // crypto/rand failing means the platform's entropy source is broken; + // there is no sane fallback that keeps the token unguessable. + panic("server: crypto/rand unavailable: " + err.Error()) + } + token := base64.RawURLEncoding.EncodeToString(buf) + + s.mu.Lock() + defer s.mu.Unlock() + s.tokens[token] = time.Now().Add(sessionTTL) + return token +} + +// valid reports whether token is a live session, sliding its expiry forward +// on a hit and pruning expired entries as it goes. +func (s *sessionStore) valid(token string) bool { + if token == "" { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + for t, exp := range s.tokens { + if now.After(exp) { + delete(s.tokens, t) + } + } + exp, ok := s.tokens[token] + if !ok || now.After(exp) { + return false + } + s.tokens[token] = now.Add(sessionTTL) + return true +} + +// revoke drops token, if present. A caller logging out with an unknown or +// already-expired token is not an error. +func (s *sessionStore) revoke(token string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.tokens, token) +} + +// throttled reports whether the failed-login window is currently exhausted. +func (s *sessionStore) throttled() bool { + s.mu.Lock() + defer s.mu.Unlock() + if time.Since(s.failWindowStart) > throttleWindow { + return false + } + return s.failCount >= throttleMax +} + +// recordFailure counts one failed login attempt toward the throttle window. +func (s *sessionStore) recordFailure() { + s.mu.Lock() + defer s.mu.Unlock() + if time.Since(s.failWindowStart) > throttleWindow { + s.failWindowStart = time.Now() + s.failCount = 0 + } + s.failCount++ +} + +// authEnabled reports whether a password has been configured. Whitespace-only +// values count as unset, so " " can't become an unusable password by accident. +func (s *Server) authEnabled() bool { + return strings.TrimSpace(s.cfg.Server.Password) != "" +} + +// bearerToken extracts the credential from "Authorization: Bearer ". +func bearerToken(c fiber.Ctx) string { + auth := c.Get(fiber.HeaderAuthorization) + const prefix = "Bearer " + if !strings.HasPrefix(auth, prefix) { + return "" + } + return strings.TrimPrefix(auth, prefix) +} + +// authenticated reports whether token is either a live session or the +// configured password itself, compared in constant time. +func (s *Server) authenticated(token string) bool { + if token == "" { + return false + } + if s.sessions.valid(token) { + return true + } + password := strings.TrimSpace(s.cfg.Server.Password) + return subtle.ConstantTimeCompare([]byte(token), []byte(password)) == 1 +} diff --git a/internal/server/server.go b/internal/server/server.go index 55e7c10..e9feab2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,12 +2,14 @@ // // An autonomous process that pushes branches and opens pull requests is // otherwise invisible, so this is how you see what it is doing and how you -// stop it. It binds to loopback by default and has no authentication: it can -// pause and cancel work, so it must not be exposed. +// stop it. It binds to loopback by default and has no authentication unless +// server.password is set: it can pause and cancel work, so it must not be +// exposed. package server import ( "context" + "crypto/subtle" "errors" "log/slog" "net" @@ -63,6 +65,7 @@ type Server struct { discord *discord.Notifier cfg config.Config registry *models.Registry + sessions *sessionStore } // New builds the API. @@ -82,6 +85,7 @@ func New(o Options) *Server { discord: o.Discord, cfg: o.Config, registry: o.Registry, + sessions: newSessionStore(), } s.routes() return s @@ -89,8 +93,12 @@ func New(o Options) *Server { func (s *Server) routes() { s.app.Use(s.sameOrigin) + s.app.Use(s.requireAuth) s.app.Get("/healthz", s.health) + s.app.Get("/auth", s.authStatus) + s.app.Post("/login", s.login) + s.app.Post("/logout", s.logout) s.app.Get("/status", s.status) s.app.Get("/runs", s.listRuns) s.app.Get("/runs/:id", s.getRun) @@ -155,6 +163,67 @@ func isLoopbackOrigin(origin, addr string) bool { return false } +// unauthenticatedPath reports whether path may be reached without a credential even when +// server.password is set: health probes, the login/auth endpoints +// themselves, the redirect root, and the console's own static assets, which +// must load in order to render the lock screen in front of them. +func unauthenticatedPath(path string) bool { + switch path { + case "/healthz", "/auth", "/login", "/": + return true + } + return strings.HasPrefix(path, "/ui") +} + +// requireAuth gates every route behind the configured password once one is +// set. With no password configured it is a no-op, preserving today's open +// behaviour. +func (s *Server) requireAuth(c fiber.Ctx) error { + if !s.authEnabled() || unauthenticatedPath(c.Path()) { + return c.Next() + } + if s.authenticated(bearerToken(c)) { + return c.Next() + } + return s.fail(c, http.StatusUnauthorized, errors.New("authentication required")) +} + +// authStatus tells the client whether a password is required at all, and +// whether its currently stored credential (if any) still works — a client +// that sends its stored token gets both answers from one call at boot. +func (s *Server) authStatus(c fiber.Ctx) error { + required := s.authEnabled() + return c.JSON(fiber.Map{ + "required": required, + "authenticated": !required || s.authenticated(bearerToken(c)), + }) +} + +func (s *Server) login(c fiber.Ctx) error { + if s.sessions.throttled() { + return s.fail(c, http.StatusTooManyRequests, errors.New("too many attempts, wait a minute")) + } + var body struct { + Password string `json:"password"` + } + if err := c.Bind().Body(&body); err != nil { + return s.fail(c, http.StatusBadRequest, errors.New("invalid request body")) + } + configured := strings.TrimSpace(s.cfg.Server.Password) + if configured == "" || subtle.ConstantTimeCompare([]byte(body.Password), []byte(configured)) != 1 { + s.sessions.recordFailure() + s.log.Warn("web console login failed") + return s.fail(c, http.StatusUnauthorized, errors.New("incorrect password")) + } + token := s.sessions.issue() + return c.JSON(fiber.Map{"token": token, "expires_in_seconds": int(sessionTTL.Seconds())}) +} + +func (s *Server) logout(c fiber.Ctx) error { + s.sessions.revoke(bearerToken(c)) + return c.JSON(fiber.Map{"ok": true}) +} + // Listen serves until ctx is cancelled, then shuts down gracefully. func (s *Server) Listen(ctx context.Context) error { errCh := make(chan error, 1) @@ -352,14 +421,18 @@ func (s *Server) cancelRun(c fiber.Ctx) error { } // getConfig returns the daemon's configuration for the web console, with the -// Discord webhook URL blanked: it is a secret, everything else here is not. +// Discord webhook URL and web console password blanked: they are secrets, +// everything else here is not. func (s *Server) getConfig(c fiber.Ctx) error { cfg := s.cfg webhookSet := cfg.Discord.WebhookURL != "" cfg.Discord.WebhookURL = "" + passwordSet := s.authEnabled() + cfg.Server.Password = "" return c.JSON(fiber.Map{ "config": cfg, "discord_webhook_set": webhookSet, + "password_set": passwordSet, }) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 343023c..57c8866 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -406,6 +406,151 @@ func TestGetConfigRedactsWebhook(t *testing.T) { } } +func TestNoPasswordLeavesEveryRouteOpen(t *testing.T) { + s, _, _ := testServer(t) // config.Default() leaves server.password empty + + for _, target := range []string{"/healthz", "/status", "/config", "/auth", "/ui/", "/ui/app.js"} { + if code, _ := do(t, s, http.MethodGet, target, nil); code == http.StatusUnauthorized { + t.Fatalf("GET %s with no password configured = 401, want it to stay open", target) + } + } +} + +func withPassword(t *testing.T, password string) (*Server, *store.Store, *fakeController) { + t.Helper() + cfg := config.Default() + cfg.Server.Password = password + return testServerWithConfig(t, cfg) +} + +func TestAuthRequiredForAPIRoutes(t *testing.T) { + s, _, _ := withPassword(t, "hunter2") + + if code, _ := do(t, s, http.MethodGet, "/status", nil); code != http.StatusUnauthorized { + t.Fatalf("GET /status with no credential = %d, want 401", code) + } + + for _, target := range []string{"/healthz", "/", "/ui/", "/ui/app.js"} { + if code, _ := do(t, s, http.MethodGet, target, nil); code == http.StatusUnauthorized { + t.Fatalf("GET %s should stay open even with a password configured, got 401", target) + } + } +} + +func doAuthed(t *testing.T, s *Server, method, target, token string, body io.Reader) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest(method, target, body) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := s.app.Test(req, fiberTimeout) + if err != nil { + t.Fatalf("%s %s: %v", method, target, err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + var decoded map[string]any + if len(raw) > 0 && raw[0] == '{' { + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("decode %s: %v (%s)", target, err, raw) + } + } + return resp.StatusCode, decoded +} + +func TestLoginWrongPassword(t *testing.T) { + s, _, _ := withPassword(t, "hunter2") + + code, body := do(t, s, http.MethodPost, "/login", strings.NewReader(`{"password":"nope"}`)) + if code != http.StatusUnauthorized { + t.Fatalf("wrong password = %d, want 401", code) + } + if body["error"] == nil { + t.Fatalf("expected an error body, got %v", body) + } +} + +func TestLoginAndUseToken(t *testing.T) { + s, _, _ := withPassword(t, "hunter2") + + code, body := do(t, s, http.MethodPost, "/login", strings.NewReader(`{"password":"hunter2"}`)) + if code != http.StatusOK { + t.Fatalf("correct password = %d, want 200: %v", code, body) + } + token, _ := body["token"].(string) + if token == "" { + t.Fatalf("login did not return a token: %v", body) + } + + if code, _ := doAuthed(t, s, http.MethodGet, "/status", token, nil); code != http.StatusOK { + t.Fatalf("GET /status with session token = %d, want 200", code) + } + + // The raw password is also accepted as a bearer credential, so scripted + // curl usage keeps working without a login round-trip. + if code, _ := doAuthed(t, s, http.MethodGet, "/status", "hunter2", nil); code != http.StatusOK { + t.Fatalf("GET /status with raw password as bearer = %d, want 200", code) + } + + if code, _ := doAuthed(t, s, http.MethodPost, "/logout", token, nil); code != http.StatusOK { + t.Fatalf("logout = %d, want 200", code) + } + if code, _ := doAuthed(t, s, http.MethodGet, "/status", token, nil); code != http.StatusUnauthorized { + t.Fatalf("GET /status after logout with revoked token = %d, want 401", code) + } +} + +func TestAuthStatusEndpoint(t *testing.T) { + s, _, _ := testServer(t) + code, body := do(t, s, http.MethodGet, "/auth", nil) + if code != http.StatusOK || body["required"] != false { + t.Fatalf("GET /auth with no password = %d %v, want required=false", code, body) + } + + s2, _, _ := withPassword(t, "hunter2") + code, body = do(t, s2, http.MethodGet, "/auth", nil) + if code != http.StatusOK || body["required"] != true || body["authenticated"] != false { + t.Fatalf("GET /auth with password unauthenticated = %d %v", code, body) + } + + code, body = doAuthed(t, s2, http.MethodGet, "/auth", "hunter2", nil) + if code != http.StatusOK || body["authenticated"] != true { + t.Fatalf("GET /auth with valid credential = %d %v, want authenticated=true", code, body) + } +} + +func TestLoginThrottlesRepeatedFailures(t *testing.T) { + s, _, _ := withPassword(t, "hunter2") + + var lastCode int + for i := 0; i < throttleMax+1; i++ { + lastCode, _ = do(t, s, http.MethodPost, "/login", strings.NewReader(`{"password":"nope"}`)) + } + if lastCode != http.StatusTooManyRequests { + t.Fatalf("after %d failures, login = %d, want 429", throttleMax+1, lastCode) + } +} + +func TestGetConfigRedactsPassword(t *testing.T) { + s, _, _ := withPassword(t, "hunter2") + + code, body := doAuthed(t, s, http.MethodGet, "/config", "hunter2", nil) + if code != http.StatusOK { + t.Fatalf("GET /config = %d", code) + } + if body["password_set"] != true { + t.Fatalf("password_set = %v, want true", body["password_set"]) + } + cfgBody, _ := body["config"].(map[string]any) + serverCfg, _ := cfgBody["server"].(map[string]any) + if serverCfg["password"] != "" { + t.Fatalf("password leaked: %v", serverCfg["password"]) + } +} + func TestPollNow(t *testing.T) { s, _, ctrl := testServer(t) diff --git a/internal/web/assets/app.css b/internal/web/assets/app.css index 96e6618..cf1b6e4 100644 --- a/internal/web/assets/app.css +++ b/internal/web/assets/app.css @@ -474,6 +474,63 @@ tbody tr:hover { word-break: break-word; } +.lock { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg); +} + +.lock[hidden] { + display: none; +} + +.lock-card { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + max-width: 320px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 24px; +} + +.lock-card h1 { + font-size: 18px; + text-align: center; + margin: 0 0 4px; +} + +.lock-card input { + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + padding: 8px 10px; + border-radius: var(--radius); + font: inherit; +} + +.lock-error { + color: var(--danger); + font-size: 13px; + margin: 0; +} + +.lock-error[hidden] { + display: none; +} + +body.locked .topbar, +body.locked #app { + display: none; +} + .empty-state { color: var(--text-muted); padding: 24px; diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js index be4299c..3c971bf 100644 --- a/internal/web/assets/app.js +++ b/internal/web/assets/app.js @@ -16,10 +16,32 @@ function showError(message) { }, 6000); } +// Deliberately sessionStorage, not localStorage like cal.theme/cal.pollInterval +// below: this is what makes closing the tab or browser log the operator out, +// per the "session persisted only" requirement. +const TOKEN_KEY = "cal.token"; + +function getToken() { + return sessionStorage.getItem(TOKEN_KEY) || ""; +} +function setToken(token) { + sessionStorage.setItem(TOKEN_KEY, token); +} +function clearToken() { + sessionStorage.removeItem(TOKEN_KEY); +} +function authHeaders() { + const token = getToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + async function apiFetch(path, options) { + const opts = options ? { ...options } : {}; + opts.headers = { ...authHeaders(), ...(opts.headers || {}) }; + let res; try { - res = await fetch(path, options); + res = await fetch(path, opts); } catch (err) { showError(`Network error calling ${path}: ${err.message}`); throw err; @@ -33,6 +55,14 @@ async function apiFetch(path, options) { body = text; } } + if (res.status === 401) { + clearToken(); + lock(); + const err = new Error("authentication required"); + err.status = res.status; + err.body = body; + throw err; + } if (!res.ok) { const message = (body && body.error) || res.statusText || `HTTP ${res.status}`; showError(`${path}: ${message}`); @@ -284,6 +314,60 @@ document.addEventListener("visibilitychange", schedulePoll); const storedInterval = localStorage.getItem(POLL_KEY); if (storedInterval) pollSelect.value = storedInterval; +// --- Lock screen --------------------------------------------------------- + +const lockScreen = document.getElementById("lock-screen"); +const lockForm = document.getElementById("lock-form"); +const lockPassword = document.getElementById("lock-password"); +const lockError = document.getElementById("lock-error"); +const lockBtn = document.getElementById("lock-btn"); + +function showLockError(message) { + lockError.textContent = message; + lockError.hidden = false; +} + +function lock() { + clearToken(); + clearInterval(pollTimer); + document.body.classList.add("locked"); + lockScreen.hidden = false; + lockPassword.value = ""; + lockPassword.focus(); +} + +function unlock() { + document.body.classList.remove("locked"); + lockScreen.hidden = true; + lockError.hidden = true; + schedulePoll(); + refreshStatus(); + currentRoute = parseHash(); + renderRoute(currentRoute); +} + +lockForm.addEventListener("submit", async (e) => { + e.preventDefault(); + lockError.hidden = true; + try { + const res = await api.post("/login", { password: lockPassword.value }); + setToken(res.token); + unlock(); + } catch (err) { + if (err.status === 429) showLockError("Too many attempts, wait a minute."); + else showLockError("Incorrect password."); + } +}); + +lockBtn.addEventListener("click", async () => { + try { + await api.post("/logout"); + } catch { + // fall through to locking locally regardless + } + lock(); +}); + // --- Router ------------------------------------------------------------ const views = { @@ -656,7 +740,7 @@ async function renderRunDetail(id, silent) { loadBtn.disabled = true; loadBtn.textContent = "Loading…"; try { - const res = await fetch(`/runs/${encodeURIComponent(id)}/log`); + const res = await fetch(`/runs/${encodeURIComponent(id)}/log`, { headers: authHeaders() }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const text = await res.text(); transcriptHolder.innerHTML = ""; @@ -814,7 +898,26 @@ function ladderTable(ladder, cooled) { // --- Boot -------------------------------------------------------------- -schedulePoll(); -refreshStatus(); -currentRoute = parseHash(); -renderRoute(currentRoute); +async function boot() { + let auth = { required: false, authenticated: true }; + try { + auth = await api.get("/auth"); + } catch { + // treat an unreachable daemon like no auth requirement; refreshStatus + // below will report "Unreachable" on the status pill either way + } + + lockBtn.hidden = !auth.required; + + if (auth.required && !auth.authenticated) { + lock(); + return; + } + + schedulePoll(); + refreshStatus(); + currentRoute = parseHash(); + renderRoute(currentRoute); +} + +boot(); diff --git a/internal/web/assets/index.html b/internal/web/assets/index.html index 0ba304a..1a6fbcb 100644 --- a/internal/web/assets/index.html +++ b/internal/web/assets/index.html @@ -7,6 +7,15 @@ + +
@@ -30,6 +39,7 @@ +
diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 31613ab..ee90a93 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -30,3 +30,25 @@ func TestIndexReferencesOtherAssets(t *testing.T) { } } } + +// TestLockScreenPresent guards the password-lock UI (issue #13): the lock +// screen markup must exist for auth.go's server.password gate to have +// anything to show, and the session token must live in sessionStorage, not +// localStorage, or "closing the tab logs you out" silently stops being true. +func TestLockScreenPresent(t *testing.T) { + html, err := fs.ReadFile(Assets, "index.html") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(html), "lock-screen") { + t.Error("index.html does not contain the lock-screen element") + } + + js, err := fs.ReadFile(Assets, "app.js") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(js), "sessionStorage") { + t.Error("app.js does not use sessionStorage for the session token") + } +}