mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-11 22:50:06 +00:00
chore: Connect local OpenCode clients to Codespaces (#38374)
Co-authored-by: Claude Fable 5.1 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
4dc6c80cac
commit
522d8795b0
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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/);
|
||||
},
|
||||
);
|
||||
@@ -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/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user