mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat: Use shared OpenCode harness (no-changelog) (#38602)
This commit is contained in:
parent
8a7e3951ca
commit
c3f9dedef6
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
|
|
@ -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" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
7
agent-harness.lock.json
Normal 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"
|
||||
}
|
||||
|
|
@ -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
140
scripts/agent-harness.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
183
scripts/agent-harness.test.mjs
Normal file
183
scripts/agent-harness.test.mjs
Normal 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/,
|
||||
);
|
||||
});
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user