Skip to content

feat: run Computer Use on another Mac over SSH - #7

Merged
malhashemi merged 9 commits into
malhashemi:mainfrom
ihabwahbi:feat/ssh-host
Sep 27, 2026
Merged

malhashemi merged 9 commits into
malhashemi:mainfrom
ihabwahbi:feat/ssh-host

Conversation

@ihabwahbi

Copy link
Copy Markdown
Contributor

What

A new ssh option lets OpenCode run on one machine (for example a Linux VM or server) while Codex Computer Use runs on a Mac that it reaches over SSH:

{ "package": "opencode-codex-computer-use", "options": { "ssh": "my-mac" } }

Leave ssh unset and the plugin runs Codex on the same machine as OpenCode, as it does today.

Why

With OpenCode 2's client/server split, many people run the OpenCode server on a VM or server and keep their desktop on a Mac. The plugin already only talks to codex app-server over stdio, so running codex over SSH is a small step. It still needs care in a few places, because some work happens on whichever machine runs the plugin:

  • Codex's working folder. The plugin sent the OpenCode project folder as the thread cwd. That folder doesn't exist on the Mac, and the first cua_repl call then fails with failed to get client: MCP startup failed: No such file or directory (os error 2).
  • Finding codex. The ChatGPT/Codex app paths were checked on the local disk.
  • OCR. osascript ran locally.
  • Doctor checks. sw_vers, the Computer Use app and plutil were checked locally.

How

  • New src/host.ts: the machine that runs Computer Use. localHost() is this machine; sshHost(destination) is another machine reached with the system ssh.
    • Commands run through ssh -T -o BatchMode=yes -o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -- <destination> <command>.
    • Each word of the remote command is single-quoted. Values reach the remote sh -c scripts as positional arguments, never inside the script text.
  • What now happens on the host:
    • Finding codex: the codexPath option, then the env var, then the app bundles if the host runs macOS, then the host's PATH.
    • Starting codex app-server.
    • The thread's working folder: the host's $HOME when the host is remote.
    • OCR: the screenshot goes to osascript on stdin, so there are no temp files and local and remote use the same code.
    • All doctor checks.
  • Errors:
    • An unreachable Mac gives Could not reach my-mac over SSH: <ssh's reason>, from tool calls and from the doctor.
    • When the app-server exits, the error now includes its last stderr lines, so SSH failures explain themselves.
  • Tool description: when ssh is set, it tells the model that the apps are on another machine than its shell and file tools, with an scp hint.
  • Robustness fixes found in review:
    • A startup that is still running when the plugin is unloaded no longer leaks an app-server.
    • Timed-out commands no longer wait for leftover descendant processes.
    • Every startup attempt gets its own generation number, so a late exit from a failed attempt can't clear its replacement.
  • Docs: a README section "Run Computer Use on another Mac (SSH)", the options table, and the CONTRIBUTING code layout. scripts/smoke.ts accepts --ssh <destination>.
  • Tests:
    • A stand-in ssh (test/fixtures/fake-ssh/ssh) runs the remote command locally through sh, so the quoting is exercised for real.
    • New tests cover the host, the remote bridge, the doctor, and the lifecycle regressions.
    • 81 tests in total, up from 43.

Tested

Where What Result
Ubuntu 24.04 VM bun run check 81 pass; format, lint and types clean
macOS 26.1 (arm64) bun run check on the committed tree pass (71 tests at the time; the later fix commits only add tests)
macOS 26.1, local mode bun scripts/smoke.ts --ocr ready
VM → macOS 26.1 over SSH bun scripts/smoke.ts --ssh <mac> ready: codex found in ChatGPT.app, Computer Use app found, cua_repl running, 17 apps listed
VM → macOS over SSH recognizeText(sshHost(...)) on a synthetic PNG both text lines recognized exactly, with correct positions
VM bun scripts/smoke.ts --ssh nonexistent-host.invalid Could not reach nonexistent-host.invalid over SSH: ssh: Could not resolve hostname …
VM, isolated OpenCode v2.0.18 server plugin loaded from a directory with {"ssh": "<mac>"}, then /computer-use-doctor run through the API full report, same as above

Not exercised: screenshots, clicks and Chrome tabs. On the test Mac no app is approved for Computer Use yet, so the doctor's Finder probe was declined as expected, and no browser was connected.

Side observation (not changed here)

Codex codex-cli 0.155.0-alpha.9.2 (ChatGPT.app) was started with -c approval_policy="never" on the command line, and config/read reported "never". It did not approve app access, though: Finder was declined without an elicitation ("Computer Use was not approved to use Finder"). Setting approval_policy = "never" in ~/.codex/config.toml was not tried, so the README's claim may still hold for that route, but it might be worth re-checking.

A new `ssh` option starts `codex app-server` on a Mac reached over SSH, so OpenCode can run on another machine (such as a Linux VM). Finding codex, Codex's working folder, OCR and the doctor's checks all happen on the machine that runs Computer Use; without the option everything runs locally as before.

- src/host.ts: a Host is this machine or an SSH destination; commands run there with exact POSIX quoting.
- OCR reads the screenshot from stdin instead of temporary files, so one path works locally and over SSH.
- When the app-server exits, its last stderr lines (for example SSH errors) are part of the error.
- Tests run the SSH path through a stand-in ssh that executes the remote command locally.
A computer_use call made while the Mac is unreachable now fails with "Could not reach <destination> over SSH: <reason>", the same wording the doctor showed, because sshHost's info() adds it. The remote tool description also shows how to copy a file to the Mac with scp.
Finding codex can take an SSH round trip. If the plugin was unloaded meanwhile, the startup went on and left an app-server (an ssh process and a remote codex) that nothing closed. dispose() now waits for a startup in flight, which gives up after discovery or closes the client it just started.
…processes

runOnHost only settled on "close", which waits for every process holding the output pipes: descendants of the killed command, or an ssh ProxyCommand. On timeout or maxBuffer it now rejects at once, kills the process and closes its own ends of the pipes.
The platform check caches the Mac's details, so when SSH failed afterwards the doctor's codex lookup threw instead of returning a report. Looking for codex over SSH now fails with the same "Could not reach <destination> over SSH" wording as the first contact, and the doctor shows it as a failed codex check.
…xits

The exit error was built as soon as the process exited, so stderr still in flight, such as a last line without a newline, was lost. Requests now fail as soon as the process exits, and pending ones are rejected once stderr has ended, or after at most 500 ms when a leftover process keeps it open.
Waiting for stderr to drain before calling onExit let a process from a failed startup, exiting late, clear the
replacement that had taken over (both used the same generation number). onExit now runs at the exit and only the
rejection of pending calls waits for the rest of stderr; every startup attempt gets its own number; and an unfinished
last stderr line is kept even when stderr stays open past the drain.
@malhashemi
malhashemi merged commit 079419f into malhashemi:main Sep 27, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants