@jc_stack/ez-agents 0.1.0-beta.49 → 0.1.0-beta.51
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/AGENTS.md +8 -6
- package/CHANGELOG.md +26 -0
- package/README.md +9 -5
- package/bin/ez +16 -7
- package/docs/architecture/ai-selection.md +4 -0
- package/docs/docker-runtime.md +28 -2
- package/docs/plugin-guide.md +3 -3
- package/docs/plugins.md +13 -10
- package/docs/scheduling.md +62 -22
- package/docs/setup.md +2 -2
- package/docs/standalone-cli.md +4 -4
- package/docs/upgrades.md +16 -15
- package/package.json +4 -2
- package/src/agent-guidance.ts +2 -2
- package/src/ai-cli.ts +4 -2
- package/src/ai.ts +17 -5
- package/src/application-channel.ts +12 -8
- package/src/control-state.ts +10 -4
- package/src/delivery-socket-client.mjs +15 -1
- package/src/delivery-socket.ts +18 -4
- package/src/executor.ts +63 -10
- package/src/host-executor-client.ts +23 -8
- package/src/host-executor.ts +63 -32
- package/src/index.ts +157 -35
- package/src/menu.ts +3 -2
- package/src/plugins/manager.mjs +43 -40
- package/src/plugins/workspace-lease.mjs +1 -1
- package/src/runs.ts +67 -3
- package/src/schedule-cli.ts +24 -15
- package/src/scheduled-tasks.ts +53 -17
- package/src/scheduler.ts +7 -8
- package/src/session-close.ts +17 -0
- package/src/setup-fallback.ts +62 -25
- package/src/tools-line.d.mts +1 -0
- package/src/tools-line.mjs +3 -0
- package/src/updates/binding.mjs +4 -10
- package/src/updates/control.mjs +3 -2
- package/src/updates/runtime.mjs +25 -9
- package/src/updates/supervisor.mjs +105 -16
- package/src/workspace.ts +12 -1
- package/test/ai-cli.test.ts +23 -1
- package/test/ai.test.ts +72 -4
- package/test/application-channel.test.ts +24 -0
- package/test/application-controls.test.ts +4 -1
- package/test/delivery-transport.test.ts +41 -2
- package/test/executor.test.ts +32 -4
- package/test/host-executor.test.ts +70 -3
- package/test/intake-relay.test.ts +71 -3
- package/test/model-policy.test.ts +1 -1
- package/test/plugin-broker.test.mjs +48 -1
- package/test/plugin-manager.test.mjs +52 -38
- package/test/run-handoff.test.ts +136 -0
- package/test/schedule-cli.test.ts +24 -1
- package/test/scheduled-tasks.test.ts +45 -15
- package/test/session-close.test.ts +17 -0
- package/test/setup-fallback.test.ts +194 -16
- package/test/updates.test.mjs +55 -12
- package/test/workspace.test.ts +6 -0
package/test/ai.test.ts
CHANGED
|
@@ -106,13 +106,17 @@ test('OpenCode keeps same-provider models together and separates providers encod
|
|
|
106
106
|
const other = { ...first, id: 'other', name: 'Other', model: 'openrouter/other' }
|
|
107
107
|
const choice = await store.captureChoice(first)
|
|
108
108
|
await store.saveNativeSession(choice.sessionId, 'ses_original')
|
|
109
|
-
const
|
|
109
|
+
const closed: string[] = []
|
|
110
|
+
const menu = createAiMenu(store, 'opencode', async () => [first, second, other].map(p => ({ ...p, efforts: [] })), dir, undefined, async () => true,
|
|
111
|
+
undefined, async (session) => { if (session) closed.push(session.sessionId) })
|
|
110
112
|
await store.savePreset(second)
|
|
111
113
|
assert.equal((await menu.select(second, choice.sessionId)).fresh, false)
|
|
114
|
+
assert.deepEqual(closed, [])
|
|
112
115
|
assert.equal((await store.captureChoice(first)).sessionId, choice.sessionId)
|
|
113
116
|
await store.savePreset(other)
|
|
114
117
|
assert.equal(await store.selectPreset(other.id, choice.sessionId), false)
|
|
115
118
|
assert.equal((await menu.select(other, choice.sessionId)).fresh, true)
|
|
119
|
+
assert.deepEqual(closed, [choice.sessionId]) // switching provider closes the old conversation once
|
|
116
120
|
const next = await store.captureChoice(first)
|
|
117
121
|
assert.notEqual(next.sessionId, choice.sessionId)
|
|
118
122
|
assert.equal((await store.executionSession(next)).nativeSessionId, undefined)
|
|
@@ -519,7 +523,7 @@ test('opencode model and variant selections validate against the installed catal
|
|
|
519
523
|
await validateSelection({ id: 'default', name: 'Default', cli: 'opencode', model: 'opencode/big-pickle' }, catalog, available)
|
|
520
524
|
await assert.rejects(
|
|
521
525
|
validateSelection({ id: 'bad', name: 'Bad', cli: 'opencode', model: 'opencode/ling-free', effort: 'max' }, catalog, available),
|
|
522
|
-
/
|
|
526
|
+
/Effort max is not supported.*low, medium/)
|
|
523
527
|
await assert.rejects(
|
|
524
528
|
validateSelection({ id: 'missing', name: 'Missing', cli: 'opencode', model: 'opencode/unknown' }, catalog, available),
|
|
525
529
|
/installed client catalog/)
|
|
@@ -627,7 +631,7 @@ test('private channel model choices do not consume owner preset slots', async ()
|
|
|
627
631
|
assert.equal(readback.preset?.model, model.model)
|
|
628
632
|
assert.equal(readback.preset?.effort, 'low')
|
|
629
633
|
assert.deepEqual((await store.status()).ai, before)
|
|
630
|
-
|
|
634
|
+
assert.equal((await menu.saveSelection(model, 'max')).effort, 'max')
|
|
631
635
|
await assert.rejects(store.changeApplicationSession(scope, {...guard, expectedSession:null}, preset), /Conversation changed/)
|
|
632
636
|
assert.deepEqual((await store.applicationSession(scope))?.preset, readback.preset)
|
|
633
637
|
} finally { await rm(dir, {recursive:true, force:true}) }
|
|
@@ -644,7 +648,29 @@ test('detected presets do not count against the saved AI cap', async () => {
|
|
|
644
648
|
assert.equal((await store.status()).ai!.presets.length, 13)
|
|
645
649
|
const next = {id:'one-more', name:'One more', cli:'claude', model:'sonnet', effort:'medium'}
|
|
646
650
|
await store.savePreset(next)
|
|
647
|
-
await
|
|
651
|
+
await store.savePreset({...next, id:'one-past-cap', name:'One past cap'})
|
|
652
|
+
const ids = (await store.status()).ai!.presets.map(p => p.id)
|
|
653
|
+
assert.equal(ids.filter(id => !id.startsWith('detected_')).length, 12)
|
|
654
|
+
assert.ok(!ids.includes('saved-1') && ids.includes('one-past-cap') && ids.includes(initial.id))
|
|
655
|
+
} finally { await rm(dir, {recursive:true, force:true}) }
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
test('a new model choice at the saved AI cap replaces the oldest unused choice', async () => {
|
|
659
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-ai-cap-select-'))
|
|
660
|
+
try {
|
|
661
|
+
const store = new ControlStore(dir, 1000)
|
|
662
|
+
const initial = initialPreset('claude')
|
|
663
|
+
await store.captureChoice(initial)
|
|
664
|
+
for (let n = 1; n < 12; n++) await store.savePreset({...initial, id:`saved-${n}`, name:`Saved ${n}`, model:'opus', effort:n % 2 ? 'high' : 'xhigh'})
|
|
665
|
+
await store.selectPreset('saved-1', (await store.getActiveSession())?.sessionId ?? null)
|
|
666
|
+
const model = {cli:'claude', model:'sonnet', name:'sonnet', efforts:['low', 'high']}
|
|
667
|
+
const menu = createAiMenu(store, 'claude', async () => [model], dir, undefined, async () => true)
|
|
668
|
+
const preset = await menu.saveSelection(model, 'high')
|
|
669
|
+
await menu.select(preset, (await store.getActiveSession())?.sessionId ?? null)
|
|
670
|
+
const ai = (await store.status()).ai!
|
|
671
|
+
assert.equal(ai.presets.find(p => p.id === ai.selectedId)?.model, 'sonnet')
|
|
672
|
+
assert.equal(ai.presets.length, 12)
|
|
673
|
+
assert.ok(ai.presets.some(p => p.id === 'saved-1') && !ai.presets.some(p => p.id === 'saved-2'))
|
|
648
674
|
} finally { await rm(dir, {recursive:true, force:true}) }
|
|
649
675
|
})
|
|
650
676
|
|
|
@@ -672,3 +698,45 @@ test('Claude catalog projects only aliases and efforts from the installed native
|
|
|
672
698
|
await validateSelection({ id: 'c', name: 'c', cli: 'claude', model: 'opus', effort: 'xhigh' }, models, async () => true)
|
|
673
699
|
await assert.rejects(validateSelection({ id: 'c', name: 'c', cli: 'claude', model: 'opus-guess' }, models, async () => true), /not in the installed client catalog/)
|
|
674
700
|
})
|
|
701
|
+
|
|
702
|
+
test('Telegram Sonnet choice survives restart and reaches the native launch without Opus substitution', async () => {
|
|
703
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-sonnet-selection-'))
|
|
704
|
+
const priorPath = process.env.PATH
|
|
705
|
+
try {
|
|
706
|
+
const store = new ControlStore(dir, 1000)
|
|
707
|
+
await store.captureChoice({id:'opus',name:'Opus',cli:'claude',model:'opus',effort:'xhigh'})
|
|
708
|
+
const models = [{cli:'claude',model:'opus',name:'Opus',efforts:['xhigh']},
|
|
709
|
+
{cli:'claude',model:'sonnet',name:'Sonnet',efforts:['low','medium','high']}]
|
|
710
|
+
const menu = createAiMenu(store, 'claude', async()=>models, dir, undefined, async()=>true)
|
|
711
|
+
const replies: Array<{text:string; buttons:Array<{text:string;callback_data:string}>}> = []
|
|
712
|
+
const context = (data?:string) => ({callbackQuery:data ? {data} : undefined,answerCallbackQuery:async()=>({}),
|
|
713
|
+
reply:async(text:string, options?:{reply_markup?:{inline_keyboard?:Array<Array<{text:string;callback_data:string}>>}})=>{
|
|
714
|
+
replies.push({text,buttons:options?.reply_markup?.inline_keyboard?.flat() ?? []});return {} as never
|
|
715
|
+
}})
|
|
716
|
+
const click = async(text:string) => menu.handle(context(replies.at(-1)!.buttons.find(b=>b.text===text)!.callback_data) as never)
|
|
717
|
+
await menu.list(context() as never)
|
|
718
|
+
await click('claude');await click('Sonnet');await click('high')
|
|
719
|
+
const restarted = new ControlStore(dir,1000)
|
|
720
|
+
const captured = await restarted.captureChoice(menu.initial)
|
|
721
|
+
assert.equal(captured.preset.model,'sonnet');assert.equal(captured.preset.effort,'high')
|
|
722
|
+
const before = await restarted.status()
|
|
723
|
+
await assert.rejects(menu.saveSelection(models[1], 'xhigh'), /Effort xhigh is not supported.*low, medium, high/)
|
|
724
|
+
await assert.rejects(validateSelection({id:'s',name:'Sonnet',cli:'claude',model:'sonnet',effort:'high'}, [], async()=>true), /No models advertised for claude.*current selection unchanged/)
|
|
725
|
+
assert.deepEqual(await restarted.status(), before)
|
|
726
|
+
const {ownerRun} = await import('./helpers/owner-run.js')
|
|
727
|
+
const {startExecutorJob} = await import('../src/executor.js')
|
|
728
|
+
await mkdir(join(dir,'bin'))
|
|
729
|
+
await writeFile(join(dir,'bin','claude'), `#!${process.execPath}\nrequire('fs').writeFileSync('launched.json',JSON.stringify(process.argv.slice(2)));require('fs').readFileSync(0,'utf8')`, {mode:0o700})
|
|
730
|
+
process.env.PATH = join(dir,'bin') + ':' + priorPath
|
|
731
|
+
await ownerRun(dir,'r_sonnet_selected')
|
|
732
|
+
const job = await startExecutorJob(['synthetic request'], {workspace:dir,controlDir:dir,binDir:join(dir,'bin'),runId:'r_sonnet_selected',timeoutMs:5000,repairEnabled:false,
|
|
733
|
+
cli:captured.preset.cli,model:captured.preset.model,effort:captured.preset.effort,sessionId:captured.sessionId})
|
|
734
|
+
const closed = new Promise<number|null>(resolve=>job.child.once('close',resolve))
|
|
735
|
+
assert.equal(await closed,0)
|
|
736
|
+
assert.deepEqual(JSON.parse(await readFile(join(dir,'launched.json'),'utf8')).slice(-4), ['--model','sonnet','--effort','high'])
|
|
737
|
+
await job.cleanup()
|
|
738
|
+
} finally {
|
|
739
|
+
if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath
|
|
740
|
+
await rm(dir,{recursive:true,force:true})
|
|
741
|
+
}
|
|
742
|
+
})
|
|
@@ -450,3 +450,27 @@ test('administrator task grant replacement preserves the runtime file owner', {s
|
|
|
450
450
|
const disabled=await stat(file);assert.equal(disabled.uid,20000);assert.equal(disabled.gid,20000)
|
|
451
451
|
}finally{await rm(dir,{recursive:true,force:true})}
|
|
452
452
|
})
|
|
453
|
+
|
|
454
|
+
test('empty server Claude catalog rejects Sonnet chat selection without changing Opus xhigh', async t => {
|
|
455
|
+
const root = await mkdtemp(join(tmpdir(),'ez-app-missing-claude-'))
|
|
456
|
+
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
457
|
+
const owned = await owner(root), control = new ControlStore(root,1000)
|
|
458
|
+
const initial = {id:'opus',name:'Opus',cli:'claude',model:'opus',effort:'xhigh'}
|
|
459
|
+
await control.captureChoice(initial)
|
|
460
|
+
const {createAiMenu} = await import('../src/menu.js')
|
|
461
|
+
let models: import('../src/ai.js').ModelChoice[] = []
|
|
462
|
+
const menu = createAiMenu(control,'claude',async()=>models,root,undefined,async()=>true)
|
|
463
|
+
const channel = new ApplicationChannel({controlDir:root,initial,wake:()=>{},cancel:async()=>{},aiControls:menu})
|
|
464
|
+
const binding = (await channel.bindings.register('app',token(),owned,true))!
|
|
465
|
+
const before = await control.status()
|
|
466
|
+
const input = {action:'model',expectedSession:before.activeSession!.sessionId,cli:'claude',model:'sonnet',effort:'high'}
|
|
467
|
+
await assert.rejects(channel.changeControls(binding.bindingId,input), /No models advertised for claude.*current selection unchanged/)
|
|
468
|
+
assert.deepEqual(await control.status(),before)
|
|
469
|
+
models = [{cli:'claude',model:'sonnet',name:'Sonnet',efforts:['low','medium','high']}]
|
|
470
|
+
await assert.rejects(channel.changeControls(binding.bindingId,{...input,effort:'xhigh'}), /Effort xhigh is not supported.*low, medium, high/)
|
|
471
|
+
assert.deepEqual(await control.status(),before)
|
|
472
|
+
await channel.changeControls(binding.bindingId,input)
|
|
473
|
+
const admitted = await channel.submit(binding.bindingId,{requestId:'sonnet',scope:'chat',text:'Synthetic request',followTelegram:true})
|
|
474
|
+
assert.equal(admitted.execution!.preset.model,'sonnet')
|
|
475
|
+
assert.equal(admitted.execution!.preset.effort,'high')
|
|
476
|
+
})
|
|
@@ -53,7 +53,8 @@ test('private scope reset and model controls preserve running sessions and share
|
|
|
53
53
|
{cli:'codex',model:'fixture-model',name:'Fixture',efforts:['low','high']},
|
|
54
54
|
{cli:'opencode',model:'fixture-other',name:'Other',efforts:[]},
|
|
55
55
|
],root,join(root,'native-home'),async()=>true)
|
|
56
|
-
const
|
|
56
|
+
const closed:string[] = []
|
|
57
|
+
const channel = new ApplicationChannel({controlDir:root,initial:menu.initial,aiControls:menu,wake:()=>{},cancel:async()=>{},closeSession:async session=>{if(session)closed.push(session.sessionId)}})
|
|
57
58
|
t.after(async()=>{await channel.stop();await rm(root,{recursive:true,force:true})})
|
|
58
59
|
const token = randomBytes(32).toString('base64url')
|
|
59
60
|
const binding = (await channel.bindings.register('private',token,owner))!
|
|
@@ -77,6 +78,7 @@ test('private scope reset and model controls preserve running sessions and share
|
|
|
77
78
|
assert.equal((await call({action:'new',expectedSession:before.activeSessionId})).status,200)
|
|
78
79
|
const next = await channel.scopeControls(binding.bindingId,'exercise')
|
|
79
80
|
assert.notEqual(next.activeSessionId,before.activeSessionId)
|
|
81
|
+
assert.deepEqual(closed,[]) // a private scope runs under its binding, so no owner-authority close turn
|
|
80
82
|
assert.equal(next.ai.selectedId,menu.initial.id)
|
|
81
83
|
assert.equal((await call({action:'new',expectedSession:before.activeSessionId})).status,400)
|
|
82
84
|
assert.equal((await control.executionSession(run.execution!)).nativeSessionId,'native-private')
|
|
@@ -89,6 +91,7 @@ test('private scope reset and model controls preserve running sessions and share
|
|
|
89
91
|
assert.deepEqual((await control.status()).ai!.presets,presets)
|
|
90
92
|
assert.equal((await call({action:'model',expectedSession:next.activeSessionId,cli:'codex',model:'fixture-model',effort:'high'})).status,200)
|
|
91
93
|
const updated = await channel.submit(binding.bindingId,{requestId:'updated-model',scope:'exercise',text:'same engine'})
|
|
94
|
+
assert.deepEqual(closed,[])
|
|
92
95
|
assert.equal(updated.execution!.sessionId,next.activeSessionId)
|
|
93
96
|
assert.equal(updated.execution!.preset.effort,'high')
|
|
94
97
|
assert.deepEqual((await new RunStore(root).get(after.id))!.execution,after.execution)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
-
import { mkdtemp, rm, unlink } from 'node:fs/promises'
|
|
3
|
+
import { mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { createConnection } from 'node:net'
|
|
@@ -64,9 +64,48 @@ test('a response timeout is not replayed over the loopback endpoint', async t =>
|
|
|
64
64
|
const dir = await mkdtemp(join(tmpdir(), 'ez-delivery-timeout-'))
|
|
65
65
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
66
66
|
let calls = 0
|
|
67
|
-
const server = await serveDeliverySocket(dir, async () => { calls++;
|
|
67
|
+
const server = await serveDeliverySocket(dir, async () => { calls++; await new Promise(resolve=>setTimeout(resolve,300)); return {accepted:true} }, { tcpPort: 0 })
|
|
68
68
|
t.after(() => server.stop())
|
|
69
69
|
assert.ok(server.endpoint)
|
|
70
70
|
await assert.rejects(callDeliverySocket(deliverySocketPath(dir), { op: 'enqueue' }, 200), /Delivery relay unavailable/)
|
|
71
71
|
assert.equal(calls, 1)
|
|
72
72
|
})
|
|
73
|
+
|
|
74
|
+
test('shutdown waits for an admitted enqueue before the final delivery drain',async t=>{
|
|
75
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-delivery-admission-'))
|
|
76
|
+
t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
77
|
+
let release!:()=>void,entered!:()=>void,accepted=0,drained=0
|
|
78
|
+
const gate=new Promise<void>(resolve=>{release=resolve}),admitted=new Promise<void>(resolve=>{entered=resolve})
|
|
79
|
+
const server=await serveDeliverySocket(dir,async()=>{entered();await gate;accepted++;return {accepted:true}},{beforeClose:async()=>{drained=accepted}})
|
|
80
|
+
const request=callDeliverySocket(deliverySocketPath(dir),{op:'enqueue'})
|
|
81
|
+
await admitted
|
|
82
|
+
let stopped=false
|
|
83
|
+
const stop=server.stop().then(()=>{stopped=true})
|
|
84
|
+
await new Promise(resolve=>setTimeout(resolve,50))
|
|
85
|
+
assert.equal(stopped,false)
|
|
86
|
+
assert.equal(drained,0)
|
|
87
|
+
release()
|
|
88
|
+
assert.deepEqual(await request,{accepted:true})
|
|
89
|
+
await stop
|
|
90
|
+
assert.equal(drained,1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
test('a swap connection failure waits for the relay but an accepted timeout is never retried',async t=>{
|
|
95
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-delivery-swap-'))
|
|
96
|
+
t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
97
|
+
await writeFile(join(dir,'upgrade-pause.json'),'{}')
|
|
98
|
+
const waiting=callDeliverySocket(deliverySocketPath(dir),{op:'enqueue'},1000)
|
|
99
|
+
await new Promise(r=>setTimeout(r,300))
|
|
100
|
+
let accepted=0
|
|
101
|
+
const server=await serveDeliverySocket(dir,async()=>({accepted:++accepted}))
|
|
102
|
+
t.after(()=>server.stop())
|
|
103
|
+
assert.deepEqual(await waiting,{accepted:1})
|
|
104
|
+
assert.equal(accepted,1)
|
|
105
|
+
await server.stop()
|
|
106
|
+
let attempted=0
|
|
107
|
+
const hanging=await serveDeliverySocket(dir,async()=>{attempted++;await new Promise(r=>setTimeout(r,200));return {accepted:true}})
|
|
108
|
+
t.after(()=>hanging.stop())
|
|
109
|
+
await assert.rejects(callDeliverySocket(deliverySocketPath(dir),{op:'enqueue'},40),/unavailable/)
|
|
110
|
+
assert.equal(attempted,1)
|
|
111
|
+
})
|
package/test/executor.test.ts
CHANGED
|
@@ -2,11 +2,11 @@ import { ownerRun } from './helpers/owner-run.js'
|
|
|
2
2
|
import { RunStore } from '../src/runs.js'
|
|
3
3
|
import assert from 'node:assert/strict'
|
|
4
4
|
import test from 'node:test'
|
|
5
|
-
import { mkdtemp, mkdir, rm, writeFile, readFile } from 'node:fs/promises'
|
|
5
|
+
import { mkdtemp, mkdir, rm, writeFile, readFile, rename, stat } from 'node:fs/promises'
|
|
6
6
|
import { spawn } from 'node:child_process'
|
|
7
7
|
import { tmpdir } from 'node:os'
|
|
8
8
|
import path from 'node:path'
|
|
9
|
-
import { EXECUTOR_REGISTRY, executorEnvironment, grokInvocation, grokJobEnv, opencodeDataHome, opencodeInvocation, piAgentDir, resolveExecutor, resolveHostCommand, startExecutorJob, terminateJob, validateCodexProvider } from '../src/executor.js'
|
|
9
|
+
import { EXECUTOR_REGISTRY, attachHostJob, executorEnvironment, grokInvocation, grokJobEnv, opencodeDataHome, opencodeInvocation, piAgentDir, resolveExecutor, resolveHostCommand, startExecutorJob, terminateJob, validateCodexProvider } from '../src/executor.js'
|
|
10
10
|
import { requireOwnerExecution } from '../src/execution-authority.js'
|
|
11
11
|
import { splitTelegramText } from '../src/reply.js'
|
|
12
12
|
import { matchingProcessIds, processSnapshot } from '../src/process-tree.js'
|
|
@@ -139,8 +139,10 @@ test('host transport does not throw when selecting the desktop adapter', async (
|
|
|
139
139
|
workspace: root, controlDir, binDir: path.join(root, 'bin'), cli: 'codex-gui', runId: 'r_hostgui', timeoutMs: 1500,
|
|
140
140
|
})
|
|
141
141
|
assert.ok(job.child.pid)
|
|
142
|
-
|
|
143
|
-
await
|
|
142
|
+
const closed = new Promise((resolve) => job.child.once('close', resolve))
|
|
143
|
+
await terminateJob(job.child)
|
|
144
|
+
await closed
|
|
145
|
+
assert.equal(await readFile(path.join(controlDir,'host-executor/r_hostgui.cancel'),'utf8'),'')
|
|
144
146
|
await job.cleanup()
|
|
145
147
|
} finally {
|
|
146
148
|
if (prior === undefined) delete process.env.EZ_EXECUTOR_TRANSPORT
|
|
@@ -149,6 +151,32 @@ test('host transport does not throw when selecting the desktop adapter', async (
|
|
|
149
151
|
}
|
|
150
152
|
})
|
|
151
153
|
|
|
154
|
+
test('host cancellation persists before a stalled observer receives a signal', {skip:process.platform==='win32'}, async () => {
|
|
155
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-cancel-observer-'))
|
|
156
|
+
const directory=path.join(root,'host-executor'),id='r_stalled'
|
|
157
|
+
await mkdir(directory)
|
|
158
|
+
await assert.rejects(attachHostJob(root,'../escape'),/Invalid host executor run ID/)
|
|
159
|
+
await writeFile(path.join(directory,id+'.running.json'),'{}')
|
|
160
|
+
const child=await attachHostJob(root,id)
|
|
161
|
+
const closed=new Promise(resolve=>child.once('close',resolve))
|
|
162
|
+
child.kill('SIGSTOP')
|
|
163
|
+
try {
|
|
164
|
+
await rename(directory,directory+'.saved')
|
|
165
|
+
await writeFile(directory,'Blocked transport directory')
|
|
166
|
+
assert.equal(await terminateJob(child),false)
|
|
167
|
+
assert.equal(child.exitCode,null)
|
|
168
|
+
assert.equal(child.signalCode,null)
|
|
169
|
+
await assert.rejects(readFile(path.join(directory+'.saved',id+'.cancel')),{code:'ENOENT'})
|
|
170
|
+
await rm(directory);await rename(directory+'.saved',directory)
|
|
171
|
+
assert.equal(await terminateJob(child),true)
|
|
172
|
+
assert.equal(await readFile(path.join(directory,id+'.cancel'),'utf8'),'')
|
|
173
|
+
assert.equal((await stat(path.join(directory,id+'.cancel'))).mode&0o777,0o600)
|
|
174
|
+
} finally {
|
|
175
|
+
child.kill('SIGCONT');child.kill('SIGKILL')
|
|
176
|
+
await closed;await rm(root,{recursive:true,force:true})
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
|
|
152
180
|
test('Codex jobs disable global memory and host skill discovery', () => {
|
|
153
181
|
const args = EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent'},'/prompt','hello')
|
|
154
182
|
assert.ok(args.includes('memories'))
|
|
@@ -8,7 +8,7 @@ import { serveHostExecutor, refreshAgentCatalog } from '../src/host-executor.js'
|
|
|
8
8
|
import { spawn } from 'node:child_process'
|
|
9
9
|
import { fileURLToPath } from 'node:url'
|
|
10
10
|
import { isHostRunId } from '../src/host-executor-protocol.js'
|
|
11
|
-
import { EXECUTOR_REGISTRY } from '../src/executor.js'
|
|
11
|
+
import { EXECUTOR_REGISTRY, attachHostJob, terminateJob } from '../src/executor.js'
|
|
12
12
|
import { RunStore } from '../src/runs.js'
|
|
13
13
|
import { packageVersion } from '../src/version.js'
|
|
14
14
|
import { executionDefaults } from '../src/model-policy.js'
|
|
@@ -294,11 +294,17 @@ test('one installed CLI executes two agent bindings with separate minds and sani
|
|
|
294
294
|
assert.equal(await workspaceLease(agents[0].toolsHome),undefined)
|
|
295
295
|
await ownerRun(agents[0].controlDir,'tg_43')
|
|
296
296
|
await writeFile(path.join(directory,'tg_43.request.json'),JSON.stringify({texts:['Hold chat'],options:{cli:'grok'}}))
|
|
297
|
-
for(let n=0;n<100;n++){try{await readFile(path.join(directory,'tg_43.process.json'))
|
|
297
|
+
for(let n=0;n<100;n++){try{if(JSON.parse(await readFile(path.join(directory,'tg_43.process.json'),'utf8')).pid)break}catch{await new Promise(r=>setTimeout(r,20))}}
|
|
298
298
|
await readFile(path.join(directory,'tg_43.running.json'))
|
|
299
299
|
assert.doesNotMatch(await readFile(path.join(directory,'r_hold.events'),'utf8'),/"stream":"exit"/)
|
|
300
300
|
await readFile(path.join(directory,'r_hold.running.json'))
|
|
301
|
-
|
|
301
|
+
// An observer that cannot handle signals must still stop the native run.
|
|
302
|
+
const observer=await attachHostJob(agents[0].controlDir,'r_hold')
|
|
303
|
+
const observerClosed=new Promise(resolve=>observer.once('close',resolve))
|
|
304
|
+
observer.stdout?.resume();observer.stderr?.resume()
|
|
305
|
+
observer.kill('SIGSTOP')
|
|
306
|
+
try { assert.equal(await terminateJob(observer),true) }
|
|
307
|
+
finally { observer.kill('SIGCONT');observer.kill('SIGKILL');await observerClosed }
|
|
302
308
|
let heldOutput=''
|
|
303
309
|
for(let n=0;n<200;n++){heldOutput=await readFile(path.join(directory,'r_hold.events'),'utf8');if(heldOutput.includes('"stream":"exit"'))break;await new Promise(r=>setTimeout(r,20))}
|
|
304
310
|
assert.match(heldOutput, /"stream":"exit","code":1/)
|
|
@@ -568,3 +574,64 @@ test('host transport runs a registered script from the run record and refuses ch
|
|
|
568
574
|
await rm(root,{recursive:true,force:true})
|
|
569
575
|
}
|
|
570
576
|
})
|
|
577
|
+
|
|
578
|
+
test('a native process that settles during launch records completion without hanging the worker',async t=>{
|
|
579
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-fast-native-')),workspace=path.join(root,'mind'),controlDir=path.join(root,'control')
|
|
580
|
+
await mkdir(workspace);await mkdir(controlDir)
|
|
581
|
+
const abort=new AbortController(),directory=path.join(controlDir,'host-executor')
|
|
582
|
+
const launch=async()=>{
|
|
583
|
+
const child=spawn(process.execPath,['-e','process.exit(0)'],{stdio:['pipe','pipe','pipe']})
|
|
584
|
+
await new Promise(resolve=>child.once('close',resolve))
|
|
585
|
+
return {child,cleanup:async()=>{},stdout:''}
|
|
586
|
+
}
|
|
587
|
+
const server=serveHostExecutor({cli:'grok',agents:[{name:'test',workspace,controlDir,binDir:root}]},abort.signal,launch,async()=>[])
|
|
588
|
+
t.after(async()=>{abort.abort();await server;await rm(root,{recursive:true,force:true})})
|
|
589
|
+
for(let n=0;n<300;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
|
|
590
|
+
await ownerRun(controlDir,'r_fast_native')
|
|
591
|
+
await writeFile(path.join(directory,'r_fast_native.request.json'),JSON.stringify({texts:['fast'],options:{cli:'grok'}}))
|
|
592
|
+
let events=''
|
|
593
|
+
for(let n=0;n<300;n++){try{events=await readFile(path.join(directory,'r_fast_native.events'),'utf8');if(events.includes('"stream":"exit"'))break}catch{}await new Promise(r=>setTimeout(r,20))}
|
|
594
|
+
assert.match(events,/"stream":"exit","code":0/)
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
test('retiring native generation preserves its run while the replacement claims new work once',async t=>{
|
|
598
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-host-generation-'))
|
|
599
|
+
const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),toolsHome=path.join(root,'tools')
|
|
600
|
+
await mkdir(workspace);await mkdir(toolsHome);await mkdir(controlDir)
|
|
601
|
+
await writeFile(path.join(toolsHome,'config.json'),JSON.stringify({schemaVersion:1,workspace:await realpath(workspace)}))
|
|
602
|
+
const agent={name:'qa',workspace,controlDir,toolsHome,binDir:root},installation={cli:'grok',agents:[agent]}
|
|
603
|
+
const stopA=new AbortController(),stopB=new AbortController(),retireA=new AbortController()
|
|
604
|
+
const children=new Map<string,ReturnType<typeof spawn>>(),launched:string[]=[]
|
|
605
|
+
const launch=async(_texts:string[],options:any)=>{
|
|
606
|
+
launched.push(options.runId)
|
|
607
|
+
const child=spawn(process.execPath,['-e',"process.stdin.resume();process.stdin.once('data',()=>process.exit(0));"],{stdio:['pipe','pipe','pipe']})
|
|
608
|
+
children.set(options.runId,child)
|
|
609
|
+
return {child,cleanup:async()=>{},stdout:''}
|
|
610
|
+
}
|
|
611
|
+
let a:Promise<void>|undefined,b:Promise<void>|undefined
|
|
612
|
+
t.after(async()=>{stopA.abort();stopB.abort();for(const child of children.values())child.stdin?.end('done');await Promise.all([a,b]);await rm(root,{recursive:true,force:true})})
|
|
613
|
+
const directory=path.join(controlDir,'host-executor')
|
|
614
|
+
const wait=async(check:()=>Promise<boolean>)=>{for(let n=0;n<300;n++){if(await check())return;await new Promise(r=>setTimeout(r,20))}assert.fail('generation did not reach expected state')}
|
|
615
|
+
const exists=(file:string)=>readFile(file).then(()=>true,()=>false)
|
|
616
|
+
a=serveHostExecutor(installation,stopA.signal,launch,async()=>[],retireA.signal)
|
|
617
|
+
await wait(()=>exists(path.join(directory,'heartbeat.json')))
|
|
618
|
+
await ownerRun(controlDir,'r_old_generation')
|
|
619
|
+
await writeFile(path.join(directory,'r_old_generation.request.json'),JSON.stringify({texts:['old'],options:{cli:'grok'}}))
|
|
620
|
+
await wait(async()=>children.has('r_old_generation'))
|
|
621
|
+
retireA.abort()
|
|
622
|
+
await wait(async()=>!await exists(path.join(directory,'worker.lock')))
|
|
623
|
+
assert.equal(children.get('r_old_generation')!.exitCode,null)
|
|
624
|
+
b=serveHostExecutor(installation,stopB.signal,launch,async()=>[])
|
|
625
|
+
await wait(()=>exists(path.join(directory,'worker.lock')))
|
|
626
|
+
await ownerRun(controlDir,'r_new_generation')
|
|
627
|
+
await writeFile(path.join(directory,'r_new_generation.request.json'),JSON.stringify({texts:['new'],options:{cli:'grok'}}))
|
|
628
|
+
await wait(async()=>children.has('r_new_generation'))
|
|
629
|
+
assert.deepEqual(launched,['r_old_generation','r_new_generation'])
|
|
630
|
+
children.get('r_old_generation')!.stdin!.end('done')
|
|
631
|
+
await a
|
|
632
|
+
assert.ok(await exists(path.join(directory,'worker.lock')),'retiring host must not delete replacement lock')
|
|
633
|
+
assert.match(await readFile(path.join(directory,'r_old_generation.events'),'utf8'),/"stream":"exit","code":0/)
|
|
634
|
+
children.get('r_new_generation')!.stdin!.end('done')
|
|
635
|
+
await wait(async()=>(await readFile(path.join(directory,'r_new_generation.events'),'utf8')).includes('"stream":"exit"'))
|
|
636
|
+
assert.match(await readFile(path.join(directory,'r_new_generation.events'),'utf8'),/"stream":"exit","code":0/)
|
|
637
|
+
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
-
import { randomBytes } from 'node:crypto'
|
|
3
|
+
import { randomBytes, randomUUID } from 'node:crypto'
|
|
4
4
|
import { mkdtemp, readdir, rm } from 'node:fs/promises'
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
6
|
import { join } from 'node:path'
|
|
@@ -17,6 +17,8 @@ import { atomicTaskFile, Tasks } from '../src/tasks.js'
|
|
|
17
17
|
import { EventSources } from '../src/event-sources.js'
|
|
18
18
|
import { ownerRun } from './helpers/owner-run.js'
|
|
19
19
|
import { packageVersion } from '../src/version.js'
|
|
20
|
+
import { SESSION_CLOSE_PROMPT } from '../src/session-close.js'
|
|
21
|
+
import type { ExecutorOptions } from '../src/executor.js'
|
|
20
22
|
import type { Config } from '../src/config.js'
|
|
21
23
|
|
|
22
24
|
const message = (id: number, text = 'hello'): Update => ({
|
|
@@ -32,6 +34,7 @@ const message = (id: number, text = 'hello'): Update => ({
|
|
|
32
34
|
const fixture = async (overrides: Partial<Config> = {}) => {
|
|
33
35
|
const dir = await mkdtemp(join(tmpdir(), 'ez-intake-relay-'))
|
|
34
36
|
const launched: string[][] = []
|
|
37
|
+
const launchOptions: ExecutorOptions[] = []
|
|
35
38
|
const replies: string[] = []
|
|
36
39
|
const members = new Map<number, string>()
|
|
37
40
|
const keyboards: { text: string; callback_data: string }[][][] = []
|
|
@@ -47,8 +50,9 @@ const fixture = async (overrides: Partial<Config> = {}) => {
|
|
|
47
50
|
...overrides,
|
|
48
51
|
}
|
|
49
52
|
const make = () => {
|
|
50
|
-
const relay = createRelay(config, async (texts) => {
|
|
53
|
+
const relay = createRelay(config, async (texts, options) => {
|
|
51
54
|
launched.push(texts)
|
|
55
|
+
launchOptions.push(options)
|
|
52
56
|
const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 50)'])
|
|
53
57
|
children.push(child)
|
|
54
58
|
await once(child, 'spawn')
|
|
@@ -79,6 +83,7 @@ const fixture = async (overrides: Partial<Config> = {}) => {
|
|
|
79
83
|
return {
|
|
80
84
|
dir,
|
|
81
85
|
launched,
|
|
86
|
+
launchOptions,
|
|
82
87
|
replies,
|
|
83
88
|
members,
|
|
84
89
|
keyboards,
|
|
@@ -290,7 +295,7 @@ test('conversation menu is owner-only and removes the retired settings control',
|
|
|
290
295
|
await f.relay.bot.handleUpdate(message(8, '/status'))
|
|
291
296
|
assert.ok(!f.keyboards.at(-1)!.flat().some((button) => button.text === 'Scheduled tasks'))
|
|
292
297
|
await f.relay.bot.handleUpdate(message(9, '/tasks'))
|
|
293
|
-
assert.match(f.replies.at(-1)!, /📅 Scheduled tasks[\s\S]*No
|
|
298
|
+
assert.match(f.replies.at(-1)!, /📅 Scheduled tasks[\s\S]*No scheduled tasks/)
|
|
294
299
|
await f.relay.bot.handleUpdate(message(10, '/tasks 2'))
|
|
295
300
|
assert.match(f.replies.at(-1)!, /Scheduled task 2 was not found/)
|
|
296
301
|
assert.equal(f.launched.length, 0)
|
|
@@ -336,6 +341,35 @@ test('new conversation leaves already accepted messages pinned to the old conver
|
|
|
336
341
|
} finally { await f.close() }
|
|
337
342
|
})
|
|
338
343
|
|
|
344
|
+
test('new conversation gives the started one a single silent close turn in its own native session', async () => {
|
|
345
|
+
const f = await fixture()
|
|
346
|
+
try {
|
|
347
|
+
const runs = new RunStore(f.dir), store = new ControlStore(f.dir, 1000)
|
|
348
|
+
const settled = async () => {
|
|
349
|
+
for (let i = 0; i < 200 && (await runs.list()).some((r) => ['queued', 'running'].includes(r.status)); i++)
|
|
350
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
351
|
+
}
|
|
352
|
+
await f.relay.bot.handleUpdate(message(1, '/new'))
|
|
353
|
+
assert.equal(f.launched.length, 0) // an unstarted conversation has nothing to keep
|
|
354
|
+
await f.relay.bot.handleUpdate(message(2, 'My sister is called Ana'))
|
|
355
|
+
await f.relay.drainInbox(true)
|
|
356
|
+
await settled()
|
|
357
|
+
const first = (await store.getActiveSession())!
|
|
358
|
+
assert.equal(first.hasStarted, true)
|
|
359
|
+
await f.relay.bot.handleUpdate(message(3, '/new'))
|
|
360
|
+
await settled()
|
|
361
|
+
assert.deepEqual(f.launched.at(-1), [SESSION_CLOSE_PROMPT])
|
|
362
|
+
const close = (await runs.list()).find((r) => r.sessionClose)!
|
|
363
|
+
assert.equal(close.execution!.sessionId, first.sessionId)
|
|
364
|
+
assert.equal(close.chatId, undefined); assert.equal(close.messageId, undefined)
|
|
365
|
+
const options = f.launchOptions.at(-1)!
|
|
366
|
+
assert.equal(options.isResume, true)
|
|
367
|
+
assert.equal(options.promptSuffix, '') // no reply mechanism: nothing is delivered
|
|
368
|
+
assert.notEqual((await store.getActiveSession())!.sessionId, first.sessionId)
|
|
369
|
+
assert.equal((await runs.list()).filter((r) => r.sessionClose).length, 1)
|
|
370
|
+
} finally { await f.close() }
|
|
371
|
+
})
|
|
372
|
+
|
|
339
373
|
test('accepted DM survives relay restart and duplicate delivery creates exactly one run', async () => {
|
|
340
374
|
const f = await fixture()
|
|
341
375
|
try {
|
|
@@ -752,3 +786,37 @@ test('tasks command never reaches the executor on invalid input or foreign sende
|
|
|
752
786
|
assert.equal(f.launched.length, 0)
|
|
753
787
|
} finally { await f.close() }
|
|
754
788
|
})
|
|
789
|
+
|
|
790
|
+
test('tasks list and detail show saved frequency and ordered setup path without changing or launching work',async()=>{
|
|
791
|
+
const f=await fixture()
|
|
792
|
+
try{
|
|
793
|
+
const {Scheduler}=await import('../src/scheduler.js')
|
|
794
|
+
const control=new ControlStore(f.dir,1000),scheduler=new Scheduler(f.dir)
|
|
795
|
+
const preset={id:'primary',name:'Primary',cli:'claude',model:'opus',effort:'high'}
|
|
796
|
+
const fallbacks=[
|
|
797
|
+
{...preset,id:'second',authProfile:'claude2'},
|
|
798
|
+
{id:'third',name:'Third',cli:'codex',authProfile:'work',model:'gpt-6-sol',effort:'xhigh'},
|
|
799
|
+
{id:'fourth',name:'Fourth',cli:'pi',model:'anthropic/claude-sonnet',effort:'medium'}]
|
|
800
|
+
const saved=await scheduler.save({id:'analyst',name:'Analyst',text:'Read the saved role.',owner:(await control.status()).owner!,
|
|
801
|
+
execution:{sessionId:randomUUID(),preset,fallbacks},
|
|
802
|
+
enabled:true,trigger:{cron:'*/30 * * * *',timezone:'America/New_York',start:'2027-01-01T00:00:00Z'}})
|
|
803
|
+
const path='claude (default login) · opus · high → claude@claude2 · opus · high → codex@work · gpt-6-sol · xhigh → pi · anthropic/claude-sonnet · medium'
|
|
804
|
+
await f.relay.bot.handleUpdate(message(1,'/tasks'))
|
|
805
|
+
const list=f.replies.at(-1)!
|
|
806
|
+
assert.ok(list.includes('Schedule · Every 30 minutes · America/New_York'))
|
|
807
|
+
assert.ok(list.includes(`Execution · ${path}`))
|
|
808
|
+
await f.relay.bot.handleUpdate(message(2,'/tasks 1'))
|
|
809
|
+
const detail=f.replies.at(-1)!
|
|
810
|
+
assert.ok(detail.includes('Schedule\nEvery 30 minutes · America/New_York'))
|
|
811
|
+
assert.ok(detail.includes(`Execution path\n${path}`))
|
|
812
|
+
assert.deepEqual(await scheduler.get(saved.id),saved)
|
|
813
|
+
const paused=await scheduler.enable(saved.id,false)
|
|
814
|
+
await f.relay.bot.handleUpdate(message(3,'/tasks'))
|
|
815
|
+
assert.match(f.replies.at(-1)!,/0 active · 1 paused[\s\S]*⏸ Paused[\s\S]*Next · —/)
|
|
816
|
+
await f.relay.bot.handleUpdate(message(4,'/tasks 1'))
|
|
817
|
+
assert.match(f.replies.at(-1)!,/⏸ Paused[\s\S]*Next run\n—/)
|
|
818
|
+
assert.ok(f.replies.at(-1)!.includes(`Execution path\n${path}`))
|
|
819
|
+
assert.deepEqual(await scheduler.get(saved.id),paused)
|
|
820
|
+
assert.equal(f.launched.length,0)
|
|
821
|
+
}finally{await f.close()}
|
|
822
|
+
})
|
|
@@ -17,7 +17,7 @@ test('engine defaults stay omitted and explicit native settings survive every ad
|
|
|
17
17
|
const preset={id:'chosen',name:'Chosen',cli,model:'native-model',effort:'ultra'}
|
|
18
18
|
assert.deepEqual(executionDefaults(cli,preset),preset)
|
|
19
19
|
await validateSelection(preset,[{cli,model:'native-model',name:'Native',efforts:['ultra']}],async()=>true)
|
|
20
|
-
await assert.rejects(validateSelection({...preset,effort:'unsupported'},[{cli,model:'native-model',name:'Native',efforts:['ultra']}],async()=>true),/
|
|
20
|
+
await assert.rejects(validateSelection({...preset,effort:'unsupported'},[{cli,model:'native-model',name:'Native',efforts:['ultra']}],async()=>true),/Effort unsupported is not supported.*Supported efforts: ultra/)
|
|
21
21
|
assert.throws(()=>executionDefaults(cli,{effort:'bad option'}),/Invalid reasoning effort/)
|
|
22
22
|
}
|
|
23
23
|
assert.deepEqual(executionOverrides('codex',{model:'old',effort:'max'},'new'),{model:'new',effort:undefined})
|
|
@@ -103,7 +103,8 @@ test('isolated broker authenticates an owned run, pins plugin revision, and writ
|
|
|
103
103
|
assert.deepEqual(JSON.parse(listed.stdout), { sample: 'sample' });
|
|
104
104
|
const detailed = await request(f.socket, { version: 1, id: randomUUID(), operation: 'manager', runId: f.runId, args: ['tools', 'list', '--details'] });
|
|
105
105
|
assert.equal(detailed.ok, true);
|
|
106
|
-
assert.deepEqual(JSON.parse(detailed.stdout).sample.
|
|
106
|
+
assert.deepEqual(JSON.parse(detailed.stdout).sample.aliases, ['sample']);
|
|
107
|
+
assert.equal(JSON.parse(detailed.stdout).sample.example, 'ez sample --help');
|
|
107
108
|
assert.deepEqual(JSON.parse(detailed.stdout).sample.skillReads, [['ez', 'tools', 'skill', 'sample', 'SKILL.md']]);
|
|
108
109
|
const skill = await request(f.socket, { version: 1, id: randomUUID(), operation: 'manager', runId: f.runId, args: ['tools', 'skill', 'sample', 'SKILL.md'] });
|
|
109
110
|
assert.equal(skill.code, 0);
|
|
@@ -249,3 +250,49 @@ test('task launch broker admits only its fixed application command and writes no
|
|
|
249
250
|
const revoked=await request(f.socket,{version:1,id:randomUUID(),operation:'task-launch',runId:taskRun,stdin:JSON.stringify(input)});assert.equal(revoked.ok,false);
|
|
250
251
|
}finally{abort.abort();await serving;process.env.PATH=oldPath;await ledger.stop();provider.closeAllConnections();await new Promise(r=>provider.close(r));}
|
|
251
252
|
});
|
|
253
|
+
|
|
254
|
+
// Native engines and Node scripts wire child stdin as a socket they may never
|
|
255
|
+
// close. The isolated client must reach the broker instead of waiting for EOF,
|
|
256
|
+
// while pipes and files still deliver their complete input.
|
|
257
|
+
test('isolated client does not wait forever on an inherited open stdin socket', async t => {
|
|
258
|
+
const { createServer: createSocketServer } = await import('node:net');
|
|
259
|
+
const { spawn } = await import('node:child_process');
|
|
260
|
+
const { once } = await import('node:events');
|
|
261
|
+
const root = await fs.realpath(await fs.mkdtemp(path.join(tmpdir(), 'e-')));
|
|
262
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
263
|
+
const socket = path.join(root, 'b.sock'), invokes = [];
|
|
264
|
+
const broker = createSocketServer(connection => {
|
|
265
|
+
let buffer = '';
|
|
266
|
+
connection.on('data', chunk => {
|
|
267
|
+
buffer += chunk;
|
|
268
|
+
if (!buffer.includes('\n')) return;
|
|
269
|
+
const frame = JSON.parse(buffer);
|
|
270
|
+
if (frame.operation === 'invoke') invokes.push(frame);
|
|
271
|
+
connection.end(JSON.stringify(frame.operation === 'resolve' ? { ok: true, revision, capability: 'c'.repeat(64) } : { ok: true, code: 0, stdout: 'ok' }) + '\n');
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
await new Promise(resolve => broker.listen(socket, resolve));
|
|
275
|
+
t.after(() => broker.close());
|
|
276
|
+
const client = new URL('../bin/ez', import.meta.url).pathname;
|
|
277
|
+
const env = { PATH: process.env.PATH, EZ_PLUGIN_BROKER_SOCKET: socket, EZ_RUN_ID: 'r_client' };
|
|
278
|
+
const finish = async child => {
|
|
279
|
+
let stdout = '';
|
|
280
|
+
child.stdout.on('data', chunk => stdout += chunk);
|
|
281
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 10_000);
|
|
282
|
+
const [code, signal] = await once(child, 'close');
|
|
283
|
+
clearTimeout(timer);
|
|
284
|
+
return { code, signal, stdout };
|
|
285
|
+
};
|
|
286
|
+
const open = spawn(process.execPath, [client, 'sample', 'read'], { env });
|
|
287
|
+
t.after(() => open.kill('SIGKILL'));
|
|
288
|
+
assert.deepEqual(await finish(open), { code: 0, signal: null, stdout: 'ok' });
|
|
289
|
+
assert.deepEqual(invokes.at(-1).args, ['read']);
|
|
290
|
+
assert.equal(invokes.at(-1).stdin, undefined);
|
|
291
|
+
const supplied = spawn(process.execPath, [client, 'sample', 'write'], { env });
|
|
292
|
+
supplied.stdin.end('payload');
|
|
293
|
+
assert.equal((await finish(supplied)).code, 0);
|
|
294
|
+
assert.equal(invokes.at(-1).stdin, 'payload');
|
|
295
|
+
const piped = spawn('/bin/sh', ['-c', '(sleep 1.5; printf late) | "$0" "$1" sample pipe', process.execPath, client], { env, stdio: ['ignore', 'pipe', 'inherit'] });
|
|
296
|
+
assert.equal((await finish(piped)).code, 0);
|
|
297
|
+
assert.equal(invokes.at(-1).stdin, 'late');
|
|
298
|
+
});
|