| 1 | // A minimal fake core: spawns the committed host bundle and speaks CWX1 to it. |
| 2 | // Tests import only dist/ — no type stripping, no install. |
| 3 | import { spawn } from 'node:child_process' |
| 4 | import { createHash } from 'node:crypto' |
| 5 | import { readFileSync } from 'node:fs' |
| 6 | import { dirname, join } from 'node:path' |
| 7 | import { fileURLToPath } from 'node:url' |
| 8 | import { FrameDecoder, encodeFrame, validateMessage } from '../dist/protocol.mjs' |
| 9 | |
| 10 | const here = dirname(fileURLToPath(import.meta.url)) |
| 11 | export const BUNDLE = join(here, '..', 'dist', 'codewhale-extension-host.mjs') |
| 12 | export const FIXTURES = join(here, '..', '..', 'tests', 'fixtures', 'extension_host') |
| 13 | |
| 14 | export function sha256File(path) { |
| 15 | return createHash('sha256').update(readFileSync(path)).digest('hex') |
| 16 | } |
| 17 | |
| 18 | /** The runtime running these tests also runs the host: `node --test` or `bun test`. */ |
| 19 | export const IS_BUN = typeof process.versions.bun === 'string' |
| 20 | |
| 21 | /** |
| 22 | * The runtime flags and environment the Rust core passes |
| 23 | * (`supervisor::runtime_args` / `runtime_env`); keep them in sync. Bun ignores |
| 24 | * Node's heap and `__proto__` flags, so it gets its own: never auto-install |
| 25 | * packages, ignore `bunfig.toml` and `.env` files in the working directory, |
| 26 | * and no ShadowRealm. Node switches off `node:sqlite`, and `node:ffi` where |
| 27 | * this Node has it. Both get a blank `NODE_OPTIONS`, so an inherited preload |
| 28 | * cannot run before the native-code lockdown. |
| 29 | */ |
| 30 | export const HOST_ARGS = IS_BUN |
| 31 | ? ['--no-install', '--no-env-file', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '--no-addons'] |
| 32 | : [ |
| 33 | '--max-old-space-size=256', |
| 34 | '--disable-proto=throw', |
| 35 | '--no-addons', |
| 36 | ...(process.platform === 'win32' ? ['--preserve-symlinks', '--preserve-symlinks-main'] : []), |
| 37 | '--no-experimental-sqlite', |
| 38 | ...(process.allowedNodeEnvironmentFlags.has('--no-experimental-ffi') ? ['--no-experimental-ffi'] : []), |
| 39 | ] |
| 40 | export const HOST_ENV = { NODE_OPTIONS: '', ...(IS_BUN ? { BUN_JSC_useShadowRealm: '0', BUN_OPTIONS: '', BUN_BE_BUN: '0' } : {}) } |
| 41 | |
| 42 | export const LIMITS = { max_frame: 32 * 1024 * 1024, max_inflight: 256, dispose_deadline_ms: 2000, activate_deadline_ms: 5000 } |
| 43 | |
| 44 | /** |
| 45 | * Start a host. `admit(spec, owner)` decides `registry/register`: return a |
| 46 | * handle number or `{ refused }`. Registry traffic is recorded in `registry`. |
| 47 | * `tier` is the trust tier it serves (`--tier=`, after the bundle, as the Rust |
| 48 | * core passes it); `null` passes none, as a host started by hand would. |
| 49 | * `coreCall(params, id)` answers a `core/call` request: return a tool result, or |
| 50 | * `{ error: { code, message } }`; it may be async, and a request the host |
| 51 | * cancelled is not answered. Requests are recorded in `coreCalls`, the host's |
| 52 | * `$/cancel` ids in `cancels`. |
| 53 | */ |
| 54 | export async function startHost({ admit, env, ownGroup = false, tier = 'plugin', coreCall, compiledHost, cwd } = {}) { |
| 55 | const started = performance.now() |
| 56 | // `ownGroup` spawns the host as a process-group leader and tells it so, as |
| 57 | // the Rust core does on Unix. |
| 58 | const child = spawn(compiledHost ?? process.execPath, [...(compiledHost ? [] : [...HOST_ARGS, BUNDLE]), ...(tier === null ? [] : [`--tier=${tier}`])], { |
| 59 | stdio: ['pipe', 'pipe', 'pipe'], |
| 60 | cwd, |
| 61 | env: { ...process.env, ...HOST_ENV, ...env, ...(compiledHost ? { NODE_OPTIONS: '', BUN_OPTIONS: '', BUN_BE_BUN: '0', BUN_JSC_useShadowRealm: '0' } : {}), ...(ownGroup ? { CODEWHALE_HOST_PROCESS_GROUP: '1' } : {}) }, |
| 62 | detached: ownGroup, |
| 63 | }) |
| 64 | const decoder = new FrameDecoder() |
| 65 | const pending = new Map() |
| 66 | const waiters = [] |
| 67 | let stopping = false |
| 68 | const host = { |
| 69 | child, |
| 70 | stderr: '', |
| 71 | logs: [], |
| 72 | faulted: [], |
| 73 | coreCalls: [], |
| 74 | cancels: [], |
| 75 | registry: [], |
| 76 | hello: null, |
| 77 | nextId: 1, |
| 78 | nextHandle: 1, |
| 79 | exit: new Promise((resolve) => child.on('exit', (code, signal) => resolve({ code, signal }))), |
| 80 | send(message) { |
| 81 | validateMessage(message, 'core_to_host', tier ?? 'plugin') |
| 82 | child.stdin.write(encodeFrame(message)) |
| 83 | }, |
| 84 | request(method, params) { |
| 85 | const id = host.nextId++ |
| 86 | const promise = new Promise((resolve, reject) => pending.set(id, { resolve, reject })) |
| 87 | host.send({ jsonrpc: '2.0', id, method, params }) |
| 88 | return { id, promise } |
| 89 | }, |
| 90 | call(method, params) { |
| 91 | return host.request(method, params).promise |
| 92 | }, |
| 93 | cancel(id) { |
| 94 | host.send({ jsonrpc: '2.0', method: '$/cancel', params: { id } }) |
| 95 | }, |
| 96 | waitFor(predicate, timeoutMs = 5000) { |
| 97 | return new Promise((resolve, reject) => { |
| 98 | const timer = setTimeout(() => reject(new Error('timed out waiting for host message')), timeoutMs) |
| 99 | waiters.push({ predicate, resolve: (value) => { clearTimeout(timer); resolve(value) } }) |
| 100 | }) |
| 101 | }, |
| 102 | async stop() { |
| 103 | if (child.exitCode === null && child.signalCode === null) { |
| 104 | // The child can still have a core/call response queued on the fake |
| 105 | // core while it handles stdin EOF. Those replies are intentionally |
| 106 | // abandoned once shutdown starts. |
| 107 | stopping = true |
| 108 | child.stdin.end() |
| 109 | await host.exit |
| 110 | } |
| 111 | }, |
| 112 | } |
| 113 | child.stderr.on('data', (chunk) => { host.stderr += chunk.toString() }) |
| 114 | child.stdout.on('data', (chunk) => { |
| 115 | for (const raw of decoder.push(chunk)) { |
| 116 | const message = validateMessage(raw, 'host_to_core', tier ?? 'plugin') |
| 117 | for (let i = waiters.length - 1; i >= 0; i--) { |
| 118 | if (waiters[i].predicate(message)) waiters.splice(i, 1)[0].resolve(message) |
| 119 | } |
| 120 | if ('method' in message) { |
| 121 | switch (message.method) { |
| 122 | case 'host/hello': |
| 123 | host.hello = message.params |
| 124 | break |
| 125 | case 'log': |
| 126 | host.logs.push(message.params) |
| 127 | break |
| 128 | case 'ext/faulted': |
| 129 | host.faulted.push(message.params) |
| 130 | break |
| 131 | case 'registry/register': { |
| 132 | host.registry.push({ op: 'register', ...message.params }) |
| 133 | const verdict = admit ? admit(message.params.spec, message.params.owner) : undefined |
| 134 | const result = verdict && typeof verdict === 'object' ? verdict : { handle: verdict ?? host.nextHandle++ } |
| 135 | if ('handle' in result) host.registry.at(-1).handle = result.handle |
| 136 | host.send({ jsonrpc: '2.0', id: message.id, result }) |
| 137 | break |
| 138 | } |
| 139 | case '$/cancel': |
| 140 | host.cancels.push(message.params.id) |
| 141 | break |
| 142 | case 'core/call': { |
| 143 | host.coreCalls.push({ id: message.id, ...message.params }) |
| 144 | const answer = coreCall ? coreCall(message.params, message.id) : { error: { code: -32002, message: 'no core/call handler' } } |
| 145 | const id = message.id |
| 146 | Promise.resolve(answer).then((value) => { |
| 147 | if (host.cancels.includes(id) || child.exitCode !== null || stopping) return |
| 148 | if (value && typeof value === 'object' && 'error' in value) host.send({ jsonrpc: '2.0', id, error: value.error }) |
| 149 | else host.send({ jsonrpc: '2.0', id, result: value }) |
| 150 | }) |
| 151 | break |
| 152 | } |
| 153 | case 'registry/unregister': |
| 154 | host.registry.push({ op: 'unregister', ...message.params }) |
| 155 | host.send({ jsonrpc: '2.0', id: message.id, result: {} }) |
| 156 | break |
| 157 | } |
| 158 | } else { |
| 159 | const waiter = pending.get(message.id) |
| 160 | if (waiter) { |
| 161 | pending.delete(message.id) |
| 162 | if ('error' in message) waiter.reject(Object.assign(new Error(message.error.message), { code: message.error.code })) |
| 163 | else waiter.resolve(message.result) |
| 164 | } |
| 165 | } |
| 166 | } |
| 167 | }) |
| 168 | await host.waitFor((m) => m.method === 'host/hello') |
| 169 | const ready = host.waitFor((m) => m.method === 'host/ready') |
| 170 | await host.call('host/initialize', { protocol: 1, limits: LIMITS }) |
| 171 | await ready |
| 172 | host.readyMs = performance.now() - started |
| 173 | return host |
| 174 | } |
| 175 | |
| 176 | let token = 0 |
| 177 | export function owner(pluginId) { |
| 178 | token += 1 |
| 179 | return { plugin_id: pluginId, generation: 1, owner_token: `token-${pluginId}-${token}-${'0'.repeat(24)}` } |
| 180 | } |
| 181 | |
| 182 | /** `extra` adds or replaces `ext/activate` params, such as `config` and `data_dir`. */ |
| 183 | export async function activate(host, fixture, entryPath = join(FIXTURES, fixture, 'index.mjs'), extra = {}) { |
| 184 | const ref = owner(fixture) |
| 185 | const result = await host.call('ext/activate', { |
| 186 | owner: ref, |
| 187 | plugin_name: fixture, |
| 188 | entry: { path: entryPath, sha256: sha256File(entryPath) }, |
| 189 | config: {}, |
| 190 | ...extra, |
| 191 | }) |
| 192 | return { ref, result } |
| 193 | } |
| 194 |