@jc_stack/ez-agents 0.1.0-beta.47 → 0.1.0-beta.49
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.
- package/.env.example +3 -0
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +30 -0
- package/Dockerfile +5 -1
- package/THIRD_PARTY_NOTICES.md +6 -3
- package/bin/ez +7 -0
- package/docker/broker-readiness.mjs +54 -0
- package/docker/broker-update-smoke.mjs +100 -0
- package/docker/run.ts +1 -1
- package/docker/upgrade-smoke.mjs +1 -1
- package/docs/application-channel.md +32 -1
- package/docs/architecture/ai-selection.md +23 -4
- package/docs/docker-runtime.md +57 -0
- package/docs/isolated-plugin-update-readiness.md +66 -0
- package/docs/releasing.md +4 -0
- package/docs/scheduling.md +141 -6
- package/docs/selective-monitoring.md +13 -0
- package/docs/upgrades.md +33 -4
- package/docs/work-preflight.md +9 -0
- package/package.json +1 -1
- package/src/ai-cli.ts +28 -15
- package/src/ai.ts +98 -15
- package/src/application-channel.ts +52 -12
- package/src/application-cli.ts +7 -1
- package/src/application-origin.ts +2 -0
- package/src/audio.ts +37 -22
- package/src/auth-profile.ts +53 -0
- package/src/client-defaults.ts +3 -2
- package/src/codex-session.ts +14 -3
- package/src/config.ts +9 -0
- package/src/control-state.ts +31 -22
- package/src/delivery-socket-client.d.mts +2 -0
- package/src/delivery-socket-client.mjs +2 -0
- package/src/delivery-socket.ts +26 -1
- package/src/event-sources.ts +2 -2
- package/src/executor.ts +81 -7
- package/src/files.ts +47 -3
- package/src/host-executor.ts +68 -34
- package/src/index.ts +227 -110
- package/src/install-tools.mjs +8 -5
- package/src/menu.ts +9 -7
- package/src/plugin-broker.mjs +50 -14
- package/src/plugins/manager.mjs +34 -8
- package/src/run-evidence.ts +78 -0
- package/src/runs.ts +24 -2
- package/src/schedule-cli.ts +100 -13
- package/src/schedule-preflight.ts +23 -0
- package/src/scheduled-tasks.ts +16 -12
- package/src/scheduler.ts +85 -19
- package/src/scripts.ts +167 -0
- package/src/setup-fallback.ts +122 -0
- package/src/setup.ts +2 -2
- package/src/task-capability.ts +17 -1
- package/src/task-executor.ts +13 -9
- package/src/task-mcp.ts +20 -8
- package/src/tasks.ts +89 -9
- package/src/updates/runtime.mjs +107 -30
- package/src/updates/supervisor.mjs +27 -6
- package/test/ai.test.ts +40 -0
- package/test/application-channel.test.ts +16 -1
- package/test/audio.test.ts +19 -0
- package/test/auth-profiles.test.ts +187 -0
- package/test/config.test.ts +8 -0
- package/test/executor.test.ts +37 -0
- package/test/files.test.ts +44 -0
- package/test/host-executor.test.ts +45 -2
- package/test/install-json.test.mjs +21 -0
- package/test/plugin-broker.test.mjs +67 -0
- package/test/plugin-manager.test.mjs +44 -0
- package/test/private-speech-config.test.ts +22 -0
- package/test/run-evidence.test.ts +78 -0
- package/test/schedule-preflight.test.ts +49 -0
- package/test/scheduler-host.test.ts +7 -0
- package/test/scheduler-relay.test.ts +38 -3
- package/test/scheduler.test.ts +1 -1
- package/test/script-schedules.test.ts +219 -0
- package/test/setup-fallback.test.ts +245 -0
- package/test/task-native.test.ts +50 -12
- package/test/tasks.test.ts +97 -4
- package/test/updates.test.mjs +100 -11
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import { once } from 'node:events'
|
|
8
|
+
import { createRelay } from '../src/index.js'
|
|
9
|
+
import { ControlStore } from '../src/control-state.js'
|
|
10
|
+
import { RunStore, activeSetup } from '../src/runs.js'
|
|
11
|
+
import { Scheduler } from '../src/scheduler.js'
|
|
12
|
+
import { CliUnavailableError, EXECUTOR_REGISTRY, type ExecutorOptions } from '../src/executor.js'
|
|
13
|
+
import { codexRejection, nextSetup, quotaScope, startupObserver, startupLine } from '../src/setup-fallback.js'
|
|
14
|
+
import type { AiPreset } from '../src/ai.js'
|
|
15
|
+
|
|
16
|
+
const until=async(check:()=>Promise<boolean>)=>{for(let i=0;i<250;i++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Timed out')}
|
|
17
|
+
const setup=(id:string,cli:string,model:string):AiPreset=>({id,name:id,cli,model})
|
|
18
|
+
const claudeA=setup('claude-a','claude','opus'),claudeB=setup('claude-b','claude','sonnet')
|
|
19
|
+
const codexA=setup('codex-a','codex','gpt-6-sol'),codexB=setup('codex-b','codex','gpt-6-astra')
|
|
20
|
+
const observe=(...lines:unknown[])=>{const o=startupObserver();o.write(lines.map(l=>typeof l==='string'?l:JSON.stringify(l)).join('\n')+'\n');return o.result()}
|
|
21
|
+
|
|
22
|
+
test('startup observer advances only on typed rejection before any work',()=>{
|
|
23
|
+
assert.deepEqual(observe({type:'system',subtype:'init'},{type:'rate_limit_event',rate_limit_info:{status:'rejected',resetsAt:1791460800}},{type:'assistant',error:'rate_limit',message:{content:[]}},{type:'result',is_error:true}),
|
|
24
|
+
{workBegan:false,rejection:{category:'quota',resetAt:'2026-10-08T12:00:00.000Z'}})
|
|
25
|
+
assert.equal(observe({type:'assistant',error:'authentication_failed'}).rejection?.category,'access-denied')
|
|
26
|
+
assert.equal(observe({type:'assistant',error:'model_not_found'}).rejection?.category,'provider-rejected')
|
|
27
|
+
// Real output, unknown errors and unparseable lines all count as work.
|
|
28
|
+
assert.equal(observe({type:'assistant',message:{content:[{type:'tool_use'}]}},{type:'assistant',error:'rate_limit'}).workBegan,true)
|
|
29
|
+
assert.equal(observe({type:'assistant',error:'unknown'}).workBegan,true)
|
|
30
|
+
assert.equal(observe('plain text').workBegan,true)
|
|
31
|
+
for (const subtype of ['hook_started','hook_progress','hook_response','unknown'])
|
|
32
|
+
assert.equal(observe({type:'system',subtype},{type:'assistant',error:'rate_limit'}).workBegan,true)
|
|
33
|
+
// Ez transport summaries (Codex native session, host-forwarded Claude) and session metadata.
|
|
34
|
+
assert.deepEqual(observe({type:'thread.started',thread_id:'t'},startupLine({workBegan:false,rejection:{category:'cli-unavailable'}})),{workBegan:false,rejection:{category:'cli-unavailable'}})
|
|
35
|
+
assert.equal(observe(startupLine({workBegan:true}),startupLine({workBegan:false,rejection:{category:'quota'}})).workBegan,true)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('only scheduled Claude runs stream structured events',()=>{
|
|
39
|
+
assert.deepEqual(EXECUTOR_REGISTRY.claude.buildArgs({workspace:'/w',nativeSession:true},'','').slice(-3),['--output-format','stream-json','--verbose'])
|
|
40
|
+
assert.ok(!EXECUTOR_REGISTRY.claude.buildArgs({workspace:'/w'},'','').includes('stream-json'))
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('codex errors map to categories; policy and context errors never advance',()=>{
|
|
44
|
+
assert.equal(codexRejection('usageLimitExceeded'),'quota')
|
|
45
|
+
assert.equal(codexRejection('unauthorized'),'access-denied')
|
|
46
|
+
assert.equal(codexRejection('serverOverloaded'),'provider-rejected')
|
|
47
|
+
assert.equal(codexRejection({responseTooManyFailedAttempts:{httpStatusCode:429}}),'quota')
|
|
48
|
+
assert.equal(codexRejection({httpConnectionFailed:{httpStatusCode:403}}),'access-denied')
|
|
49
|
+
for(const info of ['cyberPolicy','contextWindowExceeded','other',{httpConnectionFailed:{httpStatusCode:null}},undefined]) assert.equal(codexRejection(info),undefined)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('a quota rejection skips later setups in the same scope, even with different credentials',()=>{
|
|
53
|
+
assert.equal(quotaScope(claudeA),quotaScope(claudeB))
|
|
54
|
+
assert.equal(quotaScope({...codexA,provider:'bridge'}),quotaScope(codexB))
|
|
55
|
+
assert.equal(quotaScope({id:'p',name:'p',cli:'pi',model:'opencode-go/m'}),quotaScope({id:'o',name:'o',cli:'opencode',model:'opencode-go/x'}))
|
|
56
|
+
const first={preset:claudeA,quotaScope:'claude',outcome:'failed' as const,category:'quota' as const}
|
|
57
|
+
const next=nextSetup([claudeA,claudeB,codexA,codexB],[first])
|
|
58
|
+
assert.equal(next.preset,codexA);assert.deepEqual(next.skipped.map(s=>[s.preset.id,s.outcome,s.category]),[['claude-b','skipped','same-quota-scope']])
|
|
59
|
+
// A non-quota rejection (model unavailable) leaves the scope eligible.
|
|
60
|
+
assert.equal(nextSetup([claudeA,claudeB],[{...first,category:'provider-rejected'}]).preset,claudeB)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
type Script={lines?:unknown[];code?:number;unavailable?:boolean;hold?:boolean}
|
|
64
|
+
const fixture=async(name:string,scripts:Script[],fallbacks=[claudeB,codexA,codexB],primary=claudeA)=>{
|
|
65
|
+
const dir=await mkdtemp(join(tmpdir(),`ez-fallback-${name}-`)),control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
|
|
66
|
+
const launches:ExecutorOptions[]=[],admitted:(AiPreset|undefined)[]=[],sent:string[]=[]
|
|
67
|
+
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'codex',telegramBotToken:'fixture'},async(_texts,options)=>{
|
|
68
|
+
const script=scripts[launches.length] ?? {code:0}
|
|
69
|
+
launches.push(options);admitted.push(activeSetup((await runs.get(options.runId))!))
|
|
70
|
+
if(script.unavailable)throw new CliUnavailableError(`Native CLI ${options.cli} is not executable on the host PATH`)
|
|
71
|
+
const out=(script.lines ?? []).map(l=>typeof l==='string'?l:JSON.stringify(l)).join('\n')
|
|
72
|
+
const child=spawn(process.execPath,['-e',`process.stdout.write(${JSON.stringify(out ? out+'\n' : '')});setTimeout(()=>process.exit(${script.code ?? 0}),${script.hold ? 60000 : 10})`],{detached:process.platform!=='win32'})
|
|
73
|
+
await once(child,'spawn');return {child,cleanup:async()=>{},stdout:''}
|
|
74
|
+
})
|
|
75
|
+
relay.bot.api.config.use(async(_prev,method,payload)=>{if(method==='sendMessage')sent.push((payload as {text:string}).text);return {ok:true,result:{message_id:42}} as never})
|
|
76
|
+
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
77
|
+
const owner=(await control.status()).owner!
|
|
78
|
+
const saved=await scheduler.save({id:'daily',name:'Daily',text:'Saved work',owner,execution:{sessionId:'00000000-0000-4000-8000-000000000000',preset:primary,fallbacks},enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}},true)
|
|
79
|
+
const run=await scheduler.trigger(saved.id,saved.revision,'fallback-'+name,owner,runs)
|
|
80
|
+
await relay.drainSources()
|
|
81
|
+
return {dir,runs,scheduler,relay,run,launches,admitted,sent,done:async()=>{await until(async()=>['completed','failed','cancelled'].includes((await runs.get(run.id))!.status));return (await runs.get(run.id))!},
|
|
82
|
+
close:async()=>{await relay.stop();await rm(dir,{recursive:true,force:true})}}
|
|
83
|
+
}
|
|
84
|
+
const quota={type:'assistant',error:'rate_limit'},work={type:'assistant',message:{content:[{type:'text',text:'working'}]}}
|
|
85
|
+
|
|
86
|
+
test('a quota rejection before work advances within the same occurrence, skipping the exhausted scope',async()=>{
|
|
87
|
+
const f=await fixture('advance',[{lines:[{type:'rate_limit_event',rate_limit_info:{status:'rejected',resetsAt:1791460800}},quota],code:1},{lines:[startupLine({workBegan:true})],code:0}])
|
|
88
|
+
try{
|
|
89
|
+
const run=await f.done()
|
|
90
|
+
assert.equal(run.status,'completed');assert.equal(run.id,f.run.id)
|
|
91
|
+
assert.deepEqual(f.launches.map(l=>[l.cli,l.model,l.runId,l.nativeSession]),[['claude','opus',f.run.id,true],['codex','gpt-6-sol',f.run.id,true]])
|
|
92
|
+
assert.notEqual(f.launches[0].sessionId,f.launches[1].sessionId)
|
|
93
|
+
assert.deepEqual(f.admitted.map(p=>p?.id),['claude-a','codex-a'])
|
|
94
|
+
assert.deepEqual(run.attempts!.map(a=>[a.preset.id,a.outcome,a.category,a.resetAt]),[
|
|
95
|
+
['claude-a','failed','quota','2026-10-08T12:00:00.000Z'],['claude-b','skipped','same-quota-scope',undefined],['codex-a','completed',undefined,undefined]])
|
|
96
|
+
assert.equal((await f.runs.list()).filter(r=>r.scheduled?.id==='daily').length,1)
|
|
97
|
+
}finally{await f.close()}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('failure after work began, access denial and unknown failures stop without replay',async()=>{
|
|
101
|
+
for(const [name,lines,category] of [['partial',[work,quota],'after-work-began'],['hook',[{type:'system',subtype:'hook_started'},quota],'after-work-began'],['denied',[{type:'assistant',error:'authentication_failed'}],'access-denied'],['silent',[],'uncertain']] as const){
|
|
102
|
+
const f=await fixture(name,[{lines:[...lines],code:1}])
|
|
103
|
+
try{
|
|
104
|
+
const run=await f.done()
|
|
105
|
+
assert.equal(run.status,'failed',name);assert.equal(f.launches.length,1,name)
|
|
106
|
+
assert.deepEqual(run.attempts!.map(a=>[a.preset.id,a.outcome,a.category]),[['claude-a','failed',category]],name)
|
|
107
|
+
}finally{await f.close()}
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('each scheduled attempt launches with its own captured auth profile',async()=>{
|
|
112
|
+
const primary={...claudeA,authProfile:'work'}
|
|
113
|
+
const f=await fixture('profile',[{lines:[{type:'assistant',error:'model_not_found'}],code:1},{lines:[work],code:0}],[claudeB],primary)
|
|
114
|
+
try{
|
|
115
|
+
assert.equal((await f.done()).status,'completed')
|
|
116
|
+
assert.deepEqual(f.launches.map(l=>[l.cli,l.model,l.authProfile]),[['claude','opus','work'],['claude','sonnet',undefined]])
|
|
117
|
+
}finally{await f.close()}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('cancellation stops the chain',async()=>{
|
|
121
|
+
const f=await fixture('cancel',[{lines:[quota],hold:true}])
|
|
122
|
+
try{
|
|
123
|
+
await until(async()=>Boolean((await f.runs.get(f.run.id))?.pid))
|
|
124
|
+
await f.scheduler.cancel(f.run.id)
|
|
125
|
+
await f.relay.drainSources()
|
|
126
|
+
const run=await f.done()
|
|
127
|
+
assert.equal(run.status,'cancelled');assert.equal(f.launches.length,1)
|
|
128
|
+
assert.deepEqual(run.attempts!.map(a=>a.outcome),['cancelled'])
|
|
129
|
+
}finally{await f.close()}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('when every setup is unavailable the run reports each setup clearly',async()=>{
|
|
133
|
+
const f=await fixture('none',[{unavailable:true},{unavailable:true}],[codexA])
|
|
134
|
+
try{
|
|
135
|
+
const run=await f.done()
|
|
136
|
+
assert.equal(run.status,'failed');assert.equal(run.failureReason,'setups-unavailable')
|
|
137
|
+
assert.deepEqual(run.attempts!.map(a=>[a.preset.id,a.category]),[['claude-a','cli-unavailable'],['codex-a','cli-unavailable']])
|
|
138
|
+
assert.match(run.failure!.error,/Every task setup was unavailable: claude · opus .*\(cli-unavailable\); codex · gpt-6-sol/)
|
|
139
|
+
assert.match(f.sent.join('\n'),/could not start\. Every task setup was unavailable/)
|
|
140
|
+
}finally{await f.close()}
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('schedules reject duplicate or excess setups and the socket cannot rewrite attempts',async()=>{
|
|
144
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-fallback-validate-')),control=new ControlStore(dir,1000),scheduler=new Scheduler(dir)
|
|
145
|
+
try{
|
|
146
|
+
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
147
|
+
const owner=(await control.status()).owner!,base={id:'t',name:'T',text:'Work',owner,enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}}
|
|
148
|
+
const execution=(fallbacks:AiPreset[])=>({sessionId:'00000000-0000-4000-8000-000000000000',preset:claudeA,fallbacks})
|
|
149
|
+
await assert.rejects(scheduler.save({...base,execution:execution([{...claudeA,id:'copy'}])}),/distinct/)
|
|
150
|
+
await assert.rejects(scheduler.save({...base,execution:execution([claudeB,codexA,codexB,setup('x','codex','other')])}),/AI selection/)
|
|
151
|
+
await assert.rejects(scheduler.save({...base,execution:execution([{...codexA,model:undefined}])}),/explicit model/)
|
|
152
|
+
const forged=[{preset:setup('forged','claude','opus-max'),quotaScope:'claude',outcome:'running' as const}]
|
|
153
|
+
assert.equal(activeSetup({execution:execution([claudeB]),attempts:forged}),undefined)
|
|
154
|
+
const {serveTestLedger}=await import('./helpers/ledger.js'),{callDeliverySocket,socketPathFor}=await import('../src/delivery-socket.js')
|
|
155
|
+
const ledger=await serveTestLedger(dir)
|
|
156
|
+
try{
|
|
157
|
+
const run=await new RunStore(dir).create({chatId:101,telegramUserId:101,texts:['x'],execution:execution([claudeB])})
|
|
158
|
+
await assert.rejects(callDeliverySocket(socketPathFor(dir),{op:'patch',payload:{runId:run.id,change:{attempts:forged}}}),/relay-owned/)
|
|
159
|
+
}finally{await ledger.stop()}
|
|
160
|
+
}finally{await rm(dir,{recursive:true,force:true})}
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('host transport admits only the active task setup and profile, forwards only Claude startup summaries',async()=>{
|
|
164
|
+
const root=await mkdtemp(join(tmpdir(),'ez-fallback-host-')),workspace=join(root,'mind'),controlDir=join(root,'control'),directory=join(controlDir,'host-executor')
|
|
165
|
+
const {mkdir,writeFile,readFile}=await import('node:fs/promises'),{serveHostExecutor}=await import('../src/host-executor.js')
|
|
166
|
+
const abort=new AbortController(),launched:{cli?:string;model?:string;authProfile?:string}[]=[],oldPath=process.env.PATH
|
|
167
|
+
const primary={...claudeA,authProfile:'work'}
|
|
168
|
+
let server:Promise<void>|undefined
|
|
169
|
+
try{
|
|
170
|
+
await mkdir(workspace,{recursive:true});await mkdir(directory,{recursive:true});await mkdir(join(root,'bin'))
|
|
171
|
+
await writeFile(join(root,'bin','claude'),'#!/bin/sh\nexit 0\n',{mode:0o700})
|
|
172
|
+
process.env.PATH=join(root,'bin')+':'+oldPath
|
|
173
|
+
const control=new ControlStore(controlDir,900_000);await control.requestPairing(101,101);await control.approveOwner(101)
|
|
174
|
+
const owner=(await control.status()).owner!,runs=new RunStore(controlDir),id='r_schedule_fallback'
|
|
175
|
+
await runs.create({id,chatId:101,telegramUserId:101,texts:['work'],execution:{sessionId:'00000000-0000-4000-8000-000000000000',preset:primary,fallbacks:[claudeB]},
|
|
176
|
+
scheduled:{id:'daily',revision:'rev',dueAt:new Date().toISOString(),pairedAt:owner.pairedAt}})
|
|
177
|
+
await runs.patch(id,{status:'running',attempts:[{preset:primary,quotaScope:'claude',outcome:'running'}]})
|
|
178
|
+
server=serveHostExecutor({cli:'claude',agents:[{name:'t',workspace,controlDir,binDir:join(root,'bin')}]},abort.signal,async(_texts,options)=>{
|
|
179
|
+
launched.push({cli:options.cli,model:options.model,authProfile:options.authProfile})
|
|
180
|
+
const lines=options.model==='opus'?[JSON.stringify({type:'system',subtype:'init'}),JSON.stringify({type:'assistant',error:'model_not_found',message:{content:[{type:'text',text:'secret tool output'}]}})].join('\n')+'\n':''
|
|
181
|
+
const child=spawn(process.execPath,['-e',`process.stdout.write(${JSON.stringify(lines)});process.exit(1)`])
|
|
182
|
+
return {child,cleanup:async()=>{},stdout:''}
|
|
183
|
+
},async()=>[{cli:'claude',authProfile:'work',model:'opus',name:'Work',efforts:[]}])
|
|
184
|
+
const events=async()=>{for(let n=0;n<150;n++){try{const text=await readFile(join(directory,id+'.events'),'utf8');if(text.includes('"stream":"exit"'))return text}catch{};await new Promise(r=>setTimeout(r,20))}throw new Error('No exit event')}
|
|
185
|
+
const request=(options:object)=>writeFile(join(directory,id+'.request.json'),JSON.stringify({texts:['work'],options}))
|
|
186
|
+
for(let n=0;n<100 && !await readFile(join(directory,'heartbeat.json')).then(()=>true,()=>false);n++)await new Promise(r=>setTimeout(r,20))
|
|
187
|
+
await request({cli:'claude',model:'opus',authProfile:'work'})
|
|
188
|
+
const first=await events()
|
|
189
|
+
const summaries=first.trim().split('\n').map(l=>JSON.parse(l)).filter(e=>e.stream==='stdout').flatMap(e=>e.text.trim().split('\n').map((l:string)=>JSON.parse(l)))
|
|
190
|
+
assert.ok(summaries.every((e:{type:string})=>e.type==='ez.startup'))
|
|
191
|
+
assert.deepEqual(summaries.at(-1),{type:'ez.startup',workBegan:false,rejection:{category:'provider-rejected'}})
|
|
192
|
+
assert.doesNotMatch(first,/secret tool output/)
|
|
193
|
+
const {rm:remove}=await import('node:fs/promises');await remove(join(directory,id+'.events'))
|
|
194
|
+
// The relay advances: the next attempt reuses the run ID.
|
|
195
|
+
await runs.patch(id,{attempts:[{preset:primary,quotaScope:'claude',outcome:'failed',category:'provider-rejected'},{preset:claudeB,quotaScope:'claude',outcome:'running'}]})
|
|
196
|
+
await request({cli:'claude',model:'sonnet'})
|
|
197
|
+
await events();await remove(join(directory,id+'.events'))
|
|
198
|
+
assert.deepEqual(launched.map(l=>[l.cli,l.model,l.authProfile]),[['claude','opus','work'],['claude','sonnet',undefined]])
|
|
199
|
+
await request({cli:'claude',model:'sonnet',authProfile:'work'})
|
|
200
|
+
assert.match(await events(),/auth profile does not match its captured AI selection/)
|
|
201
|
+
await remove(join(directory,id+'.events'))
|
|
202
|
+
// A request for a setup other than the active attempt is refused before launch.
|
|
203
|
+
await request({cli:'claude',model:'opus'})
|
|
204
|
+
assert.match(await events(),/does not match its saved task model/)
|
|
205
|
+
assert.equal(launched.length,2)
|
|
206
|
+
}finally{abort.abort();await server;process.env.PATH=oldPath;await rm(root,{recursive:true,force:true})}
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('Codex native session reports typed pre-work rejection and work start',async(t)=>{
|
|
210
|
+
t.mock.method(console,'error',()=>{})
|
|
211
|
+
const {runCodexSession}=await import('../src/codex-session.js')
|
|
212
|
+
for(const worked of [false,true]){
|
|
213
|
+
const program=`const rl=require('readline').createInterface({input:process.stdin});const send=x=>process.stdout.write(JSON.stringify(x)+'\\n');
|
|
214
|
+
const event=(method,params)=>send({method,params:{threadId:'th',...params}});
|
|
215
|
+
rl.on('line',line=>{const q=JSON.parse(line);if(!q.id)return;
|
|
216
|
+
if(q.method==='initialize')return send({id:q.id,result:{}});
|
|
217
|
+
if(q.method==='thread/start')return send({id:q.id,result:{thread:{id:'th'}}});
|
|
218
|
+
if(q.method==='turn/start'){send({id:q.id,result:{turn:{id:'one'}}});event('turn/started',{turn:{id:'one'}});
|
|
219
|
+
event('item/started',{item:{type:'userMessage'}});${worked ? "event('item/started',{item:{type:'agentMessage'}});" : ''}
|
|
220
|
+
send({method:'account/rateLimits/updated',params:{rateLimits:{primary:{usedPercent:100,resetsAt:1791460800}}}});
|
|
221
|
+
event('turn/completed',{turn:{id:'one',status:'failed',error:{message:'limit',codexErrorInfo:'usageLimitExceeded'}}})}});setInterval(()=>{},1000);`
|
|
222
|
+
const output:string[]=[]
|
|
223
|
+
const code=await runCodexSession({workspace:'/tmp',controlDir:'/tmp/control',model:'gpt-6-sol',prompt:'work'},{launch:()=>spawn(process.execPath,['-e',program],{stdio:['pipe','pipe','pipe'],detached:process.platform!=='win32'}),emit:line=>output.push(line)})
|
|
224
|
+
assert.equal(code,1)
|
|
225
|
+
const o=startupObserver();o.write(output.join('\n')+'\n')
|
|
226
|
+
assert.deepEqual(o.result(),worked ? {workBegan:true,rejection:undefined} : {workBegan:false,rejection:{category:'quota',resetAt:'2026-10-08T12:00:00.000Z'}})
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('schedule CLI saves ordered fallbacks, preserves them on edit and clears them explicitly',async t=>{
|
|
231
|
+
const {execFile}=await import('node:child_process'),{promisify}=await import('node:util'),{fileURLToPath}=await import('node:url'),{serveTestLedger}=await import('./helpers/ledger.js')
|
|
232
|
+
const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
|
|
233
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-fallback-cli-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
234
|
+
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
235
|
+
const control=new ControlStore(dir,1000);await control.requestPairing(101,101);await control.approveOwner(101)
|
|
236
|
+
const env={...process.env,EZ_CONTROL_DIR:dir,EZ_RUN_ID:''}
|
|
237
|
+
const cli=async(...args:string[])=>JSON.parse((await exec(process.execPath,[bin,...args],{env})).stdout)
|
|
238
|
+
const when=['--at','2027-09-09T09:00:00+04:00','--text','Work']
|
|
239
|
+
const saved=await cli('create','chain','--cli','claude','--model','opus',...when,'--fallback','cli=claude,model=sonnet,effort=medium','--fallback','cli=codex,model=gpt-6-sol')
|
|
240
|
+
assert.deepEqual(saved.execution.fallbacks.map((p:AiPreset)=>[p.cli,p.model,p.effort]),[['claude','sonnet','medium'],['codex','gpt-6-sol',undefined]])
|
|
241
|
+
assert.deepEqual((await cli('edit','chain','--cli','claude','--model','opus',...when)).execution.fallbacks,saved.execution.fallbacks)
|
|
242
|
+
assert.equal((await cli('edit','chain','--cli','claude','--model','opus',...when,'--clear-fallbacks')).execution.fallbacks,undefined)
|
|
243
|
+
await assert.rejects(exec(process.execPath,[bin,'create','bad','--cli','claude','--model','opus',...when,'--fallback','cli=codex'],{env}),/cli=EXECUTOR,model=MODEL/)
|
|
244
|
+
await assert.rejects(exec(process.execPath,[bin,'create','dup','--cli','claude','--model','opus',...when,'--fallback','cli=claude,model=opus'],{env}),/distinct/)
|
|
245
|
+
})
|
package/test/task-native.test.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import test from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
-
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, rm, readFile } from 'node:fs/promises'
|
|
4
4
|
import { createServer } from 'node:http'
|
|
5
|
+
import {createServer as createNetServer} from 'node:net'
|
|
5
6
|
import { spawn, execFile } from 'node:child_process'
|
|
6
7
|
import { promisify } from 'node:util'
|
|
7
8
|
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { createHash } from 'node:crypto'
|
|
8
10
|
import { homedir } from 'node:os'
|
|
9
11
|
import { taskArguments, taskModelCatalog, TASK_CODEX_VERSION } from '../src/task-executor.js'
|
|
10
12
|
import { Tasks } from '../src/tasks.js'
|
|
@@ -12,33 +14,60 @@ import { ownerRun } from './helpers/owner-run.js'
|
|
|
12
14
|
import { EventSources } from '../src/event-sources.js'
|
|
13
15
|
import { ControlStore } from '../src/control-state.js'
|
|
14
16
|
import { RunStore } from '../src/runs.js'
|
|
17
|
+
import {ApplicationBindings} from '../src/application-channel.js'
|
|
15
18
|
import { taskRequests } from '../src/task-rpc.js'
|
|
16
19
|
|
|
17
20
|
// Real bundled model metadata plus native CLI, synthetic endpoint, no credentials or provider sends.
|
|
18
21
|
// Unknown fixture model names miss model-driven tool overrides.
|
|
19
22
|
// Run explicitly with EZ_TEST_NATIVE_TASKS=1 after installing the audited CLI.
|
|
20
23
|
test('native restricted task has only bounded MCP tools, ignores private guidance, and executes broker calls', { skip: !process.env.EZ_TEST_NATIVE_TASKS, timeout: 30000 }, async () => {
|
|
24
|
+
assert.equal((await promisify(execFile)('codex',['--version'])).stdout.trim(), `codex-cli ${TASK_CODEX_VERSION}`)
|
|
21
25
|
const root = await mkdtemp('/tmp/ez-native-task-'), directory = `${root}/task`, home = `${root}/home`
|
|
22
26
|
await mkdir(directory); await mkdir(home)
|
|
23
27
|
await writeFile(`${root}/AGENTS.md`, 'PRIVATE_CANARY_DO_NOT_LOAD')
|
|
24
28
|
await writeFile(`${home}/config.toml`, 'invalid = [ syntax')
|
|
25
29
|
const requests: any[] = [], sends: any[] = []
|
|
30
|
+
const incoming={id:'1',conversationId:'contact-a',receivedAt:Date.now()+1000,text:'shop.txt attached'}
|
|
31
|
+
const png=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=','base64')
|
|
32
|
+
const videoPath = `${root}/fixture.mp4`
|
|
33
|
+
await promisify(execFile)('ffmpeg',['-v','error','-f','lavfi','-i','color=c=blue:s=64x64:d=1','-c:v','mpeg4',videoPath])
|
|
34
|
+
const video=await readFile(videoPath)
|
|
26
35
|
const provider = createServer(async (req, res) => {
|
|
27
36
|
let body = ''; for await (const chunk of req) body += chunk
|
|
28
37
|
const { command, args } = JSON.parse(body)
|
|
29
|
-
res.end(JSON.stringify({ ok: true, data: command === 'events-head' ? { cursor: 0, accountId: 'fixture-account', taskProtocol: 'message-v1' }
|
|
38
|
+
res.end(JSON.stringify({ ok: true, data: command === 'events-head' ? { cursor: 0, accountId: 'fixture-account', taskProtocol: 'message-v1',taskVoice:true }
|
|
39
|
+
: command === 'events-check' ? {events:[incoming,{...incoming,id:'2',text:'image.png attached'},{...incoming,id:'3',text:'video.mp4 attached; speech transcript: Shop closes at 6pm.'}]}
|
|
40
|
+
: command === 'task-document' && args.incomingId === '3' ? {name:'video.mp4',data:video.toString('base64'),sha256:createHash('sha256').update(video).digest('hex')}
|
|
41
|
+
: command === 'task-document' && args.incomingId === '2' ? {name:'image.png',data:png.toString('base64'),sha256:createHash('sha256').update(png).digest('hex')}
|
|
42
|
+
: 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')}
|
|
30
43
|
: command === 'task-send' ? (sends.push(args), { ...args, state: 'accepted' }) : {} }))
|
|
31
44
|
})
|
|
32
45
|
await new Promise<void>(r => provider.listen(`${root}/p.sock`, r))
|
|
33
46
|
await ownerRun(root, 'owner')
|
|
34
47
|
await new EventSources(root).register('fixture', `${root}/p.sock`, (await new ControlStore(root, 900000).status()).owner!)
|
|
35
|
-
const tasks = new Tasks(root), drain = taskRequests(tasks)
|
|
48
|
+
const tasks = new Tasks(root,async () => ({buffer:Buffer.from('OggSfixture OpusHead'),mimeType:'audio/ogg'})), drain = taskRequests(tasks)
|
|
36
49
|
const proposal: any = await tasks.ownerCall('owner', 'start', { sourceId: 'fixture', conversationId: 'contact-a', purpose: 'Book dinner without payment', context: 'Two people at 7pm', hours: 1 })
|
|
37
50
|
assert.equal(proposal.state, 'active')
|
|
38
|
-
const runs = new RunStore(root), run =
|
|
51
|
+
const runs = new RunStore(root), run = await runs.create({id:'event_document_fixture',taskId:proposal.id,chatId:101,telegramUserId:101,texts:[],external:{sourceId:'fixture',bindingId:(await tasks.get(proposal.id))!.bindingId,eventIds:['1','2','3']}})
|
|
39
52
|
await runs.patch(run.id, { status: 'running' })
|
|
53
|
+
const bindings=new ApplicationBindings(root), owner=(await new ControlStore(root,900000).status()).owner!
|
|
54
|
+
await bindings.register('browser','z'.repeat(48),owner)
|
|
55
|
+
await bindings.taskLaunch('browser',{command:'fixture',args:['launch','--task'],tasks:[proposal.id]})
|
|
56
|
+
let launchRequests=0
|
|
57
|
+
const launchSocket=`${root}/launch.sock`
|
|
58
|
+
const launchBroker=createNetServer(socket=>{
|
|
59
|
+
let raw='';socket.on('data',chunk=>{raw+=chunk;if(!raw.includes('\n'))return;void(async()=>{
|
|
60
|
+
const request=JSON.parse(raw.trim()),input=JSON.parse(request.stdin)
|
|
61
|
+
assert.equal(request.operation,'task-launch');assert.equal(request.runId,run.id)
|
|
62
|
+
assert.equal(input.taskId,proposal.id)
|
|
63
|
+
const grant=await tasks.authorizeApplicationLaunch(run.id,createHash('sha256').update(input.taskToken).digest('hex'))
|
|
64
|
+
assert.equal(grant.launch.command,'fixture');assert.deepEqual(grant.launch.args,['launch','--task'])
|
|
65
|
+
launchRequests++
|
|
66
|
+
socket.end(JSON.stringify({version:1,id:request.id,ok:true,code:0,stdout:JSON.stringify({launch:{url:'https://fixture.example/#launch='+ 'e'.repeat(64),expiresAt:Date.now()+300000}})})+'\n')
|
|
67
|
+
})().catch(error=>socket.end(JSON.stringify({ok:false,error:error.message})+'\n'))})
|
|
68
|
+
});await new Promise<void>(r=>launchBroker.listen(launchSocket,r))
|
|
40
69
|
const sequence = [
|
|
41
|
-
['context', {}], ['send', { text: 'Is a table for two available at 7pm?', key: 'first' }],
|
|
70
|
+
['context', {}], ['browser_link',{application:'browser'}], ['read_attachment',{incomingId:'1'}], ['read_attachment',{incomingId:'2'}], ['read_attachment',{incomingId:'3'}], ['send', { text: 'Is a table for two available at 7pm?', key: 'first', voice:true }],
|
|
42
71
|
['note', { text: 'Awaiting confirmation' }], ['complete', { text: 'Request sent; no booking confirmation received.' }],
|
|
43
72
|
['send', { text: 'A completed task cannot send', key: 'second' }],
|
|
44
73
|
]
|
|
@@ -61,30 +90,39 @@ test('native restricted task has only bounded MCP tools, ignores private guidanc
|
|
|
61
90
|
try {
|
|
62
91
|
const entry = new URL('../src/task-mcp.ts', import.meta.url).href
|
|
63
92
|
await writeFile(`${root}/broker.mjs`, `if(process.env.HOME !== ${JSON.stringify(homedir())}) throw Error('Broker lost host CLI configuration'); await import(${JSON.stringify(entry)});`)
|
|
64
|
-
const broker = [process.execPath, '--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)), `${root}/broker.mjs`, root, run.id]
|
|
93
|
+
const broker = [process.execPath, '--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)), `${root}/broker.mjs`, root, run.id,'','[]','["browser"]']
|
|
65
94
|
const catalog = await promisify(execFile)('codex', ['debug', 'models', '--bundled'], { maxBuffer: 4 * 1024 * 1024 });
|
|
66
95
|
await writeFile(`${root}/models.json`, JSON.stringify(taskModelCatalog(JSON.parse(catalog.stdout))));
|
|
67
|
-
const args = taskArguments(directory, broker, JSON.stringify({event:'task_activated',taskId:proposal.id}),
|
|
96
|
+
const args = taskArguments(directory, broker, JSON.stringify({event:'task_activated',taskId:proposal.id}), ['context','browser_link','read_attachment','send','note','report','complete'], {model:'gpt-6-astra'}, {EZ_PLUGIN_BROKER_SOCKET:launchSocket,EZ_DELIVERY_SOCKET:`${root}/delivery.sock`})
|
|
68
97
|
args.splice(-1, 0, '--disable', 'enable_request_compression', '-c', 'model_provider="fixture"', '-c', `model_providers.fixture={name="fixture",base_url="http://127.0.0.1:${(server.address() as any).port}/v1",wire_api="responses",requires_openai_auth=false}`)
|
|
69
98
|
child = spawn('codex', args, { cwd: directory, env: { PATH: process.env.PATH, HOME: home, CODEX_HOME: home }, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
70
99
|
child.stdin!.end(JSON.stringify({event:'task_activated',taskId:proposal.id}));
|
|
71
100
|
let stderr = ''; child.stderr!.on('data', c => { stderr += c }); child.stdout!.resume()
|
|
72
101
|
const code = await new Promise(r => child!.on('close', r))
|
|
73
102
|
assert.equal(code, 0, `Requires audited Codex ${TASK_CODEX_VERSION}: ${stderr}`)
|
|
74
|
-
assert.
|
|
103
|
+
assert.equal(launchRequests,1, 'Native MCP uses the installed isolated broker launcher without tools home')
|
|
104
|
+
assert.ok(requests.length === 10, 'Native tool call completed a second model turn')
|
|
75
105
|
assert.ok(!JSON.stringify(requests).includes('PRIVATE_CANARY_DO_NOT_LOAD'))
|
|
106
|
+
assert.ok(JSON.stringify(requests).includes('https://fixture.example/#launch='))
|
|
107
|
+
assert.ok(!JSON.stringify(requests).includes('taskToken'))
|
|
76
108
|
const messages=requests[0].input.filter((v:any)=>v.role==='user')
|
|
77
109
|
assert.ok(messages.some((m:any)=>m.content.some((c:any)=>c.text===JSON.stringify({event:'task_activated',taskId:proposal.id}))))
|
|
78
110
|
const tools = requests[0].tools ?? requests[0].input.find((v: any) => v.type === 'additional_tools')?.tools
|
|
79
111
|
assert.deepEqual(tools.filter((t: any) => t.type === 'function').map((t: any) => t.name).sort(), ['list_mcp_resource_templates', 'list_mcp_resources', 'read_mcp_resource', 'request_user_input'])
|
|
80
112
|
const namespaces = tools.filter((t: any) => t.type === 'namespace')
|
|
81
113
|
assert.equal(namespaces.length, 1); assert.equal(namespaces[0].name, 'mcp__ez')
|
|
82
|
-
assert.deepEqual(namespaces[0].tools.map((t: any) => t.name).sort(), ['complete', 'context', 'note', 'report', 'send'])
|
|
83
|
-
assert.match(JSON.stringify(requests
|
|
84
|
-
assert.
|
|
114
|
+
assert.deepEqual(namespaces[0].tools.map((t: any) => t.name).sort(), ['browser_link','complete', 'context', 'note', 'read_attachment', 'report', 'send'])
|
|
115
|
+
assert.match(JSON.stringify(requests.at(-1).input), /inactive or expired/)
|
|
116
|
+
assert.ok(JSON.stringify(requests).includes('Shop opens at 7am.'))
|
|
117
|
+
assert.ok(JSON.stringify(requests).includes('data:image/'), 'Native CLI forwards MCP images into vision input: '+JSON.stringify(requests.map(request=>request.input)))
|
|
118
|
+
assert.ok(JSON.stringify(requests).includes('durationSeconds'), 'Native input includes video frame metadata')
|
|
119
|
+
assert.ok(JSON.stringify(requests).includes('Shop closes at 6pm.'))
|
|
120
|
+
const videoTurn=requests[5].input
|
|
121
|
+
assert.ok(JSON.stringify(videoTurn).includes('data:image/'), 'Native CLI receives sampled video frames as vision input')
|
|
122
|
+
assert.equal(sends.length, 1);assert.equal(Buffer.from(sends[0].audio.data,'base64').toString(),'OggSfixture OpusHead'); assert.equal(sends[0].conversationId, 'contact-a')
|
|
85
123
|
assert.equal((await tasks.get(proposal.id))!.state, 'completed')
|
|
86
124
|
} finally {
|
|
87
|
-
child?.kill(); clearInterval(timer); server.closeAllConnections(); await new Promise<void>(r => server.close(() => r()))
|
|
125
|
+
child?.kill(); clearInterval(timer); await new Promise<void>(r=>launchBroker.close(()=>r())); server.closeAllConnections(); await new Promise<void>(r => server.close(() => r()))
|
|
88
126
|
provider.closeAllConnections(); await new Promise<void>(r => provider.close(() => r()))
|
|
89
127
|
await rm(root, { recursive: true, force: true })
|
|
90
128
|
}
|
package/test/tasks.test.ts
CHANGED
|
@@ -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.
|
|
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
|
+
})
|