@jc_stack/ez-agents 0.1.0-beta.47 → 0.1.0-beta.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.env.example +3 -0
  2. package/AGENTS.md +2 -2
  3. package/CHANGELOG.md +21 -0
  4. package/Dockerfile +5 -1
  5. package/THIRD_PARTY_NOTICES.md +6 -3
  6. package/bin/ez +7 -0
  7. package/docker/broker-readiness.mjs +54 -0
  8. package/docker/broker-update-smoke.mjs +86 -0
  9. package/docker/run.ts +1 -1
  10. package/docs/application-channel.md +32 -1
  11. package/docs/architecture/ai-selection.md +18 -3
  12. package/docs/docker-runtime.md +29 -0
  13. package/docs/isolated-plugin-update-readiness.md +66 -0
  14. package/docs/releasing.md +4 -0
  15. package/docs/scheduling.md +100 -6
  16. package/docs/selective-monitoring.md +13 -0
  17. package/docs/upgrades.md +35 -0
  18. package/docs/work-preflight.md +9 -0
  19. package/package.json +1 -1
  20. package/src/ai.ts +22 -3
  21. package/src/application-channel.ts +43 -3
  22. package/src/application-cli.ts +7 -1
  23. package/src/application-origin.ts +2 -0
  24. package/src/audio.ts +37 -22
  25. package/src/client-defaults.ts +3 -2
  26. package/src/config.ts +9 -0
  27. package/src/control-state.ts +3 -1
  28. package/src/delivery-socket-client.d.mts +2 -0
  29. package/src/delivery-socket-client.mjs +2 -0
  30. package/src/delivery-socket.ts +24 -1
  31. package/src/event-sources.ts +2 -2
  32. package/src/executor.ts +72 -1
  33. package/src/files.ts +47 -3
  34. package/src/host-executor.ts +38 -29
  35. package/src/index.ts +41 -18
  36. package/src/install-tools.mjs +8 -5
  37. package/src/menu.ts +4 -2
  38. package/src/plugin-broker.mjs +47 -4
  39. package/src/plugins/manager.mjs +11 -4
  40. package/src/run-evidence.ts +78 -0
  41. package/src/runs.ts +12 -1
  42. package/src/schedule-cli.ts +69 -8
  43. package/src/schedule-preflight.ts +23 -0
  44. package/src/scheduled-tasks.ts +16 -12
  45. package/src/scheduler.ts +79 -18
  46. package/src/scripts.ts +167 -0
  47. package/src/setup.ts +2 -2
  48. package/src/task-capability.ts +17 -1
  49. package/src/task-executor.ts +10 -7
  50. package/src/task-mcp.ts +20 -8
  51. package/src/tasks.ts +89 -9
  52. package/src/updates/runtime.mjs +46 -7
  53. package/test/ai.test.ts +40 -0
  54. package/test/application-channel.test.ts +16 -1
  55. package/test/audio.test.ts +19 -0
  56. package/test/config.test.ts +8 -0
  57. package/test/executor.test.ts +37 -0
  58. package/test/files.test.ts +44 -0
  59. package/test/host-executor.test.ts +45 -2
  60. package/test/install-json.test.mjs +21 -0
  61. package/test/plugin-broker.test.mjs +64 -0
  62. package/test/plugin-manager.test.mjs +20 -0
  63. package/test/private-speech-config.test.ts +22 -0
  64. package/test/run-evidence.test.ts +78 -0
  65. package/test/schedule-preflight.test.ts +49 -0
  66. package/test/scheduler-host.test.ts +7 -0
  67. package/test/scheduler-relay.test.ts +38 -3
  68. package/test/scheduler.test.ts +1 -1
  69. package/test/script-schedules.test.ts +219 -0
  70. package/test/task-native.test.ts +50 -12
  71. package/test/tasks.test.ts +97 -4
  72. package/test/updates.test.mjs +45 -9
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import { pathToFileURL } from 'node:url';
7
7
  import { spawn } from 'node:child_process';
8
8
  import { parseEnv } from 'node:util';
9
+ import { tmpdir } from 'node:os';
9
10
  import { atomic, compose, snapshot, checkFolders } from '../plugins/manager.mjs';
10
11
  import { read, state, eligibility, jobPath, cleanupStaleBackups } from './control.mjs';
11
12
  import { bindUpdates } from './binding.mjs';
@@ -62,9 +63,42 @@ export async function relayServices(config) {
62
63
  const env = await fs.readFile(path.join(config.deploymentDir,'docker.env'),'utf8').catch(() => '');
63
64
  return parseEnv(env).EZ_EXECUTOR_TRANSPORT === 'local' ? ['relay','plugin-broker'] : ['relay'];
64
65
  }
65
- async function refreshBroker(config, run) {
66
- if ((await relayServices(config)).includes('plugin-broker'))
67
- await run('docker', [...relayArgs(config), 'restart', 'plugin-broker']);
66
+ // Explicitly rebind single-file mounts after atomic replacement. Pin the running image
67
+ // while recreating only the broker with this deployment's Compose identity.
68
+ export async function refreshBroker(config, run=execute, image) {
69
+ if (!(await relayServices(config)).includes('plugin-broker')) return null;
70
+ const args=relayArgs(config);
71
+ if(image===undefined) {
72
+ const id=(await run('docker',[...args,'ps','-q','plugin-broker'],{stdoutOnly:true,timeout:15000})).trim();
73
+ if(!/^[a-f0-9]{12,64}$/i.test(id))throw Error('Expected one installed isolated broker');
74
+ image=(await run('docker',['inspect','--format','{{.Image}}',id],{stdoutOnly:true,timeout:15000})).trim();
75
+ }
76
+ if(!/^sha256:[a-f0-9]{64}$/.test(image))throw Error('Cannot pin broker image');
77
+ const temporary=await fs.mkdtemp(path.join(tmpdir(),'ez-broker-refresh-'));
78
+ try {
79
+ const pin=path.join(temporary,'image.env');
80
+ await fs.writeFile(pin,`EZ_RELAY_IMAGE=${image}\n`,{mode:0o600});
81
+ await run('docker',[...args,'--env-file',pin,'up','-d','--no-deps','--force-recreate','--no-build','--pull','never','--wait','--wait-timeout','90','plugin-broker']);
82
+ const current=(await run('docker',[...args,'ps','-q','plugin-broker'],{stdoutOnly:true,timeout:15000})).trim();
83
+ if(!/^[a-f0-9]{12,64}$/i.test(current)||(await run('docker',['inspect','--format','{{.Image}}',current],{stdoutOnly:true,timeout:15000})).trim()!==image)
84
+ throw Error('Recreated broker image does not match the intended installed image');
85
+ const hostConfig=config.hostConfig||path.join(config.deploymentDir,'host-executor.json');
86
+ const home=path.dirname(hostConfig)===config.deploymentDir
87
+ ? (await read(hostConfig)).agents?.find(agent=>agent.workspace===config.workspace)?.toolsHome : undefined;
88
+ if(typeof home!=='string'||!path.isAbsolute(home))throw Error('Missing broker registry binding');
89
+ const files=[hostConfig,path.join(home,'registry.json'),path.join(home,'config.json')];
90
+ const hashes=await Promise.all(files.map(async file=>digest(await fs.readFile(file))));
91
+ // Supply trusted read-only code so rollback can check an older image that
92
+ // predates this diagnostic. Its validators still come from that image.
93
+ const diagnostic=await fs.readFile(new URL('../../docker/broker-readiness.mjs',import.meta.url),'utf8');
94
+ const probe=JSON.parse(await run('docker',[...args,'exec','-T','plugin-broker','node','--import','/app/node_modules/tsx/dist/loader.mjs',
95
+ '--input-type=module','-e',diagnostic,...hashes],{stdoutOnly:true,timeout:60000}));
96
+ if(probe.version!==1||probe.ready!==true||probe.hostSha256!==hashes[0]||probe.registrySha256!==hashes[1]||probe.configSha256!==hashes[2])
97
+ throw Error('Invalid isolated broker readiness readback');
98
+ // A concurrent writer invalidates the readback; never silently accept it.
99
+ for(let i=0;i<files.length;i++)if(digest(await fs.readFile(files[i]))!==hashes[i])throw Error('Broker binding changed during verification');
100
+ return probe;
101
+ }finally {await fs.rm(temporary,{recursive:true,force:true});}
68
102
  }
69
103
  const healthCodes = new Set(['RELAY_UNREADABLE','RELAY_NOT_POLLING','RELAY_STALE','HOST_UNREADABLE','HOST_STALE','PLUGIN_BROKER_UNREADABLE']);
70
104
  async function healthEvidence(config,run) {
@@ -143,7 +177,11 @@ export async function perform(home,job,hooks) {
143
177
  await textAtomic(envFile,env);
144
178
  await bindUpdates(home,path.join(config.deploymentDir,'host-executor.json'),root);
145
179
  await hooks.startHost(root);
146
- await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build',...(await relayServices(config))]);
180
+ await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build','relay']);
181
+ if((await relayServices(config)).includes('plugin-broker')) {
182
+ const brokerImage=(await run('docker',['image','inspect','--format','{{.Id}}',image],{stdoutOnly:true})).trim();
183
+ job.brokerReadiness=await refreshBroker(config,run,brokerImage);
184
+ }
147
185
  } else {
148
186
  const old=next.old.record,r=await read(path.join(home,'registry.json'));
149
187
  const secrets=await read(path.join(home,'packages',job.target,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
@@ -163,7 +201,7 @@ export async function perform(home,job,hooks) {
163
201
  r.plugins[job.target]=candidate;
164
202
  for(const alias of Object.keys(candidate.manifest.commands))r.commands[alias]=job.target;
165
203
  await atomic(path.join(home,'registry.json'),r);
166
- await refreshBroker(config,run);
204
+ job.brokerReadiness=await refreshBroker(config,run);
167
205
  job.runtimeVerified=running;
168
206
  }
169
207
  job.status='completed';job.endedAt=new Date().toISOString();await save();
@@ -195,12 +233,13 @@ export async function recover(home,job,hooks) {
195
233
  await textAtomic(path.join(config.deploymentDir,'docker.env'),job.rollback.env);
196
234
  await bindUpdates(home,path.join(config.deploymentDir,'host-executor.json'),job.rollback.packageRoot);
197
235
  await hooks.startHost(job.rollback.packageRoot);
198
- if(!retained)await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build',...(await relayServices(config))]);
236
+ if(!retained)await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build','relay']);
237
+ job.rollbackBrokerReadiness=await refreshBroker(config,run,pinned);
199
238
  } else {
200
239
  const b=job.rollback;
201
240
  await run('docker',[...pluginArgs(b.record),'stop']);
202
241
  await atomic(b.record.compose,b.compose);await atomic(path.join(home,'registry.json'),b.registry);
203
- await refreshBroker(config,run);
242
+ job.rollbackBrokerReadiness=await refreshBroker(config,run);
204
243
  if(b.running)await run('docker',[...pluginArgs(b.record),'up','-d','--wait','--wait-timeout','90','--no-build']);
205
244
  }
206
245
  job.status='rolled-back';
package/test/ai.test.ts CHANGED
@@ -632,3 +632,43 @@ test('private channel model choices do not consume owner preset slots', async ()
632
632
  assert.deepEqual((await store.applicationSession(scope))?.preset, readback.preset)
633
633
  } finally { await rm(dir, {recursive:true, force:true}) }
634
634
  })
635
+
636
+ test('detected presets do not count against the saved AI cap', async () => {
637
+ const dir = await mkdtemp(join(tmpdir(), 'ez-ai-cap-'))
638
+ try {
639
+ const store = new ControlStore(dir, 1000)
640
+ const initial = initialPreset('codex')
641
+ await store.captureChoice(initial)
642
+ for (let n = 1; n < 11; n++) await store.savePreset({...initial, id:`saved-${n}`, name:`Saved ${n}`})
643
+ await store.syncClientPresets(initial, ['claude', 'grok'].map(cli => ({id:`detected_${cli}`, cli, name:`${cli} · client default`})))
644
+ assert.equal((await store.status()).ai!.presets.length, 13)
645
+ const next = {id:'one-more', name:'One more', cli:'claude', model:'sonnet', effort:'medium'}
646
+ await store.savePreset(next)
647
+ await assert.rejects(store.savePreset({...next, id:'too-many', name:'Too many'}), /at most 12/)
648
+ } finally { await rm(dir, {recursive:true, force:true}) }
649
+ })
650
+
651
+ test('Claude catalog projects only aliases and efforts from the installed native help', async () => {
652
+ const help = `Options:
653
+ --effort <level> Effort level for the current session
654
+ (low, medium, high, xhigh, max)
655
+ --model <model> Model for the current session. Provide
656
+ an alias for the latest model (e.g.
657
+ 'fable', 'opus', or 'sonnet') or a
658
+ model's full name.
659
+ -n, --name <name> Set a display name ('ignored')
660
+ `
661
+ const calls: string[][] = []
662
+ const models = await readModels('/nonexistent', async cli => cli === 'claude', undefined, undefined, undefined, undefined, undefined, undefined,
663
+ async (args) => { calls.push(args); return help })
664
+ assert.deepEqual(calls, [['--help']])
665
+ assert.deepEqual(models, [
666
+ { cli: 'claude', name: 'claude · client default', efforts: [] },
667
+ ...['fable', 'opus', 'sonnet'].map(model => ({ cli: 'claude', model, name: `claude · ${model}`, efforts: ['low', 'medium', 'high', 'xhigh', 'max'] })),
668
+ ])
669
+ for (const unavailable of [async () => { throw new Error('not runnable') }, async () => 'Usage: claude'])
670
+ assert.deepEqual(await readModels('/nonexistent', async cli => cli === 'claude', undefined, undefined, undefined, undefined, undefined, undefined, unavailable),
671
+ [{ cli: 'claude', name: 'claude · client default', efforts: [] }])
672
+ await validateSelection({ id: 'c', name: 'c', cli: 'claude', model: 'opus', effort: 'xhigh' }, models, async () => true)
673
+ await assert.rejects(validateSelection({ id: 'c', name: 'c', cli: 'claude', model: 'opus-guess' }, models, async () => true), /not in the installed client catalog/)
674
+ })
@@ -1,7 +1,7 @@
1
1
  import test from 'node:test'
2
2
  import { request as httpRequest } from 'node:http'
3
3
  import assert from 'node:assert/strict'
4
- import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'
4
+ import { mkdtemp, rm, writeFile, readFile, chown, stat } from 'node:fs/promises'
5
5
  import { join } from 'node:path'
6
6
  import { tmpdir } from 'node:os'
7
7
  import { spawnSync } from 'node:child_process'
@@ -435,3 +435,18 @@ test('shareTelegram bindings list Telegram intake and admit one run with context
435
435
  await runs.patch(finished.id,{status:'completed'})
436
436
  assert.equal((await attach(finished.id,sharedToken,{context:{}})).status,409)
437
437
  })
438
+
439
+ test('administrator task grant replacement preserves the runtime file owner', {skip:process.getuid?.()!==0},async()=>{
440
+ const dir=await mkdtemp(join(tmpdir(),'ez-app-grant-owner-'))
441
+ try {
442
+ const who=await owner(dir),bindings=new ApplicationBindings(dir)
443
+ await bindings.register('voice',token(),who)
444
+ const file=join(dir,'application-bindings.json')
445
+ await chown(file,20000,20000)
446
+ await bindings.taskLaunch('voice',{command:'voice',args:['launch','--task'],tasks:['task_'+'a'.repeat(32)]})
447
+ const saved=await stat(file)
448
+ assert.equal(saved.uid,20000);assert.equal(saved.gid,20000);assert.equal(saved.mode&0o777,0o600)
449
+ await bindings.taskLaunch('voice',null)
450
+ const disabled=await stat(file);assert.equal(disabled.uid,20000);assert.equal(disabled.gid,20000)
451
+ }finally{await rm(dir,{recursive:true,force:true})}
452
+ })
@@ -1,3 +1,4 @@
1
+ import {execFileSync} from 'node:child_process'
1
2
  import test from 'node:test'
2
3
  import assert from 'node:assert/strict'
3
4
  import { pcmToWav, encodeOggOpus, transcribeAudio, synthesizeSpeech, SpeechCreditsDepletedError } from '../src/audio.js'
@@ -91,3 +92,21 @@ test('synthesizeSpeech rejects empty text', async () => {
91
92
  /Cannot synthesize speech for empty text/,
92
93
  )
93
94
  })
95
+
96
+
97
+ test('OpenRouter uses the existing renderer and encoder for real Ogg Opus and WAV without provider fallback', async t => {
98
+ const mp3=execFileSync('ffmpeg',['-f','s16le','-ar','24000','-ac','1','-i','pipe:0','-f','mp3','pipe:1'],{input:Buffer.alloc(48000),stdio:['pipe','pipe','ignore']})
99
+ t.mock.method(globalThis,'fetch',async (url:string,options:RequestInit) => {
100
+ assert.equal(url,'https://openrouter.ai/api/v1/audio/speech')
101
+ assert.equal(new Headers(options.headers).get('authorization'),'Bearer fixture-key')
102
+ assert.deepEqual(JSON.parse(String(options.body)),{model:'fish-audio/s2.1-pro-free:free',input:'Fixture',response_format:'mp3'})
103
+ return new Response(mp3,{headers:{'content-type':'audio/mpeg'}})
104
+ })
105
+ const options={speechProvider:'openrouter' as const,openrouterApiKey:'fixture-key',geminiApiKey:'unused'}
106
+ const ogg=await synthesizeSpeech('Fixture',options)
107
+ assert.equal(ogg.mimeType,'audio/ogg');assert.equal(ogg.buffer.subarray(0,4).toString(),'OggS');assert.ok(ogg.buffer.includes(Buffer.from('OpusHead')))
108
+ const wav=await synthesizeSpeech('Fixture',{...options,format:'wav'})
109
+ assert.equal(wav.buffer.subarray(0,4).toString(),'RIFF');assert.equal(wav.buffer.readUInt32LE(24),24000)
110
+ t.mock.method(globalThis,'fetch',async () => new Response('private provider detail',{status:429}))
111
+ await assert.rejects(synthesizeSpeech('Fixture',options), /error 429/)
112
+ })
@@ -84,3 +84,11 @@ test('Telegram transport is inferred from a valid token; legacy flag contradicti
84
84
  assert.equal(loadConfig({ EZ_TELEGRAM_ENABLED: 'false', EZ_APPLICATION_PORT: '8110' }).telegramEnabled, false)
85
85
  assert.equal(loadConfig({ TELEGRAM_BOT_TOKEN: token, EZ_TELEGRAM_ENABLED: 'true' }).telegramEnabled, true)
86
86
  })
87
+
88
+ test('scheduled concurrency defaults to six and accepts only positive integers',()=>{
89
+ const env={EZ_APPLICATION_PORT:'8110'}
90
+ assert.equal(loadConfig(env).scheduledConcurrency,6)
91
+ assert.equal(loadConfig({...env,EZ_SCHEDULED_CONCURRENCY:'2'}).scheduledConcurrency,2)
92
+ for(const value of ['0','-1','1.5','bad','9007199254740992'])
93
+ assert.throws(()=>loadConfig({...env,EZ_SCHEDULED_CONCURRENCY:value}),/EZ_SCHEDULED_CONCURRENCY must be a positive integer/)
94
+ })
@@ -372,3 +372,40 @@ test('external local owner chat preserves literal input and admission rejects fo
372
372
  if(prior.transport===undefined)delete process.env.EZ_EXECUTOR_TRANSPORT;else process.env.EZ_EXECUTOR_TRANSPORT=prior.transport
373
373
  }
374
374
  })
375
+
376
+ test('Claude runs with agent-bound configuration, shared login and the agent directories', {skip:process.platform==='win32'}, async t => {
377
+ const root = await mkdtemp(path.join(tmpdir(), 'ez-claude-home-'))
378
+ t.after(() => rm(root, { recursive: true, force: true }))
379
+ const bin = path.join(root, 'bin'), workspace = path.join(root, 'mind'), controlDir = path.join(root, 'control'), shared = path.join(root, 'shared')
380
+ await mkdir(bin); await mkdir(workspace)
381
+ await writeFile(path.join(bin, 'claude'), `#!${process.execPath}\nrequire('fs').writeFileSync('observed.json',JSON.stringify({args:process.argv.slice(2),config:process.env.CLAUDE_CONFIG_DIR,storage:process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR,token:process.env.CLAUDE_CODE_OAUTH_TOKEN,prompt:require('fs').readFileSync(0,'utf8')}))`, { mode: 0o700 })
382
+ const previous = { PATH: process.env.PATH, EZ_ISOLATION: process.env.EZ_ISOLATION, EZ_EXECUTOR_TRANSPORT: process.env.EZ_EXECUTOR_TRANSPORT }
383
+ t.after(() => { for (const [key, value] of Object.entries(previous)) value === undefined ? delete process.env[key] : process.env[key] = value })
384
+ process.env.PATH = bin + path.delimiter + process.env.PATH
385
+ delete process.env.EZ_EXECUTOR_TRANSPORT
386
+ for (const isolation of [undefined, 'isolated']) {
387
+ if (isolation) process.env.EZ_ISOLATION = isolation; else delete process.env.EZ_ISOLATION
388
+ await ownerRun(controlDir, `r_claude_${isolation ?? 'host'}`)
389
+ const job = await startExecutorJob(['hello'], { workspace, controlDir, binDir: bin, cli: 'claude', runId: `r_claude_${isolation ?? 'host'}`, timeoutMs: 5000,
390
+ sessionId: '00000000-0000-4000-8000-000000000000', sharedWorkspace: shared, model: 'opus', effort: 'high', repairEnabled: false })
391
+ assert.equal(await new Promise(resolve => job.child.once('close', resolve)), 0); await job.cleanup()
392
+ const observed = JSON.parse(await readFile(path.join(workspace, 'observed.json'), 'utf8'))
393
+ assert.equal(observed.config, path.join(controlDir, 'cli', 'claude'))
394
+ assert.equal(observed.storage, isolation ? undefined : '')
395
+ assert.equal(observed.prompt, 'hello')
396
+ for (const directory of [controlDir, shared]) assert.equal(observed.args[observed.args.indexOf(directory) - 1], '--add-dir')
397
+ assert.deepEqual(observed.args.slice(-4), ['--model', 'opus', '--effort', 'high'])
398
+ assert.equal(observed.token, undefined)
399
+ }
400
+ const run = async (runId: string) => {
401
+ await ownerRun(controlDir, runId)
402
+ const job = await startExecutorJob(['hello'], { workspace, controlDir, binDir: bin, cli: 'claude', runId, timeoutMs: 5000, repairEnabled: false })
403
+ assert.equal(await new Promise(resolve => job.child.once('close', resolve)), 0); await job.cleanup()
404
+ return JSON.parse(await readFile(path.join(workspace, 'observed.json'), 'utf8'))
405
+ }
406
+ await writeFile(path.join(controlDir, 'cli', 'claude', 'oauth-token'), 'sk-ant-oat01-fixture_token-value\n', { mode: 0o600 })
407
+ assert.equal((await run('r_claude_token')).token, 'sk-ant-oat01-fixture_token-value')
408
+ await writeFile(path.join(controlDir, 'cli', 'claude', 'oauth-token'), 'bad token\n--flag', { mode: 0o600 })
409
+ await ownerRun(controlDir, 'r_claude_bad_token')
410
+ await assert.rejects(startExecutorJob(['hello'], { workspace, controlDir, binDir: bin, cli: 'claude', runId: 'r_claude_bad_token', timeoutMs: 5000, repairEnabled: false }), /Invalid Claude token binding/)
411
+ })
@@ -48,3 +48,47 @@ test('stages file into control attachments, never the workspace', async () => {
48
48
  await rm(tmp, { recursive: true, force: true })
49
49
  }
50
50
  })
