Skip to content

Renderer Shim & Server Boundary

dazeb edited this page Sep 17, 2026 · 2 revisions

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.

Runtime mechanism

Boot token and WebSocket handshake

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.

Request, response, and event protocol

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
Loading

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.

Connection lifecycle and failure handling

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 --> [*]
Loading

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.

Capability degradation

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 for settings.createAccount and settings.deleteAccount (managed accounts).
  • settings.permissionSupported resolves false; settings.loginCommand resolves ''.
  • contextLinks is shape-compatible but inert: list resolves { ok: true, links: [] }, while add and remove resolve { ok: false, error: 'NO_FOLDER' }.
  • files.openDialog resolves null; workspace.selectFolder implements an in-page modal that asks for a directory on the server host and returns the trimmed path or null on cancel. There is no native folder picker in a browser.
  • runtime is { kind: 'server', packaged: false }, and runtimeInfo() resolves { packaged: false }. The comment says the settings panel uses this to hide desktop-only surfaces instead of rendering dead controls.
  • openExternal calls window.open(url, '_blank') rather than a host shell.
  • The browser namespace must exist even though browser nodes are Electron-only. The visible comment explains that Canvas.tsx subscribes to browser.onAgentOpen on 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.

Server-side bind and path boundary

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]
Loading

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.

File responsibilities and collaboration

src/server/shim.js

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.termsprawl namespace 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.

src/server/server-boundary.ts

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.

Coupling points

  • The boot token is the hinge: the shim reads and refreshes it, the server serves it, and assertSafeServerBind is what makes serving it safe in the default deployment. Changing the token format or the boot script means changing getToken()/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 browser stub 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.

Developer checklist and edge cases

  • Argument shape. invoke(method, args) expects args to be an array. In the visible shim, settings.setSkillEnabled, settings.setPluginEnabled, and settings.reinstallHooks call invoke with extra positional arguments; because invoke only accepts two parameters, the extra value is dropped and args is a string rather than an array. If you touch these methods, verify the server handler's expected shape and wrap the arguments explicitly, for example invoke('settings:set-skill-enabled', [id, enabled]).
  • No request timeout. A req that the server neither answers nor disconnects will keep its promise pending indefinitely. If you add a long-running RPC, wrap invoke with 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. Use send only for operations where a lost frame is acceptable or recoverable; the visible uses are pty:write and pty:resize.
  • Listener lifetime. on() returns an unsubscribe function, but the listeners map 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 1000 stops reconnection. If the server needs to recycle connections without losing the client, it should not use 1000 unless 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. assertSafeServerBind trusts hasSpaceRouter. Only pass true when the router gate is actually in front of the process. If you add a new hosted mode, extend isLoopbackHost/assertSafeServerBind with an explicit policy rather than reusing the boolean for an unrelated condition.
  • Lexical path containment. resolveContainedPath does 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.destroy vs pty.closeNode. The former calls pty:destroy and kills the PTY; the latter calls terminal:close with the project id, so it can perform node-level workspace bookkeeping. Do not substitute one for the other.
  • workspace.selectFolder cleanup. The in-page modal only removes its overlay in its done() path. If you extend it with Escape handling or navigation cleanup, keep the removal single-shot to avoid double-remove errors.

Scope and limits

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.

Sources: src/server/shim.js, src/server/server-boundary.ts

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally