mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-11 22:50:06 +00:00
feat: Use shared OpenCode harness (no-changelog) (#38602)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user