51
+
52
+ test('shared incoming document reader extracts PDF text and removes its staged file', async t => {
53
+ const {readChatAttachment}=await import('../src/files.js')
54
+ const {execFile}=await import('node:child_process'), {promisify}=await import('node:util')
55
+ try { await promisify(execFile)('pdftotext',['-v']) } catch { t.skip('Poppler is exercised in the Docker artifact test');return }
56
+ const dir=await mkdtemp('/tmp/ez-pdf-read-');t.after(()=>rm(dir,{recursive:true,force:true}))
57
+ const stream='BT /F1 12 Tf 20 80 Td (Shop opens at 7am.) Tj ET'
58
+ const objects=['<< /Type /Catalog /Pages 2 0 R >>','<< /Type /Pages /Kids [3 0 R] /Count 1 >>','<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>','<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`]
59
+ let pdf='%PDF-1.4\n';const offsets=[0]
60
+ for(const [i,value] of objects.entries()){offsets.push(Buffer.byteLength(pdf));pdf+=`${i+1} 0 obj\n${value}\nendobj\n`}
61
+ const xref=Buffer.byteLength(pdf);pdf+='xref\n0 6\n0000000000 65535 f \n'+offsets.slice(1).map(n=>String(n).padStart(10,'0')+' 00000 n \n').join('')+`trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`
62
+ const result=await readChatAttachment(dir,'shop.pdf',Buffer.from(pdf));assert.match(result.text ?? '',/Shop opens at 7am\./);assert.equal(result.type,'pdf')
63
+ const {readdir}=await import('node:fs/promises');assert.deepEqual(await readdir(path.join(dir,'attachments')),[])
64
+ await assert.rejects(readChatAttachment(dir,'invalid.pdf',Buffer.from('%PDF-invalid')))
65
+ })
66
+
67
+ test('shared image read returns original pixels with native MIME and no staged residue',async t=>{
68
+ const {readChatAttachment}=await import('../src/files.js'),{readdir}=await import('node:fs/promises')
69
+ const dir=await mkdtemp('/tmp/ez-image-read-');t.after(()=>rm(dir,{recursive:true,force:true}))
70
+ const png=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=','base64')
71
+ const result=await readChatAttachment(dir,'photo.png',png)
72
+ assert.equal(result.image?.mimeType,'image/png');assert.equal(result.image?.data,png.toString('base64'));assert.equal(result.text,undefined)
73
+ assert.deepEqual(await readdir(path.join(dir,'attachments')),[])
74
+ await assert.rejects(readChatAttachment(dir,'photo.png',Buffer.from('Spoofed image bytes')))
75
+ })
76
+
77
+ test('video read uses bounded native frames and removes temporary decoder files',async t=>{
78
+ const {readChatAttachment}=await import('../src/files.js'),{readdir}=await import('node:fs/promises'),{execFile}=await import('node:child_process'),{promisify}=await import('node:util')
79
+ const execute=promisify(execFile)
80
+ try {await execute('ffmpeg',['-version'])}catch{t.skip('FFmpeg is exercised in the Docker artifact test');return}
81
+ const dir=await mkdtemp('/tmp/ez-video-read-');t.after(()=>rm(dir,{recursive:true,force:true}))
82
+ const video=path.join(dir,'fixture.mp4')
83
+ await execute('ffmpeg',['-v','error','-f','lavfi','-i','color=c=blue:s=64x64:r=8','-f','lavfi','-i','sine=frequency=1000:sample_rate=24000','-t','1','-c:v','mpeg4','-c:a','aac',video])
84
+ const result=await readChatAttachment(dir,'video.mp4',await readFile(video))
85
+ assert.equal(result.type,'mp4');assert.ok((result.images?.length??0)>0);assert.ok((result.images?.length??0)<=8)
86
+ assert.ok(result.images?.every(image=>image.mimeType==='image/jpeg'));assert.ok(result.durationSeconds!<=120)
87
+ assert.deepEqual(await readdir(path.join(dir,'attachments')),[]);assert.ok(!(await readdir(dir)).some(name=>name.startsWith('video-')))
88
+ })
89
+
90
+ test('HEIF stills and audio-only M4A are not treated as MP4 video', () => {
91
+ const box = (brand: string) => Buffer.concat([Buffer.from([0, 0, 0, 24]), Buffer.from(`ftyp${brand}`), Buffer.alloc(12)])
92
+ for (const brand of ['heic', 'avif', 'mif1', 'M4A ']) assert.equal(detectFileType(box(brand)), 'unknown')
93
+ for (const brand of ['isom', 'mp42']) assert.equal(detectFileType(box(brand)), 'mp4')
94
+ })
@@ -13,6 +13,8 @@ import { RunStore } from '../src/runs.js'
13
13
  import { packageVersion } from '../src/version.js'
14
14
  import { executionDefaults } from '../src/model-policy.js'
15
15
  import { workspaceLease } from '../src/plugins/workspace-lease.mjs'
16
+ import { ControlStore } from '../src/control-state.js'
17
+ import { Scripts } from '../src/scripts.js'
16
18
 
17
19
  test('host restart replaces a lock whose PID was reused by another process',async()=>{
18
20
  const root=await mkdtemp(path.join(tmpdir(),'ez-host-reused-pid-'))
@@ -285,10 +287,10 @@ test('one installed CLI executes two agent bindings with separate minds and sani
285
287
  return output.trim().split('\n').map(line=>JSON.parse(line))
286
288
  }
287
289
  // Chat completes while this agent's scheduled turn stays active. A second
288
- // scheduled turn still waits, and the shared lifecycle lease stays held.
290
+ // scheduled turn also completes, and the held job keeps the lifecycle lease.
289
291
  await completed(otherDirectory,'r_other_shared')
290
292
  const chatEvents=await completed(directory,'tg_42')
291
- assert.ok(await readFile(path.join(directory,'r_schedule_queued.request.json')))
293
+ await completed(directory,'r_schedule_queued')
292
294
  assert.equal(await workspaceLease(agents[0].toolsHome),undefined)
293
295
  await ownerRun(agents[0].controlDir,'tg_43')
294
296
  await writeFile(path.join(directory,'tg_43.request.json'),JSON.stringify({texts:['Hold chat'],options:{cli:'grok'}}))
@@ -525,3 +527,44 @@ test('startup replaces prior catalog after provider policy changes even when dis
525
527
  assert.deepEqual(JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')),[])
526
528
  } finally {abort.abort();await server;await rm(root,{recursive:true,force:true})}
527
529
  })
530
+
531
+ test('host transport runs a registered script from the run record and refuses changed entry-point bytes', async () => {
532
+ const root=await mkdtemp(path.join(tmpdir(),'ez-host-script-'))
533
+ const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),directory=path.join(controlDir,'host-executor')
534
+ const abort=new AbortController();let server:Promise<void>|undefined
535
+ const oldToken=process.env.TELEGRAM_BOT_TOKEN
536
+ try {
537
+ await mkdir(workspace);await mkdir(directory,{recursive:true})
538
+ await writeFile(path.join(workspace,'guard.mjs'),"console.log(JSON.stringify({cwd:process.cwd(),args:process.argv.slice(2),due:process.env.EZ_DUE_AT,token:process.env.TELEGRAM_BOT_TOKEN}))\n")
539
+ process.env.TELEGRAM_BOT_TOKEN='must-not-reach-host-script'
540
+ await ownerRun(controlDir,'r_seed')
541
+ const owner=(await new ControlStore(controlDir,900000).status()).owner!
542
+ const registration=await new Scripts(controlDir).save({id:'guard',owner,workspace,entry:'guard.mjs',interpreter:'node',args:['saved'],timeoutSeconds:60},true)
543
+ const runs=new RunStore(controlDir)
544
+ const scripted=async(id:string)=>{
545
+ await runs.create({id,chatId:101,telegramUserId:101,texts:['[schedule]'],script:{id:'guard',args:['extra'],revision:registration.revision,sha256:registration.sha256},
546
+ scheduled:{id:'s_guard',revision:'rev1',dueAt:'2026-10-07T11:15:00.000Z',pairedAt:owner.pairedAt}})
547
+ await runs.patch(id,{status:'running'})
548
+ // The request cannot choose the command, script or workspace.
549
+ await writeFile(path.join(directory,`${id}.request.json`),JSON.stringify({texts:['x'],options:{workspace:'/wrong',cli:'grok',script:{id:'other',args:['injected']},timeoutMs:0}}))
550
+ let events:any[]=[]
551
+ for(let n=0;n<300;n++){try{events=(await readFile(path.join(directory,`${id}.events`),'utf8')).trim().split('\n').map(l=>JSON.parse(l));if(events.some(e=>e.stream==='exit'))break}catch{}await new Promise(r=>setTimeout(r,20))}
552
+ return events
553
+ }
554
+ server=serveHostExecutor({cli:'grok',agents:[{name:'test',workspace,controlDir,binDir:root}]},abort.signal,undefined,async()=>[])
555
+ const events=await scripted('r_schedule_script_ok')
556
+ assert.equal(events.find(e=>e.stream==='exit')?.code,0)
557
+ const result=JSON.parse(events.filter(e=>e.stream==='stdout').map(e=>e.text).join(''))
558
+ assert.equal(result.cwd,await realpath(workspace));assert.deepEqual(result.args,['saved','extra'])
559
+ assert.equal(result.due,'2026-10-07T11:15:00.000Z');assert.equal(result.token,undefined)
560
+ await writeFile(path.join(workspace,'guard.mjs'),"console.log('changed')\n")
561
+ const refused=await scripted('r_schedule_script_changed')
562
+ assert.equal(refused.find(e=>e.stream==='exit')?.code,1)
563
+ assert.equal(refused.filter(e=>e.stream==='stdout').length,0)
564
+ assert.match(refused.filter(e=>e.stream==='stderr').map(e=>e.text).join(''),/entry point changed since registration/)
565
+ }finally{
566
+ abort.abort();await server
567
+ if(oldToken===undefined)delete process.env.TELEGRAM_BOT_TOKEN;else process.env.TELEGRAM_BOT_TOKEN=oldToken
568
+ await rm(root,{recursive:true,force:true})
569
+ }
570
+ })
@@ -0,0 +1,21 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { run, latestCodexVersion } from '../src/install-tools.mjs';
4
+
5
+ test('Codex discovery parses stdout and retains failure diagnostics',async()=>{
6
+ const script=`process.stdout.write('"0.160.1"\\n');process.stderr.write('package manager notice\\n');`;
7
+ const invoke=(command,args,options)=>{
8
+ assert.equal(command,'pnpm');
9
+ assert.deepEqual(args,['view','@openai/codex@latest','version','--json','--registry','https://registry.npmjs.org/']);
10
+ assert.equal(options.stdoutOnly,true);
11
+ return run(process.execPath,['-e',script],options);
12
+ };
13
+ assert.equal(await latestCodexVersion(invoke,{command:'pnpm',args:[]}),'0.160.1');
14
+ await assert.rejects(run(process.execPath,['-e',script+'process.exitCode=1'],{stdoutOnly:true}),/package manager notice/);
15
+ });
16
+
17
+ test('Codex discovery rejects contaminated stdout without leaking it',async()=>{
18
+ await assert.rejects(latestCodexVersion(async()=> '"0.160.1"\nprivate-output'),{message:'Codex version discovery returned invalid JSON on stdout'});
19
+ for(const value of ['null','{}','["0.160.1"]','"latest"'])
20
+ await assert.rejects(latestCodexVersion(async()=>value),/Invalid latest Codex version/);
21
+ });
@@ -182,3 +182,67 @@ test('broker rejects resolve and invoke for a revoked run', async t => {
182
182
  await serving;
183
183
  }
184
184
  });
