-
Notifications
You must be signed in to change notification settings - Fork 0
Renderer Shim & Server Boundary
The Server Edition reuses the Electron renderer, but the renderer expects window.termsprawl, normally installed by the Electron preload script. In the browser, src/server/shim.js installs the same namespace and routes privileged operations over a WebSocket to the server's RPC endpoint. src/server/server-boundary.ts is the host-side counterpart: it decides whether the server may listen off-host at all and provides the lexical path-containment gate used when serving renderer assets. Together they form the contract that lets the renderer boot in a browser while preventing the browser surface from reaching host capabilities the server does not explicitly expose.
The shim is served at /termsprawl-shim.js. It reads a boot token that the server injects into the page; the comment names /termsprawl-boot.js and window.__TERMPRAWL_WS_TOKEN. getToken() prefers the fresh global, persists it to localStorage['termsprawl-ws-token'], and falls back to the persisted copy if the global is absent or storage throws. wsUrl() then builds ws:// or wss:// based on location.protocol, targeting location.host + '/ws' with ?token=... when a token exists. The comment explains why: browsers cannot set an Authorization header on a WebSocket upgrade, so the token rides on the URL. The deployment is loopback by default, and the auth module named in the comment (server-auth.ts) validates the token during the upgrade.
This is exactly why assertSafeServerBind matters. The boot endpoint serves the WS token to same-origin clients. If the server binds to a non-loopback interface without an external trust boundary, any client that can reach the port can fetch the boot script and obtain the token. assertSafeServerBind is the policy that makes that scenario fail closed unless a hosted-space router is present.
The shim speaks a small JSON protocol over one WebSocket:
| Frame | Shape | Purpose |
|---|---|---|
| request | { t: 'req', id, method, args } |
invoke(); expects a response |
| response | { t: 'res', id, ok, result?, error? } |
resolves/rejects the matching pending promise |
| event | { t: 'evt', channel, payload } |
fan-out to on() listeners |
| send | { t: 'send', method, args } |
fire-and-forget, no response |
invoke(method, args) calls ensure(), allocates a monotonic id from seq, stores { resolve, reject } in pending, and sends a req. When a res arrives, the shim looks up pending by id, deletes it, and resolves or rejects. Unknown response ids are ignored. send(method, args) sends a send frame without registering a promise; the visible shim uses it for pty:write and pty:resize, where the caller does not want to await a round trip.
on(channel, cb) never sends a subscribe frame. It only appends cb to a local listeners map and returns an unsubscribe function. Event delivery is therefore server-driven: the server emits evt frames for the channels it knows about, and the shim fans them out to every local callback registered for that channel. If you add a server-side subscription model, you must also add a client frame to register it; the current protocol has no subscribe message.
sequenceDiagram
participant R as Renderer app
participant S as window.termsprawl shim
participant W as WebSocket /ws
participant D as Server RPC dispatch
R->>S: settings.get()
S->>S: ensure(); seq++; pending.set(id)
S->>W: {t:"req", id, method:"app:settings-get", args:[]}
W->>D: upgrade + token check, then frame
D-->>W: {t:"res", id, ok:true, result}
W-->>S: message
S->>S: pending.delete(id); resolve(result)
S-->>R: Promise resolves
Note over S,W: Events use {t:"evt", channel, payload} and fan out to on() listeners
The key nodes are ensure(), which owns connection creation, and pending, which correlates responses to promises. Events are deliberately not correlated to requests; they are broadcast into the local listener registry by channel name.
ensure() returns early if ws is already OPEN or CONNECTING; otherwise it creates a new WebSocket(wsUrl()). On open, it resets retries to zero. On close, it rejects and clears every pending request with Error('disconnected'). A close code of 1000 is treated as intentional shutdown: the shim does not reconnect. Any other close triggers refreshBootToken(), then a reconnect: the first retry waits 500 ms, later retries wait 2000 ms.
refreshBootToken() re-fetches /termsprawl-boot.js with XHR, extracts __TERMPRAWL_WS_TOKEN = "..." or '...' from the response text with a regex, updates window.__TERMPRAWL_WS_TOKEN and localStorage['termsprawl-ws-token'], and then calls the reconnect continuation. The comment explains the reason: the token can change between reconnects (for example, space resume mints a fresh boot token), so a stale token in localStorage must heal on the next reconnect instead of causing a 401 loop. This couples the shim to the exact assignment format emitted by the boot script.
stateDiagram-v2
[*] --> Idle
Idle --> Connecting: ensure()
Connecting --> Open: open / retries = 0
Connecting --> Recovering: close(code != 1000)
Open --> Recovering: close(code != 1000)
Open --> Stopped: close(code == 1000)
Recovering --> Connecting: refreshBootToken -> 500ms first, then 2000ms
Stopped --> [*]
The two terminal transitions are the important ones: code 1000 stops the client, while any other close enters the token-refresh/backoff loop. Pending requests do not survive either transition; callers see a rejection and must decide whether to retry.
The shim's goal is not to implement every Electron feature in the browser. It is to expose the full namespace so the renderer can mount, and to fail gracefully where the server has no equivalent. The visible patterns are:
-
notAvailable(what)returns a function that rejects with"<what> not available in server edition". This is used forsettings.createAccountandsettings.deleteAccount(managed accounts). -
settings.permissionSupportedresolvesfalse;settings.loginCommandresolves''. -
contextLinksis shape-compatible but inert:listresolves{ ok: true, links: [] }, whileaddandremoveresolve{ ok: false, error: 'NO_FOLDER' }. -
files.openDialogresolvesnull;workspace.selectFolderimplements an in-page modal that asks for a directory on the server host and returns the trimmed path ornullon cancel. There is no native folder picker in a browser. -
runtimeis{ kind: 'server', packaged: false }, andruntimeInfo()resolves{ packaged: false }. The comment says the settings panel uses this to hide desktop-only surfaces instead of rendering dead controls. -
openExternalcallswindow.open(url, '_blank')rather than a host shell. - The
browsernamespace must exist even though browser nodes are Electron-only. The visible comment explains thatCanvas.tsxsubscribes tobrowser.onAgentOpenon mount, so a missing namespace would throw; the server shim provides safe no-op/rejection methods. The exact method list continues past the provided excerpt.
The file header also lists git, cloud writes, managed accounts, and agent hooks as unsupported categories whose methods resolve or reject gracefully. The visible excerpt demonstrates the pattern for managed accounts, context links, and browser nodes; the other namespaces are outside the excerpt.
src/server/server-boundary.ts contains the host-side policy.
isLoopbackHost(host) normalizes the input (string, trim, lowercase, strip surrounding brackets) and returns true for localhost, ::1, or a dotted-quad IPv4 address whose first octet is 127 and whose octets are all 0-255. This is a syntactic check, not a DNS lookup: a hostname that resolves to loopback is not accepted, and addresses such as 0.0.0.0, ::, or IPv4-mapped IPv6 forms are not accepted. That is intentional for a bind guard: it fails closed for anything it does not explicitly recognize.
assertSafeServerBind(host, hasSpaceRouter) throws when the host is not loopback and hasSpaceRouter is false. The doc comment explains the trust model: plain Server Edition must not be reachable off-host because the browser boot endpoint serves the WS token to same-origin clients. A non-loopback bind is allowed only for hosted Spaces, where the router injects the shared header and the app port is not the public trust boundary. The boolean is the escape hatch; passing it incorrectly defeats the guard. Extending the server to a new deployment mode means extending this policy deliberately, not bypassing it.
resolveContainedPath(root, requestedPath) is the asset-serving containment gate. It resolves root and resolve(root, requestedPath), computes relative(root, candidate), and accepts only when the relative path is empty (the root itself) or is not absolute, is not .., and does not start with .. followed by the platform separator. This rejects lexical traversal (../), absolute paths outside the root, and sibling-prefix escapes such as /srv/app-evil when the root is /srv/app. It returns null for rejected paths; callers should treat null as "do not serve this asset" (404/403).
flowchart TD
A[requested asset path] --> B[resolve root + requestedPath]
B --> C[relative root -> candidate]
C --> D{inside root?}
D -- yes --> E[return candidate / serve]
D -- no --> F[return null / reject]
The important case is the sibling-prefix escape: a naive startsWith(root) check would accept /srv/app-evil, while the relative-path check rejects it. The function is lexical: it does not call realpath, so a symlink inside the root that points outside is not caught here. If the asset root can contain symlinks controlled by an attacker, add a realpath check or ensure the root is trustworthy.
This file is the browser-side adapter. It owns:
- the single WebSocket connection and the
ensure()gate; - the request/response correlation state (
seq,pending); - the event fan-out state (
listeners); - the boot-token cache and refresh path;
- the
window.termsprawlnamespace that mirrors the Electron preload surface; - the graceful-degradation policy for unsupported features.
It never touches fs, child_process, or a host shell directly. Every capability that would be privileged in Electron is converted into an RPC method name and an argument array.
This file is the host-side policy. It owns:
-
isLoopbackHost, the syntactic loopback predicate; -
assertSafeServerBind, the fail-closed guard that prevents a plain server from binding off-host; -
resolveContainedPath, the lexical containment helper for renderer asset resolution.
It does not know about the shim's frame format or the renderer's namespace. It protects the host surface that the shim's token and requests depend on.
- The boot token is the hinge: the shim reads and refreshes it, the server serves it, and
assertSafeServerBindis what makes serving it safe in the default deployment. Changing the token format or the boot script means changinggetToken()/refreshBootToken()and the server auth path together. - The RPC method names in the shim (
app:version,app:settings-get,workspace:snapshot,pty:create,pty:write, etc.) are the client half of the server dispatch table described in the RPC dispatch page. Adding a capability means adding a shim method, adding the server handler, and deciding what the shim should do when the server does not implement it. - The
browserstub is a concrete example of the renderer contract: the renderer imports code that assumes the namespace exists, so the shim must expose it even when every operation is a no-op. The same design principle applies to any new preload namespace.
-
Argument shape.
invoke(method, args)expectsargsto be an array. In the visible shim,settings.setSkillEnabled,settings.setPluginEnabled, andsettings.reinstallHookscallinvokewith extra positional arguments; becauseinvokeonly accepts two parameters, the extra value is dropped andargsis a string rather than an array. If you touch these methods, verify the server handler's expected shape and wrap the arguments explicitly, for exampleinvoke('settings:set-skill-enabled', [id, enabled]). -
No request timeout. A
reqthat the server neither answers nor disconnects will keep its promise pending indefinitely. If you add a long-running RPC, wrapinvokewith a timeout in the caller or add a protocol-level timeout in the shim. -
Fire-and-forget loss.
send()has no ack and no pending entry. If the socket fails before it opens, the queued send is attached to the failed socket's open event and can be lost. Usesendonly for operations where a lost frame is acceptable or recoverable; the visible uses arepty:writeandpty:resize. -
Listener lifetime.
on()returns an unsubscribe function, but thelistenersmap is not cleared on reconnect; callbacks stay registered until the returned function is called. Components that mount and unmount terminal or agent listeners must call the unsubscribe function to avoid leaks. -
Intentional shutdown vs network failure. Close code
1000stops reconnection. If the server needs to recycle connections without losing the client, it should not use1000unless the renderer is expected to stop. -
Token refresh format.
refreshBootToken()regex-matches the literal assignment in/termsprawl-boot.js. Changing the boot script to emit the token through a different assignment, JSON blob, or external file will silently leave the old token in place. Keep the regex and the boot script in sync, and consider parsing a structured response if the format becomes more complex. -
LocalStorage failures.
getToken()catches storage exceptions and falls back to the global. In privacy-restricted browsers, the global is the only reliable source;refreshBootToken()updates both, so the fallback still works. -
Bind guard flag.
assertSafeServerBindtrustshasSpaceRouter. Only passtruewhen the router gate is actually in front of the process. If you add a new hosted mode, extendisLoopbackHost/assertSafeServerBindwith an explicit policy rather than reusing the boolean for an unrelated condition. -
Lexical path containment.
resolveContainedPathdoes not resolve symlinks. If the served asset root can contain symlinks that point outside, add a realpath-based check. Also decide whether serving the root itself (rel === '') is acceptable for the route that calls it. - Unsupported feature policy. For a new namespace, prefer a shape-compatible no-op or an explicit rejection over omission. The renderer's ambient preload types and feature components may call the method during mount, so a missing property can crash the page before the feature panel can render an error state.
-
pty.destroyvspty.closeNode. The former callspty:destroyand kills the PTY; the latter callsterminal:closewith the project id, so it can perform node-level workspace bookkeeping. Do not substitute one for the other. -
workspace.selectFoldercleanup. The in-page modal only removes its overlay in itsdone()path. If you extend it with Escape handling or navigation cleanup, keep the removal single-shot to avoid double-remove errors.
The provided excerpt covers src/server/shim.js lines 1-260 and src/server/server-boundary.ts lines 1-37. The shim file continues beyond the visible browser namespace comment, so this page does not enumerate later namespaces (for example git, cloud, or chat shims) or the full browser stub method list. The server bootstrap, auth module, and RPC dispatch table were not part of the excerpt; statements about them are limited to the shim's comments and the page directory context. To verify the full client/server RPC contract, read the dispatch table and the auth module named in the shim comment.
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance