opencode/specs/v2/remote-workspace-execution.md
2026-07-17 22:25:16 -04:00

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.boundNode resolve hosted project/root metadata without Project.resolve or 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 ChildProcessSpawner plus ripgrep resolver.
  • Compose the existing FileMutation, AppProcess, Shell, and Ripgrep services 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 type plus extra: unknown with 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.ID and existing Session workspace_id references.
  • 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.Service whose 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.boundNode so a Location with workspaceID gets project and root metadata from the Workspace record instead of calling host-local Project.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 FileMutation to the Workspace file backend while preserving its current policy and result surface.
  • Adapt LocationMutation to provider-side resolve and inspect so existing targets, missing-target ancestors, and symlink escapes retain current containment behavior.
  • Adapt hosted process transport once to ChildProcessSpawner, then reuse AppProcess, Shell, and Ripgrep rather than adding provider command or search state machines.
  • Change Shell to 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.Service rather than selecting services through dynamic LayerNode replacements.
  • Preserve the current implicit-local path when workspaceID is 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 ChildProcessSpawner handles; 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 workspaceID behaves exactly as before.
  • A Session with a hosted Workspace boots without its directory on the server.
  • read, write, edit, patch, glob, grep, and foreground shell operate 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 fs and 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.