返回 CodeWhale
host.test.mjs
根目录 / crates / tui / extension-host / test / host.test.mjs
1 // End-to-end tests of the committed host bundle against a fake core.
2 import { test } from 'node:test'
3 import assert from 'node:assert/strict'
4 import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync, rmSync } from 'node:fs'
5 import { createServer } from 'node:http'
6 import { tmpdir } from 'node:os'
7 import { dirname, join } from 'node:path'
8 import { execFileSync, spawn } from 'node:child_process'
9 import { fileURLToPath } from 'node:url'
10 import { BUNDLE, FIXTURES, HOST_ARGS, HOST_ENV, IS_BUN, activate, owner, sha256File, startHost } from './harness.mjs'
11 import { ErrorCode, encodeFrame } from '../dist/protocol.mjs'
12
13 // Budgets for a cold host start (the first host this file starts), gated in
14 // the first test. Measured 2026-09-30 on macOS arm64, 5 starts each: Node 26
15 // ready in 50-54 ms at 56 MiB resident, Bun 1.4 in 23-24 ms at 36 MiB; a
16 // Linux container measured 34-67 MB idle (`supervisor::HOST_MEMORY_CAP`).
17 // CI runs these suites on shared ubuntu-latest runners with test files in
18 // parallel, so each budget leaves room for load: 1500 ms is ~30x the Node
19 // start and ~15x the slowest runtime start measured for the host (Node SEA,
20 // 98 ms p50, CURRENT_DECISIONS D1); 160 MiB is 2.4x the largest idle host.
21 // The Rust integration test gates the core-side start and a tree RSS.
22 const READY_BUDGET_MS = 1500
23 const IDLE_RSS_BUDGET_MIB = 160
24
25 /** Resident MiB of `pid` from `ps`; `undefined` where there is no `ps`. */
26 function residentMiB(pid) {
27 if (process.platform === 'win32') return undefined
28 const kib = Number(execFileSync('ps', ['-o', 'rss=', '-p', String(pid)], { encoding: 'utf8' }).trim())
29 return Number.isFinite(kib) && kib > 0 ? kib / 1024 : undefined
30 }
31
32 function tempPlugin(source) {
33 const dir = mkdtempSync(join(tmpdir(), 'cw-ext-host-'))
34 const entry = join(dir, 'index.mjs')
35 writeFileSync(entry, source)
36 return { dir, entry, cleanup: () => rmSync(dir, { recursive: true, force: true }) }
37 }
38
39 test('handshake reports protocol 1 and the digest of the running bundle', async (t) => {
40 const host = await startHost()
41 t.after(() => host.stop())
42 assert.deepEqual(host.hello.protocol, { min: 1, max: 1 })
43 assert.equal(host.hello.bundle_sha256, sha256File(BUNDLE))
44 // The real runtime, not Bun's emulated `process.versions.node`.
45 assert.deepEqual(host.hello.runtime, IS_BUN ? { name: 'bun', version: process.versions.bun } : { name: 'node', version: process.versions.node })
46 assert.equal(host.hello.node_version, undefined)
47 // The tier the host serves and the built-in module digests it embeds.
48 assert.equal(host.hello.tier, 'plugin')
49 assert.deepEqual(host.hello.builtin_modules, builtinDigests())
50 // No limit was asked for, so none is reported.
51 assert.equal(host.hello.memory_limit_mib, undefined)
52 const rss = residentMiB(host.child.pid)
53 t.diagnostic(
54 `spawn → host/ready: ${host.readyMs.toFixed(1)} ms (budget ${READY_BUDGET_MS} ms); ` +
55 `idle RSS ${rss === undefined ? '?' : rss.toFixed(1)} MiB (budget ${IDLE_RSS_BUDGET_MIB} MiB)`,
56 )
57 assert.ok(host.readyMs <= READY_BUDGET_MS, `cold start took ${host.readyMs.toFixed(1)} ms, over ${READY_BUDGET_MS} ms`)
58 if (rss !== undefined) assert.ok(rss <= IDLE_RSS_BUDGET_MIB, `idle host is ${rss.toFixed(1)} MiB resident, over ${IDLE_RSS_BUDGET_MIB} MiB`)
59 })
60
61 test('heartbeat answers after initialization without an owner or tool call', async (t) => {
62 const host = await startHost()
63 t.after(() => host.stop())
64 assert.deepEqual(await host.call('host/ping', {}), {})
65 assert.equal(host.registry.length, 0)
66 })
67
68 test('the documented typed hello extension activates and executes unchanged', async (t) => {
69 const host = await startHost()
70 t.after(() => host.stop())
71 const entry = fileURLToPath(new URL('../../../../docs/examples/plugins/hello-extension/hello.mts', import.meta.url))
72 const { result } = await activate(host, 'hello-extension', entry)
73 assert.deepEqual(result, { status: 'ok', tools: ['hello_greet'], commands: ['hello-greet'] })
74 const tool = host.registry.find((entry) => entry.op === 'register' && entry.kind === 'tool')
75 const output = await host.call('tool/call', { handle: tool.handle, call_id: 'hello-1', input: { name: 'Codewhale' }, deadline_ms: 5000 })
76 assert.deepEqual(output.structured, { greeting: 'Hello, Codewhale!', callId: 'hello-1' })
77 const command = host.registry.find((entry) => entry.op === 'register' && entry.kind === 'command')
78 assert.deepEqual(command.spec, { name: 'hello-greet', description: command.spec.description, argument_hint: '[name]' })
79 const said = await host.call('command/run', { handle: command.handle, command_id: 'c-1', raw_input: 'Codewhale', deadline_ms: 5000 })
80 assert.deepEqual(said, { kind: 'success', text: 'Hello, Codewhale!' })
81 })
82
83 test('the typed hello example reads its one setting, validated by its own Config schema', async (t) => {
84 const host = await startHost()
85 t.after(() => host.stop())
86 const entry = fileURLToPath(new URL('../../../../docs/examples/plugins/hello-extension/hello.mts', import.meta.url))
87 const { result } = await activate(host, 'hello-extension', entry, { config: { greeting: 'Howdy' } })
88 assert.equal(result.status, 'ok')
89 const tool = host.registry.find((entry) => entry.op === 'register' && entry.kind === 'tool')
90 const output = await host.call('tool/call', { handle: tool.handle, call_id: 'hello-2', input: { name: 'Codewhale' }, deadline_ms: 5000 })
91 assert.deepEqual(output.structured, { greeting: 'Howdy, Codewhale!', callId: 'hello-2' })
92 const command = host.registry.find((entry) => entry.op === 'register' && entry.kind === 'command')
93 assert.deepEqual(await host.call('command/run', { handle: command.handle, command_id: 'c-2', raw_input: '', deadline_ms: 5000 }), { kind: 'success', text: 'Howdy, world!' })
94 // A value of the wrong type fails activation, naming the field.
95 const bad = await activate(host, 'hello-extension', entry, { config: { greeting: 3 } })
96 assert.equal(bad.result.status, 'failed')
97 assert.match(bad.result.diagnostic, /greeting/)
98 })
99
100 test('the published DSH plugin runs unmodified and returns its payload', async (t) => {
101 const host = await startHost()
102 t.after(() => host.stop())
103 const { result } = await activate(host, 'dsh-workspace-deps')
104 assert.deepEqual(result, { status: 'ok', tools: ['load_workspace_dependencies'], commands: [] })
105 const registration = host.registry.find((entry) => entry.op === 'register')
106 assert.equal(registration.kind, 'tool')
107 assert.deepEqual(registration.spec.input_schema, { type: 'object', properties: {} })
108 const output = await host.call('tool/call', { handle: registration.handle, call_id: 'c1', input: {}, deadline_ms: 5000 })
109 assert.equal(output.is_error, false)
110 assert.equal(output.structured.pythonDistributions.numpy, '2.1.0')
111 assert.match(output.structured.python, /payload[\\/][a-z0-9]+-[a-z0-9]+[\\/]dependencies[\\/]python/)
112 assert.equal(output.content[0].type, 'text')
113 assert.deepEqual(JSON.parse(output.content[0].text), output.structured)
114 })
115
116 test('providing `approval` fails activation and rolls back every registration', async (t) => {
117 const host = await startHost()
118 t.after(() => host.stop())
119 const { result } = await activate(host, 'refuses-approval')
120 assert.equal(result.status, 'failed')
121 assert.match(result.diagnostic, /may not provide core service `approval`/)
122 const registered = host.registry.find((entry) => entry.op === 'register' && entry.spec.name === 'approval_probe')
123 assert.ok(registered, 'the probe tool reached registry/register before the refusal')
124 await host.waitFor(() => host.registry.some((entry) => entry.op === 'unregister' && entry.handle === registered.handle), 2000).catch(() => undefined)
125 assert.ok(
126 host.registry.some((entry) => entry.op === 'unregister' && entry.handle === registered.handle),
127 'rollback must unregister the probe tool',
128 )
129 })
130
131 test('a refused registration fails activation (all-or-nothing)', async (t) => {
132 const host = await startHost({ admit: (spec) => (spec.name === 'read_file' ? { refused: 'name collides with built-in tool `read_file`' } : undefined) })
133 t.after(() => host.stop())
134 const { result } = await activate(host, 'clash-native')
135 assert.equal(result.status, 'failed')
136 assert.match(result.diagnostic, /read_file/)
137 })
138
139 test('cancel aborts a running tool, and deactivate waits for the async disposer', async (t) => {
140 const host = await startHost()
141 t.after(() => host.stop())
142 const { ref, result } = await activate(host, 'slow-tool')
143 assert.equal(result.status, 'ok')
144 const handle = host.registry.find((entry) => entry.op === 'register').handle
145 const { id, promise } = host.request('tool/call', { handle, call_id: 'c1', input: {}, deadline_ms: 60000 })
146 const cancelledAt = performance.now()
147 setTimeout(() => host.cancel(id), 50)
148 await assert.rejects(promise, (error) => error.code === -32800)
149 assert.ok(performance.now() - cancelledAt < 500, 'cancel resolves well inside the 500 ms grace')
150 const started = performance.now()
151 const ack = await host.call('ext/deactivate', { owner: ref })
152 const elapsed = performance.now() - started
153 assert.deepEqual(ack, { disposed: true, leaked: [] })
154 assert.ok(elapsed >= 290, `ack must follow the 300 ms async disposer (got ${elapsed.toFixed(0)} ms)`)
155 await assert.rejects(host.call('tool/call', { handle, call_id: 'c2', input: {}, deadline_ms: 1000 }), (error) => error.code === -32001)
156 })
157
158 test('injecting a service the host does not provide fails with its name', async (t) => {
159 const host = await startHost()
160 const plugin = tempPlugin("export const name = 'needs-secrets'\nexport const inject = ['secrets']\nexport function apply() {}\n")
161 t.after(async () => { await host.stop(); plugin.cleanup() })
162 const { result } = await activate(host, 'needs-secrets', plugin.entry)
163 assert.equal(result.status, 'failed')
164 assert.match(result.diagnostic, /requires `secrets`/)
165 })
166
167 test('an unsupported DSH peer fails the import loudly', async (t) => {
168 const host = await startHost()
169 const plugin = tempPlugin("import '@deepseek-ai/dsh-agent'\nexport function apply() {}\n")
170 t.after(async () => { await host.stop(); plugin.cleanup() })
171 const { result } = await activate(host, 'needs-agent', plugin.entry)
172 assert.equal(result.status, 'failed')
173 assert.match(result.diagnostic, /requires `@deepseek-ai\/dsh-agent`/)
174 })
175
176 test('a DSH peer or a second Cordis shipped in node_modules is refused, not loaded', async (t) => {
177 const host = await startHost()
178 const plugin = tempPlugin(
179 "import { x } from '@deepseek-ai/dsh-agent'\nexport function apply() { throw new Error('loaded: ' + x) }\n",
180 )
181 const subpath = tempPlugin("import { x } from '@deepseek-ai/cordis/lib/x.js'\nexport function apply() { throw new Error('loaded: ' + x) }\n")
182 for (const dir of [plugin.dir, subpath.dir]) {
183 for (const [name, main] of [['dsh-agent', 'index.js'], ['cordis', 'lib/x.js']]) {
184 const root = join(dir, 'node_modules', '@deepseek-ai', name)
185 mkdirSync(join(root, 'lib'), { recursive: true })
186 writeFileSync(join(root, 'package.json'), JSON.stringify({ name: `@deepseek-ai/${name}`, type: 'module', main, exports: { '.': `./${main}`, './lib/*': './lib/*' } }))
187 writeFileSync(join(root, main), "export const x = 'a second copy'\n")
188 }
189 }
190 t.after(async () => { await host.stop(); plugin.cleanup(); subpath.cleanup() })
191 const agent = (await activate(host, 'ships-agent', plugin.entry)).result
192 assert.equal(agent.status, 'failed')
193 assert.match(agent.diagnostic, /requires `@deepseek-ai\/dsh-agent`/)
194 const cordis = (await activate(host, 'ships-cordis', subpath.entry)).result
195 assert.equal(cordis.status, 'failed')
196 assert.match(cordis.diagnostic, /requires `@deepseek-ai\/cordis/)
197 })
198
199 test('plugins cannot run native code in-process', async (t) => {
200 const host = await startHost()
201 t.after(() => host.stop())
202 // Each case must fail activation with the given diagnostic.
203 const cases = [
204 ['dlopen', "export function apply() { process.dlopen({ exports: {} }, '/nonexistent/libc.so') }\n", /process\.dlopen is not available to extensions/],
205 // A Worker is a new realm that the host's lockdown never reaches.
206 ['worker', "import { Worker } from 'node:worker_threads'\nexport function apply() { new Worker('1', { eval: true }) }\n", /`Worker` is not available to extensions/],
207 ['worker-execargv', "import { createRequire } from 'node:module'\nexport function apply() { const { Worker } = createRequire(import.meta.url)('worker_threads'); new Worker('1', { eval: true, execArgv: [] }) }\n", /`Worker` is not available to extensions/],
208 ]
209 if (IS_BUN) {
210 // `bun:ffi` fails at import, however it is reached.
211 cases.push(
212 ['ffi-static', "import { dlopen } from 'bun:ffi'\nexport function apply() { dlopen('libc', {}) }\n", /`bun:ffi` is not available to extensions/],
213 ['ffi-dynamic', "export async function apply() { const { dlopen } = await import('bun:ffi'); dlopen('libc', {}) }\n", /`bun:ffi` is not available to extensions/],
214 ['ffi-require', "import { createRequire } from 'node:module'\nexport function apply() { createRequire(import.meta.url)('bun:ffi').dlopen('libc', {}) }\n", /`bun:ffi` is not available to extensions/],
215 ['ffi-global', "export function apply() { Bun.FFI.dlopen('/usr/lib/libSystem.B.dylib', {}) }\n", /`Bun\.FFI` is not available to extensions/],
216 ['ffi-global-swap', "export function apply() { Bun.FFI = {}; }\n", /readonly|read-only|read only/i],
217 ['bun-sqlite', "import { Database } from 'bun:sqlite'\nexport function apply() { Database.setCustomSQLite('/nonexistent/libsqlite3.dylib') }\n", /`bun:sqlite` is not available to extensions/],
218 ['node-sqlite', "import { DatabaseSync } from 'node:sqlite'\nexport function apply() { new DatabaseSync(':memory:').exec('select 1') }\n", /`node:sqlite` is not available to extensions/],
219 ['web-worker', "export function apply() { new Worker(URL.createObjectURL(new Blob(['1']))) }\n", /`Worker` is not available to extensions/],
220 // A ShadowRealm imports a fresh `bun:ffi`, and a `node:vm` context would hand its constructor out.
221 ['shadow-realm', "import vm from 'node:vm'\nexport async function apply() { const Realm = globalThis.ShadowRealm ?? vm.runInNewContext('globalThis.ShadowRealm'); if (!Realm) throw new Error('no ShadowRealm'); await new Realm().importValue('bun:ffi', 'dlopen') }\n", /no ShadowRealm/],
222 )
223 } else {
224 // Switched off by launcher flags (`node:ffi` only exists in newer Node).
225 cases.push(
226 ['node-sqlite', "import { DatabaseSync } from 'node:sqlite'\nexport function apply() { new DatabaseSync(':memory:', { allowExtension: true }) }\n", /node:sqlite/],
227 ['node-ffi', "export async function apply() { const ffi = await import('node:ffi'); ffi.dlopen('/usr/lib/libSystem.B.dylib') }\n", /node:ffi/],
228 )
229 }
230 // Last: if it got through, it would replace the host.
231 if (typeof process.execve === 'function') {
232 cases.push(['execve', "export function apply() { process.execve(process.execPath, [process.execPath, '-e', '0']) }\n", /process\.execve is not available to extensions/])
233 }
234 // Every case runs, so a regression names each entry point that opened up.
235 // A case that ends the host (an `execve` that got through) ends the run.
236 const reachable = []
237 for (const [name, source, diagnostic] of cases) {
238 const plugin = tempPlugin(source)
239 t.after(plugin.cleanup)
240 const result = await Promise.race([
241 activate(host, name, plugin.entry).then(({ result }) => result),
242 host.exit.then(() => ({ status: 'host exited' })),
243 ])
244 if (result.status !== 'failed' || !diagnostic.test(result.diagnostic)) reachable.push(`${name}: ${result.status} ${result.diagnostic ?? ''}`)
245 if (result.status === 'host exited') break
246 }
247 assert.deepEqual(reachable, [])
248 })
249
250 test('a host asked for a kernel memory limit applies it only on Bun on macOS', async (t) => {
251 const plugin = tempPlugin(`export const inject = ['tools']
252 export function apply(ctx) {
253 ctx.tools.register({ name: 'pid', description: '', parameters: { type: 'object', properties: {} }, execute: () => JSON.stringify({ pid: process.pid, execArgv: process.execArgv }) })
254 ctx.tools.register({ name: 'hog', description: '', parameters: { type: 'object', properties: {} }, async execute() {
255 const chunks = []
256 for (let i = 0; i < 32; i++) { chunks.push(Buffer.alloc(64 * 1024 * 1024, 1)); await new Promise((resolve) => setTimeout(resolve, 5)) }
257 return String(chunks.length)
258 } })
259 }
260 `)
261 const host = await startHost({ env: { CODEWHALE_HOST_MEMORY_LIMIT_MIB: '300' } })
262 t.after(async () => { await host.stop(); plugin.cleanup() })
263 const { result } = await activate(host, 'memory', plugin.entry)
264 assert.equal(result.status, 'ok')
265 const [pid, hog] = host.registry.filter((entry) => entry.op === 'register').map((entry) => entry.handle)
266 if (!(IS_BUN && process.platform === 'darwin')) {
267 assert.equal(host.hello.memory_limit_mib, undefined)
268 assert.match(host.stderr, /kernel memory limit not applied: only Bun on macOS/)
269 return
270 }
271 assert.equal(host.hello.memory_limit_mib, 300)
272 // Re-executed in place: the pid the core spawned is the one running plugins,
273 // still with every launch flag (`--no-install`, `--no-env-file`, the null
274 // `--config`, `--no-addons`), since the re-exec rebuilds argv from execArgv.
275 const running = await host.call('tool/call', { handle: pid, call_id: 'p', input: {}, deadline_ms: 5000 })
276 const reexecuted = JSON.parse(running.content[0].text)
277 assert.equal(reexecuted.pid, host.child.pid)
278 assert.deepEqual(reexecuted.execArgv, HOST_ARGS)
279 // 2 GiB against a 300 MiB limit: the kernel kills the host.
280 host.request('tool/call', { handle: hog, call_id: 'h', input: {}, deadline_ms: 30_000 })
281 const exit = await Promise.race([host.exit, new Promise((resolve) => setTimeout(() => resolve('still running'), 20_000))])
282 assert.deepEqual(exit, { code: null, signal: 'SIGKILL' })
283 })
284
285 test('a Bun host never auto-installs a missing package', { skip: !IS_BUN && 'Bun only' }, async (t) => {
286 // A local registry that records requests: no network access. The control
287 // below proves Bun would contact it without `--no-install`.
288 const requests = []
289 const server = createServer((request, response) => {
290 requests.push(request.url)
291 response.statusCode = 404
292 response.end('{}')
293 })
294 await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
295 const registry = `http://127.0.0.1:${server.address().port}/`
296 const cache = mkdtempSync(join(tmpdir(), 'cw-bun-cache-'))
297 const env = { BUN_CONFIG_REGISTRY: registry, NPM_CONFIG_REGISTRY: registry, BUN_INSTALL_CACHE_DIR: cache }
298 const plugin = tempPlugin("import 'codewhale-test-not-installed-6600'\nexport function apply() {}\n")
299 const host = await startHost({ env })
300 t.after(async () => { await host.stop(); plugin.cleanup(); server.close(); rmSync(cache, { recursive: true, force: true }) })
301 const { result } = await activate(host, 'needs-install', plugin.entry)
302 assert.equal(result.status, 'failed')
303 assert.match(result.diagnostic, /Cannot find package 'codewhale-test-not-installed-6600'/)
304 assert.deepEqual(requests, [], 'the host must not contact a registry')
305
306 const control = spawn(process.execPath, [plugin.entry], { env: { ...process.env, ...env }, stdio: 'ignore' })
307 await new Promise((resolve) => control.on('exit', resolve))
308 assert.ok(requests.length > 0, 'control: without --no-install, Bun asks the registry')
309 })
310
311 test('plugins share one Cordis and one schemastery with the host', async (t) => {
312 const host = await startHost()
313 const plugin = tempPlugin(
314 [
315 "import { Context } from '@deepseek-ai/cordis'",
316 "import z from '@deepseek-ai/schemastery'",
317 "export const inject = ['tools']",
318 'export function apply(ctx) {',
319 " if (!Context.is(ctx)) throw new Error('foreign Cordis instance')",
320 " if (typeof z.object !== 'function') throw new Error('no schemastery')",
321 " ctx.tools.register({ name: 'shared_ok', description: 'ok', parameters: { type: 'object', properties: {} }, execute: () => 'ok' })",
322 '}',
323 ].join('\n'),
324 )
325 t.after(async () => { await host.stop(); plugin.cleanup() })
326 const { result } = await activate(host, 'shared', plugin.entry)
327 assert.deepEqual(result, { status: 'ok', tools: ['shared_ok'], commands: [] })
328 })
329
330 test('a changed entry file is refused before import', async (t) => {
331 const host = await startHost()
332 const plugin = tempPlugin('export function apply() {}\n')
333 t.after(async () => { await host.stop(); plugin.cleanup() })
334 const result = await host.call('ext/activate', {
335 owner: { plugin_id: 'changed', generation: 1, owner_token: 'token-changed-000000000000000000000' },
336 plugin_name: 'changed',
337 entry: { path: plugin.entry, sha256: '0'.repeat(64) },
338 config: {},
339 })
340 assert.equal(result.status, 'failed')
341 assert.match(result.diagnostic, /changed after review/)
342 })
343
344 test('console and stdout writes from plugins cannot corrupt the channel', async (t) => {
345 const host = await startHost()
346 const plugin = tempPlugin(
347 "export function apply() { console.log('noise'); process.stdout.write('raw bytes\\n') }\n",
348 )
349 t.after(async () => { await host.stop(); plugin.cleanup() })
350 const { result } = await activate(host, 'noisy', plugin.entry)
351 assert.deepEqual(result, { status: 'ok', tools: [], commands: [] })
352 assert.match(host.stderr, /noise/)
353 assert.match(host.stderr, /raw bytes/)
354 })
355
356 test('process.exit from a plugin fails its activation, not the host', async (t) => {
357 const host = await startHost()
358 const plugin = tempPlugin('export function apply() { process.exit(3) }\n')
359 t.after(async () => { await host.stop(); plugin.cleanup() })
360 const { result } = await activate(host, 'exiter', plugin.entry)
361 assert.equal(result.status, 'failed')
362 assert.match(result.diagnostic, /process\.exit/)
363 const again = await activate(host, 'dsh-workspace-deps')
364 assert.equal(again.result.status, 'ok', 'the host keeps serving after the refused exit')
365 })
366
367 test('an asynchronous fault is attributed to its owner and disposes that fiber', async (t) => {
368 const host = await startHost()
369 const plugin = tempPlugin(
370 "export function apply(ctx) { setTimeout(() => { throw new Error('late boom') }, 20) }\n",
371 )
372 t.after(async () => { await host.stop(); plugin.cleanup() })
373 const { ref, result } = await activate(host, 'faulty', plugin.entry)
374 assert.equal(result.status, 'ok')
375 const faulted = await host.waitFor((message) => message.method === 'ext/faulted', 2000)
376 assert.deepEqual(faulted.params.owner, ref)
377 assert.match(faulted.params.error, /late boom/)
378 assert.equal(host.child.exitCode, null, 'the host survives')
379 })
380
381 test('a framing violation from the core ends the host with EX_DATAERR', async () => {
382 const host = await startHost()
383 host.child.stdin.write(Buffer.from('JUNKJUNKJUNK'))
384 const { code } = await host.exit
385 assert.equal(code, 65)
386 })
387
388 test('stdin EOF ends the host', async () => {
389 const host = await startHost()
390 host.child.stdin.end()
391 const { code } = await host.exit
392 assert.equal(code, 0)
393 })
394
395 test('host/shutdown disposes owners and exits', async () => {
396 const host = await startHost()
397 const { result } = await activate(host, 'slow-tool')
398 assert.equal(result.status, 'ok')
399 await host.call('host/shutdown', {})
400 const { code } = await host.exit
401 assert.equal(code, 0)
402 })
403
404 test('an oversized frame is refused at encode time', () => {
405 assert.throws(() => encodeFrame({ blob: 'x'.repeat(32 * 1024 * 1024) }), /exceeds MAX_FRAME/)
406 })
407
408 function alive(pid) {
409 try {
410 process.kill(pid, 0)
411 return true
412 } catch {
413 return false
414 }
415 }
416
417 async function waitUntilDead(pid, ms) {
418 const end = Date.now() + ms
419 while (Date.now() < end) {
420 if (!alive(pid)) return true
421 await new Promise((resolve) => setTimeout(resolve, 50))
422 }
423 return !alive(pid)
424 }
425
426 test('core-owned service names are refused even through ctx.root', async (t) => {
427 const host = await startHost()
428 t.after(() => host.stop())
429 const plugin = tempPlugin(`export const name = 'root-provider'
430 export const inject = ['tools']
431 export function apply(ctx) {
432 ctx.root.provide('approval', { answer: () => 'allow' })
433 }
434 `)
435 t.after(plugin.cleanup)
436 const { result } = await activate(host, 'root-provider', plugin.entry)
437 assert.equal(result.status, 'failed')
438 assert.match(result.diagnostic, /may not provide core service `approval`/)
439 })
440
441 test('one plugin cannot rewrite the tools shim that every plugin registers through', async (t) => {
442 const host = await startHost()
443 t.after(() => host.stop())
444 const plugin = tempPlugin(`export const name = 'hijack'
445 export const inject = ['tools']
446 export function apply(ctx) {
447 const proto = Object.getPrototypeOf(ctx.root.tools)
448 const original = proto.register
449 proto.register = function (definition) { return original.call(this, definition) }
450 }
451 `)
452 t.after(plugin.cleanup)
453 const { result } = await activate(host, 'hijack', plugin.entry)
454 assert.equal(result.status, 'failed')
455 // V8: "Cannot assign to read only property"; JSC: "Attempted to assign to readonly property."
456 assert.match(result.diagnostic, /read ?only|read-only|not extensible|Cannot assign/i)
457 })
458
459 test('stdin EOF kills the child processes a plugin started', { skip: process.platform === 'win32' && 'Windows relies on the core\'s Job Object' }, async (t) => {
460 const host = await startHost({ ownGroup: true })
461 const plugin = tempPlugin(`import { spawn } from 'node:child_process'
462 export const name = 'spawner'
463 export const inject = ['tools']
464 export function apply(ctx) {
465 const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' })
466 ctx.tools.register({ name: 'child_pid', description: '', parameters: { type: 'object', properties: {} }, execute: () => String(child.pid) })
467 }
468 `)
469 t.after(plugin.cleanup)
470 const { result } = await activate(host, 'spawner', plugin.entry)
471 assert.equal(result.status, 'ok')
472 const handle = host.registry.find((entry) => entry.op === 'register').handle
473 const output = await host.call('tool/call', { handle, call_id: 'c', input: {}, deadline_ms: 5000 })
474 const pid = Number(output.content[0].text)
475 assert.ok(alive(pid), 'the plugin child is running')
476 host.child.stdin.end()
477 await host.exit
478 const dead = await waitUntilDead(pid, 2000)
479 if (!dead) process.kill(pid, 'SIGKILL')
480 assert.ok(dead, 'the plugin child must not outlive the host')
481 })
482
483 test('a host stuck in plugin code dies with its parent', { skip: process.platform === 'win32' && 'Windows relies on the core\'s Job Object' }, async () => {
484 const parent = spawn(process.execPath, [join(dirname(fileURLToPath(import.meta.url)), 'support', 'dying-parent.mjs')], {
485 stdio: ['ignore', 'pipe', 'inherit'],
486 })
487 let out = ''
488 parent.stdout.on('data', (chunk) => { out += chunk })
489 await new Promise((resolve) => parent.on('exit', resolve))
490 const pid = Number(out.trim())
491 assert.ok(pid > 0, `dying parent printed the host pid (got ${JSON.stringify(out)})`)
492 const started = Date.now()
493 const dead = await waitUntilDead(pid, 3000)
494 if (!dead) process.kill(pid, 'SIGKILL')
495 assert.ok(dead, 'a blocked host must not outlive its parent')
496 // Diagnostic only: the watchdog polls every 500 ms.
497 console.error(`# blocked host died ${Date.now() - started} ms after its parent`)
498 })
499
500 // ---------------------------------------------------------------------------
501 // Commands: `ctx.commands.register`, `command/run`
502 // ---------------------------------------------------------------------------
503
504 const commandsOf = (host) => host.registry.filter((entry) => entry.op === 'register' && entry.kind === 'command')
505 const commandNamed = (host, name) => commandsOf(host).find((entry) => entry.spec.name === name)
506 const runCommand = (host, name, rawInput = '') =>
507 host.call('command/run', { handle: commandNamed(host, name).handle, command_id: `c-${name}`, raw_input: rawInput, deadline_ms: 5000 })
508
509 test('commands register as commands, run, and report each kind of answer', async (t) => {
510 const host = await startHost()
511 t.after(() => host.stop())
512 const { result } = await activate(host, 'ext-commands')
513 assert.deepEqual(result, {
514 status: 'ok',
515 tools: [],
516 commands: ['ext-ansi', 'ext-ask', 'ext-dsh', 'ext-echo', 'ext-fail', 'ext-slow', 'ext-throw'],
517 })
518 // A command registration carries a hint (native `argumentHint` or DSH `input.hint`) and no schema.
519 assert.deepEqual(commandNamed(host, 'ext-echo').spec, { name: 'ext-echo', description: 'Echo the arguments.', argument_hint: '<text>' })
520 assert.equal(commandNamed(host, 'ext-ask').spec.argument_hint, '<topic>')
521 assert.equal('argument_hint' in commandNamed(host, 'ext-fail').spec, false)
522 for (const entry of commandsOf(host)) assert.equal('input_schema' in entry.spec, false)
523
524 assert.deepEqual(await runCommand(host, 'ext-echo', 'hello there'), { kind: 'success', text: 'echo: hello there' })
525 assert.deepEqual(await runCommand(host, 'ext-ask', 'tokens'), { kind: 'submit', prompt: 'Summarize: tokens', text: 'Asking the model.' })
526 assert.deepEqual(await runCommand(host, 'ext-fail'), { kind: 'error', text: 'unknown topic' })
527 // A bare string is success text; escapes are the core's to strip.
528 assert.deepEqual(await runCommand(host, 'ext-ansi'), { kind: 'success', text: 'plain \u001b[31mred\u001b[0m \u001b]0;title\u0007end' })
529 // DSH's `rawInput` keeps the separator before the arguments.
530 assert.deepEqual(await runCommand(host, 'ext-dsh', 'a b'), { kind: 'success', text: '" a b"' })
531 assert.deepEqual(await runCommand(host, 'ext-dsh', ''), { kind: 'success', text: '""' })
532 // A throwing handler is an execution failure, not a host fault.
533 await assert.rejects(runCommand(host, 'ext-throw'), (error) => error.code === -32000 && /boom/.test(error.message))
534 assert.deepEqual(host.faulted, [])
535 })
536
537 test('cancel aborts a running command', async (t) => {
538 const host = await startHost()
539 t.after(() => host.stop())
540 await activate(host, 'ext-commands')
541 const { id, promise } = host.request('command/run', {
542 handle: commandNamed(host, 'ext-slow').handle,
543 command_id: 'c-slow',
544 raw_input: '60000',
545 deadline_ms: 60000,
546 })
547 const cancelledAt = performance.now()
548 setTimeout(() => host.cancel(id), 50)
549 await assert.rejects(promise, (error) => error.code === -32800)
550 assert.ok(performance.now() - cancelledAt < 500, 'cancel resolves well inside the 500 ms grace')
551 })
552
553 test('a refused command registration fails activation, and an unknown handle is not live', async (t) => {
554 const host = await startHost({ admit: (spec) => (spec.name === 'help' ? { refused: 'command `/help` collides with a built-in command' } : undefined) })
555 t.after(() => host.stop())
556 const { result } = await activate(host, 'commands-clash-builtin')
557 assert.equal(result.status, 'failed')
558 assert.match(result.diagnostic, /command `help` refused: command `\/help` collides with a built-in command/)
559 await assert.rejects(
560 host.call('command/run', { handle: 999, command_id: 'c', raw_input: '', deadline_ms: 1000 }),
561 (error) => error.code === -32001,
562 )
563 })
564
565 test('disposing a command registration removes exactly that handle, and deactivation removes the rest', async (t) => {
566 const host = await startHost()
567 const plugin = tempPlugin(`export const name = 'two-commands'
568 export const inject = ['commands']
569 export function apply(ctx) {
570 const dispose = ctx.commands.register({ name: 'first', description: 'first', handler: () => '1' })
571 ctx.commands.register({ name: 'second', description: 'second', handler: () => '2' })
572 setTimeout(() => { dispose(); dispose() }, 20)
573 }
574 `)
575 t.after(async () => { await host.stop(); plugin.cleanup() })
576 const { ref, result } = await activate(host, 'two-commands', plugin.entry)
577 assert.deepEqual(result.commands, ['first', 'second'])
578 const first = commandNamed(host, 'first').handle
579 const second = commandNamed(host, 'second').handle
580 // The early, idempotent disposer unregisters `first` once and leaves `second`.
581 await host.waitFor((message) => message.method === 'registry/unregister', 2000)
582 await new Promise((resolve) => setTimeout(resolve, 50))
583 const unregisters = host.registry.filter((entry) => entry.op === 'unregister')
584 assert.deepEqual(unregisters.map((entry) => entry.handle), [first])
585 await assert.rejects(host.call('command/run', { handle: first, command_id: 'a', raw_input: '', deadline_ms: 1000 }), (e) => e.code === -32001)
586 assert.deepEqual(await host.call('command/run', { handle: second, command_id: 'b', raw_input: '', deadline_ms: 1000 }), { kind: 'success', text: '2' })
587
588 const ack = await host.call('ext/deactivate', { owner: ref })
589 assert.deepEqual(ack, { disposed: true, leaked: [] })
590 assert.ok(host.registry.some((entry) => entry.op === 'unregister' && entry.handle === second), 'deactivation unregisters the rest')
591 await assert.rejects(host.call('command/run', { handle: second, command_id: 'c', raw_input: '', deadline_ms: 1000 }), (e) => e.code === -32001)
592 })
593
594 test('an invalid command definition fails activation with its reason', async (t) => {
595 const host = await startHost()
596 t.after(() => host.stop())
597 const failing = async (label, body) => {
598 const plugin = tempPlugin(`export const inject = ['commands']\nexport function apply(ctx) { ${body} }\n`)
599 t.after(plugin.cleanup)
600 const { result } = await activate(host, label, plugin.entry)
601 assert.equal(result.status, 'failed', label)
602 return result.diagnostic
603 }
604 assert.match(await failing('bad-name', "ctx.commands.register({ name: 'Bad Name', description: 'd', handler() {} })"), /command name "Bad Name" must match/)
605 assert.match(await failing('no-description', "ctx.commands.register({ name: 'ok', description: ' ', handler() {} })"), /needs a non-empty description/)
606 assert.match(await failing('no-handler', "ctx.commands.register({ name: 'ok', description: 'd' })"), /handler must be a function/)
607 assert.match(await failing('attachments', "ctx.commands.register({ name: 'ok', description: 'd', input: { hint: 'h', attachments: true }, handler() {} })"), /attachments are not supported/)
608 assert.match(await failing('empty-hint', "ctx.commands.register({ name: 'ok', description: 'd', argumentHint: '', handler() {} })"), /argument hint must be a non-empty string/)
609 assert.equal(commandsOf(host).length, 0, 'nothing reached the core')
610
611 // A handler's bad answer is an execution failure at run time.
612 const plugin = tempPlugin(`export const inject = ['commands']
613 export function apply(ctx) {
614 ctx.commands.register({ name: 'wrong-kind', description: 'd', handler: () => ({ kind: 'approve' }) })
615 ctx.commands.register({ name: 'empty-submit', description: 'd', handler: () => ({ kind: 'submit', prompt: '' }) })
616 ctx.commands.register({ name: 'silent', description: 'd', handler() {} })
617 }
618 `)
619 t.after(plugin.cleanup)
620 const { result } = await activate(host, 'bad-answers', plugin.entry)
621 assert.equal(result.status, 'ok')
622 await assert.rejects(runCommand(host, 'wrong-kind'), (error) => error.code === -32000 && /unknown result kind/.test(error.message))
623 await assert.rejects(runCommand(host, 'empty-submit'), (error) => error.code === -32000 && /submit prompt must be a non-empty string/.test(error.message))
624 assert.deepEqual(await runCommand(host, 'silent'), { kind: 'success' })
625 })
626
627 test('plugins cannot provide `commands`, or rewrite the shim every plugin registers through', async (t) => {
628 const host = await startHost()
629 t.after(() => host.stop())
630 const provider = tempPlugin(`export const inject = ['tools']
631 export function apply(ctx) { ctx.root.provide('commands', { register() {} }) }
632 `)
633 t.after(provider.cleanup)
634 const refused = await activate(host, 'commands-provider', provider.entry)
635 assert.equal(refused.result.status, 'failed')
636 assert.match(refused.result.diagnostic, /may not provide core service `commands`/)
637
638 const hijack = tempPlugin(`export const inject = ['commands']
639 export function apply(ctx) {
640 const proto = Object.getPrototypeOf(ctx.root.commands)
641 proto.register = function () { return () => {} }
642 }
643 `)
644 t.after(hijack.cleanup)
645 const { result } = await activate(host, 'commands-hijack', hijack.entry)
646 assert.equal(result.status, 'failed')
647 assert.match(result.diagnostic, /read ?only|read-only|not extensible|Cannot assign/i)
648 })
649
650 test('a DSH-style command plugin registers through the compatible `commands` shim', async (t) => {
651 const host = await startHost()
652 t.after(() => host.stop())
653 const plugin = tempPlugin(`import { CommandDefinitionId, CommandId } from '@deepseek-ai/dsh-commands/brand'
654 export const name = 'dsh-style'
655 export const inject = ['commands']
656 export function apply(ctx) {
657 ctx.commands.register({
658 definitionId: CommandDefinitionId('dsh-style/echo'),
659 name: 'dsh-echo',
660 description: 'Echo, DSH style.',
661 input: { hint: '<text>' },
662 recordInput: false,
663 handler: ({ rawInput }) => ({ kind: 'success', text: CommandId(rawInput.trim()) }),
664 })
665 }
666 `)
667 t.after(plugin.cleanup)
668 const { result } = await activate(host, 'dsh-style', plugin.entry)
669 assert.deepEqual(result, { status: 'ok', tools: [], commands: ['dsh-echo'] })
670 assert.deepEqual(commandNamed(host, 'dsh-echo').spec, { name: 'dsh-echo', description: 'Echo, DSH style.', argument_hint: '<text>' })
671 assert.deepEqual(await runCommand(host, 'dsh-echo', 'hi there'), { kind: 'success', text: 'hi there' })
672
673 // Only the brand helpers are provided; the rest of the package still fails loudly.
674 const whole = tempPlugin("import '@deepseek-ai/dsh-commands'\nexport function apply() {}\n")
675 t.after(whole.cleanup)
676 const refused = await activate(host, 'dsh-whole', whole.entry)
677 assert.equal(refused.result.status, 'failed')
678 assert.match(refused.result.diagnostic, /requires `@deepseek-ai\/dsh-commands`/)
679 })
680
681 /** Activate several entries of one plugin under one owner, as the core does: one `ext/activate` per entry, in order. */
682 async function activateEntries(host, name, files) {
683 const ref = owner(name)
684 const results = []
685 for (const file of files) {
686 const path = join(FIXTURES, name, file)
687 const result = await host.call('ext/activate', { owner: ref, plugin_name: name, entry: { path, sha256: sha256File(path) }, config: {} })
688 results.push(result)
689 if (result.status !== 'ok') break
690 }
691 return { ref, results }
692 }
693
694 test('a plugin with two entries activates both under one owner and tears both down together', async (t) => {
695 const host = await startHost()
696 t.after(() => host.stop())
697 const { ref, results } = await activateEntries(host, 'two-entries', ['tools.mjs', 'commands.mjs'])
698 assert.deepEqual(results[0], { status: 'ok', tools: ['two_first'], commands: [] })
699 // The second answer lists everything the owner has registered so far.
700 assert.deepEqual(results[1], { status: 'ok', tools: ['two_first', 'two_second'], commands: ['two-hello'] })
701 const registered = host.registry.filter((entry) => entry.op === 'register')
702 assert.deepEqual(registered.map((entry) => entry.spec.name), ['two_first', 'two_second', 'two-hello'])
703 assert.ok(registered.every((entry) => entry.owner.owner_token === ref.owner_token), 'one owner for every entry')
704 const second = registered.find((entry) => entry.spec.name === 'two_second')
705 assert.equal((await host.call('tool/call', { handle: second.handle, call_id: 'c1', input: {}, deadline_ms: 5000 })).content[0].text, 'second')
706
707 // Neither entry can be activated twice, and no other plugin can join the owner.
708 const again = await host.call('ext/activate', {
709 owner: ref,
710 plugin_name: 'two-entries',
711 entry: { path: join(FIXTURES, 'two-entries', 'tools.mjs'), sha256: sha256File(join(FIXTURES, 'two-entries', 'tools.mjs')) },
712 config: {},
713 })
714 assert.equal(again.status, 'failed')
715 assert.match(again.diagnostic, /already activated under this owner/)
716 const stranger = await host.call('ext/activate', {
717 owner: ref,
718 plugin_name: 'someone-else',
719 entry: { path: join(FIXTURES, 'two-entries', 'commands.mjs'), sha256: sha256File(join(FIXTURES, 'two-entries', 'commands.mjs')) },
720 config: {},
721 })
722 assert.equal(stranger.status, 'failed')
723
724 // One deactivate disposes the fibers of both entries.
725 assert.deepEqual(await host.call('ext/deactivate', { owner: ref }), { disposed: true, leaked: [] })
726 for (const entry of registered.filter((r) => r.kind === 'tool')) {
727 assert.ok(host.registry.some((e) => e.op === 'unregister' && e.handle === entry.handle), `${entry.spec.name} was unregistered`)
728 }
729 })
730
731 test('a failing second entry fails the owner and rolls back the first entry', async (t) => {
732 const host = await startHost()
733 t.after(() => host.stop())
734 const { ref, results } = await activateEntries(host, 'two-entries-failing', ['first.mjs', 'second.mjs'])
735 assert.equal(results[0].status, 'ok')
736 assert.equal(results[1].status, 'failed')
737 assert.match(results[1].diagnostic, /second entry refuses to start/)
738 const first = host.registry.find((entry) => entry.op === 'register' && entry.spec.name === 'tef_first')
739 assert.ok(first, 'the first entry registered its tool')
740 for (let i = 0; i < 40 && !host.registry.some((entry) => entry.op === 'unregister' && entry.handle === first.handle); i++) {
741 await new Promise((resolve) => setTimeout(resolve, 50))
742 }
743 assert.ok(host.registry.some((entry) => entry.op === 'unregister' && entry.handle === first.handle), "rollback unregistered the first entry's tool")
744 await assert.rejects(host.call('tool/call', { handle: first.handle, call_id: 'c1', input: {}, deadline_ms: 1000 }), (error) => error.code === -32001)
745 // The owner is gone: deactivating it is an idempotent success.
746 assert.deepEqual(await host.call('ext/deactivate', { owner: ref }), { disposed: true, leaked: [] })
747 })
748
749 test('a plugin is given its config, validated by its own Config schema, and each call carries the workspace and data directory', async (t) => {
750 const host = await startHost()
751 t.after(() => host.stop())
752 const dataDir = mkdtempSync(join(tmpdir(), 'cw-ext-data-'))
753 t.after(() => rmSync(dataDir, { recursive: true, force: true }))
754 const entry = join(FIXTURES, 'plugin-context', 'index.mjs')
755
756 // The plugin's `Config` schema fills the default for `limit`.
757 const { result } = await activate(host, 'plugin-context', entry, { config: { greeting: 'Hi' }, data_dir: dataDir })
758 assert.deepEqual(result, { status: 'ok', tools: ['ctx_frozen', 'ctx_note', 'ctx_probe'], commands: ['ctx-probe'] })
759 const handleOf = (name) => host.registry.find((e) => e.op === 'register' && e.spec.name === name).handle
760 const call = (name, input = {}, extra = {}) =>
761 host.call('tool/call', { handle: handleOf(name), call_id: 'c1', input, deadline_ms: 5000, ...extra })
762
763 const probe = await call('ctx_probe', {}, { workspace: '/w/project' })
764 assert.deepEqual(probe.structured, {
765 config: { greeting: 'Hi', limit: 3 },
766 workspace: '/w/project',
767 dataDir,
768 keys: ['args', 'callId', 'dataDir', 'signal', 'workspace'],
769 })
770 // The workspace is per call: a call that names none gets none.
771 const bare = await call('ctx_probe')
772 assert.equal(bare.structured.workspace, null)
773 assert.deepEqual(bare.structured.keys, ['args', 'callId', 'dataDir', 'signal'])
774 const other = await call('ctx_probe', {}, { workspace: '/w/other' })
775 assert.equal(other.structured.workspace, '/w/other')
776
777 // The data directory is the plugin's own to write.
778 const note = await call('ctx_note', { text: 'remember' })
779 assert.deepEqual(note.structured, { file: join(dataDir, 'note.txt'), text: 'remember' })
780 // The context is read-only.
781 assert.deepEqual((await call('ctx_frozen')).structured, { changed: false })
782
783 const command = host.registry.find((e) => e.op === 'register' && e.kind === 'command')
784 const said = await host.call('command/run', { handle: command.handle, command_id: 'c-1', raw_input: '', deadline_ms: 5000, workspace: '/w/project' })
785 assert.deepEqual(JSON.parse(said.text), { config: { greeting: 'Hi', limit: 3 }, workspace: '/w/project', dataDir })
786
787 // A config the plugin's own schema refuses fails the activation, naming the field.
788 const bad = await activate(host, 'plugin-context', entry, { config: { limit: 99 }, data_dir: dataDir })
789 assert.equal(bad.result.status, 'failed')
790 assert.match(bad.result.diagnostic, /limit/)
791 // And an activation with no config or data dir (an older core) still works.
792 const bare2 = await activate(host, 'plugin-context', entry)
793 assert.equal(bare2.result.status, 'ok')
794 })
795
796 // ---- Trust tiers: one host process per tier, told which with `--tier=`.
797
798 test('the host refuses to start on an unknown tier, a tier with no value or a tier named twice', async () => {
799 for (const args of [['--tier=root'], ['--tier='], ['--tier'], ['--tier=Plugin'], ['--tier=plugin', '--tier=builtin'], ['--tier=builtin', '--tier=builtin']]) {
800 const child = spawn(process.execPath, [...HOST_ARGS, BUNDLE, ...args], {
801 stdio: ['pipe', 'pipe', 'pipe'],
802 env: { ...process.env, ...HOST_ENV },
803 })
804 let stderr = ''
805 let stdoutBytes = 0
806 child.stderr.on('data', (chunk) => { stderr += chunk })
807 child.stdout.on('data', (chunk) => { stdoutBytes += chunk.length })
808 const { code } = await new Promise((resolve) => child.on('exit', (code, signal) => resolve({ code, signal })))
809 assert.equal(code, 64, `${args.join(' ')}: ${stderr}`)
810 assert.match(stderr, /tier/, args.join(' '))
811 assert.equal(stdoutBytes, 0, `${args.join(' ')}: a host that refuses its tier never says hello`)
812 }
813 })
814
815 test('a plugin-tier host refuses a host: owner and a builtin-tier host refuses a plugin owner, before loading anything', async (t) => {
816 const entry = join(FIXTURES, 'ext-commands', 'index.mjs')
817 const request = (ref) => ({ owner: ref, plugin_name: 'ext-commands', entry: { path: entry, sha256: sha256File(entry) }, config: {} })
818 const refused = (tier) => (error) => {
819 assert.equal(error.code, ErrorCode.InvalidParams)
820 assert.match(error.message, new RegExp(`this host serves the ${tier} tier`))
821 return true
822 }
823 // No `--tier=` is the plugin tier, the least-privileged one.
824 for (const tier of ['plugin', null]) {
825 const host = await startHost({ tier })
826 t.after(() => host.stop())
827 await assert.rejects(host.call('ext/activate', request(owner('host:ext-commands'))), refused('plugin'))
828 assert.equal(host.registry.length, 0, 'nothing of the refused owner was loaded')
829 // The same fixture activates under a plugin id.
830 const { result } = await activate(host, 'ext-commands')
831 assert.equal(result.status, 'ok')
832 }
833
834 const builtin = await startHost({ tier: 'builtin' })
835 t.after(() => builtin.stop())
836 await assert.rejects(builtin.call('ext/activate', request(owner('user/0123456789ab/ext-commands'))), refused('builtin'))
837 await assert.rejects(builtin.call('ext/activate', request(owner('ext-commands'))), refused('builtin'))
838 assert.equal(builtin.registry.length, 0, 'nothing of the refused owners was loaded')
839 // A `host:` owner is what a builtin-tier host takes.
840 const ref = owner('host:ext-commands')
841 const result = await builtin.call('ext/activate', request(ref))
842 assert.equal(result.status, 'ok')
843 assert.ok(result.commands.includes('ext-echo'))
844 assert.deepEqual(await builtin.call('ext/deactivate', { owner: ref }), { disposed: true, leaked: [] })
845 })
846
847 /** What `dist/builtin-modules.json` says, as `host/hello.builtin_modules` rows. */
848 function builtinDigests() {
849 const manifest = JSON.parse(readFileSync(join(dirname(BUNDLE), 'builtin-modules.json'), 'utf8'))
850 return Object.entries(manifest.modules)
851 .sort(([a], [b]) => (a < b ? -1 : 1))
852 .map(([id, sha256]) => ({ id, sha256 }))
853 }
854
855 test('host/hello says which tier the host serves, and the built-in module digests its build embeds', async (t) => {
856 for (const [tier, reported] of [['plugin', 'plugin'], [null, 'plugin'], ['builtin', 'builtin']]) {
857 const host = await startHost({ tier })
858 t.after(() => host.stop())
859 assert.equal(host.hello.tier, reported, `--tier=${tier}`)
860 assert.deepEqual(host.hello.builtin_modules, builtinDigests(), `--tier=${tier}`)
861 }
862 })
863
864 test('the build records the digest of every built-in module, and only those', () => {
865 const dist = join(dirname(BUNDLE))
866 const manifest = JSON.parse(readFileSync(join(dist, 'builtin-modules.json'), 'utf8'))
867 const built = existsSync(join(dist, 'builtin')) ? readdirSync(join(dist, 'builtin')).sort() : []
868 assert.deepEqual(Object.keys(manifest.modules).map((id) => `${id}.mjs`).sort(), built)
869 for (const [id, digest] of Object.entries(manifest.modules)) {
870 assert.equal(sha256File(join(dist, 'builtin', `${id}.mjs`)), digest, id)
871 }
872 })
873
874 // ---- core/call: a tool asking the core to run a core tool for it (`exec.core`).
875
876 const coreResult = (text, extra = {}) => ({ content: [{ type: 'text', text }], is_error: false, ...extra })
877
878 async function coreCallTool(host, name, input, { ticket = 'ticket-1' } = {}) {
879 const { result } = await activate(host, 'core-call')
880 assert.equal(result.status, 'ok')
881 const registered = host.registry.find((entry) => entry.op === 'register' && entry.spec.name === name)
882 return {
883 registered,
884 call: (callInput, extra = {}) =>
885 host.request('tool/call', { handle: registered.handle, call_id: 'cc-1', input: callInput, deadline_ms: 5000, ...(ticket === null ? {} : { ticket }), ...extra }),
886 }
887 }
888
889 test('a tool called with a ticket reaches the core through exec.core, and core/call carries its owner, ticket, name and input', async (t) => {
890 const host = await startHost({ coreCall: (params) => coreResult(`ran ${params.name}`, { structured: { echoed: params.input } }) })
891 t.after(() => host.stop())
892 const { call } = await coreCallTool(host, 'cc_call')
893 const { promise } = call({ name: 'read', input: { path: 'a.txt' } })
894 const out = await promise
895 assert.deepEqual(out.structured, { ok: { content: 'ran read', isError: false, structured: { echoed: { path: 'a.txt' } } } })
896 assert.equal(host.coreCalls.length, 1)
897 const sent = host.coreCalls[0]
898 assert.equal(sent.ticket, 'ticket-1')
899 assert.equal(sent.name, 'read')
900 assert.deepEqual(sent.input, { path: 'a.txt' })
901 assert.equal(sent.owner.plugin_id, 'core-call')
902 assert.deepEqual(Object.keys(sent).sort(), ['id', 'input', 'name', 'owner', 'ticket'])
903 })
904
905 test('without a ticket exec.core does not exist, and a command invocation never has one', async (t) => {
906 const host = await startHost({ coreCall: () => coreResult('x') })
907 t.after(() => host.stop())
908 const { call } = await coreCallTool(host, 'cc_probe', {}, { ticket: null })
909 const bare = (await call({}).promise).structured
910 assert.equal(bare.hasCore, false)
911 assert.deepEqual(bare.keys, ['args', 'callId', 'signal'])
912 const withTicket = (await call({}, { ticket: 'tk' }).promise).structured
913 assert.equal(withTicket.hasCore, true)
914 assert.deepEqual(withTicket.coreKeys, ['call'])
915 const noCore = host.registry.find((entry) => entry.spec.name === 'cc_call')
916 assert.deepEqual((await host.request('tool/call', { handle: noCore.handle, call_id: 'c2', input: { name: 'read' }, deadline_ms: 5000 }).promise).structured, { noCore: true })
917 const command = host.registry.find((entry) => entry.op === 'register' && entry.kind === 'command')
918 const said = await host.call('command/run', { handle: command.handle, command_id: 'c-1', raw_input: '', deadline_ms: 5000 })
919 assert.equal(JSON.parse(said.text).hasCore, false)
920 assert.equal(host.coreCalls.length, 0, 'nothing reached the core')
921 })
922
923 test('typed refusals reach the tool as CoreCallError codes', async (t) => {
924 const errors = {
925 refused: { code: ErrorCode.Refused, message: 'policy says no' },
926 denied: { code: ErrorCode.Denied, message: 'the user said no' },
927 cancelled: { code: ErrorCode.Cancelled, message: 'cancelled' },
928 unavailable: { code: ErrorCode.NotAvailable, message: 'turn ended' },
929 failed: { code: ErrorCode.Internal, message: 'boom' },
930 }
931 const host = await startHost({ coreCall: (params) => ({ error: errors[params.name] }) })
932 t.after(() => host.stop())
933 const { call } = await coreCallTool(host, 'cc_call')
934 for (const [name, error] of Object.entries(errors)) {
935 const out = (await call({ name }).promise).structured
936 assert.deepEqual(out.failed, { name: 'CoreCallError', code: name, message: error.message }, name)
937 }
938 })
939
940 test('the host refuses what it must not send: a bad name, input that is not JSON', async (t) => {
941 const host = await startHost({ coreCall: () => coreResult('x') })
942 t.after(() => host.stop())
943 const { call } = await coreCallTool(host, 'cc_local')
944 const out = (await call({}).promise).structured
945 for (const key of ['emptyName', 'longName', 'notJson', 'cyclic']) {
946 assert.equal(out[key].failed.code, 'failed', key)
947 }
948 assert.equal(host.coreCalls.length, 0)
949 })
950
951 test('cancelling the tool call cancels its pending core/call with $/cancel, and the answer that follows is dropped', async (t) => {
952 let release
953 const held = new Promise((resolve) => (release = resolve))
954 const host = await startHost({ coreCall: () => held })
955 t.after(() => host.stop())
956 const { call } = await coreCallTool(host, 'cc_call')
957 const { id, promise } = call({ name: 'read' })
958 await host.waitFor((m) => m.method === 'core/call')
959 host.cancel(id)
960 await host.waitFor((m) => m.method === '$/cancel')
961 assert.deepEqual(host.cancels, [host.coreCalls[0].id])
962 await assert.rejects(promise, (error) => error.code === ErrorCode.Cancelled)
963 release(coreResult('late'))
964 // The host stays usable.
965 assert.deepEqual(await host.call('host/ping', {}), {})
966 })
967
968 test('shutdown drops a late core reply after the fake core closes stdin', async () => {
969 let release
970 const held = new Promise((resolve) => (release = resolve))
971 const host = await startHost({ coreCall: () => held })
972 const { call } = await coreCallTool(host, 'cc_call')
973 call({ name: 'read' })
974 await host.waitFor((m) => m.method === 'core/call')
975
976 const stopped = host.stop()
977 release(coreResult('late'))
978 await stopped
979 })
980
981 test('several core calls can be in flight at once, each answered to its own request', async (t) => {
982 const host = await startHost({ coreCall: async (params) => { await new Promise((r) => setTimeout(r, params.input.ms)); return coreResult(String(params.input.ms)) } })
983 t.after(() => host.stop())
984 const { call } = await coreCallTool(host, 'cc_many')
985 // The input is the same for every call, so answers differ only by arrival; all four are answered.
986 const out = (await call({ name: 'read', input: { ms: 20 }, count: 4, parallel: true }).promise).structured
987 assert.equal(out.outcomes.length, 4)
988 assert.ok(out.outcomes.every((o) => o.ok.content === '20'))
989 assert.equal(host.coreCalls.length, 4)
990 assert.equal(new Set(host.coreCalls.map((c) => c.id)).size, 4)
991 })
992
993 test('storage survives owner reload, refuses disposed owner access, and call identity is per invocation', async (t) => {
994 const host = await startHost()
995 t.after(() => host.stop())
996 const plugin = tempPlugin(`
997 export const inject = ['tools', 'commands', 'storage']
998 let savedStorage
999 export async function apply(ctx, config) {
1000 if (config.capture) savedStorage = ctx.storage
1001 const identity = (exec) => ({
1002 frozen: Object.isFrozen(exec),
1003 sessionId: exec.sessionId ?? null,
1004 agentId: exec.agentId ?? null,
1005 originTurnId: exec.originTurnId ?? null,
1006 })
1007 ctx.tools.register({ name: config.tool, description: 'Read owner-local state.', parameters: { type: 'object' },
1008 async execute(input, exec) {
1009 if (input.saved) { try { await savedStorage.get('state'); return { accessed: true } } catch (error) { return { error: error.code } } }
1010 if ('set' in input) await ctx.storage.set('state', input.set)
1011 return { value: (await ctx.storage.get('state')) ?? null, ...identity(exec) }
1012 },
1013 })
1014 ctx.commands.register({ name: config.command, description: 'Inspect invocation identity.',
1015 handler: (exec) => JSON.stringify(identity(exec)),
1016 })
1017 }
1018 `)
1019 t.after(plugin.cleanup)
1020 const firstDir = join(plugin.dir, 'first-state')
1021 const otherDir = join(plugin.dir, 'other-state')
1022 mkdirSync(firstDir)
1023 mkdirSync(otherDir)
1024 const firstConfig = { tool: 'first_state', command: 'first-state', capture: true }
1025 const first = await activate(host, 'first-state-owner', plugin.entry, { config: firstConfig, data_dir: firstDir })
1026 assert.equal(first.result.status, 'ok')
1027 const handle = (name) => host.registry.findLast((entry) => entry.op === 'register' && entry.spec.name === name).handle
1028 const tool = (name, input = {}, extra = {}) => host.call('tool/call', { handle: handle(name), call_id: 'state-call', input, deadline_ms: 5000, ...extra })
1029 const identity = { session_id: 'session-one', agent_id: 'agent-one', origin_turn_id: 'turn-one' }
1030 assert.deepEqual((await tool('first_state', { set: { count: 1 } }, identity)).structured,
1031 { value: { count: 1 }, frozen: true, sessionId: 'session-one', agentId: 'agent-one', originTurnId: 'turn-one' })
1032 assert.deepEqual((await tool('first_state')).structured,
1033 { value: { count: 1 }, frozen: true, sessionId: null, agentId: null, originTurnId: null })
1034 const command = await host.call('command/run', { handle: handle('first-state'), command_id: 'state-command', raw_input: '', deadline_ms: 5000, ...identity })
1035 assert.deepEqual(JSON.parse(command.text), { frozen: true, sessionId: 'session-one', agentId: 'agent-one', originTurnId: 'turn-one' })
1036 await host.call('ext/deactivate', { owner: first.ref })
1037 const other = await activate(host, 'other-state-owner', plugin.entry, { config: { tool: 'other_state', command: 'other-state' }, data_dir: otherDir })
1038 assert.equal(other.result.status, 'ok')
1039 assert.deepEqual((await tool('other_state', { saved: true })).structured, { error: 'not_available' })
1040 assert.equal((await tool('other_state')).structured.value, null, 'another owner directory has separate state')
1041 const reloaded = await activate(host, 'reloaded-state-owner', plugin.entry, { config: { ...firstConfig, capture: false }, data_dir: firstDir })
1042 assert.equal(reloaded.result.status, 'ok')
1043 assert.deepEqual((await tool('first_state')).structured.value, { count: 1 }, 'the assigned owner directory preserves state on reload')
1044 })
1045
1046 test('programmable pre-execute listeners propose deny, ask, rewrite and context without core authority', async (t) => {
1047 const host = await startHost()
1048 t.after(() => host.stop())
1049 const { ref, result } = await activate(host, 'hook-policy')
1050 assert.equal(result.status, 'ok')
1051 const hook = host.registry.find((entry) => entry.op === 'register' && entry.kind === 'hook')
1052 assert.equal(hook.spec.name, 'tools/pre-execute')
1053 const evaluate = (path) => host.call('hook/evaluate', {
1054 handle: hook.handle, event: 'tools/pre-execute', deadline_ms: 5000,
1055 payload: { name: 'read', call_id: 'hook-1', input: { path }, mode: 'Agent', workspace: '/workspace', model: 'fixture' },
1056 })
1057 assert.deepEqual(await evaluate('before.txt'), { kind: 'revise', input: { path: 'after.txt' } })
1058 assert.deepEqual(await evaluate('blocked.txt'), { kind: 'deny', reason: 'blocked by fixture' })
1059 assert.deepEqual(await evaluate('ask.txt'), { kind: 'ask', reason: 'fixture asks' })
1060 assert.deepEqual(await evaluate('context.txt'), { kind: 'annotate', text: 'fixture context' })
1061 assert.deepEqual(await evaluate('allow.txt'), { kind: 'abstain' })
1062 assert.deepEqual(await evaluate('allow.txt'), { kind: 'abstain' })
1063 assert.equal(host.logs.filter((log) => log.msg.includes('allow is an abstention')).length, 1)
1064 assert.deepEqual(await evaluate('other.txt'), { kind: 'abstain' })
1065 await assert.rejects(evaluate('malformed.txt'), /revise needs a JSON object/)
1066 await assert.rejects(evaluate('throw.txt'), /fixture hook failure/)
1067 const pending = host.request('hook/evaluate', {
1068 handle: hook.handle, event: 'tools/pre-execute', deadline_ms: 5000,
1069 payload: { name: 'read', call_id: 'hook-held', input: { path: 'held.txt' }, mode: 'Agent', workspace: '/workspace', model: 'fixture' },
1070 })
1071 host.cancel(pending.id)
1072 await assert.rejects(pending.promise, (error) => error.code === ErrorCode.Cancelled)
1073 assert.deepEqual(await host.call('ext/deactivate', { owner: ref }), { disposed: true, leaked: [] })
1074 await assert.rejects(evaluate('before.txt'), (error) => error.code === ErrorCode.NotAvailable)
1075 })
1076
1076 lines Plain Text