The web UI — cyberine web (browser fleet monitor + chat)
cyberine web starts a dependency-free HTTP server (Node's built-in http, no framework) that serves a
single-page browser UI mirroring the fleet TUI plus the chat planner. Same data as cyberine agents /
cyberine chat, in a browser, live over Server-Sent Events.
Like cyberine agents, cyberine web runs the registry backfill once at startup (scanning the
current project's .local/runs/ for any run missing its global-registry record) before it starts
listening — a dashboard-only user gets the same self-heal cyberine agents/cyberine reap already
had, without ever touching the CLI.
cyberine web # listen on 127.0.0.1:41700, open http://127.0.0.1:41700/
cyberine web --port 8080 # override the port
cyberine web --host 0.0.0.0 # bind all interfaces for LAN access (default is loopback only)
CYBERINE_WEB_PORT=9000 cyberine web # env form; flag > env > default (127.0.0.1:41700)Loopback binds remain open by default. For a LAN or other non-loopback bind, every mutating route
requires a Cyberine API bearer token. A non-loopback bind with neither an OAuth provider configured
nor CYBERINE_WEB_REQUIRE_AUTH=1 set also fails closed on reads: GET /api/* returns
503 AUTH_SETUP_REQUIRED instead of serving data unauthenticated (a valid bearer token still works;
GET /api/me stays public so the UI can always report login state). Issue the token locally; only
its SHA-256 hash is persisted under $CYBERINE_HOME/api-tokens.json:
cyberine api-token issue laptop
CYBERINE_WEB_CORS_ORIGINS=http://192.168.1.20:3000 cyberine web --host 0.0.0.0
curl http://server:41700/v1/runs \
-H 'Authorization: Bearer cyberine_...' \
-H 'Content-Type: application/json' \
-d '{"task":"inspect the current run"}'
cyberine api-token list
cyberine api-token revoke <id>Cross-origin browser requests are denied unless their exact origin appears in the comma-separated
CYBERINE_WEB_CORS_ORIGINS allowlist. Dispatch, authentication, and heavy-read requests use separate
per-client, one-minute in-process rate limits. A rejected request returns 429 with Retry-After.
What the UI offers:
-
Persistent 3-pane shell — a left sidebar (navigation + Active + Recent + settings/help footer), a center canvas (empty-state or the active run/chat), and a collapsible right context pane (Review / git changes). Selecting an item swaps only the center in place; top-bar toggles collapse the side panes. Project scope, + New task and the Cmd-K search trigger live in the top bar (kejx4).
-
Responsive panes (ktkrx) — below 1280px the right pane, and below 768px the left one too, have no room for a grid column, so an OPEN pane renders as an overlay drawer over the center (capped at 88vw, so it still fits at 360px) instead of being silently pinned to 0px. Crossing a breakpoint closes the pane that just lost its column — a drawer never springs open over your content on a resize — and widening again restores what it was. The toggle buttons carry
aria-expandedand their lit state is derived from the same intent the layout uses, so a toggle can no longer look active while its pane stays invisible. Known limitation: an open drawer has no click-outside backdrop; close it with its own toggle (the top-bar buttons stay visible at every width) or the right pane's × button. Guarded bynpm run selftest:responsive-panes. -
Sidebar: navigation + Active + Recent, not the whole registry (kejx4) — the sidebar used to render every run in the registry (measured on a real machine: 5,588 list children, 47,698 DOM nodes, one render taking ~848ms and rerunning every 30s and after every fleet change). It now answers "what is happening now": tabs for Runs / Chat / Queue / Costs, then an Active section (running or queued, non-stale, capped at 12, with Stop All beside its heading and a "+N more" link to
#/runs?state=runningpast the cap), then Recent (the newest 10 runs in the selected project scope), then a View all runs (N) link to the full-history#/runsview (kfqpf/k9tdq) —Nis the registry total. A nonterminal run past the stale threshold (stale: true) is excluded from both Active and Stop All. Both sections come from the paged/api/runsAPI (a smallstate=running/state=queuedfetch paged to exhaustion for Active — Stop All must act on every active run, not just the capped display list — andlimit=10&project=…for Recent), never the plain unparameterised array; a registry of thousands of runs still renders at most a few dozen sidebar rows. The 30s timer re-renders Active/Recent from data already in memory to refresh their relative timestamps — it never refetches; a fleet change (SSElifecycle/runs-dir-change) is what triggers an actual (still bounded) refetch. Each row shows its canonical project (a worktree run folds back onto its parent repo, so it never shows a raw worktree slug liket633-engine-bVNIMX), its state when notdone, and its worker count above one. Known limitation: the km0jn folder-label widening (disambiguating same-named folders under different parents) now only covers the runs in Active + Recent, not the whole registry — cosmetic-only, since a run outside both bounded lists falls back to its unwidened basename rather than showing the wrong label. Guarded bynpm run selftest:web-sidebar-active-recent. -
Navigation keeps its context (km0jn) — opening a run or a session switches the sidebar to the tab that owns it and highlights the row, so a Cmd-K jump or a deep link can no longer leave the centre showing a run while the sidebar lists chats with nothing selected.
-
Run detail — click a run to tail its events + worker output live (SSE), with reply/steer and stop controls. The header carries id, start time, cwd, step count and — since km0jn — the worker count, token total and cost the sidebar row already showed, read from the same
/api/runssummary so the two can never disagree. Those figures refresh when the run reaches a terminal state; mid-run they are as fresh as the last fleet refresh. -
Chat — the planning copilot in the browser: session list, resume a session, and stream planner tokens live (the same
plannerTurnloop the TUI uses). When the planner callsask_user, the server streams aquestionSSE frame and suspends; the browser renders a selection card and POSTs the choice back to/api/chat/:id/answer, which resumes the suspended turn (TUI/readline parity). -
One composer (km0jn) — first load shows the Cyberine mark, a "Dispatch anything" headline, the selected project scope (kejx4; see the sidebar bullet above), and the composer. A pill row under it sets the dispatch knobs on the
POST /runsbody: permission mode (dispatchMode), build worker (fleet, pinningCYBERINE_BUILD_FLEET; "Auto" leaves the pick to the router), opencode agent (openCodeAgent) and an optionalcwd. The sidebar used to carry a SECOND, reduced composer (task + cwd, no pills), so which surface you happened to use silently decided whether the knobs existed; that one is retired and the top bar's + New task button (moved out of the sidebar by kejx4, alongside the project scope select) routes to this one from any view and focuses it. Known limitation: reaching the composer from a run or chat therefore swaps the centre view — there is no dispatch-without-leaving-what-you-are-reading path. Guarded bynpm run selftest:web-ia. The worker pill is populated fromGET /api/fleet, so it can only ever offer workers this machine can actually run, and Send is enabled exactly when the composer is non-empty. -
Pill option lists (khfvx) — the three pills open a real listbox showing every option at once with the current one checked and, for workers, the model/tier detail on each row. They used to advance one option per click with no list and no way back, so the option set was invisible until cycled through and a mis-click cost a full lap to undo — on the dispatch-mode pill that lap passes through
bypassPermissions. Mouse or keyboard: Enter/Space/ArrowDown opens, ArrowUp/ArrowDown/Home/End move, Enter picks, Esc closes and returns focus to the pill. Known limitation: the list is not filterable — fine for the current option counts, but a fleet with dozens of workers would want a type-to-filter box. Guarded bynpm run selftest:pill-picker. -
Cmd-K search — a global overlay searching across runs (task/id/folder) and chat sessions; type to filter live, Enter to jump, Esc to close.
-
Dispatch + stream feedback (kf629) — a successful dispatch names the run it started (
Dispatched run 4f1c2a90) and links to it; the centre composer additionally opens it, while the sidebar dispatch bar deliberately does not navigate (it is used while watching another run). The status line is announced once through the live region and expires on its own — 6s for a confirmation, 12s for an error — instead of sitting there indefinitely. A dropped per-run SSE stream now shows a band above the output saying whether it is retrying or dead, with a Reconnect button; the run keeps going on the server either way. Known limitation: Reconnect rebuilds the stream from the server's replay, so the output pane re-renders from the start of the run rather than resuming mid-stream. Guarded bynpm run selftest:dispatch-feedback. -
Actions + review pane — a reap button for stuck runs, a per-file git-diff tree in the right pane, and loading/empty/error states on every async view; icon buttons carry aria-labels and the live/SSE indicator has a subtle breathing animation (disabled under
prefers-reduced-motion). -
URL navigation (hash router) — every view has a stable address:
#/runs/<id>,#/chat/<id>,#/queue, and#/(empty-state composer). Deep links work on a fresh load, browser Back/Forward walk the view history, and every selection path (sidebar click, Cmd-K jump, dispatch-open) routes through the hash. Unknown or stale ids render the normal error/empty state, never a blank screen. -
Keyboard list navigation (TUI parity) — with focus outside an input, ArrowUp/ArrowDown (or j/k) move a selection highlight through the sidebar list, Enter opens the highlighted item, Esc closes overlays.
-
Accessibility (kd3rw) — every input and textarea draws a focus ring on keyboard focus (mouse focus still draws none); each theme block declares its own
color-schemeso native scrollbars, carets and form widgets match the page; the run-output and chat-transcript logs arearia-live="off"because they append per token while streaming — milestones (Run done,Reply complete, a dispatch landing, a dropped stream) are written once each to a single polite#sr-announcerregion instead; and the Cmd-K listbox exposes its selection viaaria-selectedplusaria-activedescendanton the combobox input rather than a CSS class alone. Known limitation: this covers the four structural defects the 2026-08-29 audit found from source. It is not a full WCAG pass — colour-contrast and reading-order review across the whole dashboard is a separate task (ks0zk), and nothing here has been verified against a real screen reader. Guarded bynpm run selftest:web-a11y. -
Site palette, dark and light (k1fm1) — the dashboard wears the website's locked palette: Sulfur on dark, Mineral on light, and the site's mark and favicons instead of the old ouroboros PNG. It follows the OS appearance (
prefers-color-scheme); settingdata-theme="dark"ordata-theme="light"on<html>forces one. Body text holds at least 4.5:1 on every surface and each status badge's text at least 5:1 on its own tint, in both modes. To look at it, switch macOS System Settings > Appearance between Dark and Light whilecyberine webis open. Known limitation: there is no in-page theme toggle yet, and thedone(sulfur) andrunning/warn(amber) badges are close hues, so tell them apart by label rather than colour. Guarded bynpm run selftest:web-type-contrast, which checks both theme blocks. -
CRUD actions (TUI parity) — runs: a two-step confirm Delete in the run header (tombstones the registry record via
DELETE /api/runs/:id). Chat sessions: New (creates a draft and opens it), inline Rename (Enter saves, Esc cancels), Fork, and two-step Delete. All wired to the/api/chat/sessionsCRUD routes. -
Destructive controls are armed, never one-click (k5dhw) —
StopandStop Alluse the same two-step arm as Delete: the first click flips the label (Confirm stop, orConfirm stop <n>naming exactly how many runs would die) and turns it red, a second click within 3s sends the request, and 3s of inaction, switching runs, or switching sidebar tab disarms it. A stopped run has already spent its tokens and cannot be resumed, which is why it is guarded like a delete.Stop Allis additionally DISABLED whenever nothing is running or queued — its tooltip names the live count — and its stops fan out concurrently rather than one at a time, so the last run in a long fleet is not still burning tokens while the first ones are already dead. It sits beside the Active heading in the sidebar (kejx4) and always acts on the FULL active set, paged to exhaustion through the API — never just the capped rows the sidebar happens to render. Known limitation: there is still no per-run opt-out insideStop All(it is all-or-nothing) and no undo — a stopped run is stopped. Stop individual runs from their detail view when you want to keep some alive. Guarded bynpm run selftest:destructive-guards. -
Paged and filtered run list (kfqpf) —
GET /api/runsreturns the plain array only when no paging parameter is sent, so every existing caller is unchanged. Send any oflimit,cursor,q,project,state,ageand it returns{items, nextCursor, total, facets}instead:curl -s '127.0.0.1:41700/api/runs?limit=50' # first page, newest first curl -s '127.0.0.1:41700/api/runs?limit=50&cursor=<nextCursor>' # the next one curl -s '127.0.0.1:41700/api/runs?project=/path/to/repo&state=error' curl -s '127.0.0.1:41700/api/runs?age=day&q=research'limitdefaults to 25 and caps at 100.cursoris opaque — pass back thenextCursorthe previous page returned, never an offset.stateacceptsstalealongside the real run states.ageis one ofhour,day,week,month,older.projectis the repo path, with worktree runs folded onto their parent checkout, so one project row covers its own fan-out.facetscarries{states, projects, projectsTotal, ages}, each counted over the set filtered by every OTHER dimension so the numbers answer "what if I switched to this one". Each paged item also carriesproject(the canonical, worktree-folded project path) andprojectLabel(its display name) — added for kejx4's sidebar so a worktree run's row shows its parent repo's name rather than the worktree's own slug. A bad parameter is the standard{error:{code,message,details}}envelope with 400. Measured on this machine's registry (5,414 runs): the plain array is 2.39MB,?limit=50is 59KB and?limit=1is 3.9KB. Known Limitations and Deferred Work. Theprojectsfacet is capped at the busiest 25 (projectsTotalsays how many matched); there is no way to page through the rest. Filtering still reads the whole registry listing in memory before slicing it — the saving is on the wire, not on the server's read. The dashboard's sidebar now reads this endpoint exclusively (kejx4); the#/runsfull-history view (below) and the plain unparameterised array (kept for any other caller) already did. -
Run archive (
#/runs, k9tdq) — the centre-pane surface built for FINDING a run, fed by the paged API above rather than the sidebar's in-memory array. Reach it from the sidebar's View all runs (N) link, or#/runs?state=runningfrom the +N more link when Active overflows its cap (kejx4 retired the old dedicated Archive sidebar tab in favour of these two links). It carries a project selector (All projects by default, counts from the facets), a state selector includingstale, an age selector, a debounced text search, optional grouping by project or state, 50 rows a page with Previous / Next, and Compare. Every filter rides in the hash query (#/runs?project=…&state=error&age=week&q=…), so a deep link reproduces a filtered view and browser Back returns to the same filters instead of a reset list;#/runs/<id>still opens one run. Known Limitations and Deferred Work. Paging is forward-and-back through a cursor stack, so there is no jump to page N and no total page count. The project selector shows the busiest 25 projects (the facet cap) and says so in the summary line; a project outside that list is still selectable only through a hash link. -
Cmd-K searches beyond the sidebar's own Active + Recent (kmvyx) — run search goes through the paged API's
q(debounced 300ms, abortable, top 12) instead of filtering whatever the sidebar had loaded, so it finds a run from any project and any age. Chat-session search stays local, the keyboard contract (Enter / Esc / arrows) is unchanged, and an empty query costs no request: it shows the runs already in memory — the sidebar's Active + Recent (kejx4), deduplicated. -
Stale running runs (k4g31) — a
runningorqueuedrun whose registry record has not changed for longer than the reaper's wall-clock cap (CYBERINE_STUCK_WALL_MS, default 2 hours) comes back fromGET /api/runswithstale: true. The dashboard shows it asSTALE <age>with a muted, still dot, excludes it from the sidebar's Active section (kejx4) and its live count, and leaves it out ofStop All. Its row carries aReapbutton (the run header's reap control works too), andPOST /api/runs/:id/reapnow closes every zombie shape and returns{ outcome }:reapedmarks a still-nonterminalrun.jsoncancelled,syncedcopies arun.jsonthat already finished onto a registry record that never heard about it, andorphanedmarks the registry record cancelled when the run's directory no longer exists (a deleted worktree). Known limitation: nothing reaps these on its own unlesscyberine serveorcyberine daemonis running; its reaper also skips a record whoserun.jsonis gone or already terminal, which is how a registry collects runs that read as running for weeks. The dashboard marks them, it does not sweep them. -
Queue view — a sidebar "Queue" entry with a live pending-count badge opens
#/queue: entries with status chips, relative enqueue times, runId links for dispatched rows, error text on failed rows, and a confirm-delete on pending rows (a 409 shows an "already dispatched" notice). Polls every 5s while open, with a manual refresh button. -
Cost ledger view — a sidebar "Costs" entry opens
#/costs: spend aggregated by day and by provider/worker, plus a per-run dollar-per-task table, readingGET /api/costs?days=N. A "time window" dropdown (all time / 1 / 7 / 30 / 90 days) mirrorscyberine cost --days N, and the live daily-cap / hard-cap posture (the same snapshot/metricsreports) shows above the tables. This reuses the exact aggregationcyberine costalready prints — both the CLI and the route callsummarizeCostLedger()insrc/store/costSummary.tsover the sameRunStore().list()read, so the two numbers cannot drift apart. Known limitation: the per-run table caps at the 200 most recent runs in the selected window (no pagination yet) and the view does not auto-refresh — hit the toolbar's Refresh button or reopen#/costsafter a run finishes. Guarded bynpm run selftest:web-cost-ledger(cross-checks the JSON againstcmdCost's own stdout for both the all-time and a filtered window). -
Run compare — a "Compare" toggle above the run filter puts the sidebar into selection mode: click up to two run rows (a third pick drops the oldest), then "Compare" navigates to
#/compare/<a>/<b>, which readsGET /api/run-comparisons?a=<id>&b=<id>and renders the two runs side by side — per-adapter output, the judge verdict (with the winning adapter flagged), cost, duration, and token usage for each. This is a dedicated route rather than client-side composition of two/api/runs/:idfetches: it resolves each id's own projectrunsDirfrom the global registry server-side (so two runs picked from the cross-project fleet list compare correctly even when they live in different projects), and it is the one place that decides which of two bad ids to name in a single atomic 404. A malformed or unknown run id returns the standard error envelope (400 BAD_REQUEST/404 NOT_FOUND, naming which id). Known limitation: the compare view is read-only (no steer/stop from there — open one of the linked runs for that) and the sidebar selection resets when compare mode is turned off. Guarded bynpm run selftest:run-compare. -
OAuth login (when configured) — boot checks
GET /api/mefirst; when login is required the UI shows a login screen with one button per configured provider instead of data views, and after login a user chip (avatar, name, provider) with Sign out sits in the sidebar footer. Mutating fetches automatically carry theX-CSRF-Tokendouble-submit header; a mid-session expiry falls back to the login screen instead of looping.
HTTP surface (/api/* plus operational routes): GET /runs, GET /runs/:id, POST /runs (dispatch),
POST /runs/:id/{steer,stop,reap}, DELETE /runs/:id (tombstone in the global registry; the run
dir on disk is untouched; rejects with 409 if the run is still running/queued — stop or let it
finish first, so the tombstone can never race the engine's next state write), GET /events + GET /runs/:id/events (SSE),
GET /chat/sessions, POST /chat/sessions (create an empty draft, body {cwd?} -> 201 summary),
GET /chat/sessions/:id, PATCH /chat/sessions/:id (rename, body {title} -> 200), DELETE /chat/sessions/:id (204/404), POST /chat/sessions/:id/fork (clone into a new session -> 201
summary), POST /chat/:id/message (SSE token stream),
POST /chat/:id/answer (answer a suspended ask_user question — body {suspensionId, answer}),
POST /api/enqueue (queue a task for autonomous dispatch), GET /api/queue (list entries,
newest first), DELETE /api/queue/:id (cancel a still-pending entry; 409 NOT_PENDING once it has
already dispatched or failed), GET /api/fleet (the build workers usable on THIS machine right now
plus the resolvable operating-mode presets: {workers: [{id, model, tier, costModel, kind}], modes}
— what the dashboard composer's worker picker is populated from),
GET /api/costs (spend ledger by day/provider + per-run dollar-per-task, see "Cost ledger view"
below), GET /api/credits (provider credit balances, same ledger cyberine credits list reads),
GET /api/run-comparisons (two-run side-by-side comparison, see "Run compare" below),
GET /health, GET /ready, GET /metrics,
GET /doc (OpenAPI 3 spec covering every route — self-discovery for external triggers), and the
OAuth login surface GET /auth/login/:provider, GET /auth/callback/:provider, POST /auth/logout,
POST /auth/logout-everywhere (revoke every OTHER session for the caller's identity — "log out
everywhere"), GET /api/me, GET /api/me/sessions (list the caller's own active sessions,
flagging which one is "this device") (see "OAuth login (SaaS)" below).
SSE frames on the message stream: token, done, error, and question ({suspensionId, question}).
Run and chat-session ids are validated against ^[A-Za-z0-9_-]{1,64}$ before touching the store
(path-traversal guard).
The browser UI is served from src/web/public/ as split static files — index.html (markup) + app.css
(styling, with a --space-* / --text-* / --shadow-* token layer) + app.js (behavior), no inline
CSS/JS blob and no build step. The server's static handler sets the correct MIME for each.
Bind 127.0.0.1 by default. Non-loopback binds require bearer authentication for mutating routes,
enforce the configured CORS allowlist, and rate-limit expensive API traffic. Every response
(success or error, any route) also carries baseline security headers: Content-Security-Policy
with NO 'unsafe-inline' anywhere — script-src 'self', and style-src 'self' plus a single
sha256 hash whitelisting the placeholder root page's <style> block (the dashboard markup itself
carries zero inline handlers/styles; all event wiring is addEventListener, all styling lives in
app.css; guarded by npm run selftest:csp-inline-guard) — X-Frame-Options: DENY +
frame-ancestors 'none' (clickjacking), X-Content-Type-Options: nosniff, and
Referrer-Policy: same-origin. Static assets (serveStatic) additionally set
Cache-Control: no-cache so a proxy/CDN in front revalidates before serving a cached app.js.
Gate invariants (T699)
Four properties the gate now guarantees, each one closing a live-proven bypass:
- One canonical path. The request path is collapsed once — repeated slashes merged, a trailing
slash stripped — BEFORE the security gate and the router see it, so
//api/runs,///api/runsand/api/runs/are the same request as/api/runsin every auth mode: same status, same body, same rate-limit bucket. The gate used to classify the raw string while the router normalised, which let a two-character URL change skip the auth check, theAUTH_SETUP_REQUIREDgate and theread/probe/dispatchbuckets while still reaching the real route. - No cross-origin writes on the loopback bind. The fully-open local fast path now refuses a
MUTATING request that a browser marks as coming from elsewhere —
Sec-Fetch-Siteother thansame-origin/none, or anOriginthat is neither this server's own (any loopback spelling on the bound port, or theCYBERINE_WEB_PUBLIC_URLorigin behind a reverse proxy) nor inCYBERINE_WEB_CORS_ORIGINS— with403 CROSS_ORIGIN_DENIED. Local non-browser callers (curl, the CLI's remote client, the OpenAI-compatible bridge) send neither header and are unaffected, and the dashboard's own same-origin fetches still pass. - An empty bind host is not loopback.
CYBERINE_WEB_HOST=/--host ''now resolves to127.0.0.1instead of the empty string, whichlisten()reads as the wildcard::dual-stack bind;isLoopbackBind()also reportsfalsefor an empty bound host as a second line of defence. - Rate limits key on the proxy-appended hop. With
CYBERINE_WEB_TRUST_PROXY=1the identity key is the RIGHTMOSTX-Forwarded-Forhop (CYBERINE_WEB_TRUSTED_PROXY_DEPTHhops in, for a longer trusted chain), not the leftmost one the client sends — rotating the header no longer mints fresh buckets.
npm run selftest:web-auth-gate # offline; boots the real server on a random loopback portThe smoke is table-driven over (path, method) -> expected status in four modes: open loopback,
CYBERINE_WEB_REQUIRE_AUTH=1, non-loopback-with-no-provider, and an empty bound host — every
//, /// and trailing-slash spelling in each, plus the cross-origin write cases and the
X-Forwarded-For key cases. It is in ci.yml's security list, and each of the four fixes has at
least one case that goes red when that fix alone is reverted.
Known limitations and deferred work. Normalisation collapses slashes only: it does not resolve
dot segments or percent-decode, because the router compares literal still-encoded segments, so
/api/./runs and /%2Fapi/runs match no route at all rather than aliasing one. The non-loopback
modes are simulated with setBoundHost() rather than a real network bind, so the smoke never opens
a port off 127.0.0.1; a genuine LAN bind is still verified by hand. The cross-origin refusal
covers mutating methods only — a cross-origin READ on an open loopback bind is unchanged, since the
browser cannot read the response anyway and applyCors already governs every non-loopback bind.
The loopback fast path still answers OPTIONS with 404 (no preflight support), so a genuinely
cross-origin JSON write from a browser is not possible even for an allowlisted origin.
SSE connections (GET /api/events, GET /api/runs/:id/events) are capped per-client and
globally (CYBERINE_WEB_SSE_MAX_PER_CLIENT / CYBERINE_WEB_SSE_MAX_GLOBAL) and re-validate the
caller's session/bearer token on every 15s heartbeat, closing the stream within one heartbeat of a
revoked or idled-out session — the connection-open auth check alone can't catch that for a
long-lived stream. The per-run stream resolves its on-disk directory through the same
cross-project GlobalRegistry lookup steer/stop/reap use, so an unregistered/unknown
runId gets a 404 instead of silently streaming nothing.
OAuth login (SaaS)
cyberine web can gate the whole UI behind a login screen instead of (or in addition to) bearer
tokens — set up a GitHub OAuth app, point its client id/secret at Cyberine, and allowlist the
accounts allowed in:
# 1. Create a GitHub OAuth app (github.com/settings/developers -> OAuth Apps -> New OAuth App).
# Authorization callback URL: http://<host>:<port>/auth/callback/github
# (use CYBERINE_WEB_PUBLIC_URL below if the server sits behind a reverse proxy / different
# externally-visible host+port than it binds to)
# 2. Point Cyberine at it and allowlist your account.
export CYBERINE_OAUTH_GITHUB_CLIENT_ID=Iv1.xxxxxxxxxxxxxxxx
export CYBERINE_OAUTH_GITHUB_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
export CYBERINE_OAUTH_ALLOWED=your-github-username
cyberine web --host 0.0.0.0 # non-loopback + a configured provider auto-enables the login gateGoogle works the same way with CYBERINE_OAUTH_GOOGLE_CLIENT_ID / _CLIENT_SECRET (PKCE S256,
openid email profile scope); CYBERINE_OAUTH_ALLOWED accepts multiple comma-separated entries,
each optionally scoped to one provider (github:octocat,google:jane@example.com), matched
case-insensitively against the identity's login or email.
Deny-all is the default. CYBERINE_OAUTH_ALLOWED unset or empty denies every login — a SaaS
operator must explicitly allowlist at least one account before OAuth login can succeed; a denied
login returns 403 ACCOUNT_NOT_ALLOWED and is audit-logged (provider + login only, no tokens).
When the gate is active (CYBERINE_WEB_REQUIRE_AUTH=1, or a non-loopback bind with at least
one provider configured), every route requires a valid session cookie or bearer token EXCEPT
GET /api/me, GET|POST /auth/*, GET /health, GET /ready, and static assets (so the
single-page app can still load and show the login screen) — GET /metrics is deliberately NOT
exempt. A session-authenticated mutating request (not bearer) also needs a matching
X-CSRF-Token header (double-submit against the cyberine_csrf cookie set at login); a
bearer-authenticated request is exempt from the CSRF check.
Before a provider is configured (non-loopback bind, CYBERINE_WEB_REQUIRE_AUTH unset, no
OAuth provider configured yet), GET /api/* fails closed with 503 AUTH_SETUP_REQUIRED rather
than serving data unauthenticated — a valid bearer API token still works in the meantime;
GET /api/me stays public and mutating routes keep requiring a bearer token as before.
Login-CSRF protection. GET /auth/login/:provider sets a short-lived HttpOnly
cyberine_oauth_state cookie (scoped to /auth/callback, matching the state TTL) alongside the
redirect; GET /auth/callback/:provider rejects the exchange (403) unless that cookie's value
matches the state query param, before the pending state entry is even looked up further — this
binds state to the initiating browser per RFC 9700, closing the classic login-CSRF gap where an
attacker's own valid code+state pair is planted on a victim who never started that login.
Google logins additionally require the provider's email_verified: true on the userinfo response;
an unverified email is blanked before allowlist matching (mirrors the GitHub exchange, which already
only accepts verified emails).
curl http://127.0.0.1:41700/api/me # { authenticated, required, user, providers } — always public
# Browser flow: GET /auth/login/github -> redirect to GitHub -> GET /auth/callback/github ->
# sets cyberine_session (HttpOnly) + cyberine_csrf cookies, redirects to /
curl -X POST http://127.0.0.1:41700/auth/logout \
--cookie "cyberine_session=$SESSION" -H "X-CSRF-Token: $CSRF" # 204, clears both cookies
curl http://127.0.0.1:41700/api/me/sessions --cookie "cyberine_session=$SESSION"
# { sessions: [{ createdAt, lastSeenAt, expiresAt, current }, ...] } — 401 without a session
curl -X POST http://127.0.0.1:41700/auth/logout-everywhere \
--cookie "cyberine_session=$SESSION" -H "X-CSRF-Token: $CSRF" # { revoked: <count> }On loopback, the OpenAI-compatible fleet endpoint keeps its dedicated-token behavior. Set the token,
start the existing web server, then point any non-streaming OpenAI client at its /v1 base URL.
On a non-loopback bind, either a managed Cyberine API token or this legacy dedicated token is accepted:
CYBERINE_OPENAI_API_KEY=replace-with-a-long-random-token cyberine web
curl http://127.0.0.1:41700/v1/chat/completions \
-H 'Authorization: Bearer replace-with-a-long-random-token' \
-H 'Content-Type: application/json' \
-d '{"model":"cyberine","messages":[{"role":"user","content":"Compare two approaches to this task"}]}'POST /v1/chat/completions maps the text conversation to one synchronous fleet orchestration and
returns the judged winner as an OpenAI chat.completion. stream: true is not supported yet.
A typed, zero-dep client for this API lives at src/sdk/client.ts:
import { CyberineClient } from './src/sdk/client.ts';
const client = new CyberineClient('http://127.0.0.1:41700');
const runs = await client.listRuns();
const { runId } = await client.dispatch({ task: 'do the thing' });
const snapshot = await client.metrics();Remote-client mode, /v1 routes, and the error envelope. Set CYBERINE_SERVER_URL to make the
CLI a thin trigger client — cyberine run, cyberine enqueue, cyberine status, and cyberine agents
then proxy to that running server's HTTP API instead of executing in-process (cyberine run streams
back to a terminal state). Unset = identical in-process behavior. Every route is also served under a
/v1/ prefix (/v1/runs, /v1/events, /v1/enqueue, ...) alongside the unversioned aliases the web
UI uses. All error responses share one envelope: { "error": { "code": "UPPER_SNAKE_CASE", "message": "...", "details": [] } }.
cyberine web --port 41700 & # server A
export CYBERINE_SERVER_URL=http://127.0.0.1:41700
cyberine run "summarize the changelog" # dispatches via the remote server, streams back
cyberine agents --once # lists runs from the remote server
curl -s 127.0.0.1:41700/v1/metrics # /v1 alias of /metrics| Variable | Default | Purpose |
|---|---|---|
CYBERINE_SERVER_URL | unset | when set, cyberine run/enqueue/status/agents proxy to this server's API instead of running in-process |
Retiring a worker — RETIRED_WORKERS
A worker whose account is unfunded or ineligible is not the same as a worker with no credential, and Cyberine used to have no way to say so. An out-of-balance account still holds a VALID key, so...
6. Workflows + live fleet view (the orchestration layer)
Run a multi-step workflow - a DAG of tasks where each step fans out to all available adapters, gets judged, and its winning output flows into dependent steps via {{stepId}} templating: