You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Add a required password input when initially visiting the web UI. set the password in config.json at server.password. Password is session persisted only, so if tab/browser closes the user is "logged out".
Add an optional server.password that gates the control API and puts a login screen in front of the web UI, with the unlocked state held in sessionStorage so closing the tab or browser logs the operator out.
Context
internal/server is documented as "loopback-only, no authentication" (internal/server/server.go:1-7), and since #11 it also serves an embedded browser console at /ui that can pause the loop, cancel runs, and read full Claude transcripts. Issue #13 asks for a required password prompt on first visit, configured at server.password in config.json, with session-only persistence. The result should be backwards compatible: an existing config.json with no password key keeps today's open behaviour.
Reading chosen where the issue is ambiguous: "session persisted only" means the browser holds a short-lived session token in sessionStorage (per-tab, dropped on tab/browser close), not a cookie and not localStorage. The token is issued by the server and kept in memory, so a daemon restart also logs everyone out.
Config
internal/config/config.go
Add to ServerConfig: Password string \json:"password"`with a comment saying empty = no authentication (today's behaviour) and that it is stored in plaintext inconfig.json`.
Default() leaves it empty — config.Load uses DisallowUnknownFields, so the field must exist before config.example.json can carry it.
No new rule in Validate(); a blank password simply means "disabled" (compare with strings.TrimSpace, so " " is treated as unset rather than as a password nobody can type).
config.example.json — add "password": "" under "server". The Makefile's embed-ready target copies this file to the gitignored config.json that embedded.go compiles in, so nothing else needs touching for the embedded fallback.
Server: auth primitives
New file internal/server/auth.go:
type sessionStore struct — mutex, map[string]time.Time of token → expiry, a TTL constant (12h), plus a failed-attempt counter and window start for login throttling. Methods: issue() string (32 random bytes from crypto/rand, hex/base64url encoded), valid(token string) bool (prunes expired entries and slides the expiry forward on a hit), revoke(token string), and throttled() bool / recordFailure() (e.g. 10 failures per minute → 429). Constants live in the package so tests can drive them.
Server gains a sessions *sessionStore field, created in New (always; it is inert when no password is configured).
func (s *Server) authEnabled() bool — strings.TrimSpace(s.cfg.Server.Password) != "".
Credential extraction: Authorization: Bearer <value>, accepted if it is either a live session token or the configured password itself, compared with crypto/subtle.ConstantTimeCompare. Accepting the raw password keeps the documented curl localhost:8787/status workflow usable (curl -H "Authorization: Bearer $PASSWORD" ...) — see Decisions below.
Server: middleware and routes
internal/server/server.go
In routes(), register s.app.Use(s.requireAuth) immediately after the existing s.app.Use(s.sameOrigin), so the origin check still runs first and one middleware covers every route including the static mount.
requireAuth(c fiber.Ctx) error:
!s.authEnabled() → c.Next().
Unauthenticated allow-list, by c.Path(): /healthz (liveness probes / systemd), /auth, /login, / (the redirect), and anything under /ui — the console's own HTML/CSS/JS must load in order to render the lock screen, and those assets contain no run data.
Otherwise, valid credential → c.Next(); else s.fail(c, http.StatusUnauthorized, errors.New("authentication required")), reusing the existing fail helper so the body shape matches every other error and apiFetch surfaces it unchanged.
New handlers, registered unconditionally so the front end has one code path:
GET /auth → {"required": <bool>, "authenticated": <bool>}. Called at boot; because the client sends its stored token with it, this doubles as "is my stored token still good?".
POST /login → binds {"password": "..."} with c.Bind().Body, returns 429 when throttled, 401 + recordFailure() on mismatch, and {"token": ..., "expires_in_seconds": ...} on success. Log failures via s.log.Warn (no password in the log line).
POST /logout → revokes the presented token, always {"ok": true}.
getConfig must redact the password exactly like the Discord webhook: blank cfg.Server.Password on the copy and add "password_set": <bool> alongside discord_webhook_set. This matters even though /config is itself behind auth — the console renders the whole config blob into the page.
Web console
internal/web/assets/index.html
Add a <div id="lock-screen" class="lock" hidden> overlay before <header>, containing a <form id="lock-form"> with an <h1>, a type="password" input (autocomplete="current-password", autofocus), a submit button reusing .btn .btn-primary, and a <p id="lock-error" role="alert" hidden>.
Add a <button id="lock-btn"> to .topbar-right, hidden unless auth is required.
internal/web/assets/app.js
const TOKEN_KEY = "cal.token" held in sessionStorage (deliberately not localStorage, unlike the existing cal.theme / cal.pollInterval keys — that difference is what makes "tab closed = logged out" true, and deserves a comment).
authHeaders() helper; apiFetch merges it into options.headers for every request. The one raw fetch — the transcript loader at app.js:659 — gets the same headers.
In apiFetch, treat res.status === 401 specially: clear the stored token, call lock(), and skip the error banner (the lock screen is the message).
lock() clears the token, clearInterval(pollTimer), un-hides #lock-screen, hides the chrome (a locked class on <body>), focuses the input. unlock() reverses it and runs the current boot sequence.
Restructure the four boot statements at the bottom of app.js into async function boot(): GET /auth first; if required && !authenticated → lock(); otherwise show/hide the Lock button per required and run the existing schedulePoll() / refreshStatus() / parseHash() / renderRoute().
Lock form submit → api.post("/login", {password}), store token in sessionStorage, unlock(); on failure show the message in #lock-error (401 → "Incorrect password", 429 → "Too many attempts, wait a minute").
Lock button → api.post("/logout") then lock().
internal/web/assets/app.css — .lock (fixed full-viewport flex centering over --bg), .lock-card (--surface, --border, --radius, --shadow), .lock-error (--danger), and body.locked .topbar, body.locked #app { display: none }. Existing .btn/.btn-primary cover the button; the theme toggle already runs before boot so the lock screen inherits the right theme.
Tests
internal/server/server_test.go (reuse testServerWithConfig with cfg.Server.Password = "hunter2"): unauthenticated GET /status → 401; /healthz, /, /ui/, /ui/app.js still 200; wrong password → 401; correct password → token; that token as Authorization: Bearer → 200; the raw password as bearer → 200; POST /logout then reuse of the token → 401; GET /auth shape in both configured and unconfigured cases; repeated failures → 429; GET /config blanks server.password and sets password_set (mirror TestGetConfigRedactsWebhook). Keep an explicit regression test that with Password: "" every existing route stays open.
internal/config/config_test.go: {"server":{"password":"s3cret"}} loads (guards DisallowUnknownFields) and Default().Server.Password == "".
internal/web/web_test.go: extend the existing asset-reference checks to assert index.html contains lock-screen and app.js contains sessionStorage, matching the file's stated purpose of catching silent renames.
Docs (README.md)
Config table (~line 431): add 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".
API route table (~line 559): add GET /auth, POST /login, POST /logout, plus a paragraph on how scripts authenticate (-H "Authorization: Bearer $PASSWORD") and which routes stay open (/healthz, /, /ui/*).
"Web interface" section (~line 571-580): replace "loopback-only and unauthenticated by default" with the new nuance — still loopback-first, now optionally password-locked; session-only, so closing the tab logs you out; a daemon restart invalidates all sessions; and the password is not a substitute for binding to loopback, since there is no TLS.
Decisions the reviewer may want to overrule
Raw password accepted as a bearer credential. Keeps curl usable without a login round-trip, but means the password crosses the wire on every scripted call. Drop it if the lock should be browser-only, at the cost of every README curl example needing a POST /login first.
Plaintext password in config.json. What the issue asks for; no hashing, no env-var override. The mitigation is documentation plus the existing loopback default.
/ui assets served unauthenticated. Required for the login screen to render; they expose no run data. The alternative (a separate embedded login page) is more code for no real gain.
/healthz stays open so systemd/uptime probes don't need a credential.
Verification
make embed-ready && go test ./... (plus make vet lint if available) — new server/config/web tests pass, and TestUIServedAndRootRedirects / TestSameOriginGuard still pass unchanged.
make config, set "password": "hunter2", make run, then:
curl -s -XPOST localhost:8787/login -H 'Content-Type: application/json' -d '{"password":"hunter2"}' → a token; reusing it as a bearer on /status → 200.
Browser: http://127.0.0.1:8787/ → lock screen; wrong password shows an inline error; correct password loads the dashboard and polling resumes; close the tab and reopen → locked again; the Lock button returns to the lock screen immediately.
Set "password": "" and restart → no lock screen, all curl calls work without a header (backwards compatibility).
Plan file written to /home/node1/.claude/plans/write-a-plan-for-peaceful-clarke.md. No repository files were modified.
Reply with exactly implement to approve this plan and start the change. Reply with anything else and the plan will be revised to address it.
coding-agent-loop run d6f561dc-0dac-4fc8-9096-c5997d06a2f7, model claude-opus-5, cost $1.4762
Activity
ableinc commentedon Aug 26, 2026
Plan
Password lock for the web console (issue #13)
Add an optional
server.passwordthat gates the control API and puts a login screen in front of the web UI, with the unlocked state held insessionStorageso closing the tab or browser logs the operator out.Context
internal/serveris documented as "loopback-only, no authentication" (internal/server/server.go:1-7), and since #11 it also serves an embedded browser console at/uithat can pause the loop, cancel runs, and read full Claude transcripts. Issue #13 asks for a required password prompt on first visit, configured atserver.passwordinconfig.json, with session-only persistence. The result should be backwards compatible: an existingconfig.jsonwith nopasswordkey keeps today's open behaviour.Reading chosen where the issue is ambiguous: "session persisted only" means the browser holds a short-lived session token in
sessionStorage(per-tab, dropped on tab/browser close), not a cookie and notlocalStorage. The token is issued by the server and kept in memory, so a daemon restart also logs everyone out.Config
internal/config/config.goServerConfig:Password string \json:"password"`with a comment saying empty = no authentication (today's behaviour) and that it is stored in plaintext inconfig.json`.Default()leaves it empty —config.LoadusesDisallowUnknownFields, so the field must exist beforeconfig.example.jsoncan carry it.Validate(); a blank password simply means "disabled" (compare withstrings.TrimSpace, so" "is treated as unset rather than as a password nobody can type).config.example.json— add"password": ""under"server". The Makefile'sembed-readytarget copies this file to the gitignoredconfig.jsonthatembedded.gocompiles in, so nothing else needs touching for the embedded fallback.Server: auth primitives
New file
internal/server/auth.go:type sessionStore struct— mutex,map[string]time.Timeof token → expiry, a TTL constant (12h), plus a failed-attempt counter and window start for login throttling. Methods:issue() string(32 random bytes fromcrypto/rand, hex/base64url encoded),valid(token string) bool(prunes expired entries and slides the expiry forward on a hit),revoke(token string), andthrottled() bool/recordFailure()(e.g. 10 failures per minute → 429). Constants live in the package so tests can drive them.Servergains asessions *sessionStorefield, created inNew(always; it is inert when no password is configured).func (s *Server) authEnabled() bool—strings.TrimSpace(s.cfg.Server.Password) != "".Authorization: Bearer <value>, accepted if it is either a live session token or the configured password itself, compared withcrypto/subtle.ConstantTimeCompare. Accepting the raw password keeps the documentedcurl localhost:8787/statusworkflow usable (curl -H "Authorization: Bearer $PASSWORD" ...) — see Decisions below.Server: middleware and routes
internal/server/server.goroutes(), registers.app.Use(s.requireAuth)immediately after the existings.app.Use(s.sameOrigin), so the origin check still runs first and one middleware covers every route including the static mount.requireAuth(c fiber.Ctx) error:!s.authEnabled()→c.Next().c.Path():/healthz(liveness probes / systemd),/auth,/login,/(the redirect), and anything under/ui— the console's own HTML/CSS/JS must load in order to render the lock screen, and those assets contain no run data.c.Next(); elses.fail(c, http.StatusUnauthorized, errors.New("authentication required")), reusing the existingfailhelper so the body shape matches every other error andapiFetchsurfaces it unchanged.GET /auth→{"required": <bool>, "authenticated": <bool>}. Called at boot; because the client sends its stored token with it, this doubles as "is my stored token still good?".POST /login→ binds{"password": "..."}withc.Bind().Body, returns 429 when throttled, 401 +recordFailure()on mismatch, and{"token": ..., "expires_in_seconds": ...}on success. Log failures vias.log.Warn(no password in the log line).POST /logout→ revokes the presented token, always{"ok": true}.getConfigmust redact the password exactly like the Discord webhook: blankcfg.Server.Passwordon the copy and add"password_set": <bool>alongsidediscord_webhook_set. This matters even though/configis itself behind auth — the console renders the whole config blob into the page.Web console
internal/web/assets/index.html<div id="lock-screen" class="lock" hidden>overlay before<header>, containing a<form id="lock-form">with an<h1>, atype="password"input (autocomplete="current-password",autofocus), a submit button reusing.btn .btn-primary, and a<p id="lock-error" role="alert" hidden>.<button id="lock-btn">to.topbar-right, hidden unless auth is required.internal/web/assets/app.jsconst TOKEN_KEY = "cal.token"held insessionStorage(deliberately notlocalStorage, unlike the existingcal.theme/cal.pollIntervalkeys — that difference is what makes "tab closed = logged out" true, and deserves a comment).authHeaders()helper;apiFetchmerges it intooptions.headersfor every request. The one rawfetch— the transcript loader atapp.js:659— gets the same headers.apiFetch, treatres.status === 401specially: clear the stored token, calllock(), and skip the error banner (the lock screen is the message).lock()clears the token,clearInterval(pollTimer), un-hides#lock-screen, hides the chrome (alockedclass on<body>), focuses the input.unlock()reverses it and runs the current boot sequence.app.jsintoasync function boot():GET /authfirst; ifrequired && !authenticated→lock(); otherwise show/hide the Lock button perrequiredand run the existingschedulePoll() / refreshStatus() / parseHash() / renderRoute().api.post("/login", {password}), storetokeninsessionStorage,unlock(); on failure show the message in#lock-error(401 → "Incorrect password", 429 → "Too many attempts, wait a minute").api.post("/logout")thenlock().internal/web/assets/app.css—.lock(fixed full-viewport flex centering over--bg),.lock-card(--surface,--border,--radius,--shadow),.lock-error(--danger), andbody.locked .topbar, body.locked #app { display: none }. Existing.btn/.btn-primarycover the button; the theme toggle already runs before boot so the lock screen inherits the right theme.Tests
internal/server/server_test.go(reusetestServerWithConfigwithcfg.Server.Password = "hunter2"): unauthenticatedGET /status→ 401;/healthz,/,/ui/,/ui/app.jsstill 200; wrong password → 401; correct password → token; that token asAuthorization: Bearer→ 200; the raw password as bearer → 200;POST /logoutthen reuse of the token → 401;GET /authshape in both configured and unconfigured cases; repeated failures → 429;GET /configblanksserver.passwordand setspassword_set(mirrorTestGetConfigRedactsWebhook). Keep an explicit regression test that withPassword: ""every existing route stays open.internal/config/config_test.go:{"server":{"password":"s3cret"}}loads (guardsDisallowUnknownFields) andDefault().Server.Password == "".internal/web/web_test.go: extend the existing asset-reference checks to assertindex.htmlcontainslock-screenandapp.jscontainssessionStorage, matching the file's stated purpose of catching silent renames.Docs (
README.md)server.password— "optional password for the web console and control API; empty disables authentication; stored in plaintext, so keepconfig.jsonreadable only by the daemon user"."password": "".GET /auth,POST /login,POST /logout, plus a paragraph on how scripts authenticate (-H "Authorization: Bearer $PASSWORD") and which routes stay open (/healthz,/,/ui/*).Decisions the reviewer may want to overrule
curlusable without a login round-trip, but means the password crosses the wire on every scripted call. Drop it if the lock should be browser-only, at the cost of every READMEcurlexample needing aPOST /loginfirst.config.json. What the issue asks for; no hashing, no env-var override. The mitigation is documentation plus the existing loopback default./uiassets served unauthenticated. Required for the login screen to render; they expose no run data. The alternative (a separate embedded login page) is more code for no real gain./healthzstays open so systemd/uptime probes don't need a credential.Verification
make embed-ready && go test ./...(plusmake vet lintif available) — new server/config/web tests pass, andTestUIServedAndRootRedirects/TestSameOriginGuardstill pass unchanged.make config, set"password": "hunter2",make run, then:curl -i localhost:8787/status→ 401;curl -i localhost:8787/healthz→ 200.curl -s -XPOST localhost:8787/login -H 'Content-Type: application/json' -d '{"password":"hunter2"}'→ a token; reusing it as a bearer on/status→ 200.curl -s localhost:8787/config -H "Authorization: Bearer hunter2" | jq .config.server.password→"",.password_set→true.http://127.0.0.1:8787/→ lock screen; wrong password shows an inline error; correct password loads the dashboard and polling resumes; close the tab and reopen → locked again; the Lock button returns to the lock screen immediately."password": ""and restart → no lock screen, allcurlcalls work without a header (backwards compatibility).Plan file written to
/home/node1/.claude/plans/write-a-plan-for-peaceful-clarke.md. No repository files were modified.Reply with exactly
implementto approve this plan and start the change. Reply with anything else and the plan will be revised to address it.coding-agent-loop run
d6f561dc-0dac-4fc8-9096-c5997d06a2f7, modelclaude-opus-5, cost $1.4762ableinc commentedon Aug 26, 2026
implement
ableinc commentedon Aug 26, 2026
Opened a draft pull request for this issue: #16
Tests failed (
make test) — see the PR for output.Comment
implementagain if you want another attempt at this issue.coding-agent-loop run
13d5a39a-e5c7-43d3-b133-c5307d79f4faMerge pull request #16 from ableinc/agent/issue-13-password-lock-ui