24 KiB
Remote Workspace Execution
Status: implementation plan
Tracking: Organizer item 2ff92129
Goal
Prove that one OpenCode V2 Session can operate on a real hosted development environment whose checkout does not exist on the OpenCode server host.
The first slice must support:
- one stable Workspace identity referenced by a Session Location;
- one provider-backed sandbox for that Workspace;
- reconnecting to the existing provider sandbox;
- reading, writing, editing, globbing, and grepping Workspace files;
- running and interrupting foreground shell commands;
- retaining Workspace files across provider suspension and reconnection;
- local Locations continuing to behave exactly as they do today.
The design must permit Modal, Daytona, and E2B implementations without placing provider concepts in Session, Location, or tool APIs.
Non-Goals
The first slice does not include:
- Slack integration or other product surfaces;
- background or detached commands;
- PTY sessions;
- filesystem watches;
- user-visible snapshots, Session revert, or Workspace forks;
- concurrent mutating Sessions in one Workspace;
- provider migration;
- arbitrary plugin code transparently using the remote filesystem;
- post-crash retry of ambiguous provider or tool work;
- running the Session runner inside the sandbox;
- clustering Session execution.
Domain Model
Session
A Session owns conversation and agent execution history. The Session runner, LLM calls, tool registry, permissions, durable events, and tool settlement stay in the central OpenCode server.
Workspace
A Workspace is the stable project environment referenced by
Location.workspaceID. It may be the primary local checkout, a local git
worktree, or a provider-backed hosted environment.
Workspace identity is orthogonal to Session identity. The first product slice may create one primary Session per Workspace, but this is policy rather than an identity invariant.
The Workspace record stores placement metadata. It does not mirror or synchronize files, dependencies, or Git state. The environment filesystem is authoritative.
Provider Sandbox
A provider sandbox is implementation machinery backing a hosted Workspace. Its provider identifier and lifecycle state belong to the Workspace placement, not to Session or Location.
The Workspace ID remains stable when the provider reconnects, resumes, or recreates its compute from retained state.
API Shape
Keep provider lifecycle separate from environment capabilities.
interface SandboxProvider {
readonly create: (input: CreateInput) => Effect.Effect<SandboxBinding, CreateError>
readonly connect: (binding: SandboxBinding) => Effect.Effect<SandboxConnection, ConnectError, Scope.Scope>
readonly suspend: (connection: SandboxConnection) => Effect.Effect<SandboxBinding, SuspendError>
readonly reconcile: (binding: SandboxBinding) => Effect.Effect<SandboxBinding, ReconcileError>
readonly delete: (binding: SandboxBinding) => Effect.Effect<void, DeleteError>
}
interface SandboxConnection {
readonly binding: SandboxBinding
readonly environment: SandboxEnvironment
}
SandboxBinding is opaque provider state persisted with the Workspace. Core
does not inspect it beyond selecting the provider that owns it. Persisted
binding data uses provider-specific Effect Schemas whose encoded form is
Schema.Json, not unknown. Both connect and suspend may replace the
binding. This is necessary for providers such as Modal where restoring a
filesystem snapshot creates a new provider Sandbox ID.
A replacement binding must remain reconnectable and include enough state to
identify any retired provider resources. Workspace persists it before exposing
a new connection or destroying the previous durable resource, then calls the
provider's idempotent reconcile and persists the simplified binding it
returns. A crash at either boundary therefore leaves a valid binding whose
cleanup can resume on the next lifecycle operation rather than an
undiscoverable sandbox or snapshot. Creation compensates by deleting provider
state if the initial Workspace write fails. CreateInput carries a
caller-generated stable provider identity derived from the Workspace ID, and
provider creation is idempotent or discoverable by that identity. Workspace
uses interruption-masked acquire/use/release around allocation and initial
persistence, so interruption after the binding becomes known either commits
the Workspace or deletes the allocation. Automatic retry of ambiguous command
work remains out of scope.
interface SandboxEnvironment {
readonly platform: "linux"
readonly directory: string
readonly files: WorkspaceFileBackend
readonly process: ChildProcessSpawner["Service"]
readonly shell: WorkspaceShell
readonly ripgrep: Effect.Effect<string, UnsupportedCapabilityError>
}
interface WorkspaceShell {
readonly executable: string
readonly args: (command: string) => readonly string[]
readonly environmentOverrides: Readonly<Record<string, string>>
readonly detached: boolean
}
Core exposes these capabilities through a Location-scoped
WorkspaceEnvironment.Service. The process-global Workspace service owns one
connection manager per Workspace ID. Its Effect RcRef acquires a scoped
SandboxConnection lazily, so constructing or inspecting a Location does not
wake provider compute. Each Workspace operation borrows the connection in a
scope; concurrent operations share it, and an idle TTL releases the provider
client after the final borrower without suspending compute or discarding the
durable binding.
The manager also owns a lifecycle gate and active-lease count. Suspension
blocks new leases, waits for current leases to close, performs and persists the
provider transition, invalidates the RcRef, then reopens the gate. Thus a
suspend cannot run under an active command or file operation, and an old
connection cannot be handed to a new operation after a binding transition.
The provider filesystem boundary contains only primitives needed beneath the existing Core policy services:
interface WorkspaceFileBackend {
readonly inspect: (path: string) => Effect.Effect<FileInfo, FileError>
readonly resolve: (path: string) => Effect.Effect<ResolvedPath, FileError>
readonly read: (path: string) => Effect.Effect<Uint8Array, FileError>
readonly list: (path: string) => Effect.Effect<readonly DirectoryEntry[], FileError>
readonly ensureDirectory: (path: string) => Effect.Effect<void, FileError>
readonly createExclusive: (path: string, content: Uint8Array) => Effect.Effect<void, FileError>
readonly write: (path: string, content: Uint8Array) => Effect.Effect<void, FileError>
readonly writeIfUnchanged: (
path: string,
expected: Uint8Array,
content: Uint8Array,
) => Effect.Effect<void, FileError | StaleContentError>
readonly remove: (path: string) => Effect.Effect<void, FileError>
}
writeIfUnchanged is one provider-side operation. A remote implementation must
not emulate it with an unlocked client-side read followed by write.
FileMutation remains the owner of create/write/remove semantics, stale-edit
errors, parent-directory creation, result metadata, BOM handling, and
OpenCode-side mutation ordering. resolve returns a provider-canonical path
for an existing target or through the nearest existing ancestor for a new
target. LocationMutation uses it to retain symlink-safe containment.
Provider process transports adapt native command handles to Effect's scoped
ChildProcessSpawner contract. Scope finalization interrupts an unfinished
provider process. Existing AppProcess and Shell continue to own timeout,
abort, bounded output, streaming, command IDs, and settlement, but Shell must
take executable, arguments, environment, and detached policy from
WorkspaceShell instead of process.env or process.platform. Hosted policy
preserves the provider's baseline environment and adds only explicit safe
OpenCode overrides; it never copies the server environment.
Foreground Shell acquisition is scoped. It uses Effect.acquireRelease
around process creation so the interrupt finalizer is installed atomically
before acquisition completes, closing the current create-to-onInterrupt
window. The first hosted slice rejects background requests. Existing Ripgrep
continues to own command construction, bounded JSON parsing, result schemas,
and invalid-pattern behavior; the environment supplies process transport and
the remote ripgrep executable path.
OpenCode services expose three distinct lifecycle operations:
Workspace.get(id) // Read metadata without waking compute.
Workspace.connect(id) // Ensure usable compute through a short-lived lease.
Workspace.remove(id) // Delete provider state and the Workspace record.
Core also has an internal
Workspace.borrow(id): Effect<SandboxEnvironment, WorkspaceError, Scope.Scope>
operation used by the Location environment. File operations scope the borrow
to one operation. A spawned process holds its borrow in the caller's process
scope until the handle exits or is finalized. Raw provider connections and
bindings are not public API.
Placement
Location.workspaceID remains the only Session-to-Workspace reference.
Location without workspaceID
-> current implicit-local Location graph
Location with workspaceID
-> load Workspace
-> lexically normalize its directory against Workspace root metadata
-> build a lazy Location graph
-> borrow the Workspace environment only when an operation runs
The provider sandbox ID never appears in a Session row or durable Session event.
The existing experimental Workspace adapter API is not preserved. It mixes configuration, provisioning, discovery, lifecycle, and request routing. The replacement should be derived from the lifecycle and environment interfaces above.
Host And Workspace Authority
Remote execution forces a split that is currently implicit in FSUtil:
- host authority owns OpenCode global configuration, caches, credentials, durable events, and managed tool output;
- Workspace authority owns repository files, project instructions, project configuration, project skills, Git state, search, and commands.
Do not implement the permanent remote boundary by emulating Effect's complete
low-level FileSystem.FileSystem interface. Migrate Location-owned consumers
to the Workspace file backend, or compose existing policy services over that
backend and the environment's ChildProcessSpawner. Host-global consumers
continue using the host filesystem.
Provider Strategy
Build one provider-neutral contract and one real reference provider.
Vercel is the recommended first adapter because the tracer has verified the complete lifecycle against an available account. Its stable persistent name, automatic snapshot-on-stop, direct filesystem API, and reconnectable command handles map closely to the proposed contract.
Daytona is the next comparison because its public API directly supports filesystem operations, command sessions, interruption, and durable stop/pause semantics. Modal follows with an intentionally different binding transition: suspension snapshots the filesystem and reconnection creates a new Sandbox. Its process handles do not expose per-process termination, so an adapter needs an explicit provider-side mechanism that terminates a command and its children; client fiber interruption alone is insufficient. E2B can provide a fourth validation through pause/resume if the first three leave meaningful ambiguity.
No in-sandbox OpenCode worker is required for the first Daytona/E2B/Modal slice. Their SDKs can implement the small environment contract directly. Add a provider-neutral worker later only when supporting providers such as exe.dev, or when detached process reconnection and richer runtime semantics justify it.
Implementation Sequence
The first integration PR establishes the narrow seam needed by stages 1 through 4:
- add provider-neutral file, process, environment, connection, and binding reconciliation contracts;
- add the local environment and a test-only fake hosted provider;
- add browser-safe Workspace metadata while keeping provider placement private in the existing experimental row;
- add a scoped, idle-expiring Workspace connection cache and Location-scoped
WorkspaceEnvironment.Service; - make
Location.boundNoderesolve hosted project/root metadata withoutProject.resolveor any host-path access; and - prove lazy connection and scoped release using a hosted directory that does not exist on the test host.
It does not add Vercel, lifecycle mutation, or migrate existing tools. Its purpose is to establish the placement seam and prevent cloud-provider details from shaping later Core changes. The lifecycle gate and create, suspend, and remove orchestration land with stage 3 rather than as unused first-PR methods.
1. Define Behavioral Contracts
- Add internal Sandbox provider, binding, file-backend, process-transport, and environment services in Core.
- Include provider-side canonical-path and directory primitives plus explicit Workspace shell/environment policy in the contract.
- Keep provider registration process-global and provider implementations out of tool modules.
- Define typed errors for unavailable Workspace, connection failure, file operations, stale content, command failure, and unsupported capability.
- Add a contract test suite that can run against any environment implementation.
- Cover create, reconnect, file retention, conditional write, command output, timeout, caller interruption, and cleanup when a caller fails before wait.
- Observe a long command running before interruption, then assert promptly that its provider process no longer exists.
- Inject failures around replacement-binding persistence and verify that the durable binding can reconnect and reconcile retired resources.
- Interrupt creation around allocation and initial persistence, then verify that the stable provider identity resolves to either one committed Workspace or no retained provider resource.
Result: contracts and reusable tests are ready for the local and fake implementations in the same first integration PR.
2. Establish Local Parity
- Implement the file backend against the current local filesystem and use the
current
ChildProcessSpawnerplus ripgrep resolver. - Compose the existing
FileMutation,AppProcess,Shell, andRipgrepservices over those local primitives. - Run the contract suite against the local implementation.
- Do not route Sessions through it yet.
Result: the contract describes existing semantics rather than only one cloud provider's API.
3. Replace The Experimental Workspace Adapter Core
- Define a safe Workspace public metadata model in Schema instead of exposing only its ID and events.
- Replace arbitrary adapter
typeplusextra: unknownwith an explicit local or sandbox placement in Core-owned storage. Sandbox placement carries a provider key and provider-schema-validated JSON binding; it is not included in browser-facing Workspace metadata. - Add a Core Workspace service for create, get, connect, suspend, and remove.
- Keep provider lookup behind a process-global provider registry.
- Key one scoped connection manager by Workspace ID. Fence suspend/remove against new leases and wait for active leases before changing placement.
- Persist replacement bindings before destructive cleanup and reconcile interrupted transitions on the next lifecycle operation.
- Preserve
Workspace.IDand existing Sessionworkspace_idreferences. - Migrate or reset only experimental Workspace rows; do not add compatibility machinery without a concrete persisted-data requirement.
Result: Workspace owns stable identity and placement without owning files.
4. Introduce The Location Environment Seam
- Add a Location-scoped
WorkspaceEnvironment.Servicewhose local implementation wraps the current filesystem and process services. - Give hosted Workspaces an implementation whose operations borrow the process-global Workspace connection manager.
- Change
Location.boundNodeso a Location withworkspaceIDgets project and root metadata from the Workspace record instead of calling host-localProject.resolve(directory). - Treat Workspace metadata as the canonical root. Interpret Location directory as a working directory within that root and lexically reject paths outside it before looking up the Location graph. Resolve symlinks only when an operation borrows the environment.
- Prove this with a fake hosted Workspace whose directory does not exist on the test host. Do not add a cloud SDK in this change.
Result: Core has one placement seam and remote Location identity no longer requires a host checkout.
5. Separate Host Files From Workspace Files
- Inventory every Location-scoped
FSUtil,Ripgrep,AppProcess, and direct Node filesystem use. - Adapt
FileMutationto the Workspace file backend while preserving its current policy and result surface. - Adapt
LocationMutationto provider-sideresolveandinspectso existing targets, missing-target ancestors, and symlink escapes retain current containment behavior. - Adapt hosted process transport once to
ChildProcessSpawner, then reuseAppProcess,Shell, andRipgreprather than adding provider command or search state machines. - Change
Shellto consume Workspace shell/environment policy and add scoped foreground acquisition with an atomic interrupt finalizer. Reject background shell requests for hosted Workspaces in this slice. - Move read, edit, patch, path resolution, project instruction discovery, and project configuration onto those environment-aware services.
- Keep global config, global skills, caches, credentials, and managed tool output on host services.
- Report the environment platform in built-in instructions instead of
process.platform. - Make unsupported remote snapshot, watcher, VCS, and PTY services explicit for the first slice rather than accidentally acting on the server host.
Result: no supported Workspace operation can silently touch the wrong host.
6. Make The Full Location Graph Environment-Aware
- Migrate each supported Location service to
WorkspaceEnvironment.Servicerather than selecting services through dynamicLayerNodereplacements. - Preserve the current implicit-local path when
workspaceIDis absent. - Cache the connection manager by Workspace ID. Cache Location graphs by the
canonical
{ workspaceID, directoryWithinWorkspace }identity. - Keep Location graphs valid across binding replacement: their environment service borrows the current Workspace connection for each operation rather than retaining provider state.
- Add an end-to-end fake-provider test that materializes the full Location graph without reading, searching, spawning, or watching the host path.
Result: a remote Location can boot when its directory does not exist locally.
7. Implement One Real Provider
- Add the Vercel provider behind a narrow optional package or lazy import so its SDK does not affect local startup.
- Provision a named persistent Sandbox from provider configuration.
- Persist only the binding required to reconnect.
- Use the Vercel SDK directly for lifecycle and files. Adapt detached SDK
command handles to scoped
ChildProcessSpawnerhandles; do not shell through the Vercel CLI or expose detached commands to tools. - Implement atomic conditional write inside the sandbox, plus suspend, reconnect, and delete semantics.
- Never inject OpenCode model-provider credentials into the sandbox.
- Run the generic environment contract suite against a real provider when credentials are available.
Result: a provider-backed Workspace can be created and reconnected directly.
8. Prove The Hosted Coding Slice
- Create a provider-backed Workspace containing a fixture repository.
- Create a V2 Session whose Location references that Workspace.
- Verify that the central server has no copy of the checkout.
- Prompt the Session to read a file, edit it, search it, and run
git status. - Interrupt a long-running foreground command.
- Suspend the Workspace, reconnect it, and verify that edited files remain.
- Confirm that a normal local Session still passes its existing test suite.
Result: the milestone goal is complete.
9. Add Additional Providers
- Run Daytona and Modal implementations through the same contract suite, then add E2B if it reveals another required lifecycle shape.
- Document where each lifecycle maps differently while preserving OpenCode's observable contract.
- Add capabilities only when a required operation cannot be represented by the common contract.
Result: provider genericity is demonstrated rather than assumed.
Acceptance Tests
The first milestone is accepted when all of the following hold:
- A Session with no
workspaceIDbehaves exactly as before. - A Session with a hosted Workspace boots without its directory on the server.
read,write,edit,patch,glob,grep, and foregroundshelloperate only in the hosted environment.- Permission checks and tool settlement remain central.
- Shell interruption reaches the provider process.
- Interrupting a waiting tool fiber, timing it out, or failing before wait does not leave its provider process running.
- A stale conditional edit fails rather than overwriting changed content.
- Workspace metadata can be read without resuming compute.
- Reconnection targets the existing provider resource when available.
- A crash after persisting a replacement binding leaves placement reconnectable and its retired resource eligible for reconciliation.
- Suspension followed by reconnection retains files.
- Suspension waits for active Workspace leases and admits no new operations until its binding transition is durable.
- Deletion removes retained provider state.
- No model-provider credential is present in sandbox environment variables.
- Hosted commands do not inherit the OpenCode server's environment, shell, or platform decisions.
- Existing and newly created paths cannot escape the hosted Workspace through symlinks or missing parent directories.
- The same environment contract tests pass against local and hosted implementations.
Deferred Design Pressure
The following concerns are real but do not expand the first milestone:
- Mutating remote requests need idempotency before automatic network retries.
- Background jobs need durable ownership and reconnection semantics.
- Workspace sharing needs a mutation lease or another concurrency policy.
- Provider migration needs an explicit file-transfer or snapshot contract.
- Portable plugins need explicit Workspace capabilities; ambient
fsand process imports remain local-only. - A resident OpenCode worker becomes useful for SSH-only providers, richer Git operations, PTY, LSP, watches, and detached commands.
- Clustered Session execution and stale-runner fencing remain separate from sandbox placement.
Research
Disposable Daytona, Modal, and Vercel tracer experiments informed this plan and
were removed after review. Vercel was exercised live through create, reconnect,
file I/O, foreground command interruption, stop, resume with retained files,
and deletion. A later Modal live tracer exercised create, reconnect by Sandbox
ID, the direct filesystem API, foreground commands, command timeout cleanup,
filesystem snapshot, restoration into a new Sandbox, files that survived
restoration and remained writable, and Sandbox deletion. In the timeout check,
the command was no longer running and the Python SDK resolved with -1 rather
than raising. Neither the current Python nor JavaScript process handle exposes
direct per-process termination. Daytona was type-checked but not run because
credentials were unavailable.