diff --git a/.devcontainer/codespaces/Dockerfile b/.devcontainer/codespaces/Dockerfile index 8192ad35fa8..b3dd9fa209b 100644 --- a/.devcontainer/codespaces/Dockerfile +++ b/.devcontainer/codespaces/Dockerfile @@ -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 diff --git a/.devcontainer/codespaces/README.md b/.devcontainer/codespaces/README.md index 8ad8ae1805f..564d16704c4 100644 --- a/.devcontainer/codespaces/README.md +++ b/.devcontainer/codespaces/README.md @@ -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-`, 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@ +``` + +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 `. + +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 diff --git a/.devcontainer/codespaces/cloud-session.test.mjs b/.devcontainer/codespaces/cloud-session.test.mjs index 92ba28dfec4..9e5264eb21f 100644 --- a/.devcontainer/codespaces/cloud-session.test.mjs +++ b/.devcontainer/codespaces/cloud-session.test.mjs @@ -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'); diff --git a/.devcontainer/codespaces/fake-bin.mjs b/.devcontainer/codespaces/fake-bin.mjs new file mode 100644 index 00000000000..e19d8b4a49d --- /dev/null +++ b/.devcontainer/codespaces/fake-bin.mjs @@ -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)), + }; +} diff --git a/.devcontainer/codespaces/opencode-client.test.mjs b/.devcontainer/codespaces/opencode-client.test.mjs new file mode 100644 index 00000000000..804b1777cc4 --- /dev/null +++ b/.devcontainer/codespaces/opencode-client.test.mjs @@ -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')); + }); +} diff --git a/.devcontainer/codespaces/opencode-proxy.test.mjs b/.devcontainer/codespaces/opencode-proxy.test.mjs new file mode 100644 index 00000000000..2b357336466 --- /dev/null +++ b/.devcontainer/codespaces/opencode-proxy.test.mjs @@ -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/); +}); diff --git a/.devcontainer/codespaces/opencode-server.mjs b/.devcontainer/codespaces/opencode-server.mjs new file mode 100644 index 00000000000..41027490368 --- /dev/null +++ b/.devcontainer/codespaces/opencode-server.mjs @@ -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; + } +} diff --git a/.devcontainer/codespaces/opencode-server.test.mjs b/.devcontainer/codespaces/opencode-server.test.mjs new file mode 100644 index 00000000000..bdc1065d58e --- /dev/null +++ b/.devcontainer/codespaces/opencode-server.test.mjs @@ -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/); + }, +); diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml index fa486b87908..308103ca6bf 100644 --- a/.github/workflows/ci-pull-requests.yml +++ b/.github/workflows/ci-pull-requests.yml @@ -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/** diff --git a/scripts/cloud-session-opencode-proxy.mjs b/scripts/cloud-session-opencode-proxy.mjs new file mode 100644 index 00000000000..be67962ca73 --- /dev/null +++ b/scripts/cloud-session-opencode-proxy.mjs @@ -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; +} diff --git a/scripts/cloud-session-opencode.mjs b/scripts/cloud-session-opencode.mjs new file mode 100644 index 00000000000..35639a0cd52 --- /dev/null +++ b/scripts/cloud-session-opencode.mjs @@ -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); + } +} diff --git a/scripts/cloud-session.mjs b/scripts/cloud-session.mjs index db741a191e3..7dc54aa00f4 100644 --- a/scripts/cloud-session.mjs +++ b/scripts/cloud-session.mjs @@ -6,7 +6,7 @@ // pnpm session attach Claude Code in the main checkout // pnpm session 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); }