Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand All @@ -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 <value>`, where `<value>` 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
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@
},
"server": {
"addr": "127.0.0.1:8787",
"ui": true
"ui": true,
"password": ""
},
"store": {
"path": "~/.agent-loop/state.db"
Expand Down
8 changes: 6 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
135 changes: 135 additions & 0 deletions internal/server/auth.go
Original file line number Diff line number Diff line change
@@ -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 <value>".
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
}
79 changes: 76 additions & 3 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -63,6 +65,7 @@ type Server struct {
discord *discord.Notifier
cfg config.Config
registry *models.Registry
sessions *sessionStore
}

// New builds the API.
Expand All @@ -82,15 +85,20 @@ func New(o Options) *Server {
discord: o.Discord,
cfg: o.Config,
registry: o.Registry,
sessions: newSessionStore(),
}
s.routes()
return s
}

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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
})
}

Expand Down
Loading
Loading