Skip to content

feat: react web client - #526

Draft
Gratenes wants to merge 40 commits into
zeronsh:mainfrom
Gratenes:web-client
Draft

Gratenes wants to merge 40 commits into
zeronsh:mainfrom
Gratenes:web-client

Conversation

@Gratenes

@Gratenes Gratenes commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Web client

Summary

This adds an opt-in browser client for zeron: the engine's remote listener serves an embedded web app over HTTP + WebSocket, with every bearer verified as a WorkOS AuthKit access token — the same checks the edge applies to relay clients.

Remote access stays off by default. Local IPC, the edge, iOS, and sync are untouched.

How it works

Engine side

  • serve_engine_remote binds an HTTP+WebSocket listener exposing the same RPC surface as IPC. Every request's bearer is verified by remote_auth::RemoteAuthorizer: WorkOS access-token JWTs against the WorkOS JWKS (with caching + rotation refetch), or the dev bearer (userId / userId@orgId) when no WorkOS client id is configured. The same verifier protects the WebSocket data plane (first-frame Auth).
  • Pre-auth routes: GET /auth/config reports the sign-in mode and the AuthKit authorize URL; POST /auth/exchange proxies the secret-bearing code exchange to the edge, so the WorkOS API key only ever lives engine↔edge, never in the browser.
  • The web bundle is embedded with rust-embed: build.rs stages the Vite build into web-staging/ (placeholder pages in debug, ZERON_WEB_DIST override). The HTML shell loads before the credential gate; the data plane stays gated.
  • Opt-in via remote-access.json / the ZERON_NETWORK startup override.
  • web_conformance / web_smoke examples run the real listener in dev-auth mode for the test suites.

