@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.
- package/.env.example +3 -0
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +21 -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 +86 -0
- package/docker/run.ts +1 -1
- package/docs/application-channel.md +32 -1
- package/docs/architecture/ai-selection.md +18 -3
- package/docs/docker-runtime.md +29 -0
- package/docs/isolated-plugin-update-readiness.md +66 -0
- package/docs/releasing.md +4 -0
- package/docs/scheduling.md +100 -6
- package/docs/selective-monitoring.md +13 -0
- package/docs/upgrades.md +35 -0
- package/docs/work-preflight.md +9 -0
- package/package.json +1 -1
- package/src/ai.ts +22 -3
- package/src/application-channel.ts +43 -3
- package/src/application-cli.ts +7 -1
- package/src/application-origin.ts +2 -0
- package/src/audio.ts +37 -22
- package/src/client-defaults.ts +3 -2
- package/src/config.ts +9 -0
- package/src/control-state.ts +3 -1
- package/src/delivery-socket-client.d.mts +2 -0
- package/src/delivery-socket-client.mjs +2 -0
- package/src/delivery-socket.ts +24 -1
- package/src/event-sources.ts +2 -2
- package/src/executor.ts +72 -1
- package/src/files.ts +47 -3
- package/src/host-executor.ts +38 -29
- package/src/index.ts +41 -18
- package/src/install-tools.mjs +8 -5
- package/src/menu.ts +4 -2
- package/src/plugin-broker.mjs +47 -4
- package/src/plugins/manager.mjs +11 -4
- package/src/run-evidence.ts +78 -0
- package/src/runs.ts +12 -1
- package/src/schedule-cli.ts +69 -8
- package/src/schedule-preflight.ts +23 -0
- package/src/scheduled-tasks.ts +16 -12
- package/src/scheduler.ts +79 -18
- package/src/scripts.ts +167 -0
- package/src/setup.ts +2 -2
- package/src/task-capability.ts +17 -1
- package/src/task-executor.ts +10 -7
- package/src/task-mcp.ts +20 -8
- package/src/tasks.ts +89 -9
- package/src/updates/runtime.mjs +46 -7
- package/test/ai.test.ts +40 -0
- package/test/application-channel.test.ts +16 -1
- package/test/audio.test.ts +19 -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 +64 -0
- package/test/plugin-manager.test.mjs +20 -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/task-native.test.ts +50 -12
- package/test/tasks.test.ts +97 -4
- package/test/updates.test.mjs +45 -9
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {mkdtemp,rm,readFile} from 'node:fs/promises'
|
|
4
|
+
import {join} from 'node:path'
|
|
5
|
+
import {tmpdir} from 'node:os'
|
|
6
|
+
import {randomUUID} from 'node:crypto'
|
|
7
|
+
import {Scheduler} from '../src/scheduler.js'
|
|
8
|
+
import {RunStore} from '../src/runs.js'
|
|
9
|
+
import {validPreflight,combinePreflight} from '../src/schedule-preflight.js'
|
|
10
|
+
const packet=(eligible:boolean,fingerprint='a'.repeat(64))=>({eligible,fingerprint,count:eligible?1:0,observedAt:new Date().toISOString()})
|
|
11
|
+
test('preflight skips empty/unchanged work, admits saved worker once, and fails closed',async t=>{
|
|
12
|
+
const dir=await mkdtemp(join(tmpdir(),'schedule-preflight-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
13
|
+
let result=packet(false),failed=false
|
|
14
|
+
const scheduler=new Scheduler(dir,async()=>{if(failed)throw Error('Provider unavailable');return result}),runs=new RunStore(dir)
|
|
15
|
+
const owner={telegramUserId:42,telegramChatId:42,pairedAt:new Date().toISOString()},start=Date.now()+10000
|
|
16
|
+
const input={id:'preflight',name:'Check',text:'Do role work',enabled:true,owner,execution:{sessionId:randomUUID(),preset:{id:'test',name:'test',cli:'codex',model:'fixture-model'}},trigger:{everySeconds:60,start:new Date(start).toISOString()},preflight:{on:'changed' as const,checks:[{alias:'provider-work',args:['--field','Role=RoleX']}]}}
|
|
17
|
+
const s=await scheduler.save(input)
|
|
18
|
+
await scheduler.tick(owner,runs,start);assert.equal((await runs.list()).length,0)
|
|
19
|
+
result=packet(true,'b'.repeat(64));await scheduler.tick(owner,runs,start+60000)
|
|
20
|
+
const [run]=await runs.list();assert.deepEqual(run.execution,s.execution)
|
|
21
|
+
await scheduler.tick(owner,runs,start+120000);assert.equal((await runs.list()).length,1)
|
|
22
|
+
await runs.patch(run.id,{status:'completed'});await scheduler.tick(owner,runs,start+180000);assert.equal((await runs.list()).length,1)
|
|
23
|
+
failed=true;await scheduler.tick(owner,runs,start+240000);assert.equal((await runs.list()).length,1);assert.equal((await scheduler.preflightReceipt(s)).state,'unavailable')
|
|
24
|
+
failed=false;result=packet(false);await assert.rejects(()=>scheduler.trigger(s.id,s.revision,'empty',owner,runs),/no eligible/)
|
|
25
|
+
result=packet(true,'c'.repeat(64));await scheduler.tick(owner,runs,start+300000);assert.equal((await runs.list()).length,2)
|
|
26
|
+
})
|
|
27
|
+
test('only bounded literal commands and fresh typed packets enter admission',()=>{
|
|
28
|
+
assert.equal(validPreflight({on:'eligible',checks:[{alias:'queue',args:['x']}] }),true)
|
|
29
|
+
assert.equal(validPreflight({on:'eligible',checks:[{alias:'queue',args:['x\nwrite']}] }),false)
|
|
30
|
+
assert.throws(()=>combinePreflight([{schemaVersion:1,...packet(true),eligible:'true'}]),/Invalid/)
|
|
31
|
+
assert.throws(()=>combinePreflight([{schemaVersion:1,...packet(true),observedAt:'2000-01-01T00:00:00Z'}]),/stale/)
|
|
32
|
+
assert.equal(combinePreflight([{schemaVersion:1,...packet(false)},{schemaVersion:1,...packet(true)}]).eligible,true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('manual preflight before the first tick preserves future recurrence',async t=>{
|
|
36
|
+
const dir=await mkdtemp(join(tmpdir(),'schedule-preflight-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
37
|
+
const scheduler=new Scheduler(dir,async()=>packet(true)),runs=new RunStore(dir)
|
|
38
|
+
const owner={telegramUserId:42,telegramChatId:42,pairedAt:new Date().toISOString()}
|
|
39
|
+
const s=await scheduler.save({id:'manual',name:'Manual',text:'Work',enabled:true,owner,
|
|
40
|
+
execution:{sessionId:randomUUID(),preset:{id:'test',name:'test',cli:'codex',model:'fixture-model'}},
|
|
41
|
+
trigger:{everySeconds:60,start:new Date(Date.now()+60000).toISOString()},
|
|
42
|
+
preflight:{on:'eligible',checks:[{alias:'provider-work',args:[]}]}})
|
|
43
|
+
const next=await scheduler.pendingOccurrence(s)
|
|
44
|
+
const manual=await scheduler.trigger(s.id,s.revision,'first',owner,runs)
|
|
45
|
+
assert.equal(await scheduler.pendingOccurrence(s),next)
|
|
46
|
+
await runs.patch(manual.id,{status:'completed'})
|
|
47
|
+
await scheduler.tick(owner,runs,next!)
|
|
48
|
+
assert.equal((await runs.list()).length,2)
|
|
49
|
+
})
|
|
@@ -42,6 +42,10 @@ test('host transport keeps chat responsive beside a schedule in the bound mind',
|
|
|
42
42
|
await runs.patch(id,{status:'running'})
|
|
43
43
|
await submit(id)
|
|
44
44
|
await until(async()=>Boolean(await exists(id+'.process.json')))
|
|
45
|
+
const parallel='r_schedule_parallel'
|
|
46
|
+
await runs.create({id:parallel,chatId:101,telegramUserId:101,texts:['parallel'],execution:{sessionId:randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'grok',model:'fixture-model'}},scheduled:{id:'parallel',revision:'v',dueAt:new Date().toISOString(),pairedAt:'paired'}})
|
|
47
|
+
await runs.patch(parallel,{status:'running'});await submit(parallel)
|
|
48
|
+
await until(async()=>Boolean(await exists(parallel+'.process.json')))
|
|
45
49
|
await submit('tg_1')
|
|
46
50
|
await until(async()=>(await exists('tg_1.events')).includes('"stream":"exit","code":0'))
|
|
47
51
|
assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
|
|
@@ -60,6 +64,9 @@ test('host transport keeps chat responsive beside a schedule in the bound mind',
|
|
|
60
64
|
assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
|
|
61
65
|
await writeFile(join(dir,id+'.cancel'),'')
|
|
62
66
|
await until(async()=>(await exists(id+'.events')).includes('"stream":"exit"'))
|
|
67
|
+
assert.ok(!(await exists(parallel+'.events')).includes('"stream":"exit"'))
|
|
68
|
+
await writeFile(join(dir,parallel+'.cancel'),'')
|
|
69
|
+
await until(async()=>(await exists(parallel+'.events')).includes('"stream":"exit"'))
|
|
63
70
|
await until(async()=>(await exists('tg_1.events')).includes('"stream":"exit","code":0'))
|
|
64
71
|
await until(async()=>(await exists('tg_2.events')).includes('"stream":"exit","code":0'))
|
|
65
72
|
const main=JSON.parse(stdout(await exists('tg_1.events')))
|
|
@@ -32,7 +32,7 @@ test('executor admission rejects a legacy queued schedule with no model before s
|
|
|
32
32
|
scheduled:{id:saved.id,revision:saved.revision,dueAt:new Date(due).toISOString(),pairedAt:owner.pairedAt}})
|
|
33
33
|
await relay.drainSources()
|
|
34
34
|
assert.equal((await runs.get(run.id))?.status,'failed');assert.equal(launches,0)
|
|
35
|
-
assert.equal((await scheduler.get(saved.id)).execution
|
|
35
|
+
assert.equal((await scheduler.get(saved.id)).execution!.preset.model,'saved-model')
|
|
36
36
|
}finally{await relay.stop();await rm(dir,{recursive:true,force:true})}
|
|
37
37
|
})
|
|
38
38
|
|
|
@@ -89,10 +89,10 @@ test('a wrapper-shaped run id has no maintenance treatment and a stale notice is
|
|
|
89
89
|
}finally{await relay.stop();await rm(dir,{recursive:true,force:true})}
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
-
test('Slack application replies beside
|
|
92
|
+
test('Slack application replies beside schedules configured to stay serial',async()=>{
|
|
93
93
|
const dir=await mkdtemp(join(tmpdir(),'ez-slack-scheduled-')),runs=new RunStore(dir),scheduler=new Scheduler(dir),control=new ControlStore(dir,1000)
|
|
94
94
|
const children=new Map<string,ReturnType<typeof spawn>>()
|
|
95
|
-
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async(_texts,options)=>{
|
|
95
|
+
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture',scheduledConcurrency:1},async(_texts,options)=>{
|
|
96
96
|
const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:process.platform!=='win32'})
|
|
97
97
|
children.set(options.runId,child);await once(child,'spawn')
|
|
98
98
|
if(!options.runId.startsWith('r_schedule_'))await runs.enqueueMessage(options.runId,'Slack reply')
|
|
@@ -176,3 +176,38 @@ test('owner chat replies while a scheduled CLI keeps running; targeted cancellat
|
|
|
176
176
|
await rm(dir,{recursive:true,force:true})
|
|
177
177
|
}
|
|
178
178
|
})
|
|
179
|
+
|
|
180
|
+
for(const limit of [undefined,2])test(`scheduled admission bounds independent native jobs at ${limit ?? 6} and releases a cancelled slot`,async()=>{
|
|
181
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-parallel-schedules-')),control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
|
|
182
|
+
const children=new Map<string,ReturnType<typeof spawn>>(),optionsSeen:ExecutorOptions[]=[]
|
|
183
|
+
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture',scheduledConcurrency:limit},async(_texts,options)=>{
|
|
184
|
+
optionsSeen.push(options)
|
|
185
|
+
const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:process.platform!=='win32'})
|
|
186
|
+
children.set(options.runId,child);await once(child,'spawn');return {child,cleanup:async()=>{},stdout:''}
|
|
187
|
+
})
|
|
188
|
+
try{
|
|
189
|
+
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
190
|
+
const owner=(await control.status()).owner!,execution=await control.captureChoice({...initialPreset('grok'),model:'saved-model',effort:'medium'})
|
|
191
|
+
const jobs: Awaited<ReturnType<Scheduler['trigger']>>[]=[]
|
|
192
|
+
for(let n=0;n<(limit ?? 6)+1;n++){
|
|
193
|
+
const saved=await scheduler.save({id:`job${n}`,name:`Job${n}`,text:'Finite test',owner,execution,enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}},true)
|
|
194
|
+
jobs.push(await scheduler.trigger(saved.id,saved.revision,`key${n}`,owner,runs))
|
|
195
|
+
}
|
|
196
|
+
await Promise.all([relay.drainSources(),relay.drainSources()])
|
|
197
|
+
assert.equal((await runs.list()).filter(r=>r.status==='running').length,limit ?? 6)
|
|
198
|
+
assert.equal((await runs.get(jobs.at(-1)!.id))?.status,'queued')
|
|
199
|
+
assert.equal(new Set(optionsSeen.map(o=>o.sessionId)).size,limit ?? 6)
|
|
200
|
+
for(const options of optionsSeen){assert.equal(options.model,'saved-model');assert.equal(options.effort,'medium');assert.equal(options.nativeSession,true);assert.equal(options.isResume,false)}
|
|
201
|
+
const foreground=await runs.create({id:'tg_parallel_chat',chatId:101,telegramUserId:101,texts:['Owner chat'],execution})
|
|
202
|
+
await relay.drainSources();assert.equal((await runs.get(foreground.id))?.status,'running')
|
|
203
|
+
assert.equal(children.size,(limit ?? 6)+1)
|
|
204
|
+
await scheduler.cancel(jobs[0].id);await relay.drainSources()
|
|
205
|
+
await until(async()=> (await runs.get(jobs[0].id))?.status==='cancelled')
|
|
206
|
+
await relay.drainSources();assert.equal((await runs.get(jobs.at(-1)!.id))?.status,'running')
|
|
207
|
+
for(const job of jobs.slice(1))assert.equal((await runs.get(job.id))?.status,'running')
|
|
208
|
+
}finally{
|
|
209
|
+
await relay.stop()
|
|
210
|
+
await until(async()=>!(await runs.list()).some(r=>r.status==='running'))
|
|
211
|
+
await rm(dir,{recursive:true,force:true})
|
|
212
|
+
}
|
|
213
|
+
})
|
package/test/scheduler.test.ts
CHANGED
|
@@ -167,7 +167,7 @@ test('missing models cannot be saved, dispatched or triggered; legacy settings r
|
|
|
167
167
|
const file=join(f.dir,'schedules/test.json')
|
|
168
168
|
await writeFile(file,JSON.stringify({...saved,execution:missing.execution}))
|
|
169
169
|
const legacy=await f.scheduler.get(saved.id)
|
|
170
|
-
assert.equal(legacy.execution
|
|
170
|
+
assert.equal(legacy.execution!.preset.model,undefined)
|
|
171
171
|
await f.scheduler.tick(f.owner,f.runs,f.now)
|
|
172
172
|
assert.equal((await f.runs.list()).length,0)
|
|
173
173
|
assert.equal(await f.scheduler.pendingOccurrence(legacy),next,'blocked dispatch must preserve the pending occurrence')
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { execFile, spawn } from 'node:child_process'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
import { once } from 'node:events'
|
|
6
|
+
import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile, access } from 'node:fs/promises'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { createRelay } from '../src/index.js'
|
|
11
|
+
import { ControlStore } from '../src/control-state.js'
|
|
12
|
+
import { RunStore, type RunRecord } from '../src/runs.js'
|
|
13
|
+
import { Scheduler } from '../src/scheduler.js'
|
|
14
|
+
import { Scripts } from '../src/scripts.js'
|
|
15
|
+
import { initialPreset } from '../src/ai.js'
|
|
16
|
+
import { startExecutorJob, type ExecutorOptions } from '../src/executor.js'
|
|
17
|
+
import { serveTestLedger } from './helpers/ledger.js'
|
|
18
|
+
|
|
19
|
+
const exec = promisify(execFile), bin = fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs', import.meta.url))
|
|
20
|
+
const until = async (check: () => Promise<boolean>, tries = 400) => { for (let i = 0; i < tries; i++) { if (await check()) return; await new Promise(r => setTimeout(r, 25)) } throw new Error('Timed out') }
|
|
21
|
+
const alive = (pid: number) => { try { process.kill(pid, 0); return true } catch { return false } }
|
|
22
|
+
|
|
23
|
+
// The script records only what it was given; it never prints secrets.
|
|
24
|
+
const fixture = `import { writeFileSync } from 'node:fs'
|
|
25
|
+
import { spawn } from 'node:child_process'
|
|
26
|
+
const mode = process.argv[2]
|
|
27
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => k.startsWith('EZ_')))
|
|
28
|
+
writeFileSync('out-' + process.env.EZ_RUN_ID + '.json', JSON.stringify({ argv: process.argv.slice(2), env,
|
|
29
|
+
leaked: ['TELEGRAM_BOT_TOKEN', 'EZ_TEST_SECRET', 'OPENAI_API_KEY'].filter(k => process.env[k] !== undefined) }))
|
|
30
|
+
console.log('routine check ok token=abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOP')
|
|
31
|
+
console.error('diagnostic line')
|
|
32
|
+
if (mode === 'hang') {
|
|
33
|
+
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' })
|
|
34
|
+
writeFileSync('child-' + process.env.EZ_RUN_ID + '.pid', String(child.pid))
|
|
35
|
+
setInterval(() => {}, 1000)
|
|
36
|
+
}
|
|
37
|
+
if (mode === 'fail') process.exit(3)
|
|
38
|
+
`
|
|
39
|
+
|
|
40
|
+
const setup = async () => {
|
|
41
|
+
const root = await mkdtemp(join(tmpdir(), 'ez-script-schedules-'))
|
|
42
|
+
const dir = join(root, 'control'), workspace = join(root, 'workspace'), fakeBin = join(root, 'bin')
|
|
43
|
+
await Promise.all([mkdir(dir), mkdir(join(workspace, 'scripts'), { recursive: true }), mkdir(fakeBin)])
|
|
44
|
+
await writeFile(join(workspace, 'scripts', 'guard.mjs'), fixture)
|
|
45
|
+
// Any native engine launch would leave this marker: script runs must not start one.
|
|
46
|
+
for (const engine of ['codex', 'claude', 'grok']) {
|
|
47
|
+
await writeFile(join(fakeBin, engine), `#!/bin/sh\necho "$0" >> ${JSON.stringify(join(root, 'llm-invocations'))}\n`)
|
|
48
|
+
await chmod(join(fakeBin, engine), 0o755)
|
|
49
|
+
}
|
|
50
|
+
const control = new ControlStore(dir, 1000)
|
|
51
|
+
await control.requestPairing(101, 101); await control.approveOwner(101)
|
|
52
|
+
const owner = (await control.status()).owner!
|
|
53
|
+
return { root, dir, workspace, fakeBin, control, owner, runs: new RunStore(dir), scheduler: new Scheduler(dir), scripts: new Scripts(dir) }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test('script registration confines the entry point, rejects shell strings and records the entry hash', async t => {
|
|
57
|
+
const f = await setup(); t.after(() => rm(f.root, { recursive: true, force: true }))
|
|
58
|
+
const base = { id: 'guard', owner: f.owner, workspace: f.workspace, interpreter: 'node', args: [], timeoutSeconds: 60 }
|
|
59
|
+
const saved = await f.scripts.save({ ...base, entry: 'scripts/guard.mjs' }, true)
|
|
60
|
+
assert.equal(saved.entry, join('scripts', 'guard.mjs')); assert.match(saved.sha256, /^[a-f0-9]{64}$/)
|
|
61
|
+
await assert.rejects(f.scripts.save({ ...base, entry: 'scripts/guard.mjs' }, true), /already registered/)
|
|
62
|
+
await writeFile(join(f.root, 'outside.mjs'), 'process.exit(0)')
|
|
63
|
+
await assert.rejects(f.scripts.save({ ...base, id: 'escape', entry: '../outside.mjs' }, true), /inside the agent workspace/)
|
|
64
|
+
await symlink(join(f.root, 'outside.mjs'), join(f.workspace, 'link.mjs'))
|
|
65
|
+
await assert.rejects(f.scripts.save({ ...base, id: 'escape', entry: 'link.mjs' }, true), /inside the agent workspace/)
|
|
66
|
+
await assert.rejects(f.scripts.save({ ...base, id: 'shell', entry: 'scripts/guard.mjs', interpreter: 'node -e "x"' }, true), /not a shell command/)
|
|
67
|
+
await assert.rejects(f.scripts.save({ ...base, id: 'shell', entry: 'scripts/guard.mjs', interpreter: 'sh;rm' }, true), /not a shell command/)
|
|
68
|
+
await assert.rejects(f.scripts.save({ ...base, id: 'missing', entry: 'scripts/guard.mjs', interpreter: 'no-such-interpreter-ez' }, true), /not installed/)
|
|
69
|
+
await assert.rejects(f.scripts.save({ ...base, id: 'Bad ID', entry: 'scripts/guard.mjs' }, true), /Script ID/)
|
|
70
|
+
// An interpreter inside the workspace would be unhashed code.
|
|
71
|
+
await writeFile(join(f.workspace, 'run.sh'), '#!/bin/sh\n'); await chmod(join(f.workspace, 'run.sh'), 0o755)
|
|
72
|
+
await assert.rejects(f.scripts.save({ ...base, id: 'local', entry: 'scripts/guard.mjs', interpreter: join(f.workspace, 'run.sh') }, true), /outside the agent workspace/)
|
|
73
|
+
await assert.rejects(f.scripts.save({ ...base, id: 'local', entry: 'scripts/guard.mjs', interpreter: 'run.sh', pathValue: '.' }, true), /not installed/)
|
|
74
|
+
// Another owner's registration cannot be scheduled.
|
|
75
|
+
const other = { ...f.owner, generation: '00000000-0000-4000-8000-000000000000', pairedAt: new Date(Date.parse(f.owner.pairedAt) + 1000).toISOString() }
|
|
76
|
+
await assert.rejects(f.scheduler.save({ id: 'stranger', name: 'x', text: '', owner: other, enabled: true, trigger: { at: '2027-01-01T00:00:00Z' }, script: { id: 'guard', args: [] } }, true), /outside this owner binding/)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('agent registers and schedules a script natively; script runs cannot change scripts or schedules', async t => {
|
|
80
|
+
const f = await setup(); t.after(() => rm(f.root, { recursive: true, force: true }))
|
|
81
|
+
const ledger = await serveTestLedger(f.dir); t.after(() => ledger.stop())
|
|
82
|
+
const execution = await f.control.captureChoice(initialPreset('codex'))
|
|
83
|
+
const turn = await f.runs.create({ chatId: 101, telegramUserId: 101, texts: ['register it'], execution })
|
|
84
|
+
await f.runs.patch(turn.id, { status: 'running' })
|
|
85
|
+
const env = { ...process.env, EZ_CONTROL_DIR: f.dir, EZ_RUN_ID: turn.id, EZ_AGENT_WORKSPACE: f.workspace }
|
|
86
|
+
const cli = async (...args: string[]) => JSON.parse((await exec(process.execPath, [bin, ...args], { env, cwd: f.workspace })).stdout)
|
|
87
|
+
const registered = await cli('script', 'register', 'book-freshness', '--file', 'scripts/guard.mjs', '--interpreter', 'node', '--timeout-seconds', '120')
|
|
88
|
+
assert.equal(registered.interpreter, 'node'); assert.equal(registered.timeoutSeconds, 120)
|
|
89
|
+
await assert.rejects(cli('create', 'bad', '--script', 'book-freshness', '--model', 'gpt-6-astra', '--now'), /run no model/)
|
|
90
|
+
await assert.rejects(cli('create', 'bad', '--script', 'unknown', '--now'), /not registered/)
|
|
91
|
+
const schedule = await cli('create', 'actual-book-freshness-guard', '--script', 'book-freshness', '--arg=--quiet', '--cron', '15 * * * *', '--timezone', 'Asia/Dubai')
|
|
92
|
+
assert.equal(schedule.executionType, 'script'); assert.deepEqual(schedule.script, { id: 'book-freshness', args: ['--quiet'] })
|
|
93
|
+
assert.equal(schedule.execution, undefined); assert.equal(schedule.text, '')
|
|
94
|
+
const shown = await cli('script', 'show', 'book-freshness')
|
|
95
|
+
assert.equal(shown.matchesRegistration, true); assert.deepEqual(shown.schedules, ['actual-book-freshness-guard'])
|
|
96
|
+
await assert.rejects(cli('script', 'remove', 'book-freshness'), /used by schedules/)
|
|
97
|
+
// An existing agent schedule remains readable beside it.
|
|
98
|
+
const agent = await cli('create', 'daily', '--text', 'Prepare the report', '--model', 'gpt-6-astra', '--at', '2027-09-09T09:00:00+04:00')
|
|
99
|
+
assert.equal(agent.executionType, 'agent'); assert.equal(agent.execution.preset.model, 'gpt-6-astra')
|
|
100
|
+
|
|
101
|
+
const scriptRun = await f.runs.create({ chatId: 101, telegramUserId: 101, texts: ['[schedule]'],
|
|
102
|
+
script: { id: 'book-freshness', args: [], revision: registered.revision, sha256: registered.sha256 },
|
|
103
|
+
scheduled: { id: 'actual-book-freshness-guard', revision: schedule.revision, dueAt: new Date().toISOString(), pairedAt: f.owner.pairedAt } })
|
|
104
|
+
await f.runs.patch(scriptRun.id, { status: 'running' })
|
|
105
|
+
const asScript = { env: { ...env, EZ_RUN_ID: scriptRun.id }, cwd: f.workspace }
|
|
106
|
+
for (const args of [['script', 'update', 'book-freshness'], ['script', 'register', 'other', '--file', 'scripts/guard.mjs', '--interpreter', 'node'],
|
|
107
|
+
['edit', 'actual-book-freshness-guard', '--script', 'book-freshness', '--now'], ['pause', 'daily'], ['trigger', 'daily', '--key', 'k']])
|
|
108
|
+
await assert.rejects(exec(process.execPath, [bin, ...args], asScript), /Script runs cannot change scripts or schedules/)
|
|
109
|
+
assert.equal(JSON.parse((await exec(process.execPath, [bin, 'script', 'show', 'book-freshness'], asScript)).stdout).id, 'book-freshness')
|
|
110
|
+
// A relative --file resolves from the caller's directory, stored relative to the workspace.
|
|
111
|
+
const nested = JSON.parse((await exec(process.execPath, [bin, 'script', 'register', 'nested', '--file', 'guard.mjs', '--interpreter', 'node'], { env, cwd: join(f.workspace, 'scripts') })).stdout)
|
|
112
|
+
assert.equal(nested.entry, join('scripts', 'guard.mjs'))
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('script CLI creates, preserves and explicitly clears preflight gates', async t => {
|
|
116
|
+
const f=await setup();t.after(()=>rm(f.root,{recursive:true,force:true}))
|
|
117
|
+
const ledger=await serveTestLedger(f.dir);t.after(()=>ledger.stop())
|
|
118
|
+
await f.scripts.save({id:'guard',owner:f.owner,workspace:f.workspace,entry:'scripts/guard.mjs',interpreter:'node',args:[],timeoutSeconds:60},true)
|
|
119
|
+
const preflight={on:'eligible',checks:[{alias:'provider-work',args:[]}]},file=join(f.root,'preflight.json')
|
|
120
|
+
await writeFile(file,JSON.stringify(preflight))
|
|
121
|
+
const env={...process.env,EZ_CONTROL_DIR:f.dir,EZ_RUN_ID:'',EZ_AGENT_WORKSPACE:f.workspace}
|
|
122
|
+
const cli=async(...args:string[])=>JSON.parse((await exec(process.execPath,[bin,...args],{env,cwd:f.workspace})).stdout)
|
|
123
|
+
const options=['--script','guard','--at','2027-09-09T09:00:00+04:00']
|
|
124
|
+
const created=await cli('create','gated',...options,'--preflight-file',file)
|
|
125
|
+
assert.deepEqual(created.preflight,preflight)
|
|
126
|
+
const edited=await cli('edit','gated',...options,'--name','Renamed')
|
|
127
|
+
assert.deepEqual(edited.preflight,preflight)
|
|
128
|
+
const cleared=await cli('edit','gated',...options,'--clear-preflight')
|
|
129
|
+
assert.equal(cleared.preflight,undefined)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('script schedules run with zero LLM invocations, exact revision receipts, isolation and fail-safe integrity', async t => {
|
|
133
|
+
const f = await setup(); t.after(() => rm(f.root, { recursive: true, force: true }))
|
|
134
|
+
const savedEnv = { PATH: process.env.PATH, TELEGRAM_BOT_TOKEN: process.env.TELEGRAM_BOT_TOKEN, EZ_TEST_SECRET: process.env.EZ_TEST_SECRET }
|
|
135
|
+
process.env.PATH = `${f.fakeBin}:${process.env.PATH}`; process.env.TELEGRAM_BOT_TOKEN = '123456:secret-token-value-for-tests-only'; process.env.EZ_TEST_SECRET = 'do-not-inherit'
|
|
136
|
+
t.after(() => { for (const [k, v] of Object.entries(savedEnv)) { if (v === undefined) delete process.env[k]; else process.env[k] = v } })
|
|
137
|
+
const agentLaunches: ExecutorOptions[] = []
|
|
138
|
+
const relay = createRelay({ workspace: f.workspace, controlDir: f.dir, pairingTtlMs: 1000, executorTimeoutMs: 0, executorCli: 'codex', telegramBotToken: 'fixture' },
|
|
139
|
+
async (texts, options) => {
|
|
140
|
+
if (options.script) return startExecutorJob(texts, options)
|
|
141
|
+
agentLaunches.push(options)
|
|
142
|
+
const child = spawn(process.execPath, ['-e', 'setTimeout(()=>{},10)'], { detached: true }); await once(child, 'spawn')
|
|
143
|
+
return { child, cleanup: async () => {}, stdout: '' }
|
|
144
|
+
})
|
|
145
|
+
relay.bot.api.config.use(async () => ({ ok: true, result: { message_id: 42 } }) as never)
|
|
146
|
+
t.after(() => relay.stop())
|
|
147
|
+
const registration = await f.scripts.save({ id: 'book-freshness', owner: f.owner, workspace: f.workspace, entry: 'scripts/guard.mjs', interpreter: 'node', args: ['ok'], timeoutSeconds: 60 }, true)
|
|
148
|
+
const schedule = await f.scheduler.save({ id: 'guard', name: 'Guard', text: '', owner: f.owner, enabled: true, trigger: { cron: '15 * * * *', timezone: 'Asia/Dubai', start: new Date().toISOString() }, script: { id: 'book-freshness', args: ['extra'] } }, true)
|
|
149
|
+
const finished = (id: string) => until(async () => ['completed', 'failed', 'cancelled'].includes((await f.runs.get(id))?.status ?? ''))
|
|
150
|
+
const output = async (run: RunRecord) => JSON.parse(await readFile(join(f.workspace, `out-${run.id}.json`), 'utf8'))
|
|
151
|
+
|
|
152
|
+
// Manual trigger.
|
|
153
|
+
const manual = await f.scheduler.trigger(schedule.id, schedule.revision, 'smoke-1', f.owner, f.runs)
|
|
154
|
+
await relay.drainSources(); await finished(manual.id)
|
|
155
|
+
const done = (await f.runs.get(manual.id))!
|
|
156
|
+
assert.equal(done.status, 'completed'); assert.equal(done.exitCode, 0); assert.equal(done.execution, undefined)
|
|
157
|
+
assert.deepEqual(done.script, { id: 'book-freshness', args: ['extra'], revision: registration.revision, sha256: registration.sha256 })
|
|
158
|
+
assert.ok(done.startedAt && done.endedAt && done.createdAt <= done.startedAt)
|
|
159
|
+
assert.match(done.output!, /routine check ok/); assert.match(done.output!, /diagnostic line/); assert.doesNotMatch(done.output!, /abcdefghijklmnopqrstuvwxyz0123456789ABCDEF/)
|
|
160
|
+
const seen = await output(done)
|
|
161
|
+
assert.deepEqual(seen.argv, ['ok', 'extra']); assert.deepEqual(seen.leaked, [])
|
|
162
|
+
assert.equal(seen.env.EZ_RUN_ID, manual.id); assert.equal(seen.env.EZ_SCHEDULE_ID, 'guard'); assert.equal(seen.env.EZ_SCHEDULE_REVISION, schedule.revision)
|
|
163
|
+
assert.equal(seen.env.EZ_DUE_AT, done.scheduled!.dueAt); assert.equal(seen.env.EZ_SCRIPT_REVISION, registration.revision); assert.equal(seen.env.EZ_SCRIPT_SHA256, registration.sha256)
|
|
164
|
+
assert.equal((await relay.drainOutbox(), (await f.runs.pendingOutbox()).length), 0)
|
|
165
|
+
|
|
166
|
+
// Natural occurrence through the existing tick.
|
|
167
|
+
const due = await f.scheduler.pendingOccurrence(schedule)
|
|
168
|
+
await f.scheduler.tick(f.owner, f.runs, due!)
|
|
169
|
+
const natural = (await f.runs.list()).find(r => r.id !== manual.id && r.scheduled?.id === 'guard')!
|
|
170
|
+
assert.equal(natural.scheduled!.dueAt, new Date(due!).toISOString())
|
|
171
|
+
await relay.drainSources(); await finished(natural.id)
|
|
172
|
+
assert.equal((await f.runs.get(natural.id))?.status, 'completed')
|
|
173
|
+
await assert.rejects(access(join(f.root, 'llm-invocations')))
|
|
174
|
+
assert.equal(agentLaunches.length, 0)
|
|
175
|
+
|
|
176
|
+
// Changed entry-point bytes refuse to run until an explicit update.
|
|
177
|
+
await writeFile(join(f.workspace, 'scripts', 'guard.mjs'), fixture + '\n// edited\n')
|
|
178
|
+
const tampered = await f.scheduler.trigger(schedule.id, schedule.revision, 'tampered', f.owner, f.runs)
|
|
179
|
+
await relay.drainSources(); await finished(tampered.id)
|
|
180
|
+
const refused = (await f.runs.get(tampered.id))!
|
|
181
|
+
assert.equal(refused.status, 'failed'); assert.match(refused.failure!.error, /entry point changed since registration/)
|
|
182
|
+
await assert.rejects(readFile(join(f.workspace, `out-${tampered.id}.json`)))
|
|
183
|
+
const updated = await f.scripts.save({ id: 'book-freshness', owner: f.owner, workspace: f.workspace, entry: 'scripts/guard.mjs', interpreter: 'node', args: ['hang'], timeoutSeconds: 60 }, false)
|
|
184
|
+
assert.notEqual(updated.sha256, registration.sha256)
|
|
185
|
+
|
|
186
|
+
// Cancellation stops the script and its children; overlap is refused meanwhile.
|
|
187
|
+
const long = await f.scheduler.trigger(schedule.id, schedule.revision, 'long', f.owner, f.runs)
|
|
188
|
+
await relay.drainSources()
|
|
189
|
+
const childPid = Number(await until(async () => { try { await access(join(f.workspace, `child-${long.id}.pid`)); return true } catch { return false } })
|
|
190
|
+
.then(() => readFile(join(f.workspace, `child-${long.id}.pid`), 'utf8')))
|
|
191
|
+
assert.ok(alive(childPid))
|
|
192
|
+
assert.equal((await f.runs.get(long.id))?.script?.revision, updated.revision)
|
|
193
|
+
await assert.rejects(f.scheduler.trigger(schedule.id, schedule.revision, 'overlap', f.owner, f.runs), /queued or running/)
|
|
194
|
+
const before = (await f.runs.list()).length
|
|
195
|
+
await f.scheduler.tick(f.owner, f.runs, (await f.scheduler.pendingOccurrence(schedule))! + 3600_000)
|
|
196
|
+
assert.equal((await f.runs.list()).length, before)
|
|
197
|
+
await f.scheduler.cancel(long.id); await relay.drainSources(); await finished(long.id)
|
|
198
|
+
assert.equal((await f.runs.get(long.id))?.status, 'cancelled')
|
|
199
|
+
await until(async () => !alive(childPid))
|
|
200
|
+
|
|
201
|
+
// A bounded timeout terminates the process; it is a failure, never retried.
|
|
202
|
+
await f.scripts.save({ id: 'book-freshness', owner: f.owner, workspace: f.workspace, entry: 'scripts/guard.mjs', interpreter: 'node', args: ['hang'], timeoutSeconds: 1 }, false)
|
|
203
|
+
const slow = await f.scheduler.trigger(schedule.id, schedule.revision, 'slow', f.owner, f.runs)
|
|
204
|
+
await relay.drainSources(); await finished(slow.id)
|
|
205
|
+
const timedOut = (await f.runs.get(slow.id))!
|
|
206
|
+
assert.equal(timedOut.status, 'failed'); assert.equal(timedOut.failureReason, 'script-timeout'); assert.equal(timedOut.timedOut, true)
|
|
207
|
+
const slowChild = Number(await readFile(join(f.workspace, `child-${slow.id}.pid`), 'utf8'))
|
|
208
|
+
await until(async () => !alive(slowChild))
|
|
209
|
+
await relay.drainSources()
|
|
210
|
+
assert.equal((await f.runs.list()).filter(r => r.scheduled?.id === 'guard' && r.status === 'queued').length, 0)
|
|
211
|
+
|
|
212
|
+
// An existing LLM schedule still launches its native engine settings.
|
|
213
|
+
const execution = await f.control.captureChoice({ ...initialPreset('codex'), model: 'gpt-6-astra' })
|
|
214
|
+
const agent = await f.scheduler.save({ id: 'report', name: 'Report', text: 'Prepare the report', owner: f.owner, execution, enabled: true, trigger: { at: '2027-01-01T00:00:00Z' } }, true)
|
|
215
|
+
const agentRun = await f.scheduler.trigger(agent.id, agent.revision, 'agent', f.owner, f.runs)
|
|
216
|
+
await relay.drainSources(); await finished(agentRun.id)
|
|
217
|
+
assert.equal((await f.runs.get(agentRun.id))?.status, 'completed')
|
|
218
|
+
assert.equal(agentLaunches.length, 1); assert.equal(agentLaunches[0].model, 'gpt-6-astra'); assert.equal(agentLaunches[0].nativeSession, true)
|
|
219
|
+
})
|
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
|
}
|