cyberine
UsageRun recipes

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-expanded and 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 by npm 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=running past the cap), then Recent (the newest 10 runs in the selected project scope), then a View all runs (N) link to the full-history #/runs view (kfqpf/k9tdq) — N is 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/runs API (a small state=running/state=queued fetch paged to exhaustion for Active — Stop All must act on every active run, not just the capped display list — and limit=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 (SSE lifecycle / 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 like t633-engine-bVNIMX), its state when not done, 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 by npm 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/runs summary 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 plannerTurn loop the TUI uses). When the planner calls ask_user, the server streams a question SSE 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 /runs body: permission mode (dispatchMode), build worker (fleet, pinning CYBERINE_BUILD_FLEET; "Auto" leaves the pick to the router), opencode agent (openCodeAgent) and an optional cwd. 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 by npm run selftest:web-ia. The worker pill is populated from GET /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 by npm 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 by npm 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-scheme so native scrollbars, carets and form widgets match the page; the run-output and chat-transcript logs are aria-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-announcer region instead; and the Cmd-K listbox exposes its selection via aria-selected plus aria-activedescendant on 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 by npm 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); setting data-theme="dark" or data-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 while cyberine web is open. Known limitation: there is no in-page theme toggle yet, and the done (sulfur) and running/warn (amber) badges are close hues, so tell them apart by label rather than colour. Guarded by npm 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/sessions CRUD routes.

  • Destructive controls are armed, never one-click (k5dhw) — Stop and Stop All use the same two-step arm as Delete: the first click flips the label (Confirm stop, or Confirm 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 All is 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 inside Stop 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 by npm run selftest:destructive-guards.

  • Paged and filtered run list (kfqpf) — GET /api/runs returns the plain array only when no paging parameter is sent, so every existing caller is unchanged. Send any of limit, cursor, q, project, state, age and 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'

    limit defaults to 25 and caps at 100. cursor is opaque — pass back the nextCursor the previous page returned, never an offset. state accepts stale alongside the real run states. age is one of hour, day, week, month, older. project is the repo path, with worktree runs folded onto their parent checkout, so one project row covers its own fan-out. facets carries {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 carries project (the canonical, worktree-folded project path) and projectLabel (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=50 is 59KB and ?limit=1 is 3.9KB. Known Limitations and Deferred Work. The projects facet is capped at the busiest 25 (projectsTotal says 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 #/runs full-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=running from 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 including stale, 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 running or queued run whose registry record has not changed for longer than the reaper's wall-clock cap (CYBERINE_STUCK_WALL_MS, default 2 hours) comes back from GET /api/runs with stale: true. The dashboard shows it as STALE <age> with a muted, still dot, excludes it from the sidebar's Active section (kejx4) and its live count, and leaves it out of Stop All. Its row carries a Reap button (the run header's reap control works too), and POST /api/runs/:id/reap now closes every zombie shape and returns { outcome }: reaped marks a still-nonterminal run.json cancelled, synced copies a run.json that already finished onto a registry record that never heard about it, and orphaned marks the registry record cancelled when the run's directory no longer exists (a deleted worktree). Known limitation: nothing reaps these on its own unless cyberine serve or cyberine daemon is running; its reaper also skips a record whose run.json is 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, reading GET /api/costs?days=N. A "time window" dropdown (all time / 1 / 7 / 30 / 90 days) mirrors cyberine cost --days N, and the live daily-cap / hard-cap posture (the same snapshot /metrics reports) shows above the tables. This reuses the exact aggregation cyberine cost already prints — both the CLI and the route call summarizeCostLedger() in src/store/costSummary.ts over the same RunStore().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 #/costs after a run finishes. Guarded by npm run selftest:web-cost-ledger (cross-checks the JSON against cmdCost'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 reads GET /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/:id fetches: it resolves each id's own project runsDir from 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 by npm run selftest:run-compare.

  • OAuth login (when configured) — boot checks GET /api/me first; 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 the X-CSRF-Token double-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/runs and /api/runs/ are the same request as /api/runs in 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, the AUTH_SETUP_REQUIRED gate and the read/probe/dispatch buckets 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-Site other than same-origin/none, or an Origin that is neither this server's own (any loopback spelling on the bound port, or the CYBERINE_WEB_PUBLIC_URL origin behind a reverse proxy) nor in CYBERINE_WEB_CORS_ORIGINS — with 403 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 to 127.0.0.1 instead of the empty string, which listen() reads as the wildcard :: dual-stack bind; isLoopbackBind() also reports false for an empty bound host as a second line of defence.
  • Rate limits key on the proxy-appended hop. With CYBERINE_WEB_TRUST_PROXY=1 the identity key is the RIGHTMOST X-Forwarded-For hop (CYBERINE_WEB_TRUSTED_PROXY_DEPTH hops 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 port

The 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 gate

Google 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
VariableDefaultPurpose
CYBERINE_SERVER_URLunsetwhen set, cyberine run/enqueue/status/agents proxy to this server's API instead of running in-process