@claxedo/workspace-runtime
v0.5.1
Published
Per-workspace host runtime for agent sessions, terminals, processes, files, diffs, and relay attachment.
Maintainers
Readme
@claxedo/workspace-runtime
The workspace-runtime is the per-workspace host service. One process per
workspace. It owns workspace host wiring around the runner adapters, plus the
PTY and process managers, the LSP/VCS surface, and the relay-host tunnel to
workspace-relay. Runner adapters live in @claxedo/agent-sdk-runtime;
workspace-runtime only wraps them where it needs host storage or environment
wiring.
UI never imports this package directly. The user's browser talks to
claxedo-server, which proxies to a workspace-runtime instance via the
gateway pattern in claxedo-server/src/proxy.ts.
Public surface
The package root is intentionally small and checked by
docs/api-manifest.json. Use focused subpaths for
lower-level helpers:
| Import | Use |
| --- | --- |
| @claxedo/workspace-runtime | Standalone bootstrap, host creation, exposure/management contracts, route manifest, and stable config types. |
| @claxedo/workspace-runtime/client | Manual typed HTTP client for health, capabilities, config apply, events, files, diff/git, PTY, and process routes. |
| @claxedo/workspace-runtime/host | Low-level host construction and route mounting. |
| @claxedo/workspace-runtime/exposure | Explicit loopback, relay, private-network, and embedded exposure declarations. |
| @claxedo/workspace-runtime/relay | Relay-host auth and host tunnel helpers. |
| @claxedo/workspace-runtime/config | Runtime config snapshot and management-auth contracts. |
| @claxedo/workspace-runtime/routes | Neutral /api/wr/* route manifest. |
| @claxedo/workspace-runtime/testing | Small test management-auth helpers. |
Root runtime value exports:
WORKSPACE_RUNTIME_MANAGEMENT_TOKEN_HEADER, WorkspaceRuntimeRouteManifest,
WorkspaceRuntimeRoutes, createWorkspaceHost,
createWorkspaceRuntimeApp, createWorkspaceRuntimeJwtManagementAuth,
embeddedWorkspaceRuntimeExposure,
loadWorkspaceRuntimeManagementVerificationKey,
loopbackWorkspaceRuntimeExposure, normalizeRuntimeSnapshot,
privateNetworkDevUnsafeWorkspaceRuntimeExposure,
privateNetworkWorkspaceRuntimeExposure, relayWorkspaceRuntimeExposure,
startServer, startWorkspaceRuntime, and workspaceRuntimeRoute.
Relay host helpers are intentionally exposed from
@claxedo/workspace-runtime/relay, not the package root:
createRelayHostAuthMiddleware, startWorkspaceRelayHostTunnel,
hostTunnelFromEnv, relayHostAuthFromEnv, and
workspaceRelayRuntimeOptionsFromEnv.
The runtime root does not export Claxedo control-plane clients or agent-extension materializers. Hosts that need projection, metadata sync, or extension installation should compose those concerns outside the OSS runtime boundary.
Routes
| Route | Source | Auth |
| --- | --- | --- |
| GET /api/wr/health | server.ts | none; minimal liveness and exposure boundary metadata only |
| GET /api/wr/capabilities | server.ts | exposure-dependent runtime auth |
| POST /api/wr/config | routes/config.ts | workspace-runtime management auth |
| GET /api/wr/harness-config-options | workspace/runtime.ts | exposure-dependent runtime auth |
| GET /api/wr/events, GET /api/wr/runtime-events | routes/claxedo-events.ts, routes/events.ts | exposure-dependent runtime auth |
| * /api/wr/file/*, GET /api/wr/find/file | routes/file.ts | exposure-dependent runtime auth |
| * /api/wr/diff/*, * /api/wr/git/* | routes/diff.ts, routes/git-source.ts | exposure-dependent runtime auth |
| * /api/wr/pty/* | routes/pty.ts | exposure-dependent runtime auth |
| * /api/wr/process/* | routes/process.ts | exposure-dependent runtime auth |
| * /api/wr/hook/* | routes/agent-hook.ts | exposure-dependent runtime auth |
| * /session/* | SessionRoutes (mounted via mountWorkspaceCore) | implicit (host-level) |
| * /mcp/* | MCP routes | implicit |
| * /lsp, * /vcs, * /global/event | compatibility routes mounted by host | implicit |
Event contract
workspace-runtime has two event systems with different jobs:
| Surface | Transport | Event family | Contract |
| --- | --- | --- | --- |
| GET /global/event | SSE | OpenCode-compatible CompatEnvelope values | Primary client stream for session/message/permission/question lifecycle. For OpenCode compatibility mode this may proxy the upstream OpenCode /global/event; otherwise it fans out the host RuntimeEventHub global stream and emits server.connected plus heartbeat frames. |
| GET /api/wr/runtime-events | SSE | RuntimeEventEnvelope values wrapping raw AgentRuntimeEvent payloads | Primary runtime-event stream for host-mounted core clients that need adapter-native runtime events. This route is mounted by mountWorkspaceCore(). |
| GET /api/wr/events | SSE | ClaxedoEvent values from claxedoBus | Neutral runtime path for the compatibility/internal process-global stream. |
| GET /event | SSE | ClaxedoEvent values from claxedoBus | Compatibility/internal process-global stream for PTY lifecycle, PTY stream summaries, process status/config events, agent lifecycle, session lifecycle, and heartbeats. It is not the primary session/message event stream. |
| GET /api/wr/pty/:ptyID/connect | WebSocket | PTY bytes plus cursor metadata | Supported PTY data stream. PTY lifecycle summaries also appear on /event, but terminal bytes are delivered over this WebSocket. |
| GET /api/wr/process/logs | HTTP snapshot | Text log tail | Process output is poll/snapshot based through PTY log snapshots. There is no separate supported process-output event stream. Process status summaries appear on /event. |
RuntimeEventHub is the primary hub for session/runtime events. Session
routes publish OpenCode-compatible events to its global channel and bridge only
terminal lifecycle states into claxedoBus as agent.lifecycle compatibility
events. That bridge maps session.status with busy status to Busy,
permission/question asks to UserActionRequired, session.idle to Idle,
and session.error to Error.
claxedoBus is intentionally process-global runtime state. It is used by PTY,
process, and agent-hook code that already lives inside the workspace-runtime
process. Subscribers are isolated: a throwing or rejecting subscriber is
reported and cannot prevent later subscribers from receiving the same event.
Supported runtime shapes
workspace-runtime supports five deployment shapes. They share the same
per-workspace route surface, but they do not share the same trust boundary:
| Shape | How it is created | Listen/auth expectation |
| --- | --- | --- |
| Local loopback / trusted local | startServer(port, { exposure: loopbackWorkspaceRuntimeExposure() }) with the default host or WORKSPACE_RUNTIME_HOST=127.0.0.1 / localhost | May run without host-level auth because the socket is loopback-only. This is for local development and app-owned desktop flows. |
| Private VM runtime | startServer(port, { exposure: privateNetworkWorkspaceRuntimeExposure(...), target }) inside an operator-controlled VM, usually with WORKSPACE_RUNTIME_HOST=0.0.0.0 or a private interface | Must configure either relay-host auth or a private-network exposure with both a host guard and runtime auth. The dev-unsafe opt-out is only for self-managed deployments where surrounding controls are intentionally the auth boundary. |
| Relay-attached runtime | startServer() with workspaceRelayRuntimeOptionsFromEnv() / relayHostAuthFromEnv() and hostTunnelFromEnv() | Verifies Relay Host Tokens (RHTs) from workspace-relay, requires the relay transit marker on relay-issued tokens, and may maintain an outbound host tunnel to the relay. |
| Embedded Hono app | createWorkspaceRuntimeApp() mounted inside another trusted process | The embedding process owns the outer network/auth boundary. Pass relayHostAuth, configToken, and a WorkspaceTarget explicitly when exposing runtime routes outside loopback. |
| Low-level host object | createWorkspaceHost() / mountWorkspaceCore() used without startServer() | No socket is created. The caller owns routing, auth, lifecycle, and disposal; use this only behind an existing trusted API surface. |
Runtime CORS follows the same exposure declaration. Loopback exposure allows
local browser origins such as localhost, 127.0.0.1, and hosted
opencode.ai app origins. Relay, private-network, and embedded exposure do not
emit runtime CORS headers by default because the relay, trusted ingress, or
embedding server owns the browser origin policy.
For all shapes, WORKSPACE_RUNTIME_CONFIG_TOKEN is a trusted direct token for
health discovery through relay-host middleware, not whole-server auth.
Config mutation is authorized only by an explicit workspace-runtime management
auth adapter. The config token does not authorize PTY/process/file/session/VCS
routes or /api/wr/config.
Standalone listen policy
startServer() requires an explicit exposure declaration and defaults to
127.0.0.1 for the listen host. A runtime may listen on a non-loopback host
such as 0.0.0.0, ::, or a LAN address only when
relay-host auth is configured, a private-network exposure supplies both host
guard and runtime auth, or the operator explicitly sets
WORKSPACE_RUNTIME_ALLOW_UNAUTHENTICATED_NON_LOOPBACK=1.
The opt-out is the private-network-dev-unsafe path: it is only for
self-managed deployments behind trusted private-network controls and does not
add application-level auth.
WORKSPACE_RUNTIME_CONFIG_TOKEN participates in trusted direct health access
when relay-host auth is enabled; it is not standalone config mutation auth and
is not a whole-server credential for PTY, process, file, session, or VCS routes.
Relay-attached runtime configuration
A relay-attached runtime has two independent pieces:
- Relay-host auth verification, which protects inbound requests that the relay forwards to the runtime.
- Host tunnel attachment, which opens the outbound connection from this
runtime to
workspace-relay.
Inbound RHT verification is configured by relayHostAuthFromEnv():
| Env var | Purpose |
| --- | --- |
| WORKSPACE_RUNTIME_RELAY_JWKS_URL | Preferred RHT verification source. The runtime uses this JWKS to verify relay-minted RHTs and support signing-key rotation. |
| WORKSPACE_RUNTIME_RELAY_HOST_VERIFY_PEM | Static PEM fallback for RHT verification when JWKS discovery is unavailable. |
| WORKSPACE_RUNTIME_RELAY_HOST_PUBLIC_KEY_JWK | JWK fallback for existing simple deployments. Prefer JWKS or PEM for new deployments. |
| WORKSPACE_RUNTIME_WORKSPACE_ID | Workspace id the runtime hosts; RHT claims and x-workspace-id must match it. |
| WORKSPACE_RUNTIME_HOST_ID | Host id expected in the RHT. Defaults to workspaceId() when omitted. |
| WORKSPACE_RUNTIME_TRUSTED_DIRECT_TOKEN | Direct host token accepted by the relay-host middleware without an RHT. This bypass is for trusted supervisor/control-plane calls only; treat it as whole-host authority and keep it off untrusted clients. |
Relay-issued RHT requests must include x-forwarded-by: workspace-relay.
workspace-relay sets that marker after stripping client-supplied
x-forwarded-* headers. Direct trusted-token calls bypass the marker because
they do not originate from the public relay.
Outbound host tunnel attachment is configured by hostTunnelFromEnv():
| Env var | Purpose |
| --- | --- |
| WORKSPACE_RUNTIME_RELAY_URL | Relay base URL. If unset, the runtime does not open a host tunnel. |
| WORKSPACE_RUNTIME_RELAY_TUNNEL_TOKEN | Bearer token for the runtime-to-relay tunnel connection. |
| WORKSPACE_RUNTIME_RELAY_TUNNEL_AUTHORIZATION | Full Authorization header override for the tunnel connection. Takes precedence over WORKSPACE_RUNTIME_RELAY_TUNNEL_TOKEN. |
| WORKSPACE_RUNTIME_RELAY_WORKSPACE_IDS | Comma-separated workspace ids advertised by this host tunnel. Defaults to the current workspace id. |
| WORKSPACE_RUNTIME_LOCAL_BASE_URL | Local runtime URL forwarded by the tunnel. Defaults to http://127.0.0.1:<port>. |
| WORKSPACE_RUNTIME_RELAY_PING_INTERVAL_MS | Optional host-tunnel ping cadence. |
Programmatic callers can pass tokenProvider to
startWorkspaceRelayHostTunnel() when the tunnel token must be refreshed before
each connection attempt. Static env auth remains available for supervised
processes whose token is already rotated by the parent process. Callers can also
pass onEvent and maxReconnectAttempts to surface tunnel lifecycle state and
stop retrying after a bounded number of failed reconnect attempts.
Workspace target and path containment
workspace-runtime is a per-workspace host. Session, session-env, file, PTY,
process, OpenCode-compat, and diff/VCS routes are pinned to
WORKSPACE_RUNTIME_DIRECTORY or the WorkspaceTarget passed to
createWorkspaceRuntimeApp(). Callers may omit directory and use the pinned
workspace, pass that exact directory, or pass the synthetic
workspace:<workspaceId> target. A request for any other directory is
rejected.
Filesystem route path values and PTY/session-env cwd overrides are relative
to the pinned workspace. Absolute paths, .. escapes, null bytes, and symlinks
that resolve outside the workspace are rejected before the route reads, writes,
streams file content, or starts a subprocess. Client-provided subprocess env is
filtered through the same allowlist/denylist policy used by PTY startup.
Lifecycle
States: "ready" | "applying" | "error" (see
workspace/runtime.ts).
- Cold start —
startServer(port, { exposure, target?, relayHostAuth?, configToken? }). - Health discoverable —
/api/wr/healthreturns minimal liveness, including{ status: "ready" | "applying" | "error" }, without workspace identity, directory, diagnostics, or capabilities. - Config apply observable — accepted runtime config writes redacted metadata to
<workspace>/.workspace-runtime/runtime-config/accepted-snapshot.json, and apply progress writes<workspace>/.workspace-runtime/runtime-config/apply-status.jsonwithapplying,applied, orfailed. Auth values are never written to these status files; only auth key names are recorded. - Config applied —
claxedo-serverPOSTs aRuntimeSnapshotto/api/wr/config. The host transitions to"applying"while the adapter is reconfigured, then back to"ready". - Relay/direct discovery — supervisors and relays track runtime
availability outside the OSS runtime package.
/api/wr/healthexposes only liveness and boundary metadata; richer diagnostics stay behind authenticated host-owned surfaces. - Drain & exit — on SIGTERM/SIGINT we close the listening socket,
close the host tunnel (so the relay reroutes), dispose managed process
state, remove remaining PTYs, then
host.dispose()(withWORKSPACE_RUNTIME_DRAIN_TIMEOUT_MSwall-clock cap, default 10s). Seeserver.tsfor the exact phase sequence (P4).
Expected route, adapter, process, and relay failures are handled at their
own owner boundaries and mapped to route-specific responses or runtime
state. Process-level unhandledRejection and uncaughtException handlers
are last-resort fatal paths: they stop accepting new work, run the same
workspace drain used by SIGTERM/SIGINT, and exit non-zero so the supervisor
can restart the runtime. They are not suppress-and-continue handlers.
Runtime store durability
RuntimeStore treats per-session JSONL journals as the source of truth and
SQLite as a derived projection. Journaled control/event mutations append the
JSONL row first, then apply the SQLite projection and checkpoint in one
transaction. If projection fails after the append, the current DB transaction
rolls back and a later runtime start rebuilds the projection from the journal.
Startup replay resets the SQLite projection and replays journals in sequence.
Replay-time recovery normalization, multi-row event projections, session
deletion, and multi-field session updates run inside SQLite transactions.
Adapters that create a RuntimeStore close it during adapter disposal; callers
that inject their own store remain responsible for closing it.
AgentHarnessAdapter contract
Defined in @claxedo/agent-sdk-runtime/adapters and re-exported as a type from
the workspace-runtime root. The adapter is the single deep seam between the
host and a specific harness (OpenCode, ACP harnesses, native SDK harnesses, or
Pi).
export interface AgentHarnessAdapter {
// Session lifecycle
listSessions(directory: string): Promise<unknown[]>
getSession(id: string, directory: string): Promise<unknown | null>
createSession(directory: string, title?: string): Promise<{ id: string }>
updateSession(id: string, updates: { title?: string; time?: { archived?: number } }, directory: string): Promise<unknown | null>
getSessionConfig(id: string, directory: string): Promise<SessionConfig>
updateSessionConfig(id: string, update: SessionConfigUpdate, directory: string): Promise<SessionConfig>
deleteSession(id: string, directory: string): Promise<void>
readHarnessCapabilities(directory: string): Promise<HarnessCapabilities> | HarnessCapabilities
// Messaging
sendMessage(id: string, input: PromptInput, directory: string): AsyncIterable<AgentRuntimeStreamEvent>
getMessages(id: string, directory: string): Promise<unknown[]>
abort(id: string, directory: string): Promise<AbortResult>
revert(id: string, directory: string): Promise<void>
unrevert(id: string, directory: string): Promise<void>
forkSession(id: string, messageId: string, directory: string): Promise<{ id: string }>
// Commands
executeCommand(id: string, command: string, directory: string): Promise<void>
listCommands(directory: string): Promise<unknown[]>
// Agents / todos / permissions / questions
listAgents(directory: string): Promise<unknown[]>
getTodos(sessionId: string, directory: string): Promise<Array<{ content: string; status: string; priority: string }>>
listPermissions(directory: string): Promise<unknown[]>
respondPermission(permId: string, decision: PermissionDecision, directory: string): Promise<void>
listQuestions(directory: string): Promise<unknown[]>
replyQuestion(qId: string, answer: string, directory: string): Promise<void>
rejectQuestion(qId: string, directory: string): Promise<void>
// Config injection (called by /api/wr/config)
applyConfig(config: Record<string, unknown>): Promise<void>
// Runner config-options surface (no-op when unsupported)
probeConfigOptions(directory: string): Promise<unknown[]>
peekConfigOptions?(directory: string): Promise<unknown[] | null> | unknown[] | null
dispose(): void
}Implementations
Adapter implementations live in @claxedo/agent-sdk-runtime, not in this
package. Workspace Runtime owns hosting, routing, target containment, config
apply, PTYs, processes, files, diffs, and relay attachment around those
adapters.
Adapter selection
Strict type-based dispatch happens once, at host construction
(workspace/runtime.ts). After that, the rest
of the codebase calls into the AgentHarnessAdapter interface only — there are
no if (runner.type === "claude-acp") branches in the call paths.
The four model-fallback sites (bootstrap.ts, opencode-compat.ts)
are model defaulting decisions, not adapter-selection branches.
Error semantics
sendMessageyields aCompatEventasync iterable. Adapter-level errors surface as{ type: "error", error }events; transport faults raise as exceptions and are caught by the host route handler.abortreturns a typedAbortResult(cancelled/already_idle/not_found/recovering/failed). Callers map this onto HTTP status codes.dispose()is fire-and-forget. The drain phase runs it insidePromise.racewith a wall-clock timeout (default 10s) so a stuck dispose cannot strand SIGTERM-driven shutdown.
Crash recovery
ACP runner crashes are caught by the ACP adapter in
@claxedo/agent-sdk-runtime, which calls onDead() and emits
session.recover. The session is marked
"recovering" and the workspace itself stays alive; only the affected
session is impacted. This is the same shape as opencode adapter
crash-handling.
Plugging in auth
Relay-host auth can use the built-in JWT key path or a custom
TokenVerifier.
Use this to back the runtime's relay boundary with a custom IdP (Clerk,
OIDC, hosted control plane, or a self-managed key-table).
import { createStaticTokenVerifier } from "@claxedo/workspace-relay-protocol"
import { createRelayHostAuthMiddleware } from "@claxedo/workspace-runtime/relay"
const verifier = createStaticTokenVerifier({
tokens: {
"tok-tenant-1": {
subject: "u1",
scopes: ["workspace:write"],
claims: { workspace_id: "ws_1", host_id: "host_1", role: "editor", access: "cloud", backing: "cloud-vm" },
},
},
})
app.use("*", createRelayHostAuthMiddleware({
key: relayKey,
workspaceId: "ws_1",
hostId: "host_1",
verifier,
}))Config mutation uses the separate WorkspaceRuntimeManagementAuth contract from
@claxedo/workspace-runtime/config. The built-in JWT helper verifies
management tokens with WORKSPACE_RUNTIME_MANAGEMENT_* inputs, and Claxedo maps
its product-specific control-plane credential into that contract outside
workspace-runtime core.
The built-in HttpTokenVerifier accepts a remote endpoint that returns
{ subject, scopes, claims } for a posted token — useful for
hosted-control-plane deployments. See
packages/workspace-relay-protocol/src/token-verifier.ts for the full
contract.
Configuration env vars
| Env var | Purpose |
| --- | --- |
| WORKSPACE_RUNTIME_HOST, port arg | Listening socket. Defaults to 127.0.0.1. Non-loopback values require relay-host auth, guarded private-network exposure, or the explicit dev-unsafe opt-out below. |
| WORKSPACE_RUNTIME_ALLOW_UNAUTHENTICATED_NON_LOOPBACK | Set to 1 only for self-managed runtimes behind trusted private-network controls that intentionally expose unauthenticated host routes. Reports as private-network-dev-unsafe. |
| WORKSPACE_RUNTIME_CONFIG_TOKEN | Trusted direct token for relay-host middleware and health discovery. Not standalone config mutation auth and not whole-server auth. |
| WORKSPACE_RUNTIME_TRUSTED_DIRECT_TOKEN | Whole-host direct token accepted by relay-host auth without an RHT. Use only for trusted supervisor/control-plane traffic. Also acts as the config token when WORKSPACE_RUNTIME_CONFIG_TOKEN is unset. |
| WORKSPACE_RUNTIME_DIRECTORY, WORKSPACE_RUNTIME_WORKSPACE_ID, WORKSPACE_RUNTIME_HOST_ID | Runtime target identity. |
| WORKSPACE_RUNTIME_RUNNER, WORKSPACE_RUNTIME_ACP_BINARY | Optional CLI launcher defaults for the initial harness. Runtime config apply can replace this after startup. |
| WORKSPACE_RUNTIME_ENABLE_ACP_REMOTE_TRANSPORT | Enables remote ACP transport URLs in runner config. Disabled by default. |
| WORKSPACE_RUNTIME_DISABLE_OPENCODE_COMPAT | Disables OpenCode-compatible root routes when the runtime is used only through neutral APIs. |
| WORKSPACE_RUNTIME_TERMINAL_SESSION_TTL_MS | Retention window for terminal lifecycle session summaries. |
| WORKSPACE_RUNTIME_DISABLE_PORTLESS | Disables optional Portless named-url discovery for managed processes. |
| WORKSPACE_RUNTIME_DATA_DIR, WORKSPACE_RUNTIME_STATE_DIR, WORKSPACE_RUNTIME_STORE_DIR, WORKSPACE_RUNTIME_PTY_HISTORY_DIR | Neutral runtime-owned storage locations. Defaults are under ~/.workspace-runtime. |
| WORKSPACE_RUNTIME_MANAGEMENT_JWKS_URL, WORKSPACE_RUNTIME_MANAGEMENT_VERIFY_PEM, WORKSPACE_RUNTIME_MANAGEMENT_ISSUER, WORKSPACE_RUNTIME_MANAGEMENT_AUDIENCE | Management-token verification inputs for /api/wr/config. |
| WORKSPACE_RUNTIME_DRAIN_TIMEOUT_MS | Drain wall-clock cap (default 10000). |
| WORKSPACE_RUNTIME_RELAY_JWKS_URL, WORKSPACE_RUNTIME_RELAY_HOST_VERIFY_PEM | RHT verification inputs for relay-attached runtimes. |
| WORKSPACE_RUNTIME_RELAY_* | Host tunnel settings for relay-attached runtimes. See the relay-attached section above and workspace-relay/README.md. |
| CLAXEDO_RELAY_RESOLVER_* | Relay-process resolver settings. See workspace-relay/README.md. |
Claxedo deployments translate any product-specific environment outside this
package before launching the runtime. Runtime library APIs and examples use the
neutral WORKSPACE_RUNTIME_* names.
Runtime-only example
The local example starts a loopback runtime over a directory without
claxedo-app, claxedo-server, Clerk, Convex, or a control plane:
bun --cwd examples/runtime-only-host typecheck
bun --cwd examples/runtime-only-host smokeBoundary rule
grep -rn "claxedo-app" packages/workspace-runtime/src must return
zero hits. The host is UI-agnostic — any rendering coupling belongs in
claxedo-app and should reach the host via claxedo-server.
packages/containers/ is the OCI image build, not the host.
Development
bun --cwd packages/workspace-runtime dev
bun --cwd packages/workspace-runtime test
bun --cwd packages/workspace-runtime typecheckThe TS error baseline for this package is 0 in isolation. The
errors that appear when typechecking from the monorepo root are
project-reference leakages from claxedo-server (8 baseline errors)
and are tracked separately outside this package.
