From c3f9dedef6ef204ce3e9ae65154e8b0636f669c8 Mon Sep 17 00:00:00 2001 From: Declan Carroll Date: Tue, 15 Sep 2026 08:27:08 +0000 Subject: [PATCH] feat: Use shared OpenCode harness (no-changelog) (#38602) --- .devcontainer/codespaces/README.md | 33 +++- .devcontainer/codespaces/agent-worker.mjs | 39 +--- .../codespaces/agent-worker.test.mjs | 7 +- .../codespaces/cloud-session.test.mjs | 1 + .devcontainer/codespaces/devcontainer.json | 8 +- .devcontainer/codespaces/post-start.mjs | 35 +++- agent-harness.lock.json | 7 + package.json | 1 + scripts/agent-harness.mjs | 140 ++++++++++++++ scripts/agent-harness.test.mjs | 183 ++++++++++++++++++ scripts/cloud-session.mjs | 2 +- 11 files changed, 399 insertions(+), 57 deletions(-) create mode 100644 agent-harness.lock.json create mode 100644 scripts/agent-harness.mjs create mode 100644 scripts/agent-harness.test.mjs diff --git a/.devcontainer/codespaces/README.md b/.devcontainer/codespaces/README.md index ab0a95760e6..35eef422183 100644 --- a/.devcontainer/codespaces/README.md +++ b/.devcontainer/codespaces/README.md @@ -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. diff --git a/.devcontainer/codespaces/agent-worker.mjs b/.devcontainer/codespaces/agent-worker.mjs index 2055434f9f7..374ed46e948 100644 --- a/.devcontainer/codespaces/agent-worker.mjs +++ b/.devcontainer/codespaces/agent-worker.mjs @@ -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); }); } diff --git a/.devcontainer/codespaces/agent-worker.test.mjs b/.devcontainer/codespaces/agent-worker.test.mjs index 5b1d3937cbb..9c38f0b5823 100644 --- a/.devcontainer/codespaces/agent-worker.test.mjs +++ b/.devcontainer/codespaces/agent-worker.test.mjs @@ -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', }); }); diff --git a/.devcontainer/codespaces/cloud-session.test.mjs b/.devcontainer/codespaces/cloud-session.test.mjs index d7bd5eea5f7..92ba28dfec4 100644 --- a/.devcontainer/codespaces/cloud-session.test.mjs +++ b/.devcontainer/codespaces/cloud-session.test.mjs @@ -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/); diff --git a/.devcontainer/codespaces/devcontainer.json b/.devcontainer/codespaces/devcontainer.json index 9c11746eddb..125ec2839b5 100644 --- a/.devcontainer/codespaces/devcontainer.json +++ b/.devcontainer/codespaces/devcontainer.json @@ -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" } } } } }, diff --git a/.devcontainer/codespaces/post-start.mjs b/.devcontainer/codespaces/post-start.mjs index ed76619d131..041f542282e 100644 --- a/.devcontainer/codespaces/post-start.mjs +++ b/.devcontainer/codespaces/post-start.mjs @@ -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)); diff --git a/agent-harness.lock.json b/agent-harness.lock.json new file mode 100644 index 00000000000..9d8e3b50cc2 --- /dev/null +++ b/agent-harness.lock.json @@ -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" +} diff --git a/package.json b/package.json index fe9bd36a5f6..5101500e559 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/agent-harness.mjs b/scripts/agent-harness.mjs new file mode 100644 index 00000000000..122472ef845 --- /dev/null +++ b/scripts/agent-harness.mjs @@ -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; + } +} diff --git a/scripts/agent-harness.test.mjs b/scripts/agent-harness.test.mjs new file mode 100644 index 00000000000..a08d5534cab --- /dev/null +++ b/scripts/agent-harness.test.mjs @@ -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/, + ); +}); diff --git a/scripts/cloud-session.mjs b/scripts/cloud-session.mjs index 7f57d8f269b..db741a191e3 100644 --- a/scripts/cloud-session.mjs +++ b/scripts/cloud-session.mjs @@ -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 =