185
+
186
+ test('scheduled preflight uses authoritative configuration and rejects writes or stale revisions', async t => {
187
+ const f = await fixture(t);
188
+ const { Scheduler } = await import('../src/scheduler.js');
189
+ const { createLedgerHandler, serveDeliverySocket } = await import('../src/delivery-socket.js');
190
+ const control = new ControlStore(f.controlDir, 1000);
191
+ const owner = (await control.status()).owner;
192
+ const scheduler = new Scheduler(f.controlDir);
193
+ const schedule = await scheduler.save({id:'check', name:'Check', text:'Saved worker', enabled:true, owner,
194
+ execution:{sessionId:randomUUID(),preset:{id:'test',name:'test',cli:'codex',model:'fixture-model'}},
195
+ trigger:{everySeconds:60,start:new Date().toISOString()},preflight:{on:'eligible',checks:[{alias:'sample',args:['queue']}]}});
196
+ const ledger = await serveDeliverySocket(f.controlDir, createLedgerHandler(f.controlDir,{wake:()=>{},status:()=>({polling:false,applicationOnly:true,telegramConfigured:false,version:'fixture'})}));
197
+ t.after(()=>ledger.stop());
198
+ await fs.writeFile(path.join(f.fake,'docker'), `#!${process.execPath}\nprocess.stdout.write(JSON.stringify({schemaVersion:1,eligible:true,count:1,fingerprint:'a'.repeat(64),observedAt:new Date().toISOString()}));\n`, {mode:0o700});
199
+ const previousPath=process.env.PATH;process.env.PATH=f.fake+path.delimiter+previousPath;
200
+ const binding=await loadPluginBrokerBinding({home:f.home,workspace:f.workspace,controlDir:f.controlDir,socket:f.socket,hostConfig:f.hostConfig,timeoutMs:10000});
201
+ const abort=new AbortController(),serving=servePluginBroker(binding,abort.signal);
202
+ try {
203
+ await waitForSocket(f.socket);
204
+ const payload={version:1,id:randomUUID(),operation:'preflight',scheduleId:schedule.id,revision:schedule.revision};
205
+ assert.equal((await request(f.socket,{...payload,args:['write']})).ok,false);
206
+ assert.equal((await request(f.socket,{...payload,revision:randomUUID()})).ok,false);
207
+ assert.equal((await request(f.socket,payload)).ok,false); // undeclared read-only command
208
+ const file=path.join(f.home,'registry.json'),registry=JSON.parse(await fs.readFile(file,'utf8'));
209
+ registry.plugins.sample.manifest.commands.sample.exposure={receivesExternalContent:true,changesRecords:false,sendsExternally:false,requiresReview:false};
210
+ await fs.writeFile(file,JSON.stringify(registry));
211
+ const accepted=await request(f.socket,payload);assert.equal(accepted.ok,true);assert.equal(accepted.result.eligible,true);
212
+ await scheduler.save({...schedule,enabled:false});
213
+ assert.equal((await request(f.socket,payload)).ok,false);
214
+ } finally {abort.abort();await serving;process.env.PATH=previousPath;}
215
+ });
216
+
217
+ test('task launch broker admits only its fixed application command and writes no bearer into receipts',async t=>{
218
+ const {createServer}=await import('node:http');
219
+ const {Tasks}=await import('../src/tasks.js');const {EventSources}=await import('../src/event-sources.js');
220
+ const {ApplicationBindings}=await import('../src/application-channel.js');const {serveTestLedger}=await import('./helpers/ledger.js');
221
+ const f=await fixture(t), runs=new RunStore(f.controlDir), control=new ControlStore(f.controlDir,900000);
222
+ const sourceSocket=path.join(f.root,'source.sock');const provider=createServer(async(req,res)=>{
223
+ let raw='';for await(const c of req)raw+=c;const v=JSON.parse(raw);
224
+ res.end(JSON.stringify({ok:true,data:v.command==='events-head'?{cursor:0,accountId:'fixture-account',taskProtocol:'message-v1'}:{}}));
225
+ });await new Promise(r=>provider.listen(sourceSocket,r));
226
+ const tasks=new Tasks(f.controlDir),owner=(await control.status()).owner;
227
+ await new EventSources(f.controlDir).register('fixture',sourceSocket,owner);
228
+ const task=await tasks.ownerCall(f.runId,'start',{sourceId:'fixture',conversationId:'discussion-a',purpose:'Synthetic discussion',context:'Only synthetic records',hours:1});
229
+ const taskRun='event_'+hash(task.id);await runs.patch(taskRun,{status:'running'});
230
+ const bindings=new ApplicationBindings(f.controlDir);await bindings.register('browser','z'.repeat(48),owner);
231
+ await bindings.taskLaunch('browser',{command:'sample',args:['launch','--task'],tasks:[task.id]});
232
+ const token='c'.repeat(64),ticketHash=hash(token),grant=await tasks.workerCall(taskRun,'application_launch',{application:'browser',ticketHash});
233
+ const input={taskId:task.id,taskToken:token,expiresAt:grant.expiresAt};
234
+ const ledger=await serveTestLedger(f.controlDir);
235
+ const binding=await loadPluginBrokerBinding({home:f.home,workspace:f.workspace,controlDir:f.controlDir,socket:f.socket,hostConfig:f.hostConfig,timeoutMs:10000});
236
+ const oldPath=process.env.PATH;process.env.PATH=f.fake+path.delimiter+oldPath;
237
+ const abort=new AbortController(),serving=servePluginBroker(binding,abort.signal);
238
+ try{
239
+ await waitForSocket(f.socket);
240
+ const denied=await request(f.socket,{version:1,id:randomUUID(),operation:'resolve',runId:taskRun,alias:'sample'});assert.equal(denied.ok,false);
241
+ const launch=await request(f.socket,{version:1,id:randomUUID(),operation:'task-launch',runId:taskRun,stdin:JSON.stringify(input)});
242
+ assert.equal(launch.ok,true,launch.error);assert.equal(launch.stdout,'broker-ok');assert.equal(launch.receipt.status,'completed');
243
+ const receipt=await fs.readFile(launch.receiptPath,'utf8');assert(!receipt.includes(token));assert(!receipt.includes('taskToken'));
244
+ const wrong=await request(f.socket,{version:1,id:randomUUID(),operation:'task-launch',runId:f.runId,stdin:JSON.stringify(input)});assert.equal(wrong.ok,false);
245
+ await bindings.taskLaunch('browser',null);
246
+ const revoked=await request(f.socket,{version:1,id:randomUUID(),operation:'task-launch',runId:taskRun,stdin:JSON.stringify(input)});assert.equal(revoked.ok,false);
247
+ }finally{abort.abort();await serving;process.env.PATH=oldPath;await ledger.stop();provider.closeAllConnections();await new Promise(r=>provider.close(r));}
248
+ });
@@ -652,3 +652,23 @@ test('writable folders require an explicit per-folder grant and can be revoked',
652
652
  assert.equal((await compose(config,record)).services.sample.volumes.find(v=>v.target==='/data/files').read_only,true);
653
653
  await assert.rejects(f.call('plugins','folder-unbind','sample','--service','sample','--target','/data/files','--writable'),/Supply/);
654
654
  });
