Remote Code is an OpenCode plugin that lets AI agents operate on remote machines over SSH, with zero footprint on the remote side. The remote machine only needs an SSH daemon; no agent, runtime, or dependency installation is required.
Remote mode is activated exclusively via environment variables. The plugin reads REMOTE_SSH, REMOTE_WORKDIR, REMOTE_PASSWORD, and REMOTE_SUDO_PASSWORD from process.env at load time.
REMOTE_SSH accepts a single SSH command string exactly as a user would type in their terminal:
export REMOTE_SSH='ssh -oHostKeyAlgorithms=+ssh-rsa -i ~/.ssh/id_rsa user@host'
export REMOTE_WORKDIR='/home/project'This gives users unlimited flexibility: any SSH option (-o, -i, -p, -J for jump hosts, -v, etc.) works without the plugin needing to know about it.
From this string we parse:
-o Key=Value→ extra SSH options-i path→ identity file-p port→ port (default 22)user@host→ credentials and target
Note: OpenCode's CLI does not recognize
--remote*flags. Do not pass them on the command line — use a launcher script (.bat,.ps1, or.sh) that sets the environment variables before callingopencode.
OpenCode's core ties sessions to the local working directory (process.cwd()). If you launch OpenCode from different local folders, your remote sessions appear to "disappear" because OpenCode filters sessions by the current directory.
To fix this, Remote Code implements two complementary strategies:
Use the provided launcher scripts to always start OpenCode from a stable local directory derived from your remote target:
# Linux / macOS
chmod +x launchers/remote-opencode.sh
./launchers/remote-opencode.sh
# Windows PowerShell
.\launchers\remote-opencode.ps1
# Windows CMD
launchers\remote-opencode.batThe launcher derives a unique local session directory from the remote host + remote workdir:
~/.opencode/remote-sessions/<host>_<remote_dir_slug>/
This ensures:
- Sessions are always bound to the same stable local path, regardless of where you invoke the launcher.
- Different remote machines / directories get isolated session directories automatically.
- No changes to OpenCode core or plugin code are required.
Optional environment variables in launchers:
| Variable | Description | Default |
|---|---|---|
REMOTE_POOL_COMMAND_SIZE |
SSH exec connection pool size | 3 |
REMOTE_POOL_FILE_SIZE |
SFTP connection pool size | 2 |
REMOTE_POOL_STAGGER_MS |
Delay between sequential SSH handshakes | 0 |
All seven tools (bash, glob, grep, read, write, edit, apply_patch) are implemented as plugin tools that override built-in tools by name.
Why full overrides instead of hooks for file operations:
- Plugin tool
executefunctions run outside OpenCode’s Effect system. They cannot access internal services (LSP.Service,AppFileSystem.Service,Bus.Service). - This is actually desirable: file-editing plugin tools naturally avoid LSP diagnostics and file-watcher events that would otherwise fire against an incomplete local mirror and leak confusing output to the AI.
bash/glob/grepmust be fully rewritten anyway because their core execution logic (local shell / ripgrep) cannot be repurposed through hooks.- For
read/write/edit/patch, we rewrite them to operate on the local mirror and wrap each call withpull/pushSFTP operations.
Only files the AI actually touches are synchronized. The mirror lives at:
~/.opencode/mirrors/<host_slug>/<remote_root_slug>/
- A
manifest.jsontracks which remote files have local copies. SyncEngine.pullAll()rsyncs the manifest’s files from remote to local before a read/edit.SyncEngine.pushAll()rsyncs them back after a write/edit/patch.- We do not track "dirtiness"; we call rsync unconditionally and let its delta algorithm decide what to transfer.
All paths visible to the AI are remote absolute paths. The plugin translates them to local mirror paths transparently:
Remote: /home/project/src/main.ts
Local: ~/.opencode/mirrors/root_192.168.1.3/home_project/home/project/src/main.ts
PathMapper provides:
toLocal(remotePath)→ local mirror pathtoRemote(localPath)→ remote pathisWithinWorkspace(remotePath)→ security boundary check
┌─────────────────────────────────────────────────────────────┐
│ OpenCode Agent Engine + TUI Renderer │
│ (unchanged) │
└────────┬────────────────────────┬───────────────────────────┘
│ calls tools by name │ loads TUI slots
▼ ▼
┌──────────────────────┐ ┌─────────────────────────────────┐
│ Server Plugin │ │ TUI Plugin │
│ (dist/index.js) │ │ (dist/tui.jsx) │
│ │ │ │
│ ┌─────────────┐ │ │ ┌───────────────────────────┐ │
│ │ Tool Overrides│ │ │ │ session_prompt_right │ │
│ │ (bash/glob/ │ │ │ │ home_prompt_right │ │
│ │ grep/read/ │ │ │ │ → "🌐 Remote: host /path" │ │
│ │ write/edit/ │ │ │ └───────────────────────────┘ │
│ │ patch) │ │ └─────────────────────────────────┘
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ PathMapper │ │
│ │ SyncEngine │ │
│ │ SSHPool │ │
│ └─────────────────┘ │
└───────────┬───────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Local Mirror FS │
│ (read/write/edit/patch operate here) │
└──────────────────────────────────────────────┘
│
SFTP over ssh2 persistent connections
│
▼
┌──────────────────────────────────────────────┐
│ Remote Machine (sshd only) │
└──────────────────────────────────────────────┘
The plugin uses the pure-Node.js ssh2 library to maintain persistent SSH connections:
- Command pool (3 connections, configurable via
REMOTE_POOL_COMMAND_SIZE): forbash,glob,grepexecution viaexec() - File pool (2 connections, configurable via
REMOTE_POOL_FILE_SIZE): for SFTP file transfers viasftp() - Pool sizes are deliberately conservative for legacy SSH servers (e.g. OpenSSH 5.3) that struggle with concurrent handshakes.
No external ssh, sshpass, or rsync binaries are required. Password authentication is handled natively by ssh2.
| Tool | Strategy | Sync Trigger |
|---|---|---|
bash |
SSH exec; stdout/stderr separated; SSH noise filtered; non-zero exit throws. Falls back to stderr heuristics when the remote shell reports an error but the exit code channel is not usable (rare, depends on ssh2 behavior). | none |
glob |
SSH rg --files --sortr=modified (fallback: find + stat + sort) |
none |
grep |
SSH rg --json (fallback: grep -rn) |
none |
read |
Read local mirror; binary detection; BOM preserved | pullAll() before read |
write |
Write local mirror; BOM preserved; unified diff preview in permission request | pullAll() if exists, pushAll() after |
edit |
Edit local mirror; 9 fallback replacers (full OpenCode native set); per-file lock; BOM preserved; unified diff preview | pullAll() before, pushAll() after |
apply_patch |
Apply unified diff OR native OpenCode patch to local mirror; BOM preserved | pullAll() before, pushAll() after |
SyncEngineuses a per-mirror mutex so only one SFTP operation runs at a time.edittool uses per-file promise locks so concurrent edits on the same file are serialized.SSHPoolqueues SSH exec commands when all connections in the pool are busy.
PathMapper.isWithinWorkspace()prevents../escapes before any filesystem or SSH operation.bashtool arguments are passed directly to SSH without intermediate shell parsing when possible; if shell features are needed, we rely on the remote shell and escape user-controlled strings.- Remote file operations are bounded by the SSH user’s permissions; no privilege escalation.
- Runtime: Node.js / Bun (OpenCode runs on Bun; plugin uses standard
fs,child_process) - SSH:
ssh2(pure Node.js SSH library; bundled) - Sync: SFTP via
ssh2(no externalrsyncrequired) - Language: TypeScript
- Schema: Zod (matches
@opencode-ai/pluginconventions) - TUI Framework: SolidJS via
@opentui/solid(peer dependency; OpenCode provides at runtime) - JSX: Preserved (
"jsx": "preserve"in tsconfig); OpenCode's bundler handles Solid JSX transform
- LSP for remote files: Plugin tools cannot access OpenCode’s LSP service. Remote projects will not get LSP diagnostics. This is acceptable for the primary use case (legacy embedded systems) but may be revisited later via an LSP-over-SSH bridge.
Directory reads via(FIXED):readreadnow lists directories over SSH (ls -1pA) so the full remote directory contents are visible, regardless of the local mirror state.Binary file handling(FIXED):readdetects binary files by extension blacklist + content heuristic (null bytes or >30% non-printable chars in first 4KB).- Mirror wipe on init: To avoid stale files from previous sessions confusing the AI,
index.tswipes the local mirror base (rm -rf) and resets the manifest on every plugin load. This trades caching efficiency for correctness. A future enhancement could implement selective cleanup (remove only files not in the new manifest) or manifest snapshot/restore for rollback. - Concurrent multi-session: If two local OpenCode sessions target the same remote directory, their mirrors may conflict. The
edittool now has per-file locks to prevent concurrent edits on the same file within a single session. Cross-session conflicts still follow "last write wins" semantics. SSH password auth(FIXED): Password auth is supported natively byssh2. ProvideREMOTE_PASSWORDenv var andssh2will authenticate withoutsshpass.- Windows remote targets: The plugin is developed from a Windows host perspective. Remote Windows with OpenSSH should work for
bash(PowerShell) butglob/grepmay need remotefindstr/rg.exeadjustments.
- Bun or Node.js >= 20
- OpenCode >= 1.15.0
bun install
bun run build# OpenCode plugins directory
cp -r dist/ ~/.config/opencode/plugins/remote-code- Copy
launchers/remote-opencode.sh(or.ps1) to a location in your$PATH. - Edit the "User Configuration" block inside the launcher with your
REMOTE_SSH,REMOTE_WORKDIR, andREMOTE_PASSWORD. - Run the launcher instead of
opencodedirectly.
# Local development
bun install
bun run build
# Install as OpenCode plugin
cp -r dist/ ~/.config/opencode/plugins/remote-codeTests require a live SSH target. Credentials are never hardcoded — they are read from .env.test (gitignored) or environment variables.
# 1. Copy the example config
cp .env.test.example .env.test
# 2. Edit .env.test with your target
cat > .env.test << 'EOF'
REMOTE_SSH=ssh user@your-host
REMOTE_WORKDIR=/tmp/opencode-remote-test
REMOTE_PASSWORD=your-password
REMOTE_SUDO_PASSWORD=your-sudo-password
EOF
# 3. Run the test suites
npx tsx src/test-e2e.ts # 34 tests — full tool coverage
npx tsx src/test-extended.ts # 27 tests — strict assertions, BOM, diff, securityBoth suites will fail early with a clear message if REMOTE_SSH is missing.
| Suite | Focus |
|---|---|
test-e2e.ts |
Connection pools, bash/glob/grep/read/write/edit/patch/sync/health — broad functional coverage |
test-extended.ts |
BOM preservation, unified diff format, strict content matching, patch move,isWithinWorkspace boundary security, quoteShell injection safety, read pagination, glob brace expansion, edit replacer fallback |
Temporary files created during debugging (e.g. tmp_test_*.js) should be placed in tmp_test_artifacts/ (already gitignored via tmp_*).