@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
@@ -15,16 +15,17 @@ import { requireOwnerExecution } from '../src/execution-authority.js'
15
15
  import { execFile } from 'node:child_process'
16
16
  import { promisify } from 'node:util'
17
17
 
18
- async function fixture(t: test.TestContext, channelOwner = false) {
18
+ async function fixture(t: test.TestContext, channelOwner = false, speech?: (text:string) => Promise<{buffer:Buffer;mimeType:string}>) {
19
19
  const dir = await mkdtemp('/tmp/ez-task-test-'), socket = join(dir, 's.sock')
20
20
  let accountId = 'account-a', events: SourceEvent[] = [], uncertain = false
21
21
  const sends: any[] = [], watches: any[] = []
22
22
  const server = createServer(async (req, res) => {
23
23
  let text = ''; for await (const chunk of req) text += chunk
24
24
  const { command, args } = JSON.parse(text)
25
- const data = command === 'events-head' ? { cursor: 0, accountId, taskProtocol: 'message-v1', persistentWatch: true, wildcardWatch: true }
25
+ const data = command === 'events-head' ? { cursor: 0, accountId, taskProtocol: 'message-v1', persistentWatch: true, wildcardWatch: true, taskVoice: true }
26
26
  : command === 'task-watch' ? (watches.push(args), { watching: args.conversationId })
27
27
  : command === 'events-check' ? { events: events.filter(e => args.ids.includes(e.id)) }
28
+ : command === 'task-document' ? {name:'shop.txt',data:Buffer.from('Shop opens at 7am.').toString('base64'),sha256:createHash('sha256').update('Shop opens at 7am.').digest('hex')}
28
29
  : command === 'task-send' ? (sends.push(args), { ...args, state: uncertain ? 'uncertain' : 'accepted', receiptId: 'provider-1' }) : {}
29
30
  res.end(JSON.stringify({ ok: true, data }))
30
31
  })
@@ -35,7 +36,7 @@ async function fixture(t: test.TestContext, channelOwner = false) {
35
36
  await runs.create({ id: 'owner', ownerId: ownerId(owner), ownerEpoch: ownerEpoch(owner), texts: ['Own this conversation'] })
36
37
  await runs.patch('owner', { status: 'running' })
37
38
  } else await ownerRun(dir, 'owner')
38
- const control = new ControlStore(dir, 900000), sources = new EventSources(dir), runs = new RunStore(dir), tasks = new Tasks(dir)
39
+ const control = new ControlStore(dir, 900000), sources = new EventSources(dir), runs = new RunStore(dir), tasks = new Tasks(dir,speech)
39
40
  await sources.register('generic', socket, (await control.status()).owner!)
40
41
  t.after(async () => { server.closeAllConnections(); await new Promise<void>(r => server.close(() => r())); await rm(dir, { recursive: true, force: true }) })
41
42
  async function proposal() {
@@ -247,7 +248,7 @@ test('approved initial task crosses the real host file client and uses a fresh r
247
248
  const { isHostRunId } = await import('../src/host-executor-protocol.js')
248
249
  const f = await fixture(t), { run } = await f.activate()
249
250
  assert.ok(isHostRunId(run.id))
250
- await writeFile(join(f.dir, 'codex'), `#!${process.execPath}\nif(process.argv[2]==='--version')console.log('codex-cli 0.160.0');else if(process.argv[2]==='debug')console.log(JSON.stringify({models:[{slug:'fixture',tool_mode:'code_mode_only',apply_patch_tool_type:'freeform',multi_agent_version:'v2'}]}));else console.log(JSON.stringify({cwd:process.cwd(),args:process.argv.slice(2),control:process.env.EZ_CONTROL_DIR}));`, { mode: 0o700 })
251
+ await writeFile(join(f.dir, 'codex'), `#!${process.execPath}\nif(process.argv[2]==='--version')console.log('codex-cli 0.160.1');else if(process.argv[2]==='debug')console.log(JSON.stringify({models:[{slug:'fixture',tool_mode:'code_mode_only',apply_patch_tool_type:'freeform',multi_agent_version:'v2'}]}));else console.log(JSON.stringify({cwd:process.cwd(),args:process.argv.slice(2),control:process.env.EZ_CONTROL_DIR}));`, { mode: 0o700 })
251
252
  const priorPath = process.env.PATH
252
253
  process.env.PATH = `${f.dir}:${priorPath}`
253
254
  const abort = new AbortController(), host = serveHostExecutor({ cli: 'codex', agents: [{ name: 'test', workspace: f.dir, controlDir: f.dir, binDir: f.dir }] }, abort.signal)
@@ -341,3 +342,95 @@ test('public grants enforce persisted hourly and cumulative admission budgets',a
341
342
  assert.equal(await f.tasks.admitPublic(proposal.id),'revoked')
342
343
  assert.equal((await f.tasks.get(proposal.id))?.state,'revoked')
343
344
  })
345
+
346
+
347
+ test('voice reuses task send authority and receipt keys, rechecks the account after synthesis, and never falls back to text', async t => {
348
+ let generated = 0, changed = false
349
+ let changeAccount: (value:string) => void
350
+ const f = await fixture(t,false,async text => {
351
+ generated++; assert.equal(text, 'Spoken fixture')
352
+ if(changed)changeAccount('replaced')
353
+ return {buffer:Buffer.from('OggSfixture OpusHead'),mimeType:'audio/ogg'}
354
+ })
355
+ changeAccount = f.account
356
+ const {run} = await f.activate()
357
+ const sent = await f.tasks.workerCall(run.id,'send',{text:'Spoken fixture',key:'spoken',voice:true})
358
+ assert.ok('state' in sent);assert.equal(sent.state,'accepted');assert.equal(generated,1);assert.equal(f.sends.length,1)
359
+ assert.equal(Buffer.from(f.sends[0].audio.data,'base64').toString(),'OggSfixture OpusHead')
360
+ await f.tasks.workerCall(run.id,'send',{text:'Spoken fixture',key:'spoken',voice:true})
361
+ assert.equal(generated,1);assert.equal(f.sends.length,1)
362
+ await assert.rejects(f.tasks.workerCall(run.id,'send',{text:'Spoken fixture',key:'spoken'}), /different text/)
363
+ changed=true
364
+ assert.equal((await f.tasks.workerCall(run.id,'send',{text:'Spoken fixture',key:'changed',voice:true}) as {state:string}).state,'uncertain')
365
+ assert.equal(f.sends.length,1)
366
+ })
367
+
368
+ test('voice generation failure is a definite non-send: no text, no stored operation, same key retryable', async t => {
369
+ let generated=0
370
+ const f=await fixture(t,false,async () => { if(++generated===1)throw new Error('provider failed'); return {buffer:Buffer.from('OggSfixture OpusHead'),mimeType:'audio/ogg'} })
371
+ const {run, taskId}=await f.activate()
372
+ await assert.rejects(f.tasks.workerCall(run.id,'send',{text:'Spoken fixture',key:'failed',voice:true}),/provider failed/)
373
+ assert.equal(f.sends.length,0);assert.equal(Object.hasOwn((await f.tasks.get(taskId))!.operations,'failed'),false)
374
+ assert.equal((await f.tasks.workerCall(run.id,'send',{text:'Spoken fixture',key:'failed',voice:true}) as {state:string}).state,'accepted')
375
+ assert.equal(generated,2);assert.equal(f.sends.length,1)
376
+ })
377
+
378
+
379
+ test('a cancelled worker cannot dispatch a voice generated after stop', async t => {
380
+ let stop: () => Promise<unknown>
381
+ const f=await fixture(t,false,async () => { await stop(); return {buffer:Buffer.from('OggSfixture OpusHead'),mimeType:'audio/ogg'} })
382
+ const {run}=await f.activate();stop=()=>f.runs.patch(run.id,{status:'cancelled'})
383
+ assert.equal((await f.tasks.workerCall(run.id,'send',{text:'Spoken fixture',key:'stopped',voice:true}) as {state:string}).state,'uncertain')
384
+ assert.equal(f.sends.length,0)
385
+ })
386
+
387
+ test('incoming documents use shared staging and never expose another event or owner path', async t => {
388
+ const f=await fixture(t), {taskId}=await f.activate(), task=(await f.tasks.get(taskId))!
389
+ const row={id:'1',conversationId:'contact-a',receivedAt:Date.now(),text:'Document attached'};f.rows([row])
390
+ const run=await f.runs.create({id:'event_document',taskId,chatId:101,telegramUserId:101,texts:[],external:{sourceId:'generic',bindingId:task.bindingId,eventIds:['1']}})
391
+ await f.runs.patch(run.id,{status:'running'})
392
+ const document:any=await f.tasks.workerCall(run.id,'read_attachment',{incomingId:'1'})
393
+ assert.equal(document.text,'Shop opens at 7am.');assert.equal(document.name,'shop.txt');assert.equal(document.type,'text')
394
+ await assert.rejects(f.tasks.workerCall(run.id,'read_attachment',{incomingId:'2'}),/outside/)
395
+ await assert.rejects(f.tasks.workerCall(run.id,'read_attachment',{incomingId:'/etc/passwd'}),/outside/)
396
+ f.rows([{...row,conversationId:'contact-b'}]);await assert.rejects(f.tasks.workerCall(run.id,'read_attachment',{incomingId:'1'}),/correspondence/)
397
+ })
398
+
399
+ test('explicit application launch continues restricted task; cross-task, owner mode, revocation and replay changes fail closed',async t=>{
400
+ const {ApplicationBindings,ApplicationChannel}=await import('../src/application-channel.js')
401
+ const {initialPreset}=await import('../src/ai.js')
402
+ const f=await fixture(t), {taskId,run}=await f.activate(), owner=(await f.control.status()).owner!
403
+ const bindings=new ApplicationBindings(f.dir), binding=(await bindings.register('browser','x'.repeat(48),owner))!
404
+ const token='a'.repeat(64),ticketHash=createHash('sha256').update(token).digest('hex')
405
+ await assert.rejects(f.tasks.workerCall(run.id,'application_launch',{application:'browser',ticketHash}),/not authorized/)
406
+ await bindings.taskLaunch('browser',{command:'fixture',args:['launch','--task'],tasks:[taskId]})
407
+ const grant=await f.tasks.workerCall(run.id,'application_launch',{application:'browser',ticketHash}) as {taskId:string;expiresAt:number}
408
+ assert.equal(grant.taskId,taskId)
409
+ assert(!(await readFile(join(f.dir,'task-applications',ticketHash+'.json'),'utf8')).includes(token))
410
+ const admission=await f.tasks.authorizeApplicationLaunch(run.id,ticketHash)
411
+ assert.deepEqual(admission.launch,{command:'fixture',args:['launch','--task'],tasks:[taskId]})
412
+ await assert.rejects(f.tasks.authorizeApplicationLaunch('owner',ticketHash))
413
+ const app=new ApplicationChannel({controlDir:f.dir,initial:initialPreset('codex'),wake(){},async cancel(){}})
414
+ const input={requestId:'voice-test',scope:'task:'+taskId,text:'When is the booking?',taskToken:token}
415
+ await assert.rejects(app.submit(binding.bindingId,{...input,followOwner:true}),/Invalid application task/)
416
+ await assert.rejects(app.submit(binding.bindingId,{...input,scope:'task:other'}),/scope/)
417
+ const delegated=await app.submit(binding.bindingId,input)
418
+ assert.equal(delegated.taskId,taskId);assert.equal(delegated.version,2)
419
+ assert.equal((await app.submit(binding.bindingId,input)).id,delegated.id)
420
+ await assert.rejects(app.submit(binding.bindingId,{...input,text:'Changed request'}),/conflicts/)
421
+ await f.runs.patch(delegated.id,{status:'running'})
422
+ const context=await f.tasks.workerCall(delegated.id,'context',{}) as {notes:string[];request:string;context:string}
423
+ assert.match(context.context,/Two people/);assert.equal(context.request,input.text)
424
+ await f.tasks.workerCall(delegated.id,'note',{text:'Discussion-only voice result'})
425
+ const reply=await f.tasks.workerCall(delegated.id,'send',{key:'answer',text:'7pm'}) as {receipt:{queued:string}}
426
+ assert.equal(f.sends.length,0)
427
+ const outbox=(await f.runs.pendingOutbox()).find(x=>x.id===reply.receipt.queued)!
428
+ await app.deliver(delegated,outbox)
429
+ assert.deepEqual((await app.snapshot(binding.bindingId,delegated.id)).messages.map(m=>m.text),['7pm'])
430
+ const other=(await bindings.register('other','y'.repeat(48),owner))!
431
+ await bindings.taskLaunch('other',{command:'fixture',args:['launch'],tasks:'all'})
432
+ await assert.rejects(app.submit(other.bindingId,{...input,requestId:'other'}),/revoked/)
433
+ await bindings.taskLaunch('browser',null)
434
+ await assert.rejects(f.tasks.authorize(delegated),/revoked/)
435
+ await assert.rejects(app.snapshot(binding.bindingId,delegated.id),/revoked/)
436
+ })
@@ -114,13 +114,16 @@ test('root preparation leaves private plugin updates readable by the installer',
114
114
  await exec('setpriv',['--reuid=20001','--regid=20002','--clear-groups','--no-new-privs',process.execPath,'-e',script]);
115
115
  });
116
116
  function runtime(f,{fail,stopped=false,buildArgs={CODEX_CLI_VERSION:'0.156.1'},latest='0.160.0'}={}) {
117
- const calls=[];let failed=false;
117
+ const calls=[];let failed=false,brokerImage='sha256:'+'a'.repeat(64);
118
118
  const execute=async(command,args,opts)=>{
119
119
  calls.push([command,...args]);if(args.includes('view'))return JSON.stringify(latest);if(fail&&!failed&&fail(command,args)){failed=true;throw Error('Synthetic failure');}
120
120
  if(args.at(-1)==='--version')return '10.30.3';
121
121
  if(args.includes('config'))return JSON.stringify({services:{relay:{build:{args:buildArgs}}}});
122
- if(args.includes('ps'))return stopped?'':'container-id';
123
- if(args[0]==='inspect')return 'sha256:'+'a'.repeat(64);
122
+ if(args.includes('ps'))return stopped?'':'a'.repeat(64);
123
+ if(args.some(arg=>arg.includes('EZ_BROKER_STRUCTURAL_READINESS_FAILED'))) {const [hostSha256,registrySha256,configSha256]=args.slice(-3);return JSON.stringify({version:1,ready:true,hostSha256,registrySha256,configSha256,plugins:[]});}
124
+ if(args[0]==='image'&&args[1]==='inspect')return 'sha256:'+'b'.repeat(64);
125
+ if(args.includes('--force-recreate'))brokerImage=(await fs.readFile(args[args.lastIndexOf('--env-file')+1],'utf8')).trim().split('=')[1];
126
+ if(args[0]==='inspect')return brokerImage;
124
127
  if(args[0]==='volume'&&args[1]==='ls')return 'existing';
125
128
  if(args[0]==='volume')return JSON.stringify([{Name:args[2]}]);
126
129
  if(args[0]==='run')await fs.writeFile(opts.outputFile,'retained-private-state',{mode:0o600});
@@ -384,22 +387,55 @@ test('plugin updates register additive command routes on an unchanged deployment
384
387
  const dispatch=await prepareCommand(f.home,'query',[]);assert.deepEqual(dispatch.argv.slice(-4),['node','sample','/app/bin/example.mjs','query']);
385
388
  assert(r.calls.some(call=>call.includes('up')));
386
389
  });
387
- test('isolated plugin replacement refreshes broker version inventory after registry activation',async t=>{
390
+ test('isolated plugin replacement recreates only the pinned broker and verifies activation and rollback',async t=>{
388
391
  const f=await fixture(t,'plugin');
389
392
  await fs.appendFile(path.join(f.config.deploymentDir,'docker.env'),'EZ_EXECUTOR_TRANSPORT=local\n');
390
393
  const job=await queued(f),r=runtime(f),execute=r.execute;
391
394
  let observed;
392
395
  r.execute=async(command,args,options)=>{
393
- if(args.includes('restart')&&args.at(-1)==='plugin-broker') observed=(await read(path.join(f.home,'registry.json'))).plugins.sample.manifest.version;
396
+ if(args.some(arg=>arg.includes('EZ_BROKER_STRUCTURAL_READINESS_FAILED'))) observed=(await read(path.join(f.home,'registry.json'))).plugins.sample.manifest.version;
397
+ if(args.includes('--force-recreate')) {
398
+ assert.equal(args.at(-1),'plugin-broker');
399
+ for(const flag of ['--no-deps','--no-build','--wait'])assert(args.includes(flag));
400
+ assert.equal(args[args.indexOf('--pull')+1],'never');
401
+ assert.equal(await fs.readFile(args[args.lastIndexOf('--env-file')+1],'utf8'),'EZ_RELAY_IMAGE=sha256:'+ 'a'.repeat(64)+'\n');
402
+ }
394
403
  return execute(command,args,options);
395
404
  };
396
405
  assert.equal((await perform(f.home,job,r)).status,'completed');
397
- assert.equal(observed,'0.1.1');
398
- assert.equal(r.calls.filter(call=>call.includes('restart')).length,1);
406
+ assert.equal(observed,'0.1.1');assert.equal(job.brokerReadiness.ready,true);
399
407
  const interrupted=await read(path.join(jobPath(f.home,job.id),'job.json'));interrupted.status='applying';
400
408
  assert.equal((await perform(f.home,interrupted,r)).status,'rolled-back');
401
- assert.equal(observed,'0.1.0');
402
- assert.equal(r.calls.filter(call=>call.includes('restart')).length,2);
409
+ assert.equal(observed,'0.1.0');assert.equal(interrupted.rollbackBrokerReadiness.ready,true);
410
+ assert.equal(r.calls.filter(call=>call.includes('--force-recreate')).length,2);
411
+ assert(!r.calls.some(call=>call.includes('restart')||call[0]==='stopHost'));
412
+ });
413
+ for(const boundary of ['recreate','probe','readback'])test(`isolated broker ${boundary} failure cannot complete an update`,async t=>{
414
+ const f=await fixture(t,'plugin');await fs.appendFile(path.join(f.config.deploymentDir,'docker.env'),'EZ_EXECUTOR_TRANSPORT=local\n');
415
+ const job=await queued(f),r=runtime(f),base=r.execute;
416
+ r.execute=async(c,a,o)=>{
417
+ if(boundary==='recreate'&&a.includes('--force-recreate')||boundary==='probe'&&a.some(arg=>arg.includes('EZ_BROKER_STRUCTURAL_READINESS_FAILED')))throw Error('Synthetic broker failure');
418
+ if(boundary==='readback'&&a.some(arg=>arg.includes('EZ_BROKER_STRUCTURAL_READINESS_FAILED')))return JSON.stringify({version:1,ready:true,hostSha256:'0'.repeat(64)});
419
+ return base(c,a,o);
420
+ };
421
+ const result=await perform(f.home,job,r);
422
+ assert.equal(result.status,'recovery-required');assert(result.error);assert(result.recoveryError);
423
+ assert.equal((await read(path.join(f.home,'registry.json'))).plugins.sample.manifest.version,'0.1.0');
424
+ });
425
+ test('main isolated activation and retained-relay rollback also refresh the broker',async t=>{
426
+ const f=await fixture(t);await fs.appendFile(path.join(f.config.deploymentDir,'docker.env'),'EZ_EXECUTOR_TRANSPORT=local\n');
427
+ const job=await queued(f),r=runtime(f);
428
+ assert.equal((await perform(f.home,job,r)).status,'completed');assert.equal(job.brokerReadiness.ready,true);
429
+ const base=r.execute;r.execute=async(c,a,o)=>a[0]==='inspect'&&a.includes('{{json .}}')?JSON.stringify({Image:'sha256:'+'a'.repeat(64),State:{Running:true,Health:{Status:'healthy'}}}):base(c,a,o);
430
+ const interrupted=await read(path.join(jobPath(f.home,job.id),'job.json'));interrupted.status='applying';
431
+ assert.equal((await perform(f.home,interrupted,r)).status,'rolled-back');
432
+ assert.equal(interrupted.rollbackBrokerReadiness.ready,true);
433
+ assert.equal(r.calls.filter(call=>call.includes('--force-recreate')).length,2);
434
+ assert.equal(r.calls.filter(call=>call.includes('up')&&call.at(-1)==='relay').length,1);
435
+ });
436
+ test('host-capable updates never inspect, recreate or probe an isolated broker',async t=>{
437
+ const f=await fixture(t,'plugin'),r=runtime(f);assert.equal((await perform(f.home,await queued(f),r)).status,'completed');
438
+ assert(!r.calls.some(call=>call.includes('plugin-broker')||call.some(arg=>arg.includes('EZ_BROKER_STRUCTURAL_READINESS_FAILED'))));
403
439
  });
404
440
  test('plugin update preserves a created but never running service as stopped',async t=>{
405
441
  const f=await fixture(t,'plugin'),job=await queued(f),r=runtime(f),execute=r.execute;