feat: Use shared OpenCode harness (no-changelog) (#38602)

This commit is contained in:
Declan Carroll 2026-09-15 08:27:08 +00:00 committed by GitHub
parent 8a7e3951ca
commit c3f9dedef6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 399 additions and 57 deletions

View File

@ -48,6 +48,19 @@ create an 8-core Codespace by default.
- First codespace creation takes ~20 min uncached (image + full build). After
that, sessions attach instantly; new worktrees cost a `pnpm install` (~1–2 min).
## Configure the local OpenCode harness
Install the pinned shared harness:
```bash
pnpm agent:harness
```
The command verifies the release checksum. It caches the bundle under
`~/.cache/n8n-agent-harness`. It then links the bundle plugin into
`~/.config/opencode/plugins`. Run the command again after the lock changes.
Restart OpenCode after the command completes.
## 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
@ -104,7 +117,12 @@ instead. It asks n8n for a turn addressed to this box's owner (`$GITHUB_USER`).
It runs the turn. It sends the result to the turn's resume URL. It uses no
tunnel, no open port, and no domain.
The worker starts on each container start (`postStartCommand`). It needs three
The post-start command installs the pinned OpenCode harness before it starts the
worker. It verifies a valid cached bundle before reuse. The worker does not start
if the harness is unavailable. `/tmp/post-start-status.json` contains the harness
and worker status.
The worker needs three
secrets and uses three optional secrets. Add them at
[github.com/settings/codespaces](https://github.com/settings/codespaces), the
same way as `ANTHROPIC_API_KEY`:
@ -124,8 +142,12 @@ worker posts one placeholder in that thread. It coalesces completed tool calls.
It updates the message at most once every 1.5 seconds. It does not send reasoning
text. The worker replaces the placeholder with the final answer.
If the Slack API fails, the turn still completes through the n8n resume URL.
The worker does not export its dequeue or Slack credentials to OpenCode.
The worker does not export its dequeue or Slack credentials to OpenCode. It uses
the harness `sandbox` runtime and `slack` profile. The profile supplies the
atomic-turn instruction.
Interactive sessions remove all worker-only credentials before they start.
Interactive OpenCode uses the global harness plugin with the `sandbox` runtime.
It does not set a profile. It keeps the dynamic OpenRouter configuration.
An idle worker starts with a 3-second poll interval. After each empty dequeue,
it doubles the interval and limits it to 30 seconds. Work resets the interval
@ -139,16 +161,15 @@ A turn stops after about 25 minutes (`TURN_TIMEOUT_MS`). This limit is below the
n8n Wait limit. So the worker reports a clear message before n8n reports a
generic timeout. Keep the worker limit below the n8n limit if you change either.
**A turn is atomic, and the worker tells the session so.** The turn ends on the
**A turn is atomic, and the harness tells the session so.** The turn ends on the
session's final message, and its children end with it: a background `Bash` task
is killed, `Monitor` events never arrive, `PushNotification` has nowhere to go,
and `ScheduleWakeup` never fires. The session also gets no turn of its own to
report back in — the turn's resume URL continues one waiting n8n execution and is
then spent, so nothing on the box can post to the thread unprompted. A session
that backgrounds a build and signs off with "I'll verify once it finishes" is
therefore describing something that cannot happen. The worker states this in
each OpenCode prompt (`turnContract`), together with a pointer to this file for
the box-specific parts. This is only the n8n/Slack path: an
therefore describing something that cannot happen. The harness `slack` profile
states this contract. This is only the n8n/Slack path: an
interactive session (`pnpm session`, tmux) is long-lived, so background work,
monitors and notifications behave normally there.

View File

@ -90,6 +90,8 @@ export function openCodeEnvironment(environment) {
delete childEnvironment.N8N_DEQUEUE_URL;
delete childEnvironment.SLACK_BOT_TOKEN;
childEnvironment.OPENCODE_CONFIG_CONTENT = JSON.stringify(openCodeConfig(childEnvironment));
childEnvironment.N8N_AGENT_RUNTIME = 'sandbox';
childEnvironment.N8N_AGENT_PROFILE = 'slack';
return childEnvironment;
}
@ -98,37 +100,6 @@ const TURN_ENV = openCodeEnvironment(process.env);
if (BOX_ID) TURN_ENV.CODESPACE_NAME = BOX_ID;
if (GITHUB_USER) TURN_ENV.GITHUB_USER = GITHUB_USER;
const CODESPACE_DOCS = '.devcontainer/codespaces/README.md';
// The worker has no later turn, so each prompt must define the atomic runtime contract.
function turnContract(author) {
return [
'# Your runtime',
'You are one turn of a Slack thread, driven by an n8n workflow that runs you as a headless',
'OpenCode session on a GitHub codespace. Your final message is the reply that reaches Slack, so keep',
'it short and skip heavy markdown.',
author ? `You are replying to ${author}.` : '',
'',
'# A turn is atomic',
'The turn ends when you emit your final message, and everything you started ends with it:',
'background Bash tasks are killed, Monitor events never arrive, PushNotification has nowhere to',
'go, and ScheduleWakeup never fires. You get no turn of your own afterwards — you cannot speak',
'again until a human writes again. So run long work (builds, test suites, restarts) in the',
'foreground of this turn and wait for it, or do not start it at all. Never end a turn promising',
`to verify, check back, or follow up. Work that will not fit the turn limit of ~${Math.round(
TURN_TIMEOUT_MS / 60_000,
)} minutes`,
'should be split: do the part that fits, then say what to ask for next.',
'',
'# This box',
`You are on codespace ${BOX_ID ?? '(unknown)'}, not a laptop. Before you build, start, or expose`,
`the app, read ${CODESPACE_DOCS} ("Build and run the app in a session"). It is box-specific and`,
'the repo AGENTS.md does not cover it.',
]
.filter(Boolean)
.join('\n');
}
function safeCwd(cwd) {
const safeCwd = resolvePath(typeof cwd === 'string' && cwd ? cwd : `${ROOT}/n8n`);
if (safeCwd !== ROOT && !safeCwd.startsWith(ROOT + sep))
@ -150,7 +121,7 @@ function eventError(event) {
}
export function runOpenCode(
{ message, sessionId, cwd, author },
{ message, sessionId, cwd },
onEvent,
onSession,
{
@ -238,9 +209,7 @@ export function runOpenCode(
resolve({ result: text.join('\n').trim(), session_id: activeSessionId });
});
child.stdin.end(
`${turnContract(typeof author === 'string' ? author : '')}\n\n# Request\n${message}`,
);
child.stdin.end(message);
});
}

View File

@ -24,6 +24,9 @@ test('reloads Codespaces secrets before the worker starts', () => {
postStart,
/bash -lc "\. \/usr\/local\/lib\/codespaces-env\.sh; .*node \/workspaces\/n8n\/\.devcontainer\/codespaces\/agent-worker\.mjs/,
);
assert.match(postStart, /installAgentHarness\(\)/);
assert.match(postStart, /harness\.status === 'active' &&\s+tryRun\('worker start'/);
assert.match(postStart, /JSON\.stringify\(\{ installed, failed, harness, workerStarted \}/);
});
function slackRecorder() {
@ -296,7 +299,7 @@ test('streams OpenCode CLI events and resumes an OpenCode session', async () =>
'ses_existing',
]);
assert.equal(invocation.options.detached, true);
assert.match(prompt, /# Request\nTest message/);
assert.equal(prompt, 'Test message');
assert.deepEqual(
events.map((event) => event.type),
['step_start', 'tool_use', 'text'],
@ -345,6 +348,8 @@ test('keeps broker credentials out of the OpenCode process', () => {
ANTHROPIC_API_KEY: 'model',
OPENROUTER_API_KEY: 'openrouter',
GITHUB_TOKEN: 'github',
N8N_AGENT_RUNTIME: 'sandbox',
N8N_AGENT_PROFILE: 'slack',
});
});

View File

@ -64,6 +64,7 @@ test('starts a named OpenCode session in a worktree', () => {
assert.match(command, /tmux new -As fix-flaky-opencode/);
assert.match(command, /unset AGENT_WORKER_TOKEN N8N_DEQUEUE_URL SLACK_BOT_TOKEN/);
assert.match(command, /OPENCODE_CONFIG_CONTENT/);
assert.match(command, /export N8N_AGENT_RUNTIME=sandbox; unset N8N_AGENT_PROFILE/);
assert.match(command, /git -C \/workspaces\/n8n worktree add "\/workspaces\/wt-fix-flaky"/);
assert.match(command, /cd "\/workspaces\/wt-fix-flaky" && opencode --auto --model test/);
assert.doesNotMatch(command, /unset OPENROUTER_API_KEY/);

View File

@ -18,13 +18,13 @@
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
"forwardPorts": [8080, 5678],
// Grant the codespace's own GitHub token read access to the private skills
// marketplace (same org), so Claude can install it with the codespace's normal
// git auth — no extra PAT. Each user authorizes this once at codespace creation.
// Grant the Codespace token read access to the private skills and harness repositories.
// Each user authorizes this access once when they create the Codespace.
"customizations": {
"codespaces": {
"repositories": {
"n8n-io/n8n-agent-skills": { "permissions": { "contents": "read" } }
"n8n-io/n8n-agent-skills": { "permissions": { "contents": "read" } },
"n8n-io/cat-bot": { "permissions": { "contents": "read" } }
}
}
},

View File

@ -1,9 +1,10 @@
#!/usr/bin/env node
// Runs on each codespace start. Installs the skills marketplace, starts the worker.
// Runs on each Codespace start. Installs the skills and harness, then starts the worker.
import { execFileSync } from 'node:child_process';
import { rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { installAgentHarness } from '../../scripts/agent-harness.mjs';
import { MARKETPLACE, PLUGINS } from './plugins.mjs';
const STATUS_FILE = '/tmp/post-start-status.json';
@ -61,19 +62,33 @@ if (addMarketplace()) {
failed.push(...PLUGINS);
}
writeFileSync(STATUS_FILE, JSON.stringify({ installed, failed }, null, 2));
// A skipped install is otherwise invisible until someone misses a skill mid-session.
if (failed.length > 0) {
console.error(`\n!! SKILLS NOT INSTALLED: ${failed.join(', ')}`);
console.error('!! Sessions start without them. Retry with:');
console.error('!! node /workspaces/n8n/.devcontainer/codespaces/post-start.mjs\n');
}
let harness;
try {
const result = installAgentHarness();
harness = { status: 'active', version: result.version, cacheHit: result.cacheHit };
} catch (error) {
harness = { status: 'unavailable', error: error.message };
console.error(`agent harness: ${error.message}`);
}
tryRun('worker start', 'tmux', [
'new-session',
'-d',
'-s',
'agent-worker',
'bash -lc ". /usr/local/lib/codespaces-env.sh; export CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 CLAUDE_CODE_PLUGIN_KEEP_MARKETPLACE_ON_FAILURE=1; node /workspaces/n8n/.devcontainer/codespaces/agent-worker.mjs >> /tmp/agent-worker.log 2>&1"',
]);
const workerStarted =
harness.status === 'active' &&
tryRun('worker start', 'tmux', [
'new-session',
'-d',
'-s',
'agent-worker',
'bash -lc ". /usr/local/lib/codespaces-env.sh; export CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 CLAUDE_CODE_PLUGIN_KEEP_MARKETPLACE_ON_FAILURE=1; node /workspaces/n8n/.devcontainer/codespaces/agent-worker.mjs >> /tmp/agent-worker.log 2>&1"',
]);
if (!workerStarted && harness.status !== 'active') {
console.error('worker start: skipped because the pinned agent harness is unavailable');
}
writeFileSync(STATUS_FILE, JSON.stringify({ installed, failed, harness, workerStarted }, null, 2));

7
agent-harness.lock.json Normal file
View File

@ -0,0 +1,7 @@
{
"repository": "n8n-io/cat-bot",
"releaseTag": "harness-v0.2.2",
"version": "0.2.2",
"assetName": "n8n-opencode-harness-0.2.2.tgz",
"sha256": "127f0ec0e1026474ab2e275208e4c1335ca5824ad9e43ef6d487c98bfca3fd78"
}

View File

@ -49,6 +49,7 @@
"seed:test": "node --test scripts/instance-seeding/*.test.mjs",
"nathan": "node scripts/nathan.mjs",
"agent:setup": "node scripts/agent-setup.mjs",
"agent:harness": "node scripts/agent-harness.mjs",
"format:check": "turbo run format:check",
"lint": "turbo run lint",
"lint:styles": "turbo run lint:styles",

140
scripts/agent-harness.mjs Normal file
View File

@ -0,0 +1,140 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
renameSync,
rmSync,
symlinkSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const REPOSITORY = 'n8n-io/cat-bot';
const BUNDLE_ROOT = 'n8n-opencode-harness';
const PLUGIN_FILE = 'n8n-harness.js';
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export function validateLock(lock) {
if (
lock?.repository !== REPOSITORY ||
!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(lock.version) ||
lock.releaseTag !== `harness-v${lock.version}` ||
lock.assetName !== `n8n-opencode-harness-${lock.version}.tgz` ||
!/^[0-9a-f]{64}$/.test(lock.sha256)
) {
throw new Error('The agent harness lock is invalid.');
}
return lock;
}
export function readLock(lockPath = join(REPO_ROOT, 'agent-harness.lock.json')) {
try {
return validateLock(JSON.parse(readFileSync(lockPath, 'utf8')));
} catch (error) {
throw new Error(`Cannot read the agent harness lock at ${lockPath}: ${error.message}`);
}
}
export function sha256File(path) {
return createHash('sha256').update(readFileSync(path)).digest('hex');
}
export function downloadReleaseAsset(lock, destination, { run = execFileSync } = {}) {
try {
run(
'gh',
[
'release',
'download',
lock.releaseTag,
'--repo',
lock.repository,
'--pattern',
lock.assetName,
'--dir',
destination,
],
{ stdio: 'pipe' },
);
} catch (error) {
const detail = error.stderr?.toString().trim() || error.message;
throw new Error(
`Cannot download the private agent harness release. Confirm that gh can read ${lock.repository}. ${detail}`,
);
}
}
function activatePlugin(pluginPath, pluginLink) {
if (existsSync(pluginLink) && !lstatSync(pluginLink).isSymbolicLink()) {
throw new Error(`Refusing to overwrite the non-symlink path ${pluginLink}.`);
}
mkdirSync(dirname(pluginLink), { recursive: true });
const temporaryLink = `${pluginLink}.tmp-${process.pid}`;
try {
rmSync(temporaryLink, { force: true });
symlinkSync(pluginPath, temporaryLink);
renameSync(temporaryLink, pluginLink);
} finally {
rmSync(temporaryLink, { force: true });
}
}
export function installAgentHarness({
lockPath = join(REPO_ROOT, 'agent-harness.lock.json'),
cacheRoot = join(homedir(), '.cache', 'n8n-agent-harness'),
pluginLink = join(homedir(), '.config', 'opencode', 'plugins', PLUGIN_FILE),
download = downloadReleaseAsset,
extract = (archive, destination) =>
execFileSync('tar', ['-xzf', archive, '-C', destination], { stdio: 'pipe' }),
} = {}) {
const lock = readLock(lockPath);
const versionDir = join(cacheRoot, lock.version);
const bundlePath = join(versionDir, BUNDLE_ROOT);
const pluginPath = join(bundlePath, 'plugins', PLUGIN_FILE);
mkdirSync(cacheRoot, { recursive: true });
// The version cache is write-once and trusted after its checksum succeeds.
const cacheHit = existsSync(pluginPath);
if (!cacheHit) {
const stagingRoot = mkdtempSync(join(cacheRoot, '.install-'));
const stagedVersion = join(stagingRoot, lock.version);
const archivePath = join(stagedVersion, lock.assetName);
try {
mkdirSync(stagedVersion);
download(lock, stagedVersion);
if (sha256File(archivePath) !== lock.sha256) {
throw new Error('Agent harness checksum mismatch.');
}
// Cat-bot validates archive contents before publishing these pinned bytes.
extract(archivePath, stagedVersion);
if (!existsSync(join(stagedVersion, BUNDLE_ROOT, 'plugins', PLUGIN_FILE))) {
throw new Error(`The agent harness bundle has no ${PLUGIN_FILE} plugin.`);
}
rmSync(versionDir, { recursive: true, force: true });
renameSync(stagedVersion, versionDir);
} finally {
rmSync(stagingRoot, { recursive: true, force: true });
}
}
activatePlugin(pluginPath, pluginLink);
return { version: lock.version, cacheHit, bundlePath, pluginLink };
}
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
try {
const result = installAgentHarness();
console.log(
`Agent harness ${result.version} is active${result.cacheHit ? ' from cache' : ''}.`,
);
} catch (error) {
console.error(`Agent harness setup failed: ${error.message}`);
process.exitCode = 1;
}
}

View File

@ -0,0 +1,183 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import {
copyFileSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
readlinkSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { afterEach, test } from 'node:test';
import {
downloadReleaseAsset,
installAgentHarness,
sha256File,
validateLock,
} from './agent-harness.mjs';
const temporaryDirectories = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function fixtureDirectory() {
const directory = mkdtempSync(join(tmpdir(), 'agent-harness-test-'));
temporaryDirectories.push(directory);
return directory;
}
function makeAsset(root, version = '1.2.3') {
const source = join(root, 'source');
const bundle = join(source, 'n8n-opencode-harness');
mkdirSync(join(bundle, 'plugins'), { recursive: true });
writeFileSync(join(bundle, 'plugins', 'n8n-harness.js'), 'export default async () => ({});\n');
const asset = join(root, `n8n-opencode-harness-${version}.tgz`);
execFileSync('tar', ['-czf', asset, '-C', source, 'n8n-opencode-harness']);
return asset;
}
function writeLock(root, asset, overrides = {}) {
const version = '1.2.3';
const lock = {
repository: 'n8n-io/cat-bot',
releaseTag: `harness-v${version}`,
version,
assetName: `n8n-opencode-harness-${version}.tgz`,
sha256: sha256File(asset),
...overrides,
};
const lockPath = join(root, 'agent-harness.lock.json');
writeFileSync(lockPath, JSON.stringify(lock));
return lockPath;
}
function installerPaths(root) {
return {
cacheRoot: join(root, 'cache'),
pluginLink: join(root, 'config', 'opencode', 'plugins', 'n8n-harness.js'),
};
}
function copyAsset(asset) {
return (lock, destination) => copyFileSync(asset, join(destination, lock.assetName));
}
test('validates the pinned release values', () => {
const lock = {
repository: 'n8n-io/cat-bot',
releaseTag: 'harness-v1.2.3',
version: '1.2.3',
assetName: 'n8n-opencode-harness-1.2.3.tgz',
sha256: 'a'.repeat(64),
};
assert.equal(validateLock(lock), lock);
for (const invalid of [
{ repository: 'other/repo' },
{ releaseTag: 'latest' },
{ version: '../outside' },
{ assetName: 'other.tgz' },
{ sha256: 'invalid' },
]) {
assert.throws(() => validateLock({ ...lock, ...invalid }), /lock is invalid/);
}
});
test('installs the verified release and activates its plugin', () => {
const root = fixtureDirectory();
const asset = makeAsset(root);
const result = installAgentHarness({
...installerPaths(root),
lockPath: writeLock(root, asset),
download: copyAsset(asset),
});
assert.equal(result.cacheHit, false);
assert.equal(
readlinkSync(result.pluginLink),
join(result.bundlePath, 'plugins', 'n8n-harness.js'),
);
assert.match(readFileSync(result.pluginLink, 'utf8'), /export default/);
assert.deepEqual(readdirSync(join(root, 'cache')), ['1.2.3']);
});
test('reuses an installed release without downloading it again', () => {
const root = fixtureDirectory();
const asset = makeAsset(root);
const options = {
...installerPaths(root),
lockPath: writeLock(root, asset),
download: copyAsset(asset),
};
installAgentHarness(options);
const result = installAgentHarness({
...options,
download: () => assert.fail('downloaded a cached release'),
});
assert.equal(result.cacheHit, true);
});
test('keeps the active plugin when verification fails', () => {
const root = fixtureDirectory();
const asset = makeAsset(root);
const paths = installerPaths(root);
mkdirSync(dirname(paths.pluginLink), { recursive: true });
const previous = join(root, 'previous-plugin.js');
writeFileSync(previous, 'previous plugin\n');
symlinkSync(previous, paths.pluginLink);
assert.throws(
() =>
installAgentHarness({
...paths,
lockPath: writeLock(root, asset, { sha256: '0'.repeat(64) }),
download: copyAsset(asset),
}),
/checksum mismatch/,
);
assert.equal(readlinkSync(paths.pluginLink), previous);
});
test('does not overwrite a user plugin file', () => {
const root = fixtureDirectory();
const asset = makeAsset(root);
const paths = installerPaths(root);
mkdirSync(dirname(paths.pluginLink), { recursive: true });
writeFileSync(paths.pluginLink, 'user plugin\n');
assert.throws(
() =>
installAgentHarness({
...paths,
lockPath: writeLock(root, asset),
download: copyAsset(asset),
}),
/non-symlink path/,
);
});
test('reports a clear GitHub access failure', () => {
assert.throws(
() =>
downloadReleaseAsset(
{ repository: 'n8n-io/cat-bot', releaseTag: 'harness-v1.2.3', assetName: 'asset.tgz' },
fixtureDirectory(),
{
run: () => {
throw new Error('release not found');
},
},
),
/Confirm that gh can read n8n-io\/cat-bot.*release not found/,
);
});

View File

@ -77,7 +77,7 @@ function ensureCodespace() {
const SESSION_SECRETS =
'. /usr/local/lib/codespaces-env.sh 2>/dev/null || true; unset AGENT_WORKER_TOKEN N8N_DEQUEUE_URL SLACK_BOT_TOKEN';
const OPENCODE_CONFIG =
'export OPENCODE_CONFIG_CONTENT="{\\"provider\\":{\\"openrouter\\":{\\"options\\":{\\"apiKey\\":\\"{env:OPENROUTER_API_KEY}\\"}}}}"';
'export OPENCODE_CONFIG_CONTENT="{\\"provider\\":{\\"openrouter\\":{\\"options\\":{\\"apiKey\\":\\"{env:OPENROUTER_API_KEY}\\"}}}}"; export N8N_AGENT_RUNTIME=sandbox; unset N8N_AGENT_PROFILE';
// Worktrees share the pnpm store but not the turbo cache; a shared TURBO_CACHE_DIR
// (seeded from the main checkout) keeps new-worktree builds at cache-hit speed.
const CACHE =