The opencode-remote-worker plugin executes shell commands on a remote
machine instead of (or in addition to) the local one. sshd runs the command,
and output streams back incrementally with the same permission gating,
truncation, and abort behavior as the built-in bash tool.
The feature is a plugin, not a core change. The core provides the seams (custom tool registration, streaming metadata, permission prompts, abort signals). The plugin supplies the transport and remote execution semantics.
- Ships as an npm package (
opencode-remote-worker) withserverandtuientrypoint targets (exports["./server"],exports["./tui"]), installable viaopencode plugin opencode-remote-workeror thepluginconfig array.
| Seam | Where | Role in this plugin |
|---|---|---|
Custom tool registration (Hooks.tool) |
ToolDefinition in packages/plugin/src/tool.ts, loaded by ToolRegistry.fromPlugin in packages/opencode/src/tool/registry.ts |
Registers remote_bash (and optionally a bash override) |
Streaming output (ToolContext.metadata) |
ToolContext.metadata() in packages/plugin/src/tool.ts, wired through SessionProcessor.updateToolCall in packages/opencode/src/session/tools.ts |
Incremental remote output in the transcript |
Permission prompts (ToolContext.ask) |
ToolContext.ask() / AskInput in packages/plugin/src/tool.ts |
Gate on the distinct remote_bash permission id, annotated with the target host |
Abort signal (ToolContext.abort) |
ToolContext.abort: AbortSignal in packages/plugin/src/tool.ts |
Esc/Ctrl-C propagates to the remote process |
| Plugin options | Config.plugin: Array<string | [string, PluginOptions]> in packages/plugin/src/index.ts |
Host definitions, defaults, per-project mapping |
| Tool result shaping | ToolRegistry.fromPlugin (packages/opencode/src/tool/registry.ts) |
Title/output/truncation contract |
- opencode core has no SSH code and no way to place a single tool on
another host. Plugin tool
execute()always runs in the server process. - The V2 plugin SDK has no tool-registration domain yet (
specs/v2/tools.md, "follow-up"). V1Hooks.toolis the surface used.
The plugin depends on two npm packages:
ssh2: the SSH-2 client with exec channels, streamed stdout/stderr, channel signals (SIGINT), exit codes, SFTP (phase 2), key/password/agent auth, andkeepaliveInterval. It does not read~/.ssh/config(the plugin supplies that).ssh-config: actively maintained parser for~/.ssh/config. The plugin adds a thin resolver for the v1 subset:HostName,User,Port,IdentityFile(with~/%d/%u/%hexpansion), andServerAliveInterval(mapped tokeepaliveInterval).ProxyJump,ProxyCommand,Match, andLocalForwardare parsed but not applied in v1.
Resolution precedence for a configured host: explicit plugin-config values →
~/.ssh/config alias resolution → ssh2 defaults. Directive keywords are
matched case-insensitively (OpenSSH semantics: Hostname and HostName are
both valid). Host verification uses hostVerifier against
~/.ssh/known_hosts: plain entries are verified in v1, hashed (|1|...)
entries are not (see
Limitations and future work). Matching
follows OpenSSH's CheckHostIP default: an entry recorded under the connect
hostname or under any of its resolved addresses accepts the key.
model ──> tool registry ──> shell tool ──> ChildProcess (local OS)
The built-in bash tool (ShellTool) spawns a local child process and echoes
stdout chunks through ctx.metadata. This remains the default when no remote
is configured.
model ──> tool registry
└── remote_bash (plugin tool, runs in server process)
└── RemoteWorker client ── ssh2 exec channel ──> sshd ──> remote shell
│ stdout/stderr chunks (channel data events)
▼
ctx.metadata({ output }) ──> message.part.updated ──> TUI
- Client-side orchestration is local: the plugin's
execute()runs inside the opencode server process, exactly like any other plugin tool. - Command execution is remote:
sshdon the target host runs the command through the remote user's default shell. No agent, daemon, or helper binary is installed on the remote.sshdis the only server-side requirement. - No state on the remote: the plugin does not persist sessions or spawn long-lived processes between tool calls. Each call is a bounded command.
SFTP (via ssh2's sftp() subsystem) could route read/write/edit/
glob/grep to the remote filesystem by intercepting those tools with
tool.execute.before. Deliberately not in v1 (see Limitations and future
work).
There is no custom wire protocol. The plugin speaks the standard SSH-2
protocol suite through ssh2:
| Layer | Mechanism |
|---|---|
| Transport/auth | SSH-2, auth via privateKey / password / agent (SSH_AUTH_SOCK), host verification per Transport |
| Command execution | One exec channel per tool call. ssh2 has no cwd option, so the working directory is applied as a cd <cwd> && <command> prefix |
| Output | Channel stdout/stderr data events, forwarded chunk-by-chunk |
| Interrupt | Channel signal SIGINT, escalation per the Abort path |
| Files (phase 2) | SFTP subsystem (sftp()) |
- Model calls
remote_bashwith{ command, host?, cwd?, env? }(JSON Schema produced from the Zod args byToolRegistry.fromPlugin). - The registry's
fromPluginwrapper runsexecute(args, ctx)in-process. executeresolves the remote host and working directory per the resolution order.- The permission gate fires:
ctx.ask({ permission: "remote_bash", patterns: [command], always: [], metadata: {} }), with the target host annotated. Deny → immediate{ title, output: "Permission denied" }result, nothing touches the network. RemoteWorker.exec()ensures a connection (lazy connect if needed), then opens an exec channel for thecd-prefixed command with the resolved env.- Each stdout/stderr chunk is forwarded via
ctx.metadata({ metadata: { output: chunk } }), which updates themessage.partfor the in-flight tool call, the same streaming mechanism the built-inShellTooluses. Truncation follows core limits (Truncate.limits()), spilling to a file for very large outputs. - On exit:
executereturns{ title, output, metadata: { host, exitCode } }.tool.execute.afterhooks and result handling proceed as for any tool.
- User presses Esc / abort →
ctx.abort(anAbortSignal) fires. - The plugin sends a
SIGINTchannel signal to the remote process group. If the process is still alive after a grace period, it closes the channel and ends the connection (parity with the built-in shell tool'skill({ forceKillAfter: ... })escalation). - The tool result is marked interrupted. The core handles the rest.
- Connection drop / auth failure / timeout: the worker synthesizes a
failure result (
{ title, output: "SSH connection failed: ...", metadata: { host, dropped: true } }) instead of hanging. The worker never blocks the tool call on a dead transport: every failure surfaces as a result, so the model and the user always get a terminal answer. Output already streamed into the transcript is retained, and the final result carries whatever was received (Recovery and failure handling). - Non-zero exit: output streams as usual. The result carries the exit
code in
metadata(parity with the built-in tool).
- One
RemoteWorkerper configured host, created lazily on first use and shared across tool calls (no reconnect storm on repeatedremote_bashcalls). - Reconnect on drop. Running commands fail fast.
- keepalive config (
ServerAliveIntervalor the pluginkeepaliveoption) maps to ssh2'skeepaliveInterval(Transport). - Connections are closed when the plugin is unloaded.
An alternative design runs a worker daemon on the remote machine exposing process and filesystem operations over JSON-RPC (websocket or SSH-tunneled stdio), plus remote HTTP. That buys capabilities negotiation, remote HTTP, and a rich remote filesystem interface. We reject the daemon for v1 because:
ssh2already provides exec + SFTP natively. A daemon adds install and version-skew burden on every host.- Shell-only scope does not need the fs/http surface.
- SSH-only keeps the security boundary simple: the remote user's identity
and
sshdpolicy govern everything.
If phase 2 (remote fs) needs richer semantics than SFTP or
server-side sandboxing,
the daemon pattern is the fallback. The codebase keeps the worker behind the
RemoteWorker interface so the transport is swappable.
A remote MCP server's tools also execute off-host, and a plugin could
register one at runtime (client.mcp.add, the ACP pattern). Rejected: MCP's
tool shape and streaming are more constrained (progress notifications rather
than raw chunks), auth/transport are MCP-specific, and there is no interrupt
as direct as an SSH channel signal.
experimental_workspace already moves whole sessions (including bash) to a
remote opencode server over HTTP/WebSocket. That is a different feature: it
requires a full opencode server on the target, is experimental/flagged, and
is session-scoped. The plugin targets the lighter "point this host's shell at
a remote box" case.
The remote host does not share the local filesystem, and ToolContext.directory
is the local session directory. Host selection and working directory
resolution are two sequential chains. The second only runs after the
first succeeds.
Host resolution (first hit wins):
- Tool
hostarg projectslongest-prefix match onToolContext.directory- Config
default - Error: "no host configured for this session"
Working directory resolution (applies to the chosen host, first hit wins):
- Tool
cwdarg - Matched project's
remoteCwd - Host's
remoteCwd - Remote home directory
No auto-mirroring. The mapping is explicit user configuration.
Only command execution is routed remotely. read/write/edit remain local
(SSH-only users get a remote executor, not a remote workspace). Permission
and merge semantics for remote edits are substantial, and the common "run the
build/tests on the worker box" workflow needs exec alone.
Where behavior overlaps, the plugin mirrors the built-in bash tool: same
streaming-metadata mechanism, truncation limits, metadata shape (exit code),
and abort escalation. Deliberate differences: the distinct remote_bash
permission id (Permission UX), host-annotated permission
prompts, and host in tool title/metadata. Behavior stays recognizable between
local and remote execution, while remote execution is independently
allowable/deniable.
- Credentials (keys, agent, passwords) are never stored by the plugin. They
come from the user's SSH agent, key files, or config. Host aliases are
resolved from
~/.ssh/configviassh-config. - Commands run as the SSH user on the remote with that user's environment.
- Hardening recommendations are covered in Security, sandboxing and filesystem isolation.
Per-project host selection flows through one global plugin instance, not
project-local config files. opencode currently concatenates plugin arrays
across scopes without deduplication:
mergeConfigConcatArrays(inconfig/config.ts) concatenates global + projectpluginarrays.plugin/index.tsonly dedupes exports within a module (getLegacyPlugins), never across config entries.- Declaring
["opencode-remote-worker", {...}]in both global and project config therefore registers two instances. BothHooks.toolmaps feed the tool registry, which collapses the duplicate tool id by load order (ToolRegistrybuilds its tool map from all plugins'toolmaps), a nondeterministic collision.
The plugin therefore configures itself through one global instance with a
projects map:
- Resolution: longest-prefix match on
ToolContext.directory(sessions opened in subdirectories resolve correctly). - No-match behavior: falls back to
defaulthost (requireProjectMatch: false, default). WithrequireProjectMatch: true, remote execution fails outside listed projects. - Git-remote keying (useful when clones live at different paths across machines) is a future option, rejected for v1 for predictability.
Alternatively, project-local-only installs (the plugin declared in a single
project's config, no global declaration) work as-is. The constraint is
coexistence: never declare the plugin in both scopes. If the upstream core
supports deduplicating plugin entries across scopes (project-local wins,
options replace), project-local config files become safe alongside a global
instance, and the projects map becomes an optional convenience.
opencode plugin opencode-remote-worker -g # installs globally. Without -g, installs into the project's .opencode/ (project-local-only mode)Host aliases and credentials come from the user's existing SSH setup (see Transport and Auth and security boundary).
| Mode | Config | Model behavior |
|---|---|---|
| Opt-in per call | host? tool param present |
Model explicitly picks a host, otherwise local bash |
| Default host | default set, no host arg |
All remote_bash calls go to that host |
| Full override | overrideBash: true |
Registers a tool literally named bash (documented override pattern, "Name collisions with built-in tools" in custom-tools.mdx). All shell execution goes remote |
Even in overrideBash mode the tool stays identifiable: tool title renders as
bash@worker1 and result metadata carries { host, exitCode, dropped }. The
user always sees where a command ran.
The package's tui entrypoint (TuiPlugin, packages/plugin/src/tui.ts)
renders an interactive panel (a palette-registered command; a configurable keybind is deferred):
- Host picker lists
~/.ssh/configaliases plus configured hosts and shows connection state. - "Link this directory": binds the current session directory (from
state.session) to a host. Probes the remote cwd (pwd) and tests connectivity through the plugin's own transport, so the probe validates the real auth path, before writing. - "Unlink": removes the entry.
- Writes by editing the config file that declares the plugin (project scope first, then global fallback), keeping a single plugin instance (Per-project configuration). Parsed with JSON5, written back as plain JSON. Applies on the next opencode start.
- Web/desktop users (no TUI panels) fall back to manual config. The docs cover the JSONC shape.
The tool description (delivered via Hooks.tool's ToolDefinition, the same
channel the built-in shell tool uses via the render() prompt in
tool/shell/prompt.ts) is the prompting story:
- Where it runs: "Executes a bash command on REMOTE host
worker1(OS: linux, shell: bash) as the remote SSH user, not on your local machine." - Path semantics warning: "The remote working directory is
<remoteCwd>, which is NOT the local session directory. Paths in the command refer to the REMOTE filesystem. The read/write/edit/glob/grep tools operate on the LOCAL machine and cannot see remote files." - Remote environment: the remote's temp directory replaces the local tmp
hint. Remote env comes from the tool
envarg + sshd environment. - Use guidance: "Use this tool only when the user asked for execution on the remote machine. Do not auto-route. Local operations use bash."
- Failure semantics: "If the connection drops, output is truncated and a failure result is returned. Prefer idempotent commands."
No auto-load-balancing in v1. Remote execution is security-relevant, so
routing stays explicit. A future tool.definition hook could inject per-host
OS/shell discovered at connect time. v1 keeps a static description built from
config.
- The ask gate uses the distinct permission id
remote_bash(notbash), so remote execution is independently allowable/deniable:"permission": { "remote_bash": { "allow": ["*"], "deny": [...] } }inopencode.json. - The prompt is annotated with the target host: "Run
make deployon worker1?" - No tree-sitter path scanning /
external_directorychecks: remote paths cannot be validated against local rules, so the command itself is the pattern (theexternal_directorypermission is meaningless off-host). - Optional
requireProjectMatchadds a config-level gate beyond permissions (deny remote execution outside listed projects).
opencode has no OS-level sandbox today. "Sandbox" in the codebase means git
worktrees (Project.sandboxes), and the system prompt states "The operating
environment is not in a sandbox." The plugin therefore loses nothing relative
to local execution. The question is what protects the remote.
Blast radius = whatever the remote SSH user can do. Isolation is by account, not by sandbox. Hardening = documented best practice, not enforcement:
- Dedicated low-privilege SSH user on the worker, no sudo.
- sshd-side lockdown:
ForceCommand, restricted shell,PermitUserEnvironment off. - Disposable/immutable worker image (build box).
- The permission gate (Permission UX) is the same trust surface as local bash.
Real sandboxing requires a server-side daemon: it can confine processes with
OS-level mechanisms (Linux landlock/seccomp, macOS seatbelt) and negotiate its
capabilities with the client during the connection handshake. If sandboxing
is required, the daemon design becomes mandatory, a genuine tradeoff
(zero-install/zero-sandbox vs. install-burden/sandboxable). Even a sandboxing
daemon runs unsandboxed for some operations (capability discovery and process
spawn), so "sandboxed remote execution" is only as strong as the daemon's
confinement.
Commands run in the resolved remoteCwd.
The remote working directory is not the local session directory. The
tool description states this so the model never assumes
edit/read see remote files.
| Failure | Behavior |
|---|---|
| Connection drop mid-command | Channel severed → synthesized failure result with partial output + dropped: true metadata (Failure semantics). sshd terminates the remote session when the channel/connection closes, so the remote process receives SIGHUP in practice, meaning no orphan processes (caveat: nohup-style commands may outlive the channel) |
| Partial output | Chunks already streamed via ctx.metadata stay in the transcript. No replay possible without a daemon buffering output server-side (v2 item) |
| Stale/dead host | Lazy singleton reconnects on next use. Keepalive (SSH-level) detects dead hosts at the transport layer (Connection lifecycle) |
| Timeout | Parity with built-in default (2 min, RuntimeFlags.bashDefaultTimeoutMs), configurable per host. Timeout result notes "remote process may still be running" (session-termination SIGHUP covers most cases) |
| Auth failure | Synthesized failure result with the SSH error (Failure semantics) |
| User visibility | Tool title and result metadata always show the host (Transparency in the TUI) |
Because connection loss cannot be distinguished from a kill, the tool description advises idempotent commands.
| Item | Status |
|---|---|
| Remote file ops (SFTP routing of read/write/edit/glob/grep) | Phase 2. Keep RemoteWorker interface transport-swappable (SSH ↔ JSON-RPC daemon) as the fallback |
| Server-side sandboxing on the worker | Requires the daemon design (see server-side sandboxing). v1 is account-isolation only |
| Remote HTTP execution | Not planned. Revisit with the daemon design |
| Output replay after transport drop | Requires daemon-side buffering (Recovery and failure handling) |
~/.ssh/config exclusions |
ProxyJump, ProxyCommand, Match, LocalForward parsed but not applied in v1 |
Hashed known_hosts entries |
Plain entries verified in v1; |1|... hashed entries are skipped. ssh-keygen -H style hashing support is future work |
V2 plugin tool domain (tools.register) |
Out of our control. Migrate the tool registration when specs/v2/tools.md follow-up lands |
| Interrupt depth | Verify process-group coverage per platform at implementation time (Abort path) |
| Env parity | Remote env comes from tool env arg + sshd environment. shell.env hooks do not cross the wire |
| Per-project config via project-local files | Works when the plugin is declared in a single project's config. Coexistence with a global instance awaits upstream dedup support (Per-project configuration) |
| Panel config editing | Direct file editing only: client.config.update() (PATCH /config) returns 200 without persisting on opencode 1.18.2, and the SDK types Config.plugin as Array<string>. Writes are plain JSON because opencode's config parser rejects JSON5-only syntax (single quotes, unquoted keys) |
| TUI panel coverage | TUI-only. Web/desktop users configure manually (In-app setup) |
| Config apply | Plugin options are read at server load. Changes apply on next start (In-app setup) |
| Cluster/placement | V2 Location/SessionExecution remote placement is core future work. The plugin is orthogonal and remains viable after it |
Search the OpenCode repository codebase for more info.
- Plugin tool surface:
ToolDefinition,ToolContext,AskInputinpackages/plugin/src/tool.tsandHooksinpackages/plugin/src/index.ts - TUI plugin surface:
TuiPlugininpackages/plugin/src/tui.ts - Tool registry:
ToolRegistry.fromPlugininpackages/opencode/src/tool/registry.ts - Built-in shell tool (behavior parity):
ShellToolinpackages/opencode/src/tool/shell.ts - Shell prompt rendering:
render()inpackages/opencode/src/tool/shell/prompt.ts,shell.txt - Streaming wiring:
SessionProcessor.updateToolCallinpackages/opencode/src/session/tools.ts - Plugin install / config patch:
patchPluginConfiginpackages/opencode/src/plugin/install.ts opencode plugincommand:PluginCommandinpackages/opencode/src/cli/cmd/plug.ts- Config merge semantics:
mergeConfigConcatArrays,loadGlobal,mergePluginOriginsinpackages/opencode/src/config/config.ts - Design spec for V2 tool domains:
specs/v2/tools.md
{ "plugin": [ "opencode-remote-worker", { "hosts": { "worker1": { "host": "build.example.com", "user": "builder", "port": 22, "shell": "bash", "timeout": 120 } }, "default": "worker1", "remoteCwd": "/var/builds", "keepalive": 10, "requireProjectMatch": false, "projects": { "/home/me/code/heavy-builds": { "host": "worker1", "remoteCwd": "/srv/builds/heavy-builds" }, "/home/me/code/mobile": { "host": "gpu-01", "remoteCwd": "/data/mobile" }, }, }, ], }