chore: Connect local OpenCode clients to Codespaces (#38374)

Co-authored-by: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Svetoslav Dekov
2026-09-23 05:51:41 +00:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 4dc6c80cac
commit 522d8795b0
12 changed files with 1330 additions and 9 deletions
+2 -1
View File
@@ -4,6 +4,7 @@ ARG NODE_VERSION=24
FROM node:${NODE_VERSION}-bookworm
ARG PLAYWRIGHT_VERSION=1.62.1
ARG OPENCODE_VERSION=1.18.25
RUN apt-get update && apt-get install -y --no-install-recommends \
tmux sudo less jq ripgrep postgresql-client \
@@ -14,7 +15,7 @@ RUN npx -y playwright@${PLAYWRIGHT_VERSION} install-deps chromium
# Agent harnesses. Self-update is off: the image is the update mechanism
ENV DISABLE_AUTOUPDATER=1
RUN npm install -g @anthropic-ai/claude-code opencode-ai
RUN npm install -g @anthropic-ai/claude-code opencode-ai@${OPENCODE_VERSION}
COPY codespaces-env.sh /usr/local/lib/codespaces-env.sh
COPY codespaces-secrets.sh /etc/profile.d/codespaces-secrets.sh
+106 -4
View File
@@ -26,8 +26,10 @@ Playwright system deps, and Docker-in-Docker (for testcontainers and
```bash
pnpm session # attach Claude Code (creates everything on first run)
pnpm session:shell # open a shell in the default checkout
pnpm session:opencode # attach OpenCode in auto mode
pnpm session:opencode fix-flaky # OpenCode in a separate worktree
pnpm session:opencode # connect the local OpenCode TUI
pnpm session:opencode fix-flaky # use a separate remote worktree
pnpm session:opencode --web # open OpenCode in the local browser
pnpm session:opencode --legacy # use the remote TUI in tmux
pnpm session fix-flaky # Claude Code in a separate worktree
pnpm session ls # what's running
pnpm session tunnel # forward n8n ports (default 5678, 8080); Ctrl-C to stop
@@ -38,8 +40,9 @@ pnpm session rm # delete the codespace
The dev container supports Codespaces with 2, 4, or 8 cores. The session commands
create an 8-core Codespace by default.
- **Detach** with `Ctrl-b d` — the agent keeps working without you.
- **Scroll** with the mouse wheel (tmux mouse mode is on). To use the
- **Detach from tmux** with `Ctrl-b d` — the agent keeps working without you.
Local OpenCode connections use the exit controls described below.
- **Scroll in tmux** with the mouse wheel (tmux mouse mode is on). To use the
terminal's own text selection, hold **Shift** and drag.
- **Reattach** by running the same session command from any machine.
- Each named session gets its own worktree (`/workspaces/wt-<name>`, branch
@@ -62,6 +65,105 @@ The command verifies the release checksum. It caches the bundle under
`~/.config/opencode/plugins`. Run the command again after the lock changes.
Restart OpenCode after the command completes.
## Local OpenCode clients
Run these commands from a local checkout. The TUI runs on your laptop. The
OpenCode server, repository, tools, and builds run in the Codespace. Browser
mode opens the remote web interface through a local connection.
```bash
pnpm session:opencode fix-flaky # resume the saved conversation
pnpm session:opencode fix-flaky --web # open that conversation in a browser
pnpm session:opencode fix-flaky --new # start a new conversation in that worktree
pnpm session:opencode --web --port 4100 # override the default browser port
pnpm session:opencode --help
```
For the TUI, install the same OpenCode version as the remote server. The
`OPENCODE_VERSION` argument in the [Dockerfile](Dockerfile) pins the version
for new images:
```bash
pnpm add --global opencode-ai@<version>
```
The launcher checks both versions. It reports a mismatch with both version
numbers before it opens the TUI. An existing Codespace can have a different
version. Install that version locally, or use `--web`. Browser mode does not
need a local OpenCode install.
The launcher sends its bootstrap code over SSH, so an existing Codespace does
not need a rebuild to use this connection method.
The remote server loads the shared OpenCode harness that the Codespace
post-start command installs. The local harness setup above is not required for
these commands.
The command prepares the worktree, starts or reuses one server, opens an SSH
tunnel, and connects the client. Each workspace name has a saved conversation
ID on the Codespace. TUI and browser modes open the same saved conversation.
`--new` replaces that saved ID. It preserves the worktree and the old conversation.
If you switch conversations inside a client, the next launcher run still opens
the ID saved for that workspace name.
- **Exit the TUI** with `/exit` or its quit shortcut. The launcher closes its
tunnel. The remote server stays running.
- **Disconnect browser mode** with `Ctrl-C` in the launcher terminal. Closing
the browser tab does not close the tunnel. Keep the launcher running while
you use the browser.
- **Reconnect** with the same command after a network interruption. After a
Codespace stop, the command restarts the server and opens the saved conversation.
A stop terminates running tools. It does not resume interrupted commands.
- **Browser mode uses local port 4096 by default.** Use `--port` to override it.
TUI mode selects an available port unless you specify one.
A fixed browser port preserves the browser origin across runs.
An occupied fixed port causes an error. It does not stop the existing listener.
- **Use model and permission controls in the client.** The new connection uses
the remote OpenCode configuration. It does not force the legacy `--auto` mode.
For the old CLI flags, use `pnpm session:opencode fix-flaky --legacy --model <model>`.
Both server and tunnel bind to `127.0.0.1`. The server uses a generated password.
The TUI receives it through its environment. Browser mode uses a local proxy
that adds authentication and rejects requests from other browser origins. The
proxy supports streamed responses, attachments, and terminal WebSockets. The
password does not appear in the URL or terminal output. Other processes on
your laptop can access the local browser proxy while it runs. Do not forward
this proxy or the OpenCode server port to other machines.
The server enables only OpenRouter. It reads `OPENROUTER_API_KEY` when it starts.
Browser mode opens the selected conversation directly.
The web UI stores opened projects in browser storage. If a new-session page
shows **New project**, open `/workspaces/n8n` there once. Keep the same browser
port when you reconnect to preserve this selection.
The server runs in the detached tmux session `n8n-opencode-server`. Its log is
`/workspaces/.n8n-opencode/server.log`. That directory also holds the server
credentials and saved conversation IDs. It is readable only by its owner.
An unhealthy server produces an error without stopping active work. Inspect
the log through `pnpm session:shell`. To restart it after checking active work,
run `tmux kill-session -t '=n8n-opencode-server'` in that shell. Then reconnect.
Restart the server after changing provider secrets, server configuration, or
the pinned harness release.
### Limits
- The launcher supports macOS and Linux. On Windows, run it in WSL. If the
browser does not open automatically, open the printed URL yourself.
- Local clipboard and attachment controls depend on the client, terminal,
file type, and model. A laptop file path does not copy a file to the Codespace.
Use a supported attachment control or copy the file with `gh codespace cp`.
- Tools and local MCP processes run on the Codespace. Laptop configuration,
browser sessions, and files do not sync automatically.
- Workspace names identify worktrees. Two agents that use the same name share
files. Separate conversations alone do not isolate edits.
- The server survives a client disconnect while the Codespace stays running.
Codespaces idle timeouts still apply. An open tunnel is not a guarantee that
the Codespace will stay awake. Use `pnpm session stop` to stop compute billing.
- Existing tmux OpenCode conversations are not migrated automatically. Use
`--legacy` to return to them.
For upstream behavior, see the [OpenCode CLI](https://opencode.ai/docs/cli/),
[server](https://opencode.ai/docs/server/), and [web](https://opencode.ai/docs/web/)
documentation.
## PR previews (a running instance of someone else's PR)
A preview is a codespace that serves one pull request, so a reviewer can use the
@@ -59,7 +59,7 @@ function remoteCommand(args) {
}
test('starts a named OpenCode session in a worktree', () => {
const command = remoteCommand(['--opencode', 'fix-flaky', '--model', 'test']);
const command = remoteCommand(['--opencode', 'fix-flaky', '--legacy', '--model', 'test']);
assert.match(command, /tmux new -As fix-flaky-opencode/);
assert.match(command, /unset AGENT_WORKER_TOKEN N8N_DEQUEUE_URL SLACK_BOT_TOKEN/);
@@ -71,6 +71,15 @@ test('starts a named OpenCode session in a worktree', () => {
assert.doesNotMatch(command, /claude plugin/);
});
test('forwards legacy flags without a workspace name to the main checkout', () => {
for (const flags of [['--help'], ['--model', 'test']]) {
const command = remoteCommand(['--opencode', '--legacy', ...flags]);
assert.match(command, /tmux new -As agent-opencode/);
assert.ok(command.includes(`cd /workspaces/n8n && opencode --auto ${flags.join(' ')}`));
assert.doesNotMatch(command, /git .*worktree add/);
}
});
test('keeps removed credentials out of the login shell', () => {
const command = remoteCommand(['--shell']);
const profile = readFileSync(new URL('./codespaces-secrets.sh', import.meta.url), 'utf8');
+36
View File
@@ -0,0 +1,36 @@
// Fake executables for tests. Each shim is a Node script. It receives `args`, `root`,
// `file(name)` under the root, and `log(event)`, which appends to calls.jsonl in the root.
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const COMMON = `
const fs = require('node:fs');
const path = require('node:path');
const args = process.argv.slice(2);
const root = process.env.TEST_ROOT;
const file = (name) => path.join(root, name);
const log = (event) => fs.appendFileSync(file('calls.jsonl'), JSON.stringify(event) + '\\n');
`;
export function fakeBinaries(prefix) {
const root = mkdtempSync(join(tmpdir(), prefix));
const binDir = join(root, 'bin');
mkdirSync(binDir);
const logFile = join(root, 'calls.jsonl');
writeFileSync(logFile, '');
return {
root,
env: { PATH: `${binDir}:${process.env.PATH}`, TEST_ROOT: root },
bin: (name, body) =>
writeFileSync(join(binDir, name), `#!${process.execPath}\n${COMMON}\n${body}`, {
mode: 0o755,
}),
calls: () =>
readFileSync(logFile, 'utf8')
.trim()
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line)),
};
}
@@ -0,0 +1,199 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { rmSync } from 'node:fs';
import { test } from 'node:test';
import { setTimeout as delay } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { fakeBinaries } from './fake-bin.mjs';
import { parseOpenCodeArgs } from '../../scripts/cloud-session-opencode.mjs';
const script = fileURLToPath(new URL('../../scripts/cloud-session.mjs', import.meta.url));
const secret = 'a'.repeat(64);
function fixture(t, env = {}) {
const { root, bin, calls, env: binEnv } = fakeBinaries('opencode-client-');
t.after(() => rmSync(root, { recursive: true, force: true }));
bin(
'gh',
`
log({ command: 'gh', args });
if (args[0] === 'codespace' && args[1] === 'list') {
console.log(JSON.stringify([
{ name: 'dev-box', state: 'Available' },
]));
} else if (args.includes('-N')) {
if (process.env.TEST_TUNNEL_FAIL) process.exit(1);
const mapping = args[args.indexOf('-L') + 1];
const port = +mapping.split(':')[1];
const ssh = require('node:child_process').spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
log({ event: 'ssh-child', pid: ssh.pid });
const server = require('node:http').createServer((req, res) => {
const expected = 'Basic ' + Buffer.from('opencode:' + '${secret}').toString('base64');
if (process.env.TEST_UNAUTHORIZED || req.headers.authorization !== expected) { res.writeHead(401).end(); return; }
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ healthy: true, version: process.env.TEST_SERVER_VERSION || '1.2.3' }));
});
server.listen(port, '127.0.0.1');
if (process.env.TEST_TUNNEL_DROP) setTimeout(() => process.exit(1), 500);
process.on('SIGTERM', () => { log({ event: 'tunnel-stopped' }); server.closeAllConnections(); server.close(() => process.exit(0)); });
} else if (args.at(-1).includes('node --input-type=module')) {
let source = '';
process.stdin.on('data', chunk => source += chunk);
process.stdin.on('end', () => {
log({ event: 'bootstrap', hasSource: source.includes('prepareOpenCode') });
if (process.env.TEST_BOOTSTRAP_FAIL) process.exit(1);
const name = args.at(-1).split(' ').at(-2);
console.log(JSON.stringify({ port: 4242, password: '${secret}', sessionID: 'ses_saved',
directory: name === 'agent' ? '/workspaces/n8n' : '/workspaces/wt-' + name }));
});
}
`,
);
bin(
'opencode',
`
if (args[0] === '--version') {
if (process.env.TEST_NO_CLIENT) process.exit(1);
console.log(process.env.TEST_CLIENT_VERSION || '1.2.3');
} else {
log({ command: 'opencode', args, password: process.env.OPENCODE_SERVER_PASSWORD });
if (process.env.TEST_CLIENT_WAIT) setInterval(() => {}, 1000);
}
`,
);
for (const opener of ['open', 'xdg-open']) bin(opener, `log({ command: 'browser', args });`);
return {
calls,
start(args) {
const child = spawn(process.execPath, [script, '--opencode', ...args], {
env: { ...process.env, ...binEnv, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
child.stdout.on('data', (chunk) => {
output += chunk;
});
child.stderr.on('data', (chunk) => {
output += chunk;
});
const done = new Promise((resolve) => child.on('exit', (code) => resolve({ code, output })));
t.after(() => child.kill('SIGKILL'));
return { child, done };
},
};
}
async function waitFor(check) {
for (let attempt = 0; attempt < 100; attempt++) {
const result = check();
if (result) return result;
await delay(50);
}
throw new Error('Fixture did not become ready.');
}
test('parses options before or after the workspace and rejects unsupported flags', () => {
assert.equal(parseOpenCodeArgs(['--web']).port, 4096);
assert.equal(parseOpenCodeArgs([]).port, 0);
assert.equal(parseOpenCodeArgs(['--port', '4100', '--web']).port, 4100);
assert.deepEqual(parseOpenCodeArgs(['--web', 'fix-flaky', '--new', '--port', '4100']), {
name: 'fix-flaky',
web: true,
fresh: true,
port: 4100,
});
for (const args of [
['--port'],
['--port', '0'],
['--port', '65536'],
['--model', 'test'],
['a/b'],
['one', 'two'],
]) {
assert.throws(() => parseOpenCodeArgs(args));
}
});
test(
'attaches the local TUI to the selected workspace and closes the whole tunnel',
{ timeout: 15000 },
async (t) => {
const f = fixture(t);
const result = await f.start(['fix-flaky']).done;
assert.equal(result.code, 0, result.output);
const calls = f.calls();
assert.ok(calls.find((call) => call.event === 'bootstrap').hasSource);
assert.ok(
calls
.filter((call) => call.command === 'gh' && call.args[1] === 'ssh')
.every((call) => call.args[3] === 'dev-box'),
);
const tunnel = calls.find((call) => call.command === 'gh' && call.args.includes('-N'));
assert.match(tunnel.args.at(-1), /^127\.0\.0\.1:\d+:127\.0\.0\.1:4242$/);
const client = calls.find((call) => call.command === 'opencode');
assert.deepEqual(client.args.slice(2), [
'--dir',
'/workspaces/wt-fix-flaky',
'--session',
'ses_saved',
]);
assert.equal(client.password, secret);
assert.ok(!result.output.includes(secret));
assert.ok(calls.some((call) => call.event === 'tunnel-stopped'));
const pid = calls.find((call) => call.event === 'ssh-child').pid;
await waitFor(() => {
try {
process.kill(pid, 0);
return false;
} catch {
return true;
}
});
},
);
for (const signal of ['SIGINT', 'SIGHUP']) {
test(
`opens the saved web conversation and cleans up on ${signal}`,
{ timeout: 15000 },
async (t) => {
const f = fixture(t, { TEST_NO_CLIENT: '1' });
const run = f.start(['--web', 'fix-flaky']);
const browser = await waitFor(() => f.calls().find((call) => call.command === 'browser'));
const url = new URL(browser.args[0]);
assert.equal(
url.pathname,
`/${Buffer.from('/workspaces/wt-fix-flaky').toString('base64url')}/session/ses_saved`,
);
assert.equal(url.password, '');
const response = await fetch(`${url.origin}/global/health`);
assert.equal(response.status, 200);
await response.text();
run.child.kill(signal);
const result = await run.done;
assert.equal(result.code, 0, result.output);
assert.ok(f.calls().some((call) => call.event === 'tunnel-stopped'));
await assert.rejects(fetch(url.origin));
},
);
}
for (const [label, env, message] of [
['a version mismatch', { TEST_SERVER_VERSION: '1.14.22' }, /versions differ/],
['an authentication failure', { TEST_UNAUTHORIZED: '1' }, /health check failed \(401\)/],
['a tunnel startup failure', { TEST_TUNNEL_FAIL: '1' }, /SSH tunnel closed/],
['a remote preparation failure', { TEST_BOOTSTRAP_FAIL: '1' }, /Could not prepare/],
['a missing local client', { TEST_NO_CLIENT: '1' }, /Install OpenCode locally/],
['a connection loss', { TEST_CLIENT_WAIT: '1', TEST_TUNNEL_DROP: '1' }, /SSH connection closed/],
]) {
test(`reports ${label} without printing credentials`, { timeout: 15000 }, async (t) => {
const f = fixture(t, env);
const result = await f.start([]).done;
assert.equal(result.code, 1, result.output);
assert.match(result.output, message);
assert.ok(!result.output.includes(secret));
if (label !== 'a connection loss')
assert.ok(!f.calls().some((call) => call.command === 'opencode'));
});
}
@@ -0,0 +1,147 @@
import assert from 'node:assert/strict';
import { createServer, request } from 'node:http';
import { connect } from 'node:net';
import { test } from 'node:test';
import { openCodeProxy } from '../../scripts/cloud-session-opencode-proxy.mjs';
test('proxies uploads and event streams with authentication and rejects other origins', async (t) => {
const requests = [];
const backend = createServer(async (req, res) => {
requests.push(req.headers);
res.writeHead(200, { 'content-type': 'text/event-stream' });
for await (const chunk of req) res.write(chunk);
res.end();
});
await new Promise((resolve) => backend.listen(0, '127.0.0.1', resolve));
t.after(() => {
backend.closeAllConnections();
backend.close();
});
const proxy = await openCodeProxy({ password: 'test-secret' });
proxy.targetPort = backend.address().port;
t.after(proxy.close);
const response = await fetch(`${proxy.origin}/session`, {
method: 'POST',
body: 'data: attached image\n\n',
headers: { origin: proxy.origin },
});
assert.equal(response.status, 200);
assert.equal(await response.text(), 'data: attached image\n\n');
assert.equal(
requests[0].authorization,
`Basic ${Buffer.from('opencode:test-secret').toString('base64')}`,
);
assert.ok(!proxy.origin.includes('test-secret'));
for (const headers of [
{ origin: 'https://example.com' },
{ host: 'example.com' },
{ 'sec-fetch-site': 'cross-site' },
]) {
const status = await new Promise((resolve, reject) => {
const req = request(proxy.origin, { headers }, (response) => {
response.resume();
resolve(response.statusCode);
});
req.on('error', reject);
req.end();
});
assert.equal(status, 403);
}
assert.equal(requests.length, 1);
});
test('forwards WebSocket upgrades and closes their sockets', async (t) => {
const peers = new Set();
const backend = createServer();
backend.on('upgrade', (req, socket) => {
assert.equal(
req.headers.authorization,
`Basic ${Buffer.from('opencode:test-secret').toString('base64')}`,
);
peers.add(socket);
socket.on('close', () => peers.delete(socket));
socket.write(
'HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n',
);
socket.pipe(socket);
});
await new Promise((resolve) => backend.listen(0, '127.0.0.1', resolve));
t.after(() => {
for (const peer of peers) peer.destroy();
backend.close();
});
const proxy = await openCodeProxy({ password: 'test-secret' });
proxy.targetPort = backend.address().port;
t.after(proxy.close);
const socket = await new Promise((resolve, reject) => {
const req = request(`${proxy.origin}/pty/test/connect`, {
headers: { origin: proxy.origin, connection: 'Upgrade', upgrade: 'websocket' },
});
req.on('upgrade', (_response, peer) => resolve(peer));
req.on('error', reject);
req.end();
});
t.after(() => socket.destroy());
const echoed = new Promise((resolve) => socket.once('data', (data) => resolve(data.toString())));
socket.write('terminal input');
assert.equal(await echoed, 'terminal input');
const closed = new Promise((resolve) => socket.once('close', resolve));
proxy.close();
await closed;
});
test(
'closes the browser response when the upstream stream disconnects',
{ timeout: 5000 },
async (t) => {
const backend = createServer((_req, res) => {
res.writeHead(200, { 'content-type': 'text/event-stream' });
res.write('data: partial\n\n');
setTimeout(() => res.destroy(), 50);
});
await new Promise((resolve) => backend.listen(0, '127.0.0.1', resolve));
t.after(() => {
backend.closeAllConnections();
backend.close();
});
const proxy = await openCodeProxy({ password: 'test' });
proxy.targetPort = backend.address().port;
t.after(proxy.close);
const signal = AbortSignal.timeout(3000);
const response = await fetch(proxy.origin, { signal });
await assert.rejects(response.text());
assert.equal(
signal.aborted,
false,
'The proxy must close the response before the client times out.',
);
},
);
test('rejects an occupied browser port and reports a missing or closed upstream', async (t) => {
const proxy = await openCodeProxy({ password: 'test' });
t.after(proxy.close);
await assert.rejects(openCodeProxy({ password: 'test', port: +new URL(proxy.origin).port }), {
code: 'EADDRINUSE',
});
const waiting = await fetch(proxy.origin);
assert.equal(waiting.status, 503);
await waiting.text();
proxy.targetPort = 1;
const response = await fetch(proxy.origin);
assert.equal(response.status, 502);
await response.text();
const rejection = await new Promise((resolve) => {
const socket = connect(+new URL(proxy.origin).port, '127.0.0.1', () => {
socket.write(
'GET /pty/test/connect HTTP/1.1\r\nHost: example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n',
);
});
socket.once('data', (data) => {
socket.destroy();
resolve(data.toString());
});
});
assert.match(rejection, /403 Forbidden/);
});
@@ -0,0 +1,219 @@
#!/usr/bin/env node
// The laptop sends this file over SSH as one standalone module. Do not import sibling files.
// It also works in an older Codespace checkout.
import { execFileSync, spawnSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { createServer } from 'node:net';
import { join } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
function readJson(file) {
try {
return JSON.parse(readFileSync(file, 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') return undefined;
throw new Error(`Cannot read ${file}. Restore or remove this state file, then retry.`);
}
}
function saveJson(file, value) {
writeFileSync(`${file}.tmp`, JSON.stringify(value), { mode: 0o600 });
renameSync(`${file}.tmp`, file);
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
stdio: ['ignore', 'inherit', 'inherit'],
...options,
});
if (result.error || result.status !== 0) {
throw new Error(`${command} failed. Check the output above, then retry.`);
}
}
async function freePort() {
const server = createServer();
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const { port } = server.address();
await new Promise((resolve) => server.close(resolve));
return port;
}
async function request(server, path, directory, options = {}, timeout = 60_000) {
return await fetch(`http://127.0.0.1:${server.port}${path}`, {
...options,
headers: {
authorization: `Basic ${Buffer.from(`opencode:${server.password}`).toString('base64')}`,
'x-opencode-directory': encodeURIComponent(directory),
'content-type': 'application/json',
},
signal: AbortSignal.timeout(timeout),
});
}
async function health(server, directory) {
try {
const response = await request(server, '/global/health', directory, {}, 3000);
if (!response.ok) return undefined;
const result = await response.json();
return result.healthy === true && typeof result.version === 'string' ? result : undefined;
} catch {
return undefined;
}
}
const serverFile = (stateDir) => join(stateDir, 'server.json');
async function runningServer({ stateDir, mainDirectory }) {
const previous = readJson(serverFile(stateDir));
return previous && (await health(previous, mainDirectory)) ? previous : undefined;
}
async function startServer({ stateDir, mainDirectory, workspaces }) {
const tmuxSession = 'n8n-opencode-server';
if (spawnSync('tmux', ['has-session', '-t', `=${tmuxSession}`]).status === 0) {
throw new Error(
`OpenCode is still starting or is unhealthy. Check ${stateDir}/server.log, then retry. The running server was not stopped.`,
);
}
const server = { port: await freePort(), password: randomBytes(32).toString('hex') };
const launcher = join(stateDir, 'serve.sh');
// Read secrets when the server starts. Do not store provider keys in the launcher.
writeFileSync(
launcher,
[
'#!/bin/bash',
'. /usr/local/lib/codespaces-env.sh 2>/dev/null || true',
'unset AGENT_WORKER_TOKEN N8N_DEQUEUE_URL SLACK_BOT_TOKEN',
`export TURBO_CACHE_DIR=${quote(join(workspaces, '.turbo-cache'))}`,
`[ -d "$TURBO_CACHE_DIR" ] || cp -r ${quote(join(mainDirectory, '.turbo/cache'))} "$TURBO_CACHE_DIR" 2>/dev/null || mkdir -p "$TURBO_CACHE_DIR"`,
`export OPENCODE_SERVER_USERNAME=opencode OPENCODE_SERVER_PASSWORD=${quote(server.password)}`,
`export OPENCODE_CONFIG_CONTENT=${quote(
JSON.stringify({
enabled_providers: ['openrouter'],
provider: { openrouter: { options: { apiKey: '{env:OPENROUTER_API_KEY}' } } },
}),
)}`,
// Interactive sessions use the harness sandbox runtime without a profile.
'export N8N_AGENT_RUNTIME=sandbox; unset N8N_AGENT_PROFILE',
`cd ${quote(mainDirectory)} || exit 1`,
`exec opencode serve --hostname 127.0.0.1 --port ${server.port} >> ${quote(join(stateDir, 'server.log'))} 2>&1`,
'',
].join('\n'),
{ mode: 0o600 },
);
saveJson(serverFile(stateDir), server);
run('tmux', ['new-session', '-d', '-s', tmuxSession, `bash ${quote(launcher)}`]);
console.error('Starting the OpenCode server…');
for (let attempt = 0; attempt < 60; attempt++) {
if (await health(server, mainDirectory)) return server;
await delay(500);
}
throw new Error(`OpenCode did not become ready. Check ${stateDir}/server.log.`);
}
function prepareWorkspace({ name, directory, mainDirectory, stateDir }) {
if (!existsSync(directory)) {
console.error(`Creating worktree ${directory}…`);
const branch = `session/${name}`;
const exists =
spawnSync('git', [
'-C',
mainDirectory,
'show-ref',
'--verify',
'--quiet',
`refs/heads/${branch}`,
]).status === 0;
run(
'git',
[
'-C',
mainDirectory,
'worktree',
'add',
...(exists ? [] : ['-b', branch]),
directory,
...(exists ? [branch] : []),
],
{ stdio: ['ignore', 2, 2] },
);
}
// A failed install must be retried even if it left a node_modules directory.
const installed = join(stateDir, `${name}.installed`);
const installing = join(stateDir, `${name}.installing`);
if (
!existsSync(join(directory, 'node_modules')) ||
existsSync(installing) ||
(name !== 'agent' && !existsSync(installed))
) {
console.error(`Installing dependencies in ${directory}…`);
writeFileSync(installing, '', { mode: 0o600 });
run('pnpm', ['install'], { cwd: directory, stdio: ['ignore', 2, 2] });
writeFileSync(installed, '', { mode: 0o600 });
rmSync(installing);
}
}
async function ensureSession({ name, fresh, directory, stateDir, server }) {
const sessionFile = join(stateDir, `${name}.session.json`);
const saved = fresh ? undefined : readJson(sessionFile);
let session;
if (saved) {
const response = await request(server, `/session/${encodeURIComponent(saved.id)}`, directory);
if (response.ok) session = await response.json();
else if (response.status !== 404)
throw new Error(`Cannot resume OpenCode session (${response.status}).`);
if (session && session.directory !== directory)
throw new Error('The saved OpenCode session belongs to another directory. Use --new.');
}
if (!session) {
const response = await request(server, '/session', directory, {
method: 'POST',
body: JSON.stringify({ title: `n8n: ${name}` }),
});
if (!response.ok) throw new Error(`Cannot create OpenCode session (${response.status}).`);
session = await response.json();
if (typeof session.id !== 'string' || session.directory !== directory)
throw new Error('OpenCode returned an invalid session.');
saveJson(sessionFile, { id: session.id });
}
return session.id;
}
export async function prepareOpenCode({
name = 'agent',
fresh = false,
workspaces = '/workspaces',
} = {}) {
const stateDir = join(workspaces, '.n8n-opencode');
const mainDirectory = join(workspaces, 'n8n');
if (!/^\w[\w-]*$/.test(name)) throw new Error('Invalid OpenCode workspace name.');
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
const directory = name === 'agent' ? mainDirectory : join(workspaces, `wt-${name}`);
let server = await runningServer({ stateDir, mainDirectory });
// On a cold start, fail before the worktree install when OpenCode is missing on the Codespace.
if (!server) execFileSync('opencode', ['--version'], { stdio: ['ignore', 'pipe', 'inherit'] });
prepareWorkspace({ name, directory, mainDirectory, stateDir });
server ??= await startServer({ stateDir, mainDirectory, workspaces });
const sessionID = await ensureSession({ name, fresh, directory, stateDir, server });
// Only the parent process reads stdout. Never send this record to terminal output.
return { ...server, directory, sessionID };
}
if (process.argv[1] === '-') {
try {
const [name, fresh] = process.argv.slice(2);
console.log(JSON.stringify(await prepareOpenCode({ name, fresh: fresh === 'true' })));
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
@@ -0,0 +1,201 @@
import assert from 'node:assert/strict';
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { test } from 'node:test';
import { setTimeout as delay } from 'node:timers/promises';
import { fakeBinaries } from './fake-bin.mjs';
import { prepareOpenCode } from './opencode-server.mjs';
function fixture(t) {
const fake = fakeBinaries('opencode-server-');
const dir = fake.root;
mkdirSync(join(dir, 'n8n', 'node_modules'), { recursive: true });
const savedEnv = { ...process.env };
// prepareOpenCode spawns the shims in this process. Log every call before the shim body runs.
Object.assign(process.env, fake.env, {
AGENT_WORKER_TOKEN: 'test-worker',
N8N_DEQUEUE_URL: 'test-queue',
SLACK_BOT_TOKEN: 'test-slack',
});
const bin = (name, body) =>
fake.bin(name, `log({ command: ${JSON.stringify(name)}, args });\n${body}`);
bin(
'git',
`
if (args.includes('show-ref')) process.exit(fs.existsSync(file('branch-exists')) ? 0 : 1);
fs.mkdirSync(args.includes('-b') ? args.at(-1) : args.at(-2), { recursive: true });
`,
);
bin(
'pnpm',
`
fs.mkdirSync(path.join(process.cwd(), 'node_modules'), {recursive:true});
if (process.env.TEST_INSTALL_FAIL) process.exit(1);
`,
);
bin(
'tmux',
`
if (args[0] === 'has-session') {
try { process.kill(+fs.readFileSync(file('pid'), 'utf8'), 0); } catch { process.exit(1); }
} else {
const child = require('node:child_process').spawn('bash', ['-c', args.at(-1)], { detached:true, stdio:'ignore' });
fs.writeFileSync(file('pid'), String(child.pid)); child.unref();
}
`,
);
bin(
'opencode',
`
if (args[0] === '--version') { console.log('1.2.3'); process.exit(0); }
fs.writeFileSync(file('server-env.json'), JSON.stringify({
worker: !!process.env.AGENT_WORKER_TOKEN, queue: !!process.env.N8N_DEQUEUE_URL, slack: !!process.env.SLACK_BOT_TOKEN,
cache: process.env.TURBO_CACHE_DIR, config: JSON.parse(process.env.OPENCODE_CONFIG_CONTENT),
runtime: process.env.N8N_AGENT_RUNTIME, profile: process.env.N8N_AGENT_PROFILE ?? null,
}));
let sessions = fs.existsSync(file('sessions.json')) ? JSON.parse(fs.readFileSync(file('sessions.json'), 'utf8')) : {};
const server = require('node:http').createServer(async (req, res) => {
res.setHeader('content-type', 'application/json');
const expected = 'Basic ' + Buffer.from('opencode:' + process.env.OPENCODE_SERVER_PASSWORD).toString('base64');
if (req.headers.authorization !== expected) { res.writeHead(401).end('{}'); return; }
if (req.url === '/global/health') { res.end(JSON.stringify({ healthy:true, version:'1.2.3' })); return; }
if (req.method === 'POST' && req.url === '/session') {
for await (const chunk of req) {}
const id = 'ses_' + (Object.keys(sessions).length + 1);
sessions[id] = { id, directory:decodeURIComponent(req.headers['x-opencode-directory']) };
fs.writeFileSync(file('sessions.json'), JSON.stringify(sessions));
res.end(JSON.stringify(sessions[id])); return;
}
if (fs.existsSync(file('reject-session'))) { res.writeHead(503).end('{}'); return; }
const session = sessions[req.url.split('/').at(-1)];
if (!session) { res.writeHead(404).end('{}'); return; }
res.end(JSON.stringify(session));
});
server.listen(+args[args.indexOf('--port') + 1], '127.0.0.1');
`,
);
const stop = async () => {
try {
const pid = +readFileSync(join(dir, 'pid'), 'utf8');
process.kill(-pid, 'SIGTERM');
for (let count = 0; count < 100; count++) {
try {
process.kill(pid, 0);
} catch {
return;
}
await delay(20);
}
throw new Error('Fixture server did not stop.');
} catch (error) {
if (!['ENOENT', 'ESRCH'].includes(error.code)) throw error;
}
};
t.after(async () => {
await stop();
for (const key of Object.keys(process.env)) if (!(key in savedEnv)) delete process.env[key];
Object.assign(process.env, savedEnv);
rmSync(dir, { recursive: true, force: true });
});
return {
dir,
stop,
prepare: (options = {}) => prepareOpenCode({ workspaces: dir, ...options }),
commands: fake.calls,
};
}
test(
'reuses the server and saved conversation, creates separate workspaces, and resumes after restart',
{ timeout: 15000 },
async (t) => {
const f = fixture(t);
const first = await f.prepare({ name: 'fix-flaky' });
assert.equal(first.directory, join(f.dir, 'wt-fix-flaky'));
assert.deepEqual(await f.prepare({ name: 'fix-flaky' }), first);
const second = await f.prepare({ name: 'another-task' });
assert.equal(second.port, first.port);
assert.notEqual(second.sessionID, first.sessionID);
const fresh = await f.prepare({ name: 'fix-flaky', fresh: true });
assert.notEqual(fresh.sessionID, first.sessionID);
assert.equal((await f.prepare({ name: 'fix-flaky' })).sessionID, fresh.sessionID);
const env = JSON.parse(readFileSync(join(f.dir, 'server-env.json'), 'utf8'));
assert.deepEqual([env.worker, env.queue, env.slack], [false, false, false]);
assert.equal(env.cache, join(f.dir, '.turbo-cache'));
assert.equal(env.config.provider.openrouter.options.apiKey, '{env:OPENROUTER_API_KEY}');
assert.deepEqual(env.config.enabled_providers, ['openrouter']);
assert.deepEqual([env.runtime, env.profile], ['sandbox', null]);
assert.equal(statSync(join(f.dir, '.n8n-opencode')).mode & 0o777, 0o700);
for (const file of ['serve.sh', 'server.json', 'fix-flaky.session.json']) {
assert.equal(statSync(join(f.dir, '.n8n-opencode', file)).mode & 0o777, 0o600);
}
assert.ok(!f.commands().some((entry) => JSON.stringify(entry.args).includes(first.password)));
assert.equal(f.commands().filter((entry) => entry.command === 'pnpm').length, 2);
await f.stop();
const restarted = await f.prepare({ name: 'fix-flaky' });
assert.equal(restarted.sessionID, fresh.sessionID);
assert.notEqual(restarted.password, first.password);
rmSync(first.directory, { recursive: true });
writeFileSync(join(f.dir, 'branch-exists'), '');
assert.equal((await f.prepare({ name: 'fix-flaky' })).sessionID, fresh.sessionID);
const worktree = f
.commands()
.filter((entry) => entry.command === 'git' && entry.args.includes('add'))
.at(-1);
assert.deepEqual(worktree.args, [
'-C',
join(f.dir, 'n8n'),
'worktree',
'add',
first.directory,
'session/fix-flaky',
]);
},
);
test(
'retries a failed worktree install before starting the server',
{ timeout: 10000 },
async (t) => {
const f = fixture(t);
process.env.TEST_INSTALL_FAIL = '1';
await assert.rejects(f.prepare({ name: 'retry' }), /pnpm failed/);
assert.ok(!f.commands().some((entry) => entry.command === 'tmux'));
delete process.env.TEST_INSTALL_FAIL;
assert.equal((await f.prepare({ name: 'retry' })).directory, join(f.dir, 'wt-retry'));
assert.equal(f.commands().filter((entry) => entry.command === 'pnpm').length, 2);
},
);
test(
'retries a failed main checkout install even when node_modules exists',
{ timeout: 10000 },
async (t) => {
const f = fixture(t);
rmSync(join(f.dir, 'n8n', 'node_modules'), { recursive: true });
process.env.TEST_INSTALL_FAIL = '1';
await assert.rejects(f.prepare(), /pnpm failed/);
delete process.env.TEST_INSTALL_FAIL;
assert.equal((await f.prepare()).directory, join(f.dir, 'n8n'));
assert.equal(f.commands().filter((entry) => entry.command === 'pnpm').length, 2);
},
);
test(
'preserves saved sessions on API errors and replaces only missing sessions',
{ timeout: 10000 },
async (t) => {
const f = fixture(t);
const first = await f.prepare();
writeFileSync(join(f.dir, 'reject-session'), '');
await assert.rejects(f.prepare(), /Cannot resume OpenCode session \(503\)/);
const saved = join(f.dir, '.n8n-opencode', 'agent.session.json');
assert.equal(JSON.parse(readFileSync(saved, 'utf8')).id, first.sessionID);
rmSync(join(f.dir, 'reject-session'));
writeFileSync(saved, JSON.stringify({ id: 'ses_missing' }));
assert.notEqual((await f.prepare()).sessionID, first.sessionID);
writeFileSync(saved, '{');
await assert.rejects(f.prepare(), /Cannot read/);
},
);
+2 -1
View File
@@ -129,9 +129,10 @@ jobs:
workflows: .github/**
workflow-scripts:
.github/scripts/**
.devcontainer/codespaces/agent-worker*.mjs
.devcontainer/codespaces/*.mjs
OWNERS
scripts/*.test.mjs
scripts/cloud-session*.mjs
scripts/codespace-env.mjs
scripts/codespace-preview/**
scripts/licenses/**
+109
View File
@@ -0,0 +1,109 @@
// Keep browser authentication in this process. The browser URL contains no password.
import { createServer, request } from 'node:http';
export const basicAuth = (password) =>
`Basic ${Buffer.from(`opencode:${password}`).toString('base64')}`;
// Set `targetPort` on the result once the tunnel port is known. Requests before that get 503.
export async function openCodeProxy({ password, port = 0 }) {
const sockets = new Set();
const authorization = basicAuth(password);
let host;
let origin;
const proxy = {
targetPort: undefined,
close: () => {
for (const socket of sockets) socket.destroy();
server.close();
},
};
const allowed = (req) =>
req.headers.host === host &&
(!req.headers.origin || req.headers.origin === origin) &&
(!req.headers['sec-fetch-site'] ||
['same-origin', 'none'].includes(req.headers['sec-fetch-site'])) &&
req.url.startsWith('/') &&
!req.url.startsWith('//');
const upstream = (req) =>
request({
hostname: '127.0.0.1',
port: proxy.targetPort,
path: req.url,
method: req.method,
headers: { ...req.headers, authorization },
});
const waiting = 'OpenCode is still connecting. Retry in a moment.';
const server = createServer((req, res) => {
if (!allowed(req)) {
res.writeHead(403).end('Use the local OpenCode URL printed by pnpm session:opencode.');
return;
}
if (!proxy.targetPort) {
res.writeHead(503).end(waiting);
return;
}
const remote = upstream(req);
remote.on('response', (response) => {
res.writeHead(response.statusCode, response.headers);
response.on('error', () => res.destroy());
response.pipe(res);
});
remote.on('error', () => {
if (!res.headersSent) res.writeHead(502);
res.end('The OpenCode connection is unavailable. Run the session command again.');
});
res.on('close', () => remote.destroy());
req.pipe(remote);
});
// The web terminal uses WebSockets. Stream uploads and events without buffering.
server.on('upgrade', (req, socket, head) => {
if (!allowed(req)) {
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
return;
}
if (!proxy.targetPort) {
socket.end(`HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n${waiting}`);
return;
}
const remote = upstream(req);
remote.on('upgrade', (response, peer, remoteHead) => {
sockets.add(peer);
peer.on('close', () => {
sockets.delete(peer);
socket.destroy();
});
peer.on('error', () => socket.destroy());
socket.on('close', () => peer.destroy());
socket.on('error', () => peer.destroy());
const headers = response.rawHeaders;
socket.write(`HTTP/1.1 ${response.statusCode} ${response.statusMessage}\r\n`);
for (let index = 0; index < headers.length; index += 2)
socket.write(`${headers[index]}: ${headers[index + 1]}\r\n`);
socket.write('\r\n');
if (remoteHead.length) socket.write(remoteHead);
if (head.length) peer.write(head);
socket.pipe(peer).pipe(socket);
});
remote.on('response', (response) => {
response.resume();
socket.end(
`HTTP/1.1 ${response.statusCode} ${response.statusMessage}\r\nConnection: close\r\n\r\n`,
);
});
remote.on('error', () => socket.destroy());
socket.on('close', () => remote.destroy());
remote.end();
});
server.on('connection', (socket) => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(port, '127.0.0.1', resolve);
});
host = `127.0.0.1:${server.address().port}`;
origin = `http://${host}`;
proxy.origin = origin;
return proxy;
}
+272
View File
@@ -0,0 +1,272 @@
import { spawn, spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { createServer } from 'node:net';
import { setTimeout as delay } from 'node:timers/promises';
import { parseArgs } from 'node:util';
import { basicAuth, openCodeProxy } from './cloud-session-opencode-proxy.mjs';
const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
export function parseOpenCodeArgs(args) {
let parsed;
try {
parsed = parseArgs({
args,
allowPositionals: true,
options: { web: { type: 'boolean' }, new: { type: 'boolean' }, port: { type: 'string' } },
});
} catch (error) {
throw new Error(`${error.message}. Use --help. Use --legacy for remote OpenCode CLI flags.`);
}
const { positionals, values } = parsed;
if (positionals.length > 1 || (positionals[0] && !/^\w[\w-]*$/.test(positionals[0])))
throw new Error('Use one workspace name with letters, digits, - or _.');
let port = values.web ? 4096 : 0;
if (values.port !== undefined) {
if (!/^\d+$/.test(values.port) || +values.port < 1 || +values.port > 65535)
throw new Error('--port must be 1–65535.');
port = +values.port;
}
return { name: positionals[0] ?? 'agent', web: !!values.web, fresh: !!values.new, port };
}
function localVersion() {
const result = spawnSync('opencode', ['--version'], { encoding: 'utf8' });
if (result.error || result.status !== 0)
throw new Error('Install OpenCode locally, or use --web to connect with a browser.');
const version = result.stdout.trim();
if (!/^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/.test(version))
throw new Error('Cannot read the local OpenCode version. Use --web or --legacy.');
return version;
}
async function freePort() {
const server = createServer();
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const { port } = server.address();
await new Promise((resolve) => server.close(resolve));
return port;
}
function startChild(command, args, options = {}) {
const child = spawn(command, args, options);
let finished = false;
const done = new Promise((resolve) => {
child.once('error', (error) => {
finished = true;
resolve({ code: 1, error });
});
// Wait for stdout to close before parsing the SSH response.
child.once('close', (code, signal) => {
finished = true;
resolve({ code: code ?? (signal ? 1 : 0) });
});
});
const kill = (signal) => {
if (!child.pid) return;
try {
if (options.detached) process.kill(-child.pid, signal);
else child.kill(signal);
} catch (error) {
if (error.code !== 'ESRCH') throw error;
}
};
return {
child,
done,
get finished() {
return finished;
},
async stop() {
if (finished) return;
// gh starts ssh as a child. Terminate the process group to close the tunnel too.
kill('SIGTERM');
await Promise.race([done, delay(2000, undefined, { ref: false })]);
if (!finished) kill('SIGKILL');
},
};
}
async function bootstrap(codespace, options, signal) {
signal.throwIfAborted();
console.log(`Preparing OpenCode workspace '${options.name}' on ${codespace}…`);
const command = `umask 077; mkdir -p /workspaces/.n8n-opencode && flock --close -w 600 /workspaces/.n8n-opencode/launch.lock node --input-type=module - ${options.name} ${options.fresh}`;
const remote = startChild('gh', ['codespace', 'ssh', '-c', codespace, '--', command], {
stdio: ['pipe', 'pipe', 'inherit'],
detached: true,
});
let stdout = '';
remote.child.stdout.setEncoding('utf8');
remote.child.stdout.on('data', (chunk) => {
stdout += chunk;
});
remote.child.stdin.on('error', () => {});
const abort = () => {
void remote.stop();
};
signal.addEventListener('abort', abort, { once: true });
try {
remote.child.stdin.end(
readFileSync(new URL('../.devcontainer/codespaces/opencode-server.mjs', import.meta.url)),
);
const result = await remote.done;
signal.throwIfAborted();
if (result.code !== 0)
throw new Error('Could not prepare OpenCode. Check the SSH output above, then retry.');
let state;
try {
state = JSON.parse(stdout.trim().split('\n').at(-1));
} catch {
throw new Error('Invalid OpenCode server response.');
}
// The server owns the workspace layout and the credential format. Check the shape only.
const text = (value) => typeof value === 'string' && value.length > 0;
if (
!state ||
!Number.isInteger(state.port) ||
!text(state.password) ||
!text(state.sessionID) ||
!text(state.directory)
) {
throw new Error('Invalid OpenCode server response.');
}
return state;
} finally {
signal.removeEventListener('abort', abort);
await remote.stop();
}
}
async function waitForTunnel(url, password, tunnel, signal) {
for (let attempt = 0; attempt < 60; attempt++) {
signal.throwIfAborted();
if (tunnel.finished)
throw new Error('The SSH tunnel closed. Check the SSH output above, then reconnect.');
let response;
try {
response = await fetch(`${url}/global/health`, {
headers: { authorization: basicAuth(password) },
signal: AbortSignal.any([signal, AbortSignal.timeout(1000)]),
});
} catch {
/* The listener is not ready yet. */
}
if (response) {
if (!response.ok)
throw new Error(
`OpenCode health check failed (${response.status}). Reconnect after checking the remote server.`,
);
const health = await response.json();
if (health.healthy !== true || typeof health.version !== 'string')
throw new Error('Invalid OpenCode health response.');
return health;
}
await delay(500, undefined, { signal });
}
throw new Error('Timed out while connecting to OpenCode. Run the session command again.');
}
function openBrowser(url) {
const command = process.platform === 'darwin' ? 'open' : 'xdg-open';
const browser = spawn(command, [url], { stdio: 'ignore', detached: true });
browser.on('error', () => console.log('Open the URL above in your browser.'));
browser.on('exit', (code) => {
if (code) console.log('Open the URL above in your browser.');
});
browser.unref();
}
export async function connectOpenCode(options, ensureCodespace) {
if (process.platform === 'win32')
throw new Error('Run this command in WSL. Native Windows is not supported.');
const version = options.web ? undefined : localVersion();
const controller = new AbortController();
const interrupt = () => controller.abort();
for (const signal of SIGNALS) process.on(signal, interrupt);
let tunnel;
let client;
let proxy;
try {
const codespace = ensureCodespace();
const state = await bootstrap(codespace, options, controller.signal);
// Bind the browser port first so the tunnel cannot receive the same port.
if (options.web) proxy = await openCodeProxy({ password: state.password, port: options.port });
const port = !options.web && options.port ? options.port : await freePort();
const url = `http://127.0.0.1:${port}`;
tunnel = startChild(
'gh',
[
'codespace',
'ssh',
'-c',
codespace,
'--',
'-N',
'-o',
'ExitOnForwardFailure=yes',
'-o',
'ServerAliveInterval=15',
'-o',
'ServerAliveCountMax=3',
'-L',
`127.0.0.1:${port}:127.0.0.1:${state.port}`,
],
{ stdio: ['ignore', 'ignore', 'inherit'], detached: true },
);
const health = await waitForTunnel(url, state.password, tunnel, controller.signal);
if (version && health.version !== version) {
throw new Error(
`OpenCode versions differ: local ${version}, server ${health.version}. Install the matching local version, or use --web or --legacy. The remote server is still running.`,
);
}
const stopped = new Promise((resolve) =>
controller.signal.addEventListener('abort', () => resolve('interrupted'), { once: true }),
);
controller.signal.throwIfAborted();
if (proxy) {
proxy.targetPort = port;
const webUrl = `${proxy.origin}/${Buffer.from(state.directory).toString('base64url')}/session/${state.sessionID}`;
console.log(
`OpenCode: ${webUrl}\nKeep this command running. Press Ctrl-C to disconnect. The remote server stays running.`,
);
openBrowser(webUrl);
} else {
console.log(`Connecting to OpenCode ${health.version} in ${state.directory}…`);
client = startChild(
'opencode',
['attach', url, '--dir', state.directory, '--session', state.sessionID],
{
stdio: 'inherit',
env: {
...process.env,
OPENCODE_SERVER_PASSWORD: state.password,
OPENCODE_SERVER_USERNAME: 'opencode',
},
},
);
}
const result = await Promise.race([
stopped,
tunnel.done.then(() => 'tunnel'),
...(client ? [client.done] : []),
]);
if (typeof result === 'object' && result.code !== 0)
throw new Error(
'The local OpenCode client exited with an error. The remote session is saved.',
);
if (result === 'tunnel')
throw new Error(
'The SSH connection closed. Run the same command to reconnect to the saved session.',
);
} catch (error) {
if (!controller.signal.aborted) throw error;
} finally {
proxy?.close();
await Promise.all([client?.stop(), tunnel?.stop()]);
for (const signal of SIGNALS) process.removeListener(signal, interrupt);
}
}
+27 -2
View File
@@ -6,7 +6,7 @@
// pnpm session attach Claude Code in the main checkout
// pnpm session <name> attach Claude Code in a separate worktree
// pnpm session:shell [name] attach a shell
// pnpm session:opencode [name] attach OpenCode in auto mode
// pnpm session:opencode [name] connect a local OpenCode client
// pnpm session ls list Codespaces and tmux sessions
// pnpm session tunnel [port…] forward ports to localhost
// pnpm session stop stop the Codespace
@@ -124,6 +124,31 @@ let launcher = 'claude';
if (args[0] === '--shell') launcher = 'shell';
else if (args[0] === '--opencode') launcher = 'opencode';
if (launcher !== 'claude') args.shift();
// Keep the remote terminal interface available while local clients become the default.
if (launcher === 'opencode' && !args.includes('--legacy')) {
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: pnpm session:opencode [name] [--web] [--new] [--port PORT]
pnpm session:opencode [name] --legacy [OpenCode flags]
The local TUI requires the same OpenCode version as the server.
Web mode needs a browser only and uses local port 4096 by default.
Use --port to override it. Use Ctrl-C to close the local connection.`);
process.exit();
}
try {
const { connectOpenCode, parseOpenCodeArgs } = await import('./cloud-session-opencode.mjs');
await connectOpenCode(parseOpenCodeArgs(args), ensureCodespace);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
// Do not interpret OpenCode options as legacy session names.
process.exit();
}
if (launcher === 'opencode') args.splice(args.indexOf('--legacy'), 1);
// Flags without a session name apply to the main checkout.
if (args[0]?.startsWith('-')) args.unshift('agent');
const [cmd = 'agent', ...rest] = args;
switch (cmd) {
@@ -198,7 +223,7 @@ switch (cmd) {
}
default: {
// treat cmd as the session name; each name = an independent agent in its own worktree
if (!/^[\w-]+$/.test(cmd)) {
if (!/^\w[\w-]*$/.test(cmd)) {
console.error(`Invalid session name '${cmd}' — use letters, digits, - or _`);
process.exit(1);
}