The serving layer: turns a workspace (or a terminal) into a web app, hosts the MCP sandbox, and builds the devserver. Per tenant it builds Router::new().merge(api).fallback(serve_static).
- Request tracing: tenant request spans redact extension path capabilities and every
t=query bearer; other query parameters retain their bytes and order. - Per-tenant API: files, search, graph, drafts, and the terminal PTY WebSocket: thin HTTP/WS over a
chan-workspacehandle. serve_static: serves the embedded workspace SPA per tenant with SPA fallback +inject_chan_meta(thechan-prefix/chan-settings-disabled/chan-files/chan-drafts/chan-webgl-renderermeta).- Launcher root: embeds the launcher SPA and assembles the launcher bundle (the
/SPA plus the/api/library/{workspaces,windows}data routes).install_launcher_root_fallbackinstalls that bundle on thechan-libraryWorkspaceHostroot fallback, so the library/devserver root serves the launcher instead of 404ing. The install is per-surface: the desktop loopback installs it bearer-Some(a minted loopback token) with full workspace mutation; the devserver installs it bearer-Sometoo (its rotatable devserver token), and tunnel-origin requests bypass the local bearer because they already passed the gateway edge. A grant is all-or-nothing on the devserver: a signed grantee assertion gets the same launcher as the owner's, the full surface meta and every/api/library/*mutation included, and the reverse-tunnel legs are the one launcher route it does not share; over the gateway they are the owner's desktop app's alone. A tunnel request with a missing or unverifiable assertion is refused with 401 before it reaches the launcher. - MCP host: hosts
chan-llmin-process over a Unix socket (+chan __mcp-proxy). The Unix proxy requires a real socket owned by its effective uid, without following symlinks, on both the configured endpoint and discovered fallback candidates. Fallback retains/tmpfor servers started withoutXDG_RUNTIME_DIR; candidates are checked again before connecting. - Graph adapter: assembles the visualization graph while delegating authored link and mention/contact normalization to
chan-workspace. - Workspace search adapter:
POST /api/search/workspaceaccepts the shared typed request and returns the core result unchanged. The legacy/api/search/contentroute is a projection over the same effective-mode policy. Control-socketworkspace_searchuses the same contract forcs. Both transports run workspace search on the blocking pool so content scanning and ranking do not stall the async executor. - Excluded-directory updates: PUT persists the workspace policy and synchronously refreshes a warm report on the blocking pool, then requests the indexer rebuild and reads the updated view there. GET only reads the current view.
- Workspace open pressure:
WorkspaceFdPressuremaps to HTTP 503 withRetry-After: 3, including launcher add/on routes. It identifies temporary resource pressure and tells callers to close a workspace or retry. - Workspace readiness envelope:
/api/index/status,/api/indexing/state,/api/preflight, and/api/search/contenteach carry aWorkspaceReadinessready/recovering envelope. A content query issued during recovery returns an explicit not-ready/recovering result rather than a fresh-looking empty one. - Transfer backpressure: file and archive sends and terminal, workspace and standalone Files multipart upload receives share a cancellation-aware wait on the dedicated bulk lane. A full send or empty receive parks until channel readiness; a retained wake prevents a notification between polling and parking from being lost. Standard-library wall-clock waits check cancellation at most every 100 ms, so lane shutdown can join workers even while a current-thread Tokio runtime is stopped in that join. No channel progress for
[transfer].stall_timeout_secsinserver.tomlaborts the job and returns its lane slot even while the response stays unpolled. The default is 300 seconds; zero selects that default and positive values are preserved. Each tenant captures it at startup, like the terminal registry idle timeout, so file edits apply when that tenant next starts. The section is file-only and survives config PATCH saves without changing the preferences wire shape. Download responses check the job outcome after draining queued bytes and end with an error on abort, including when the full channel could not carry an error item. Upload handlers race multipart input against the job outcome, so a stalled sender receives a failed response without sending more bytes; the atomic writer discards the partial temporary file. - Download archives: terminal and workspace tar streams store symlinks as symlinks, including top-level links to files, directories, or missing targets. The shared builder disables symlink following; workspace archives read stored link targets through the facade and preserve their listing filters. Preflight counts regular-file payload only and reads link metadata without opening targets; the encoded stream still counts headers, padding, and termination blocks against the ceiling. Files changing after preflight and archive framing can still exhaust that ceiling mid-body. Standalone download commands classify with lstat and request archive filenames for links.
- Metadata import and storage reset: hold the workspace-cell write guard through drain, flush/close, mutation, and restoration. A missing or undrainable cell returns Busy before closing document, scene, or terminal sessions. Once drained, flush document and scene authorities against the retained old workspace, close all sessions, then release its writer lock and run the operation. A Busy drain restores the original workspace with a fresh watcher/indexer. The blocking worker drives flush futures using the retained workspace, without reacquiring the cell.
- Standalone mutation attribution: a mutation ticket remains in flight until commit or cancellation, regardless of idle age. Commit emits attributed frames and starts the 1.5-second idle suppression tail; per-path echo allowances still apply. Dropping an uncommitted ticket removes its state immediately, including cancellation or unwinding, so abandoned writers cannot pin suppression indefinitely.
- Attachment uploads: names are claimed with
Workspace::create_bytes, whose exclusive publication cannot replace an existing entry. Collisions retry numbered suffixes through 1000 and one timestamp fallback; the fallback also refuses collisions. Each failed attempt cancels its self-write reservation. - Streamed text writes: disk-only PUT checks the requested mtime token before reading the body and again after consuming it, inside the workspace atomic-stream callback before self-write reservation and publication. A conflict discards the temporary file and returns 409 with the disk token. The final check, sink validation/fsync, and rename remain separate steps; an external write in that remaining window can still race publication. Tokenless writes retain last-write-wins behavior.
- Live editor authority: document and Excalidraw WebSockets share one server-side authority per path. Clean external edits fold into that authority; overlapping dirty edits retain a three-way conflict until an explicit reload or overwrite via
POST /api/session-conflicts/resolve. Each authority writes a bounded recovery record under.chan/editor-sessions/v1/through the workspace atomic stream writer. On restart, dirty/conflicted authority, durable baseline, versions, and the current disk side rehydrate before any flush can run, so stale authority cannot silently replace a newer disk file. - Desktop handoff: every Unix client verb (open/close workspace, register/control devserver, upgrade) shares an owner-socket gate and newline request exchange. Refused nodes return each verb's no-desktop outcome. The connect-only liveness probe uses the same gate with its 250 ms bound. Requests retain the 1.5 s connect timeout, 3 s normal IO timeout, request-specific control reply budgets and distinct
NoReply, and 15 s upgrade budget. Windows named-pipe behavior is independent. - Fdstore restart manifest: server-owned atomic writes set mode 0600 on the temporary file before publishing terminal replay and sync the parent directory once. Replacement does not inherit a permissive target mode. Each snapshot records the Linux boot id and each child's
/procstart time. Explicit cleanup signals require both identities to match, with a pidfd pinning the process across validation and signalling. Orphan names, inconsistent metadata, old manifests without identity, and unavailable identity checks authorize no explicit signal; skipped reasons explain the refusal. PTY liveness-based restore is independent of these optional fields. Removing invalid entries from systemd and releasing inherited masters still closes those PTYs; the last master close hangs up the controlling foreground process group. Legacy cleanup therefore keeps normal PTY hangup while forgoing explicit termination of unverifiable stragglers. - Devserver builder:
build_devserver_appcomposes theWorkspaceHost+ per-tenant apps into one merged router forrun_devserver;chan devserverand the desktop loopback run the same app. - Local extension runtime: one process-owned
ExtensionRuntimescansCHAN_HOME/extensions, starts valid subprocess declarations, supervises their process groups, and injects one immutable ready catalog into every workspace tenant.GET /api/extensionsexposes only capability-scoped tenant paths;/_chan/extensions/<id>/<capability>/*reverse-proxies HTTP to the process-private loopback URL with the extension token added upstream. The same path works through standalone, desktop, devserver, and gateway-tunnel serving modes without exposing a second port. - Control connection lifetime: the tenant accept task owns its connection tasks. Unmount aborts idle requests, parked handlers, and
cs tunnelconnections. A decodedCloseoperation retains only its teardown scope and reply half until teardown finishes, then gets five seconds to write the reply; it can acknowledge the unmount that drops its own tenant. - Reverse-tunnel legs: two GET WebSocket routes on the launcher router (
/api/library/tunnel/{control,conn}, bearer-gated with?t=accepted, and restricted over the gateway to the owner's desktop app:require_owner_desktop403s every tunnel caller whose assertion does not name the owner on adesktopclient, so a grantee on any client, the owner on a browser session, and the owner through a gateway that states no client are all refused. A reverse tunnel dials out through an addressed app window whose host can be the owner's own machine, outside the devserver a grant covers, and only chan-desktop dials the legs, natively, on the session the gateway's desktop entry route minted; a browser session, or a stolen browser cookie, cannot open a listener. The refusal is logged asreverse tunnel leg refusedwith the subject, whether it is the owner, and the client). The control socket carries the long-livedcs tunnelconversation: validate the spec server-side, mint an unguessable tunnel id, register in the host'schan_revtunnel::TunnelRegistry, trigger the addressed window over/ws(window_command: tunnel_open), race the 10s ready report against client EOF, then hold until either side ends. A refused devserver-side dial closes one data socket without ending the tunnel. Seecrates/chan-revtunnel/design.md.
flowchart TB
subgraph chan-server["chan-server (one tenant)"]
API["/api/* + /ws: files, search, graph, terminal PTY"]
Static["serve_static: embedded workspace SPA + fallback"]
Launcher["serve_launcher: web-launcher SPA + /api/library/*"]
MCPsvc["MCP host (chan-llm over UDS)"]
end
Extensions["ExtensionRuntime: process-wide discovery + supervision"] --> Child["local extension subprocess"]
Extensions --> API
API --> WS["chan-workspace"]
Launcher --> Host["chan-library WorkspaceHost (root_fallback)"]
MCPsvc --> WS
Static --> Bundle["workspace SPA bundle"]
Launcher --> LBundle["launcher SPA bundle"]
Client["browser / webview"] -->|HTTP/WS| API
Client -->|"sandboxed loopback iframe"| Child
Client -->|GET /| Static
Client -->|"GET / (library root)"| Launcher
- chan-server depends on
chan-library, so the launcher assets + handlers live here (the higher layer) and are injected into chan-library's root fallback; chan-library never references a frontend bundle. - Launcher builds are wired into the root web build so clean CI/release builds embed a real launcher, not an empty bundle.
- Search ranking, selector resolution, traversal, normalization, limits, and structured partial errors stay in
chan-workspace; HTTP and control-socket handlers only deserialize, choose the active tenant, and serialize. Identifyreportsworkspace_rootandmetadata_keyfor a workspace tenant and omits both for terminal-only processes. Multi-workspace CLI routing must match both fields (plus pid) and never fall back to another same-pid tenant.- Extension discovery and child ownership stay above per-tenant
build_app: standalone serve, devserver, and desktop each create exactly one runtime and pass only its immutable catalog into tenant route builders. Extension endpoints are independent loopback trust domains, never workspace filesystem authorities. - Route authority table:
src/route_authority.rsis a test-only ratchet, compiled only undercfg(test); nothing in the built server reads it. It holds one table per route table chan-server assembles (router_with_extensions,terminal_router,launcher_router,build_devserver_app) with one row per mounted(verb, path), recording what each kind of caller meets on that route in the code as it stands:Public(answered before any caller authority),NonOwner(every caller reaches the handler),DesktopOwner(only the owner on a desktop session and a local caller reach it; the owner on a browser or unknown client and a grantee on any client are refused with 403; the two reverse-tunnel legs), orLocal(only the devserver's local bearer, so no tunnel caller). The caller model: a local caller carries noTunnelOrigin; the owner is a tunnel caller whose verified subject is the devserver's owner, and the assertion also names the client the owner's gateway session was minted for (desktop, browser, or unknown); a grantee is a tunnel caller with any other verified subject, on any client, and because a grant is all-or-nothing (gateway/migrations/0014_drop_devserver_grant_roles.sql) it meets what the owner meets everywhere except the reverse-tunnel legs. There is no anonymous tunnel caller: the gateway forwards nothing without a signed-in principal, binding extension frame links to the user who opened them, andmark_tunnel_originrefuses an assertion whose subject is nil or empty with 401 before any router runs. One test walks each assembled router and fails on a mounted route with no row or a row with no route, so a new route cannot land unclassified; a second sends every row through its router as a local caller, the owner on a desktop session, the owner on a browser session and a grantee on a desktop session, and fails where the router's answer contradicts the row.