655
+
656
+ test('command admission waits for a live short lock without removing it',async t=>{
657
+ const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
658
+ let release,started;
659
+ const ready=new Promise(resolve=>started=resolve),hold=new Promise(resolve=>release=resolve);
660
+ const writer=locked(f.home,async()=>{started();await hold;});
661
+ await ready;
662
+ const call=f.call('sample','read');
663
+ await new Promise(resolve=>setTimeout(resolve,100));
664
+ assert.equal(JSON.parse(await fs.readFile(path.join(f.home,'registry.lock'),'utf8')).pid,process.pid);
665
+ release();await writer;
666
+ assert.deepEqual(JSON.parse((await call).stdout),['read']);
667
+ });
668
+
669
+ test('bounded lock admission times out without stealing an interrupted lock',async t=>{
670
+ const f=await fixture(t),file=path.join(f.home,'registry.lock');
671
+ await fs.mkdir(f.home,{recursive:true});await fs.writeFile(file,'preserved');
672
+ await assert.rejects(locked(f.home,async()=>assert.fail('must not enter'),{waitMs:50}),/Registry busy/);
673
+ assert.equal(await fs.readFile(file,'utf8'),'preserved');
674
+ });
@@ -0,0 +1,22 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {mkdtempSync,writeFileSync,openSync,closeSync,rmSync} from 'node:fs'
4
+ import {spawnSync} from 'node:child_process'
5
+ import {join} from 'node:path'
6
+ import {loadConfig} from '../src/config.js'
7
+
8
+ test('installed private descriptor admits speech selectors and keeps speech keys out of the executor', () => {
9
+ const dir=mkdtempSync('/tmp/ez-speech-env-'),file=join(dir,'relay.env'),key='synthetic-private-speech-marker'
10
+ try {
11
+ writeFileSync(file,`OPENROUTER_API_KEY=${key}\nEZ_SPEECH_PROVIDER=invalid-private-selector\nEZ_SPEECH_MODEL=fish-audio/s2.1-pro-free:free\n`,{mode:0o600})
12
+ let fd=openSync(file,'r')
13
+ const invalid=spawnSync(process.execPath,['--import','tsx','docker/run.ts','start'],{encoding:'utf8',timeout:15000,env:{PATH:process.env.PATH,EZ_APPLICATION_PORT:'8787'},stdio:['ignore','pipe','pipe',fd]});closeSync(fd)
14
+ assert.equal(invalid.status,1);assert.match(invalid.stderr,/Invalid EZ_SPEECH_PROVIDER/);assert.ok(!invalid.stderr.includes(key))
15
+ writeFileSync(file,`OPENROUTER_API_KEY=${key}\nEZ_SPEECH_PROVIDER=openrouter\nEZ_SPEECH_MODEL=fish-audio/s2.1-pro-free:free\n`,{mode:0o600})
16
+ fd=openSync(file,'r')
17
+ const child=spawnSync(process.execPath,['--import','tsx','docker/run.ts','exec',process.execPath,'-e',"if(process.env.OPENROUTER_API_KEY||process.env.EZ_SPEECH_PROVIDER)process.exit(91);console.log('isolated')"],{encoding:'utf8',timeout:15000,env:{PATH:process.env.PATH},stdio:['ignore','pipe','pipe',fd]});closeSync(fd)
18
+ assert.equal(child.status,0,child.stderr);assert.equal(child.stdout.trim(),'isolated')
19
+ const config=loadConfig({EZ_APPLICATION_PORT:'8787',OPENROUTER_API_KEY:key,EZ_SPEECH_PROVIDER:'openrouter',EZ_SPEECH_MODEL:'fish-audio/s2.1-pro-free:free'})
20
+ assert.equal(config.openrouterApiKey,key);assert.equal(config.speechProvider,'openrouter');assert.equal(config.speechModel,'fish-audio/s2.1-pro-free:free')
21
+ } finally {rmSync(dir,{recursive:true,force:true})}
22
+ })
@@ -0,0 +1,78 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { createHash } from 'node:crypto'
4
+ import { mkdtemp, rm } from 'node:fs/promises'
5
+ import { join } from 'node:path'
6
+ import { tmpdir } from 'node:os'
7
+ import { execFile } from 'node:child_process'
8
+ import { promisify } from 'node:util'
9
+ import { ownerRun } from './helpers/owner-run.js'
10
+ import { serveTestLedger } from './helpers/ledger.js'
11
+ import { RunStore } from '../src/runs.js'
12
+ import { ControlStore } from '../src/control-state.js'
13
+ import { runEvidence, projectRunEvidence } from '../src/run-evidence.js'
14
+
15
+ async function fixture(t: test.TestContext) {
16
+ const dir = await mkdtemp(join(tmpdir(), 'ez-evidence-'))
17
+ await ownerRun(dir, 'owner_run')
18
+ const ledger = await serveTestLedger(dir)
19
+ t.after(async () => { await ledger.stop(); await rm(dir, { recursive: true, force: true }) })
20
+ return { dir, runs: new RunStore(dir), owner: (await new ControlStore(dir, 1000).status()).owner! }
21
+ }
22
+
23
+ test('CLI projects operational proof without sessions, failure prose or delivered text', async t => {
24
+ const { dir, runs } = await fixture(t)
25
+ const secret = 'private-session-or-token'
26
+ await runs.patch('owner_run', { nativeSessionId: secret, failureReason: secret,
27
+ failure: { error: secret, relayVersion: '0.1.0-beta.47' } })
28
+ const sent = await runs.enqueueMessage('owner_run', 'private financial report')
29
+ await runs.claimOutbox(sent.id); await runs.markOutboxSent(sent.id, [7])
30
+ const uncertain = await runs.enqueueMessage('owner_run', 'uncertain report')
31
+ await runs.claimOutbox(uncertain.id); await runs.failOutbox(uncertain.id, secret, true)
32
+ const control = await new ControlStore(dir, 1000).status()
33
+ const { stdout } = await promisify(execFile)(process.execPath, ['bin/ezenciel-agents-schedule.mjs', 'evidence'], {
34
+ env: { ...process.env, EZ_CONTROL_DIR: dir, EZ_RUN_ID: 'owner_run' },
35
+ })
36
+ const result = JSON.parse(stdout)
37
+ assert.equal(result.runs.length, 1)
38
+ assert.ok(!stdout.includes(secret) && !stdout.includes('private financial report') && !stdout.includes('uncertain report'))
39
+ const receipts = result.runs[0].deliveries
40
+ assert.equal(receipts.find((r: any) => r.id === sent.id).receiptVerified, true)
41
+ assert.equal(receipts.find((r: any) => r.id === sent.id).content.sha256, createHash('sha256').update('private financial report').digest('hex'))
42
+ assert.equal(receipts.find((r: any) => r.id === uncertain.id).state, 'unknown')
43
+ assert.equal(receipts.find((r: any) => r.id === uncertain.id).receiptVerified, false)
44
+ assert.equal(result.runs[0].toolDelegationMetadata.state, 'unavailable')
45
+ assert.deepEqual(await new ControlStore(dir, 1000).status(), control)
46
+ })
47
+
48
+ test('pages require an unchanged snapshot and exact identity', async t => {
49
+ const { dir, runs } = await fixture(t)
50
+ await ownerRun(dir, 'second_run')
51
+ const first = await runEvidence(dir, { limit: 1 })
52
+ assert.equal(first.coverage.total, 2)
53
+ assert.equal(first.coverage.nextOffset, 1)
54
+ const second = await runEvidence(dir, { limit: 1, offset: 1, expected: first.snapshotSha256 })
55
+ assert.equal(second.coverage.nextOffset, null)
56
+ assert.notEqual(second.runs[0].id, first.runs[0].id)
57
+ await assert.rejects(runEvidence(dir, { offset: 1 }), /requires/)
58
+ await runs.patch('second_run', { status: 'completed' })
59
+ await assert.rejects(runEvidence(dir, { offset: 1, expected: first.snapshotSha256 }), /changed/)
60
+ await assert.rejects(runEvidence(dir, { runId: '../owner_run' }), /identifier/)
61
+ for (const options of [{ limit: 101 }, { offset: -1 }, { offset: 0.5 }, { expected: 'bad' }])
62
+ await assert.rejects(runEvidence(dir, options))
63
+ })
64
+
65
+ test('revoked owners, restricted callers and unrelated bindings are excluded', async t => {
66
+ const { dir, runs, owner } = await fixture(t)
67
+ const stranger = await runs.create({ id: 'other_owner', chatId: 202, telegramUserId: 202, texts: ['secret'] })
68
+ const external = await ownerRun(dir, 'external', { sourceId: 'test', bindingId: 'binding', eventIds: ['1'] })
69
+ const old = { ...(await runs.get('owner_run'))!, id: 'old_run', scheduled: { id: 'schedule', revision: 'rev', dueAt: '2026-01-01T00:00:00Z', pairedAt: 'old-pairing' } }
70
+ const app = { ...(await runs.get('owner_run'))!, id: 'app_run', delivery: { bindingId: 'private', scope: 'private' } }
71
+ assert.deepEqual(projectRunEvidence([stranger, external, old, app], [], owner), [])
72
+ await assert.rejects(runEvidence(dir, { callerRunId: 'external' }), /blocked/)
73
+ await runs.patch('owner_run', { replyOnly: true })
74
+ await assert.rejects(runEvidence(dir, { callerRunId: 'owner_run' }), /Telegram owner/)
75
+ await assert.rejects(runEvidence(dir, { runId: 'other_owner' }), /Unknown/)
76
+ await new ControlStore(dir, 1000).revokeOwner()
77
+ await assert.rejects(runEvidence(dir), /No authorized owner/)
78
+ })