Web side (web/ — a pnpm workspace, @zeron/{app,engine-client,proto,theme,icons})

  • @zeron/engine-client: the RPC client (reconnect + re-auth, generation-based watch streams), the watch cache, the fleet registry (one supervised connection per engine, merged under scoped ids), and the sign-in flow (fetchSignInConfig / exchangeSignInCode) — a dev engine takes a local user id; a WorkOS engine redirects through AuthKit and the pasted code exchanges through the engine's proxied route.
  • @zeron/app: the full web app — chat transcript, composer, fleet sidebar, settings, dialogs, command palette, mobile layouts — adapted to the sign-in model: /pair is the connect page, the Devices settings links into it, and the pairing-code surface (methods, proto types, remote-access settings page) is removed.
  • Browser-side engine registry persists per-origin in localStorage; damaged persisted config is preserved and blocks sign-in until repaired (the desktop registry's refusal semantics).

Commits

  • feat(engine): WorkOS-authenticated remote engine listener — listener, remote_auth verifier, rpc WebSocket pump, off-by-default remote access
  • feat(web): web client workspace + engine-served bundle — the ported web/ workspace + rust-embed staging
  • feat(engine): pre-auth sign-in routes for the web client — /auth/config + proxied /auth/exchange
  • feat(web): sign-in flow replaces engine pairing codes — the browser sign-in migration; pairing deleted
  • ci(web): web client build, unit, and live-engine conformance jobs

Testing

  • cargo test -p zeron-engine --test remote_listener — 11/11 (HTTP credential gate, first-frame envelope auth, web routes, dev bearer)
  • cargo test -p zeron-rpc — 12/12; cargo test -p zeron-proto — 43/43
  • pnpm -r build — typecheck + Vite build green
  • engine-client: 43/43 (unit 33; conformance 5 and smoke 5 run against live engines spawned from the examples — full sign-in, watch, mutate, reconnect-with-resubscribe, cache swap semantics)
  • app suite: 1873/1873
  • CI: new Web client workflow runs the build, unit suites, remote_listener tests, and the live-engine conformance/smoke suites on web/**-touching PRs

Notes for reviewers

  • The branch is based on current zeron main (c914b319) with no merges; every commit is a forward port.
  • Nothing here changes default behavior: without remote-access.json / ZERON_NETWORK, the listener never binds and the web bundle is never served.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Port of roboco's remote-access listener (hoangvu12/roboco, the
feat/remote-access and web-client lines: 6c42d32, 420bf07, 8366467),
adapted to upstream zeron: the same RPC surface as IPC, served over
HTTP + WebSocket to browsers and native remote clients, with every
bearer verified as a WorkOS AuthKit access-token JWT against the WorkOS
JWKS — the same checks the edge applies to relay clients — or accepted
as the dev-mode user id when no WorkOS client id is configured. Local
IPC, the edge, iOS, and sync are untouched; remote access stays off by
default and is enabled per engine through the remote-access settings
file or ZERON_NETWORK.

- rpc: bounded WebSocket pump (twin of zeron-sync's session pumps,
  specialized to ndjson frames), serve_websocket over an already
  upgraded connection, and a Bearer-authenticated native dial
- engine: remote listener (credential-gated health, browser upgrades
  authenticate with a first-frame Auth envelope, 4401 closes),
  remote-access settings/startup-override controller, WorkOS JWT
  verifier with cached JWKS and rotation refetch, daemon wiring
- tests: listener integration tests in dev mode (HTTP gate, native
  Bearer dial, first-frame envelope, refused credentials) and
  remote-access settings resolution round-trips
Port of roboco's web client foundation (hoangvu12/roboco, the
web-client line: 8366467, 3f6b069, 78e001b, b47fa24), adapted to
upstream zeron with zeron naming throughout: the pnpm workspace
(@zeron/{app,engine-client,proto,theme,icons}), the Vite + React app
the engine serves from its remote listener, the generated TS wire
types and theme artifacts (committed; regeneration scripts target the
zeron crates), and the engine's rust-embed staging (build.rs stages
into web-staging/: placeholder pages in debug, the Vite build in
release, overridable via ZERON_WEB_DIST).

The web shell loads ahead of the credential gate; the RPC socket and
API routes stay WorkOS-gated. The engine-client still carries
roboco's direct-pairing sign-in flow, which does not work against
this engine's WorkOS listener yet — swapping it for the AuthKit
sign-in flow is the next change.
GET /auth/config reports the sign-in mode and the AuthKit authorize URL
(the same URL auth.rs builds for device sign-in, redirecting to the
edge's hosted paste-code callback, with a fresh state nonce per call);
POST /auth/exchange proxies the secret-bearing code exchange to the
edge's /auth/exchange, so the browser stays same-origin and the WorkOS
API key never leaves the edge. RemoteAuthorizer now builds from the
engine's WorkOS client id plus edge URL; dev mode reports mode "dev"
with no authorize URL and never exchanges.
The browser client no longer redeems pairing codes against the engine.
Connecting an engine is now a sign-in: GET /auth/config reports the mode,
and the browser either enters a dev-mode user id or exchanges a WorkOS
AuthKit code through the engine's proxied POST /auth/exchange (the
secret-bearing exchange stays between engine and edge).

- engine-client: auth.ts (fetchSignInConfig, exchangeSignInCode,
  parseEngineUrl) replaces pairing.ts; the pairing RPC method names and
  the PairedSession/PairingLink/RemoteAccessSnapshot/SessionGrant proto
  types are gone with it
- app: EngineStore.signInEngine stores the credential directly
  (re-signing in to an origin replaces its credential; the
  damaged-config refusal is preserved); /pair is now the connect page
  (address -> config -> dev id or AuthKit code); the Devices page's
  pairing box and the Remote-access settings page give way to a link
  into the connect flow ("Sign in again" for parked engines)
- engine: web_conformance/web_smoke examples run the real listener in
  dev-auth mode (no pairing store); proto advertises the web-client
  capability (roboco parity) so the web client can gate on it
- tests: the conformance/smoke suites sign in with the dev bearer and
  resolve example binaries from CARGO_TARGET_DIR (an external target dir
  previously hung beforeAll to its 600s hook timeout); the app
  store/registry/session-provider suites follow signInEngine; one
  rename artifact fixed (add-space prefix length 6 -> 5)

Verification: pnpm -r build green; engine-client 43/43 (unit 33,
conformance 5, smoke 5); app 1873/1873; zeron-proto 43/43; engine
remote_listener 11/11.
Two jobs, matching the repo's workflow conventions (pull_request path
filters, concurrency group, read-only permissions):

- build-and-tests: pnpm install (frozen lockfile), `pnpm -r build`
  (typecheck + Vite bundle for every package), the engine-client unit
  project, and the app test suite (1,873 tests).
- engine-conformance: builds the web bundle so the engine's rust-embed
  staging embeds the real pages, runs the remote_listener Rust tests
  (WorkOS credential gate, web routes, dev bearer), builds the
  web_conformance / web_smoke examples, and runs the conformance and
  smoke vitest projects against those live engines — the full sign-in,
  watch, and send flow over the wire.
The stale /pairing/redeem proxy entry goes (the pairing surface was
deleted with the sign-in migration). The browser now calls
GET /auth/config and POST /auth/exchange over plain HTTP before any
credential or WebSocket exists, and the catch-all proxy serves
non-WebSocket requests from Vite itself — so without explicit entries
the connect page can never reach the engine in dev.

Verified against a live WorkOS-mode engine: vite on :3000 proxies
both routes (auth config reports workos mode with the AuthKit
authorize URL) and the RPC WebSocket at `/` traverses the proxy to
the engine's remote listener.
…very

The web client now uses PR zeronsh#319's browser-session model end to end; the
manual connect flow (engine address, dev user id, pasted AuthKit code)
is gone.

- engine-client: `device-frame.ts` (the DeviceRoom binary framing ported
  from crates/rpc/src/device_frame.rs plus `RelaySocket` — the browser
  transport with ping/pong/echo liveness and buffered-amount
  backpressure) and `edge.ts` (the /api/browser/* session, login,
  devices, and logout API). EngineClient skips the first-frame Auth
  envelope when the credential is empty: the relay authenticates the
  browser session cookie at the WebSocket upgrade.
- app: `state/fleet.ts` is rewritten around the edge — boot checks
  /api/browser/session and redirects signed-out visitors straight to
  WorkOS (PKCE authorize URL from the edge, callback returns to the app
  root with the HttpOnly session cookie); signed in, the owner-scoped
  device list is polled and every device is supervised through the
  cookie-authenticated relay WebSocket. `/pair` is now an automatic
  connect gate, and the Devices settings page describes the
  zeron-login-driven flow instead of manual pairing.
- dev serving: vite allows the dev.embedez.com tunnel host and proxies
  /api/browser (HTTP + WS) to the local staging edge
  (ZERON_DEV_EDGE, default http://127.0.0.1:27641).

Verification: pnpm -r build green (engine-client + app typecheck, Vite
bundle); app suite 1873/1873 (one session-provider expectation updated:
registry restart re-pins identity to the device-id key).
… dev edge

Through the dev tunnel the Vite proxy forwarded the public Host
(dev.embedez.com) to the local `wrangler dev` edge, whose dev paths gate
on `loopback(url)` — every /api/browser/* request came back 403
forbidden before Origin was even consulted. Set `changeOrigin: true`
so the upstream sees its loopback Host while the browser-side Origin
header still validates against WORKOS_BROWSER_ORIGIN, matching what
upstream's scripts/browser-dev-proxy.mjs does for its own proxy hop.

Verified live: POST https://dev.embedez.com/api/browser/login now
returns the AuthKit authorization URL for the staging WorkOS client
with redirect_uri=https://dev.embedez.com/api/browser/callback, and
GET /api/browser/session reports {"authenticated":false}.
The post-login crash: useFleet() derived a fresh engines array with
fresh StoredEngine objects on every render, and the session provider
reconciles on that array — reconcileEngineSessions clones every session
whose wrapper identity changed, so the effect looped setSessions
forever ("Maximum update depth exceeded" at session-provider.tsx:85,
taking TerminalProvider/RightPane down with the churn).

Two guards:
- useFleet memoizes the derived engines on (devices, ownerId), so the
  array identity changes only when the device list actually changes.
- refreshDevices skips the state update when a poll reports the same
  (id, name) list: the store's devices array is the derivation input,
  and a new-but-equal array every 10s would churn every reconciled
  session and catalog consumer on each poll.

Regression test (tests/fleet.test.ts) fails on the old code and locks
both contracts: re-renders and unchanged polls must keep engines (and
entry) identity. Full app suite 1874/1874.
The browser session now carries the signed-in account's profile, and
the sidebar's bottom identity control renders it: the WorkOS user's
picture in the avatar circle, name and email in the menu card, with
the engine's device name as the fallback while no profile exists. The
account menu also gains a Sign out row, which revokes the browser
session through the edge and reloads into the login gate.

The right pane's resize seam now parks on the pane's left edge when
the docked files column is open: the CSS offsets by the pane plus the
files column's width, and the drag commits the pane's own width with
the open files column subtracted.

`vite preview` mirrors the dev server's proxy and host allowlist so
the dev domain can serve the optimized bundle — the unbundled dev
server pays the tunnel's per-module latency on ~500 modules.

Comment scrub: fork-attribution narration removed from web sources;
no reference to any prior project name remains.

Full app suite 1876/1876.
The compact f956339 trigger (21px button, 13px avatar) rendered the
WorkOS profile picture as a speck and left no readable hit target. Now
that the edge carries the avatar (gratenes/zeron c07070fd), size the
row for it: a 44px avatar (16px mono initial fallback) with the
account's name to its right, truncated at the sidebar's edge — the
whole row is the clickable trigger, and the name rides both the visible
label and the aria label.
The legacy pairing-era Engines card (ticket 45's folded drawer row)
listed the fleet's engines again above the device rows, so every engine
appeared twice — under the WorkOS browser-session fleet the two lists
describe the same engines, so the device rows are the registry of
record. The engines card, its registry wiring, and the section header
are gone, and the pairing hint now carries the rhythm the header used
to provide (a 16px gap before the device list — the two cards touched
without it).

Reproducer: tests/settings-devices.test.ts mounts the page with one
engine over one device and asserts exactly one device row renders.
The composer-defaults store (zeron.composer-defaults.v1) already
persisted every model pick per harness, but a new chat never saw it:
same-harness picks didn't remember the harness (a fresh chat resolved
to the first enabled harness, so the remembered model was never
consulted), and the new-chat draft seeding ignored the sticky picks
entirely — both deviations from the desktop's pickers.rs
effective_harness/effective_model_id resolution.

pickModel now remembers the harness on every pick, and the draft
initializer seeds the remembered harness + model when the catalog and
model list still offer them, falling back to today's first-enabled
default otherwise. Ten new composer-draft tests pin the sticky
seeding, the fallbacks, and the versioned store key.
@Gratenes Gratenes changed the title feat: engine-served web client with WorkOS-authenticated remote access feat: react web client Sep 24, 2026
The web client shipped no favicon at all — /favicon.ico 404'd and
index.html declared no icon <link>. Port the landing site's set from
the public repo (zeronsh/zeron, apps/landing/public): favicon.svg (the
Zeron mark) for modern browsers, zeron-favicon-v3.png as the 128px PNG
fallback, and zeron-app-icon.png as apple-touch-icon.

The bundled default new-thread background becomes Purple_Nebula_4K
(3840x2160) — the hero artwork behind the composer on empty new
threads. No code changes: resolveNewThreadBackground already falls
back to this bundled asset, and a user-installed image still
overrides it.
@Gratenes
Gratenes marked this pull request as draft September 25, 2026 00:43
hoangvu12 added a commit to hoangvu12/roboco that referenced this pull request Oct 9, 2026
Port zeron 5cd23bd (PR zeronsh#526): fleetSpaceRows now actually dedups — the
base implementation compared the engine scopes of space.id and
space.deviceId, which projectRegistrySnapshot always scopes with the
REPORTING engine's key, so mirrors were never dropped. Roboco keys
engines by baseUrl (not device id like zeron), so the owner's engine is
resolved through engine.info.deviceId: a projected space survives only
when the device named in its owner field is the reporting engine's host.
fleetDeviceRows was already correct at base; its tests pin it, including
the deliberate no-row-when-info-unloaded deviation from zeron's
'?? engine.key' fallback (a baseUrl is never a device id).
hoangvu12 added a commit to hoangvu12/roboco that referenced this pull request Oct 9, 2026
Zeron 97f8611 (PR zeronsh#526) ported onto 05faf5a, ticket 91
(.scratch/web-parity/issues/91-settings-engine-labels.md):

- lib/settings-engine.ts: settingsDeviceName(engine, registry) takes
  the fleet engine (baseUrl + pinned deviceId, engineDisplayName's
  parameter type) and names it from its OWN registry entry's
  WatchDevices row — renames included, blank names are missing names,
  entry?.info?.deviceId ?? engine.deviceId resolving the row before
  EngineInfo lands, and never a peer entry's copy of the row.
  settingsEngineLabel names the active engine only when the fleet is
  plural and the active key has a fleet engine (else null — the
  indicator hides).
- settings-engine-indicator.tsx: popover rows name engines through
  settingsDeviceName(engine, registry); the trigger keeps 05faf5a's
  unprefixed label form.
- NEW components/settings-engine-page.tsx: SettingsEnginePage keys the
  engine-addressing pages' bodies on settingsEngineKey(fleet) — Remote
  access, Agents and Accounts now REMOUNT on a settings-engine switch,
  dropping engine-local state (in-flight sign-ins, install/update
  replies, expanded rows, error strips, the DeviceSwitcher passthrough
  target) with its engine instead of reconciling it onto the new one.
  The old setTarget(null)-on-fleet.active reset effects were subsumed
  and removed.
- settings-devices.tsx: the host row title call adapts to the new
  signature (stored engine, synthetic identity for registry-only rows).

Deliberate deviations from the zeron commit (recorded in the ticket):
the fallback is engineDisplayName (host · short id), not zeron's
engine.label || baseUrl — roboco's StoredEngine.label IS the
pair-session label this ticket forbids; the DeviceSwitcher stays on
Accounts/Agents for intra-engine targetDeviceId routing (CAUTION b —
not ported, zeron's relay transport has no device addressing); the
canvas engine chip is untouched.

Tests: settings-engine.test.ts extended (own row vs pair label/peer
copy, rename, pinned identity, blank-name fallback, fleet-missing
null), settings-engine-indicator.test.ts ported, and the new
settings-fleet-routing.test.ts (mounted pages: re-list on the picked
engine with no passthrough target, the Agents sign-in failure dropped
by the remount — red at base — and the DeviceSwitcher's kept
intra-engine targetDeviceId pick). tsc --noEmit clean, pnpm build
green, focused + neighbor suites green (11 + 85).
hoangvu12 added a commit to hoangvu12/roboco that referenced this pull request Oct 9, 2026
Port zeron 4928e1b (PR zeronsh#526) onto the landed ticket-86 wiring, plus
4510bab's composer-side label comments:

- DeviceCard.pick branches on targetForDevicePick's noProject result:
  a projectless engine switch clears the visible sidebar filter through
  rememberNoProject (last-project history stays navigation state) while
  same-owner projects are retained.
- ProjectChip/ProjectCard take currentDeviceId (the resolved engine the
  chips show) so 'Don't work in a project' never silently restores the
  remembered device of a stale host; the footer and the canvas selector
  row both pass their effective device id.
- tests/browser-engine-picker.test.ts (new, ported from zeron's
  4510bab suite): engine identity labels ('Selected engine' tag on the
  effective row, 'Select engine' empty state, registry presence through
  the real engineStatesOf, one offline glyph) under both viewports, plus
  the canvas pick reconciliation (foreign project + sidebar filter
  cleared on engine switch, project pick routes to its owning engine,
  no-project keeps the visible engine).
hoangvu12 added a commit to hoangvu12/roboco that referenced this pull request Oct 9, 2026
Port zeron 779cc2e (PR zeronsh#526) onto the integration base. The IA change
that commit makes — deleting the pairing-era Engines card that listed
every fleet engine again above the device rows — was already roboco's
state (05faf5a removed the card and folded the engine actions into the
device-style EngineHostDeviceRow; 87 landed fleetDeviceRows; 91 the
settingsDeviceName titles), so the delta is the remainder of the commit's
intent:

- settings-devices.tsx: the page doc comment still described the removed
  engines card and the pre-05faf5a0 local-row wording; rewritten to the
  post-commit shape (one row per engine; pairing URL box kept, WorkOS
  sign-in hint not ported). No behavior change.
- app.css: the commit's rhythm fix — .settings-pairing-box gains
  margin-bottom 16px, the gap the section header used to provide before
  the flat device card (roboco's pre-identity arm; the sectioned arm is
  unchanged through sibling margin collapse).
- NEW tests/settings-devices.test.ts: the mounted reproducer, adapted to
  this ticket's two-engine acceptance shape — exactly two device rows,
  names of record, the section split, one Active engine badge, no
  'Roboco web on' pair-session label anywhere; a parked engine's host row
  carries Pair again (routes to /pair) and Forget (confirm-in-place,
  forgetEngine(baseUrl)). Mutation-checked: re-adding an engines card
  fails the test.

Ticket: .scratch/web-parity/issues/92-devices-single-list.md
hoangvu12 added a commit to hoangvu12/roboco that referenced this pull request Oct 9, 2026
…ds (wpn-93)

Port zeron zeronsh#526's 631a8e0 + 6e4f363 (ticket 93) on the integration head:

- selectedHarnessUnavailable gains the settled offered-on-the-selected-
  engine arm (d57b27f's two-arm form); selectedModelUnavailable replaces
  newChatNoAgents — a fresh send waits for the resolved engine's OWN
  harness and model catalog (pending/empty/error all block; the sticky
  draft is never substituted) and for the reasoning/options
  reconciliation of a model carried from another engine.
- The fresh draft seeds the sticky picks (remembered harness + its
  remembered model, pickers.rs:713-745) through StickyDraftPicks and
  defaultDraftHarness; the harness reconcile effect becomes
  reconcileFreshDraftHarness (offered-set membership, not raw rows) and
  discovery targets offered harnesses only.
- reconcileFreshDraftModel + the reconciliation hook's fresh flag drop
  option picks the new engine's metadata does not offer; established
  chats keep their committed picks.
- The pickers attribute catalog states to the selected engine:
  engineLabel, the "{label} unavailable" chip/card takeover for a
  committed harness the engine no longer offers, and
  modelDiscoveryErrorMessage (missing_executable names the engine and
  its override; the browser cannot fix it). The compact card (the app
  default presentation) gets the same states.
- PickerCatalog tracks per-harness model flight identities so a
  reset/retry drops a stale discovery reply instead of repainting the
  new target's catalog.

Adaptations (documented in the ticket): roboco's per-model reasoning
memory (zeronsh#471) stays the preference layer at both seeding sites; the
per-engine canvas drafts (wpn-86) change the blocked-draft assertion to
a switch-back round trip; durableCalls includes upload RPCs.

Tests: composer-availability 15/15 (engine-switch suite + offered-arm
bite checks, red first), composer-draft 49/49, composer-reasoning 46/46
(availability states + wiring pin), composer-send 29/29,
picker-catalog 20/20; adjacent ring 28 files 501/501; tsc --noEmit and
pnpm run build clean. Full suite deferred to the orchestrator.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant