@jc_stack/ez-agents 0.1.0-beta.36 → 0.1.0-beta.38

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.
@@ -6,7 +6,7 @@ import { mkdtemp, mkdir, rm, writeFile, readFile } 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, antigravityInvocation, executorEnvironment, grokInvocation, grokJobEnv, opencodeInvocation, resolveExecutor, resolveHostCommand, startExecutorJob, terminateJob, validateCodexProvider } from '../src/executor.js'
9
+ import { EXECUTOR_REGISTRY, 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'
@@ -97,14 +97,6 @@ test('the Grok invocation is headless, workspace-scoped, and token-free', () =>
97
97
  assert.equal(invocation.args.includes('TELEGRAM_BOT_TOKEN'), false)
98
98
  })
99
99
 
100
- test('the antigravity invocation is headless, skips permissions, and uses print mode', () => {
101
- const invocation = antigravityInvocation('test prompt')
102
- assert.equal(invocation.command, 'agy')
103
- assert.deepEqual(invocation.args, [
104
- '--dangerously-skip-permissions', '--print=test prompt',
105
- ])
106
- })
107
-
108
100
  test('the opencode invocation is headless, auto-approves, and sets model and workspace', () => {
109
101
  const invocation = opencodeInvocation('test prompt', { workspace: '/tmp/test-ws' })
110
102
  assert.equal(invocation.command, 'opencode')
@@ -119,15 +111,15 @@ test('the opencode invocation is headless, auto-approves, and sets model and wor
119
111
  })
120
112
 
121
113
  test('resolveExecutor correctly maps keys and aliases', () => {
122
- assert.equal(resolveExecutor('agy').command, 'agy')
123
- assert.equal(resolveExecutor('antigravity').command, 'agy')
124
114
  assert.equal(resolveExecutor('claude').command, 'claude')
125
115
  assert.equal(resolveExecutor('grok').command, 'grok')
126
116
  assert.equal(resolveExecutor('opencode').command, 'opencode')
127
117
  assert.equal(resolveExecutor('oc').command, 'opencode')
118
+ for (const cli of ['unreal-agent', 'antigravity', 'agy'])
119
+ assert.throws(() => resolveExecutor(cli), /Unsupported executor CLI/)
128
120
  assert.equal(resolveExecutor('codex-gui').name, 'codex-gui')
129
121
  assert.notEqual(resolveExecutor('codex-gui').name, resolveExecutor('codex').name)
130
- assert.equal(resolveExecutor(undefined).command, 'agy')
122
+ assert.equal(resolveExecutor(undefined).command, 'codex')
131
123
  assert.throws(() => resolveExecutor('claude-spark'), /Unsupported executor CLI "claude-spark"/)
132
124
  assert.throws(() => resolveExecutor('spark'), /Unsupported executor CLI "spark"/)
133
125
  assert.throws(() => resolveExecutor('nonexistent'), /Unsupported executor CLI "nonexistent"/)
@@ -177,6 +169,114 @@ test('Codex custom providers use declared runtime state and an environment key r
177
169
  assert.throws(()=>validateCodexProvider({...provider,models:[]}),/provider models/)
178
170
  })
179
171
 
172
+ test('opencode jobs use the agent-bound data home only when its auth binding exists', async t => {
173
+ const root=await mkdtemp(path.join(tmpdir(),'ez-opencode-auth-'));t.after(()=>rm(root,{recursive:true,force:true}))
174
+ const bin=path.join(root,'bin');await mkdir(bin)
175
+ await writeFile(path.join(bin,'opencode'),`#!${process.execPath}\nconst fs=require('fs');fs.writeFileSync(${JSON.stringify(path.join(root,'observed.json'))},JSON.stringify({args:process.argv.slice(2),xdg:process.env.XDG_DATA_HOME,telegram:process.env.TELEGRAM_BOT_TOKEN}));`,{mode:0o755})
176
+ assert.equal(opencodeDataHome(root),undefined)
177
+ await mkdir(path.join(root,'cli','opencode'),{recursive:true})
178
+ assert.equal(opencodeDataHome(root),undefined)
179
+ await writeFile(path.join(root,'cli','opencode','auth.json'),'{}',{mode:0o600})
180
+ assert.equal(opencodeDataHome(root),path.join(root,'cli'))
181
+ const prior={path:process.env.PATH,telegram:process.env.TELEGRAM_BOT_TOKEN,transport:process.env.EZ_EXECUTOR_TRANSPORT}
182
+ const launch=async(runId:string)=>{
183
+ await ownerRun(root,runId)
184
+ process.env.PATH=bin+path.delimiter+prior.path;process.env.TELEGRAM_BOT_TOKEN='relay-secret';delete process.env.EZ_EXECUTOR_TRANSPORT
185
+ const job=await startExecutorJob(['hello'],{workspace:root,controlDir:root,binDir:bin,runId,timeoutMs:0,cli:'opencode',model:'opencode-go/muse-spark-1.3-contributor',effort:'xhigh'})
186
+ assert.equal(await new Promise(resolve=>job.child.once('close',resolve)),0);await job.cleanup()
187
+ return JSON.parse(await readFile(path.join(root,'observed.json'),'utf8'))
188
+ }
189
+ try {
190
+ const bound=await launch('r_ocbound')
191
+ assert.equal(bound.xdg,path.join(root,'cli'))
192
+ assert.equal(bound.telegram,undefined)
193
+ assert.equal(bound.args[bound.args.indexOf('-m')+1],'opencode-go/muse-spark-1.3-contributor')
194
+ assert.equal(bound.args[bound.args.indexOf('--variant')+1],'xhigh')
195
+ await rm(path.join(root,'cli','opencode','auth.json'))
196
+ const free=await launch('r_ocfree')
197
+ assert.equal(free.xdg,undefined)
198
+ assert.equal(free.telegram,undefined)
199
+ } finally {
200
+ if(prior.path===undefined)delete process.env.PATH;else process.env.PATH=prior.path
201
+ if(prior.telegram===undefined)delete process.env.TELEGRAM_BOT_TOKEN;else process.env.TELEGRAM_BOT_TOKEN=prior.telegram
202
+ if(prior.transport===undefined)delete process.env.EZ_EXECUTOR_TRANSPORT;else process.env.EZ_EXECUTOR_TRANSPORT=prior.transport
203
+ }
204
+ })
205
+
206
+ test('pi jobs use the agent-bound config dir only when its auth binding exists', async t => {
207
+ const root=await mkdtemp(path.join(tmpdir(),'ez-pi-auth-'));t.after(()=>rm(root,{recursive:true,force:true}))
208
+ const bin=path.join(root,'bin');await mkdir(bin)
209
+ await writeFile(path.join(bin,'pi'),`#!${process.execPath}\nconst fs=require('fs');fs.writeFileSync(${JSON.stringify(path.join(root,'observed.json'))},JSON.stringify({args:process.argv.slice(2),agentDir:process.env.PI_CODING_AGENT_DIR,telegram:process.env.TELEGRAM_BOT_TOKEN}));`,{mode:0o755})
210
+ assert.equal(piAgentDir(root),undefined)
211
+ await mkdir(path.join(root,'cli','pi','agent'),{recursive:true})
212
+ assert.equal(piAgentDir(root),undefined)
213
+ await writeFile(path.join(root,'cli','pi','agent','auth.json'),'{}',{mode:0o600})
214
+ assert.equal(piAgentDir(root),path.join(root,'cli','pi','agent'))
215
+ const prior={path:process.env.PATH,telegram:process.env.TELEGRAM_BOT_TOKEN,transport:process.env.EZ_EXECUTOR_TRANSPORT,agentDir:process.env.PI_CODING_AGENT_DIR}
216
+ const launch=async(runId:string)=>{
217
+ await ownerRun(root,runId)
218
+ process.env.PATH=bin+path.delimiter+prior.path;process.env.TELEGRAM_BOT_TOKEN='relay-secret';delete process.env.EZ_EXECUTOR_TRANSPORT;delete process.env.PI_CODING_AGENT_DIR
219
+ const job=await startExecutorJob(['hello'],{workspace:root,controlDir:root,binDir:bin,runId,timeoutMs:0,cli:'pi',model:'opencode-go/muse-spark-1.3-contributor',effort:'xhigh'})
220
+ assert.equal(await new Promise(resolve=>job.child.once('close',resolve)),0);await job.cleanup()
221
+ return JSON.parse(await readFile(path.join(root,'observed.json'),'utf8'))
222
+ }
223
+ try {
224
+ const bound=await launch('r_pibound')
225
+ assert.equal(bound.agentDir,path.join(root,'cli','pi','agent'))
226
+ assert.equal(bound.telegram,undefined)
227
+ assert.equal(bound.args[bound.args.indexOf('--model')+1],'opencode-go/muse-spark-1.3-contributor')
228
+ assert.equal(bound.args[bound.args.indexOf('--thinking')+1],'xhigh')
229
+ await rm(path.join(root,'cli','pi','agent','auth.json'))
230
+ const free=await launch('r_pifree')
231
+ assert.equal(free.agentDir,undefined)
232
+ assert.equal(free.telegram,undefined)
233
+ // Owner-provisioned delivery skill loads by path; absence keeps prior args.
234
+ assert.ok(!bound.args.includes('--skill'))
235
+ await mkdir(path.join(root,'cli','pi','skills','ez-delivery'),{recursive:true})
236
+ await writeFile(path.join(root,'cli','pi','skills','ez-delivery','SKILL.md'),'# delivery',{mode:0o644})
237
+ const skilled=await launch('r_piskilled')
238
+ assert.equal(skilled.args[skilled.args.indexOf('--skill')+1],path.join(root,'cli','pi','skills','ez-delivery'))
239
+ } finally {
240
+ if(prior.path===undefined)delete process.env.PATH;else process.env.PATH=prior.path
241
+ if(prior.telegram===undefined)delete process.env.TELEGRAM_BOT_TOKEN;else process.env.TELEGRAM_BOT_TOKEN=prior.telegram
242
+ if(prior.transport===undefined)delete process.env.EZ_EXECUTOR_TRANSPORT;else process.env.EZ_EXECUTOR_TRANSPORT=prior.transport
243
+ if(prior.agentDir===undefined)delete process.env.PI_CODING_AGENT_DIR;else process.env.PI_CODING_AGENT_DIR=prior.agentDir
244
+ }
245
+ })
246
+
247
+ test('the pi invocation pins control sessions, model, and thinking level', () => {
248
+ assert.equal(resolveExecutor('pi').command, 'pi')
249
+ const args = EXECUTOR_REGISTRY.pi.buildArgs(
250
+ { workspace: '/agent/mind', controlDir: '/agent/control', sessionId: 'ez-session', isResume: true, model: 'opencode-go/muse-spark-1.3-contributor', effort: 'xhigh' },
251
+ '/prompt', 'review CAMT')
252
+ assert.deepEqual(args, ['-p', '--mode', 'json', '--session-dir', '/agent/control/cli/pi/sessions', '--session-id', 'ez-session',
253
+ '--model', 'opencode-go/muse-spark-1.3-contributor', '--thinking', 'xhigh', '--', 'review CAMT'])
254
+ const other = EXECUTOR_REGISTRY.pi.buildArgs({ workspace: '/agent/mind', controlDir: '/agent/control', sessionId: 'other-session', isResume: true }, '/prompt', 'other')
255
+ assert.equal(other[other.indexOf('--session-id') + 1], 'other-session')
256
+ assert.ok(!other.includes('--continue'))
257
+ assert.throws(() => EXECUTOR_REGISTRY.pi.buildArgs({ workspace: '/agent' }, '/prompt', 'hi'), /bound control directory/)
258
+ })
259
+
260
+ test('pi jobs run with control-bound sessions and no secret leakage', async t => {
261
+ const root = await mkdtemp(path.join(tmpdir(), 'ez-pi-auth-')); t.after(() => rm(root, { recursive: true, force: true }))
262
+ const bin = path.join(root, 'bin'); await mkdir(bin)
263
+ await writeFile(path.join(bin, 'pi'), `#!${process.execPath}\nconst fs=require('fs');fs.writeFileSync(${JSON.stringify(path.join(root, 'observed.json'))},JSON.stringify({args:process.argv.slice(2),telegram:process.env.TELEGRAM_BOT_TOKEN}));`, { mode: 0o755 })
264
+ const prior = { path: process.env.PATH, telegram: process.env.TELEGRAM_BOT_TOKEN, transport: process.env.EZ_EXECUTOR_TRANSPORT }
265
+ try {
266
+ await ownerRun(root, 'r_pibound')
267
+ process.env.PATH = bin + path.delimiter + prior.path; process.env.TELEGRAM_BOT_TOKEN = 'relay-secret'; delete process.env.EZ_EXECUTOR_TRANSPORT
268
+ const job = await startExecutorJob(['review CAMT'], { workspace: root, controlDir: root, binDir: bin, runId: 'r_pibound', timeoutMs: 0, cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', effort: 'xhigh' })
269
+ assert.equal(await new Promise(resolve => job.child.once('close', resolve)), 0); await job.cleanup()
270
+ const observed = JSON.parse(await readFile(path.join(root, 'observed.json'), 'utf8'))
271
+ assert.deepEqual(observed.args, ['-p', '--mode', 'json', '--session-dir', path.join(root, 'cli', 'pi', 'sessions'),
272
+ '--model', 'opencode-go/muse-spark-1.3-contributor', '--thinking', 'xhigh', '--', 'review CAMT'])
273
+ assert.equal(observed.telegram, undefined)
274
+ } finally {
275
+ if (prior.path === undefined) delete process.env.PATH; else process.env.PATH = prior.path
276
+ if (prior.telegram === undefined) delete process.env.TELEGRAM_BOT_TOKEN; else process.env.TELEGRAM_BOT_TOKEN = prior.telegram
277
+ if (prior.transport === undefined) delete process.env.EZ_EXECUTOR_TRANSPORT; else process.env.EZ_EXECUTOR_TRANSPORT = prior.transport
278
+ }
279
+ })
180
280
  test('a configured Codex provider receives only its declared key and runtime selection', async t => {
181
281
  const root=await mkdtemp(path.join(tmpdir(),'ez-codex-provider-'));t.after(()=>rm(root,{recursive:true,force:true}))
182
282
  const bin=path.join(root,'bin');await mkdir(bin)
@@ -4,7 +4,7 @@ import test from 'node:test'
4
4
  import { mkdtemp, mkdir, readFile, writeFile, rm, realpath, symlink } from 'node:fs/promises'
5
5
  import path from 'node:path'
6
6
  import { tmpdir } from 'node:os'
7
- import { serveHostExecutor } from '../src/host-executor.js'
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'
@@ -21,8 +21,8 @@ test('host restart replaces a lock whose PID was reused by another process',asyn
21
21
  try {
22
22
  await mkdir(workspace);await mkdir(directory,{recursive:true})
23
23
  await writeFile(path.join(directory,'worker.lock'),JSON.stringify({pid:process.pid,started:'reused-pid'}))
24
- server=serveHostExecutor({cli:'grok',agents:[{name:'test',workspace,controlDir,binDir:root}]},abort.signal)
25
- for(let n=0;n<100;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
24
+ server=serveHostExecutor({cli:'grok',agents:[{name:'test',workspace,controlDir,binDir:root}]},abort.signal,undefined,async()=>[])
25
+ for(let n=0;n<300;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
26
26
  await readFile(path.join(directory,'heartbeat.json'))
27
27
  const lock=JSON.parse(await readFile(path.join(directory,'worker.lock'),'utf8'))
28
28
  assert.equal(lock.pid,process.pid)
@@ -47,6 +47,16 @@ test('isolated installations cannot start host transport', async () => {
47
47
  abort.abort()
48
48
  })
49
49
 
50
+ test('host bindings reject invalid opencode provider scopes before touching state', async () => {
51
+ const abort=new AbortController()
52
+ for (const opencodeProviders of [['BAD NAME'], [], ['ok', 'ok'], new Array(17).fill('ok')]) {
53
+ await assert.rejects(serveHostExecutor({cli:'grok',agents:[{
54
+ name:'t',workspace:'/tmp/ez-scope-x',controlDir:'/tmp/ez-scope-y',binDir:'/tmp/ez-scope-z',opencodeProviders} as never]},abort.signal),
55
+ /one to sixteen unique provider IDs/)
56
+ }
57
+ abort.abort()
58
+ })
59
+
50
60
  test('host restart clears dead native lease only after proving previous CLI stopped',async()=>{
51
61
  const root=await mkdtemp(path.join(tmpdir(),'ez-native-recovery-'));
52
62
  const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),toolsHome=path.join(root,'tools'),directory=path.join(controlDir,'host-executor');
@@ -62,8 +72,8 @@ test('host restart clears dead native lease only after proving previous CLI stop
62
72
  await assert.rejects(serveHostExecutor(installation,abort.signal),/Previous host CLI is still running/);
63
73
  await readFile(path.join(toolsHome,'workspace-writer.lock'));
64
74
  await writeFile(path.join(directory,'r_old.process.json'),JSON.stringify({pid:deadPid}));
65
- server=serveHostExecutor(installation,abort.signal);
66
- for(let n=0;n<100;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
75
+ server=serveHostExecutor(installation,abort.signal,undefined,async()=>[]);
76
+ for(let n=0;n<300;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
67
77
  await readFile(path.join(directory,'heartbeat.json'));
68
78
  await assert.rejects(readFile(path.join(toolsHome,'workspace-writer.lock')),{code:'ENOENT'});
69
79
  }finally{abort.abort();await server;await rm(root,{recursive:true,force:true});}
@@ -93,6 +103,7 @@ test('host execution resolves a declared provider from the bound agent, not a la
93
103
  await mkdir(fakeBin, {recursive: true})
94
104
  await mkdir(path.join(controlDir, 'cli', 'codex'), {recursive: true})
95
105
  await writeFile(path.join(fakeBin, 'codex'), '#!/bin/sh\nexit 0\n', {mode: 0o700})
106
+ await writeFile(path.join(fakeBin, 'opencode'), '#!/bin/sh\nexit 0\n', {mode: 0o700})
96
107
  await writeFile(path.join(controlDir, 'cli', 'codex', 'models_cache.json'), JSON.stringify({models: [
97
108
  {slug: provider.models[0], visibility: 'list', display_name: 'DeepSeek V4.1 Flash', supported_reasoning_levels: [{effort: 'max'}]},
98
109
  ]}))
@@ -107,6 +118,9 @@ test('host execution resolves a declared provider from the bound agent, not a la
107
118
  try { await readFile(path.join(directory, 'heartbeat.json')); break }
108
119
  catch { await new Promise(resolve => setTimeout(resolve, 20)) }
109
120
  }
121
+ // The selected model was advertised at startup. A later catalog probe
122
+ // can fail while the client remains able to execute the selected model.
123
+ await rm(path.join(controlDir, 'cli', 'codex', 'models_cache.json'))
110
124
  await writeFile(path.join(directory, 'r_provider.request.json'), JSON.stringify({
111
125
  texts: ['hello'], options: {cli: 'codex', provider: 'openrouter', model: provider.models[0], effort: 'max'},
112
126
  }))
@@ -156,6 +170,7 @@ test('one installed CLI executes two agent bindings with separate minds and sani
156
170
  await writeFile(binary,`#!${process.execPath}\nif(process.env.EZ_RUN_ID==='r_hold')setInterval(()=>{},1000);console.log(JSON.stringify({cwd:process.cwd(),home:process.env.HOME,token:process.env.TELEGRAM_BOT_TOKEN,control:process.env.EZ_CONTROL_DIR,run:process.env.EZ_RUN_ID,repair:process.env.EZ_REPAIR_ENABLED,args:process.argv.slice(2)}));\n`,{mode:0o700})
157
171
  await writeFile(path.join(root,'claude'),await readFile(binary),{mode:0o700})
158
172
  await writeFile(path.join(root,'codex'),await readFile(binary),{mode:0o700})
173
+ await writeFile(path.join(root,'opencode'),'#!/bin/sh\nexit 0\n',{mode:0o700})
159
174
  process.env.PATH=root+path.delimiter+oldPath
160
175
  EXECUTOR_REGISTRY.grok.command=binary
161
176
  EXECUTOR_REGISTRY.grok.buildArgs=(opts,file,prompt)=>[...EXECUTOR_REGISTRY.codex.buildArgs(opts,file,prompt).slice(0,-1),prompt]
@@ -173,7 +188,7 @@ test('one installed CLI executes two agent bindings with separate minds and sani
173
188
  server=serveHostExecutor({cli:'grok',agents},abort.signal)
174
189
  for(const agent of agents){
175
190
  const dir=path.join(agent.controlDir,'host-executor')
176
- for(let n=0;n<100;n++){try{await readFile(path.join(dir,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
191
+ for(let n=0;n<300;n++){try{await readFile(path.join(dir,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
177
192
  assert.equal(JSON.parse(await readFile(path.join(dir,'heartbeat.json'),'utf8')).version,packageVersion)
178
193
  const release = await workspaceLease(agent.toolsHome)
179
194
  await ownerRun(agent.controlDir, `r_${agent.name}`)
@@ -231,10 +246,14 @@ test('one installed CLI executes two agent bindings with separate minds and sani
231
246
  switched.stdin.end(JSON.stringify({texts:['Explicit CLI change'],options:executionDefaults('claude',{cli:'claude',timeoutMs:5000,effort:undefined})}))
232
247
  assert.equal(await new Promise(resolve=>switched.once('close',resolve)),0)
233
248
  assert.ok(JSON.parse(switchedOutput).args.includes('--print'))
234
- // The bound cache may advertise a model absent from the host's cache.
249
+ // The host's published catalog can gain a model after its first probe.
235
250
  const codexHome=path.join(agents[0].controlDir,'cli','codex')
236
251
  await mkdir(codexHome,{recursive:true})
237
252
  await writeFile(path.join(codexHome,'models_cache.json'),JSON.stringify({models:[{slug:'agent-only-fixture',visibility:'list',display_name:'Agent model',supported_reasoning_levels:[]}]}))
253
+ const catalogFile=path.join(agents[0].controlDir,'host-executor','models.json')
254
+ const advertised=JSON.parse(await readFile(catalogFile,'utf8'))
255
+ advertised.push({cli:'codex',model:'agent-only-fixture',name:'Agent model',efforts:[]})
256
+ await writeFile(catalogFile,JSON.stringify(advertised))
238
257
  await ownerRun(agents[0].controlDir,'tg_6293307')
239
258
  const bound=spawn(process.execPath,['--import',fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs',import.meta.url)),fileURLToPath(new URL('../src/host-executor-client.ts',import.meta.url)),agents[0].controlDir,'tg_6293307'],{stdio:['pipe','pipe','pipe']})
240
259
  let boundOutput='',boundError=''
@@ -344,9 +363,146 @@ test('client cancels on stale or invalid heartbeat instead of waiting indefinite
344
363
 
345
364
 
346
365
  test('host rejects a plugin registry bound to another workspace',async()=>{
347
- const root=await mkdtemp(path.join(tmpdir(),'ez-host-plugin-binding-'))
348
- try {
349
- await writeFile(path.join(root,'config.json'),JSON.stringify({schemaVersion:1,workspace:'/another-agent'}))
350
- await assert.rejects(serveHostExecutor({cli:'codex',agents:[{name:'test',workspace:root,controlDir:root,binDir:root,toolsHome:root}]},new AbortController().signal),/another workspace/)
351
- } finally {await rm(root,{recursive:true,force:true})}
366
+ const root=await mkdtemp(path.join(tmpdir(),'ez-host-plugin-binding-'))
367
+ try {
368
+ await writeFile(path.join(root,'config.json'),JSON.stringify({schemaVersion:1,workspace:'/another-agent'}))
369
+ await assert.rejects(serveHostExecutor({cli:'codex',agents:[{name:'test',workspace:root,controlDir:root,binDir:root,toolsHome:root}]},new AbortController().signal),/another workspace/)
370
+ } finally {await rm(root,{recursive:true,force:true})}
371
+ })
372
+
373
+ test('refresh blip throws so the caller keeps last-good models instead of crashing',async()=>{
374
+ const root=await mkdtemp(path.join(tmpdir(),'ez-host-refresh-blip-'))
375
+ const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),directory=path.join(controlDir,'host-executor')
376
+ try {
377
+ await mkdir(workspace,{recursive:true});await mkdir(directory,{recursive:true})
378
+ const agent={name:'test',workspace,controlDir,binDir:root}
379
+ const lastGood=[{cli:'codex',model:'cached-model',name:'Cached',efforts:[]}]
380
+ await writeFile(path.join(directory,'models.json'),JSON.stringify(lastGood))
381
+ const seen=new Map()
382
+ await assert.rejects(refreshAgentCatalog(agent,async()=>{throw new Error('transient catalog blip')},seen),/transient catalog blip/)
383
+ assert.deepEqual(JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')),lastGood)
384
+ assert.equal(seen.has(agent),false)
385
+ } finally {await rm(root,{recursive:true,force:true})}
386
+ })
387
+
388
+ test('empty refresh preserves the last-good catalog and a later nonempty refresh replaces it',async()=>{
389
+ const root=await mkdtemp(path.join(tmpdir(),'ez-host-empty-refresh-'))
390
+ const directory=path.join(root,'host-executor')
391
+ try {
392
+ await mkdir(directory)
393
+ const agent={name:'test',workspace:root,controlDir:root,binDir:root}
394
+ const lastGood=[{cli:'codex',model:'cached-model',name:'Cached',efforts:[]}]
395
+ const fresh=[{cli:'codex',model:'new-model',name:'New',efforts:[]}]
396
+ await writeFile(path.join(directory,'models.json'),JSON.stringify(lastGood))
397
+ const seen=new Map([[agent,JSON.stringify(lastGood)]])
398
+ await refreshAgentCatalog(agent,async()=>[],seen)
399
+ assert.deepEqual(JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')),lastGood)
400
+ assert.equal(seen.get(agent),JSON.stringify(lastGood))
401
+ await refreshAgentCatalog(agent,async()=>fresh,seen)
402
+ assert.deepEqual(JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')),fresh)
403
+ assert.equal(seen.get(agent),JSON.stringify(fresh))
404
+ } finally {await rm(root,{recursive:true,force:true})}
405
+ })
406
+
407
+ test('slow catalog refresh keeps heartbeats live and active client completes without cancellation', {timeout:70000}, async()=>{
408
+ const root=await mkdtemp(path.join(tmpdir(),'ez-host-slow-refresh-'))
409
+ const directory=path.join(root,'host-executor')
410
+ const abort=new AbortController()
411
+ let server:Promise<void>|undefined
412
+ let client:ReturnType<typeof spawn>|undefined
413
+ let closed:Promise<number|null>|undefined
414
+ let releaseRefresh!:()=>void
415
+ let startedRefresh!:()=>void
416
+ const blocked=new Promise<void>(resolve=>{releaseRefresh=resolve})
417
+ const refreshing=new Promise<void>(resolve=>{startedRefresh=resolve})
418
+ let calls=0
419
+ let finishRun: (()=>void) | undefined
420
+ try {
421
+ await mkdir(directory)
422
+ await ownerRun(root,'r_slow_catalog')
423
+ server=serveHostExecutor({cli:'grok',agents:[{name:'test',workspace:root,controlDir:root,binDir:root}]},abort.signal,async()=>{
424
+ const child=spawn(process.execPath,['-e','process.stdin.resume();process.stdin.once("data",()=>process.exit(0))'])
425
+ finishRun=()=>{child.stdin!.end('done')}
426
+ return {child,cleanup:async()=>{},stdout:''}
427
+ },async()=>{
428
+ if(++calls>1){startedRefresh();await blocked}
429
+ return [{cli:'grok',name:'Grok',efforts:[]}]
430
+ })
431
+ for(let n=0;n<300;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
432
+ client=spawn(process.execPath,['--import',fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs',import.meta.url)),fileURLToPath(new URL('../src/host-executor-client.ts',import.meta.url)),root,'r_slow_catalog'],{stdio:['pipe','pipe','pipe']})
433
+ closed=new Promise(resolve=>client!.once('close',resolve))
434
+ let stderr='';client.stderr!.on('data',chunk=>stderr+=chunk);client.stdout!.resume()
435
+ client.stdin!.end(JSON.stringify({texts:['test'],options:{cli:'grok'}}))
436
+ await refreshing
437
+ // Exceed the real relay client's 15s offline threshold while discovery hangs.
438
+ await new Promise(resolve=>setTimeout(resolve,16500))
439
+ const heartbeat=JSON.parse(await readFile(path.join(directory,'heartbeat.json'),'utf8'))
440
+ assert.ok(Date.now()-heartbeat.at<5000,'heartbeat stopped during catalog I/O')
441
+ assert.equal(client.exitCode,null,stderr)
442
+ await assert.rejects(readFile(path.join(directory,'r_slow_catalog.cancel')),{code:'ENOENT'})
443
+ assert.equal(calls,2,'catalog refresh must not overlap itself')
444
+ assert.ok(finishRun,'the native run must have started')
445
+ finishRun()
446
+ assert.equal(await closed,0,stderr)
447
+ releaseRefresh()
448
+ } finally {
449
+ releaseRefresh();abort.abort()
450
+ if(client?.exitCode===null && client.signalCode===null)client.kill()
451
+ await closed;await server;await rm(root,{recursive:true,force:true})
452
+ }
453
+ })
454
+
455
+ test('cross-CLI admission uses the advertised catalog without another discovery probe',async()=>{
456
+ const root=await mkdtemp(path.join(tmpdir(),'ez-host-advertised-'))
457
+ const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),directory=path.join(controlDir,'host-executor')
458
+ const abort=new AbortController();let server:Promise<void>|undefined
459
+ const previousPath=process.env.PATH
460
+ try {
461
+ await mkdir(workspace,{recursive:true});await mkdir(directory,{recursive:true})
462
+ const fakeBin=path.join(root,'bin');await mkdir(fakeBin,{recursive:true})
463
+ await writeFile(path.join(fakeBin,'codex'),'#!/bin/sh\nexit 0\n',{mode:0o700})
464
+ process.env.PATH=`${fakeBin}${path.delimiter}${previousPath ?? ''}`
465
+ const installation={cli:'grok' as const,agents:[{name:'test',workspace,controlDir,binDir:fakeBin}]}
466
+ const advertised=[{cli:'codex',model:'cached-model',name:'Cached',efforts:[]}]
467
+ let reads=0
468
+ server=serveHostExecutor(installation,abort.signal,async()=>{
469
+ const child=spawn(process.execPath,['-e','process.exit(0)'])
470
+ return {child,cleanup:async()=>{},stdout:''}
471
+ },async()=>{if(++reads>1)throw new Error('unexpected discovery during admission');return advertised})
472
+ for(let n=0;n<300;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
473
+ await readFile(path.join(directory,'heartbeat.json'))
474
+ await ownerRun(controlDir,'r_advertised')
475
+ await writeFile(path.join(directory,'r_advertised.request.json'),JSON.stringify({
476
+ texts:['hello'],options:{cli:'codex',model:'cached-model'},
477
+ }))
478
+ let events=''
479
+ for(let n=0;n<200;n++){
480
+ try{events=await readFile(path.join(directory,'r_advertised.events'),'utf8');if(events.includes('"stream":"exit"'))break}catch{}
481
+ await new Promise(r=>setTimeout(r,20))
482
+ }
483
+ assert.match(events,/"stream":"exit","code":0/)
484
+ assert.equal(reads,1)
485
+ } finally {
486
+ abort.abort();await server
487
+ if(previousPath===undefined)delete process.env.PATH;else process.env.PATH=previousPath
488
+ await rm(root,{recursive:true,force:true})
489
+ }
490
+ })
491
+
492
+ test('startup replaces prior catalog after provider policy changes even when discovery is empty',async()=>{
493
+ const root=await mkdtemp(path.join(tmpdir(),'ez-host-policy-reset-'))
494
+ const directory=path.join(root,'host-executor')
495
+ const abort=new AbortController();let server:Promise<void>|undefined
496
+ try {
497
+ await mkdir(directory)
498
+ await writeFile(path.join(directory,'models.json'),JSON.stringify([
499
+ {cli:'opencode',model:'old-provider/model',name:'Old provider',efforts:[]},
500
+ ]))
501
+ server=serveHostExecutor({cli:'grok',agents:[{
502
+ name:'test',workspace:root,controlDir:root,binDir:root,opencodeProviders:['allowed-provider'],
503
+ }]},abort.signal,undefined,async()=>[])
504
+ for(let n=0;n<300;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
505
+ await readFile(path.join(directory,'heartbeat.json'))
506
+ assert.deepEqual(JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')),[])
507
+ } finally {abort.abort();await server;await rm(root,{recursive:true,force:true})}
352
508
  })
@@ -10,6 +10,9 @@ import { serveTestLedger } from './helpers/ledger.js'
10
10
  import { deliveredMessages } from '../src/message-history.js'
11
11
  import { RunStore } from '../src/runs.js'
12
12
  import { ControlStore } from '../src/control-state.js'
13
+ import { ownerId, ownerEpoch } from '../src/control-state.js'
14
+ import { ApplicationBindings } from '../src/application-channel.js'
15
+ import { randomBytes } from 'node:crypto'
13
16
 
14
17
  async function fixture(t: test.TestContext) {
15
18
  const dir = await mkdtemp(join(tmpdir(), 'ez-history-'))
@@ -117,3 +120,47 @@ test('CLI flushes complete long reports and rejects send options in history mode
117
120
  // History reads never create outbox state on disk.
118
121
  await assert.rejects(stat(join(dir, 'outbox')))
119
122
  })
123
+
124
+ test('application history reads only delivered literal text in its current binding and scope', async t => {
125
+ const { dir, runs } = await fixture(t)
126
+ const owner = (await new ControlStore(dir, 1000).status()).owner!
127
+ const bindings = new ApplicationBindings(dir)
128
+ const first = (await bindings.register('first', randomBytes(32).toString('base64url'), owner))!
129
+ const second = (await bindings.register('second', randomBytes(32).toString('base64url'), owner))!
130
+ const create = async (id: string, bindingId: string, scope: string) => {
131
+ const run = await runs.create({ id, ownerId: ownerId(owner), ownerEpoch: ownerEpoch(owner),
132
+ texts: ['test'], application: { bindingId, scope, requestId: id } })
133
+ await runs.patch(id, { status: 'running' })
134
+ return run
135
+ }
136
+ const earlier = await create('r_app_earlier', first.bindingId, 'book')
137
+ const current = await create('r_app_current', first.bindingId, 'book')
138
+ const otherScope = await create('r_app_other_scope', first.bindingId, 'private')
139
+ const otherBinding = await create('r_app_other_binding', second.bindingId, 'book')
140
+ const deliver = async (runId: string, text: string) => {
141
+ const item = await runs.enqueueMessage(runId, text)
142
+ await runs.claimOutbox(item.id)
143
+ await runs.markOutboxSent(item.id)
144
+ return item
145
+ }
146
+ const literal = 'Book is $1,234; path C:\\reports\\$raw'
147
+ const firstItem = await deliver(earlier.id, literal)
148
+ await runs.patch(earlier.id, { status: 'completed' })
149
+ await deliver(otherScope.id, 'other scope secret')
150
+ await deliver(otherBinding.id, 'other binding secret')
151
+ await deliver('tg_1', 'Telegram secret')
152
+ await runs.enqueueMessage(current.id, 'queued is not delivered')
153
+ const env = { ...process.env, EZ_CONTROL_DIR: dir, EZ_RUN_ID: current.id }
154
+ const { stdout } = await promisify(execFile)(process.execPath, ['bin/ezenciel-agents-message.mjs', 'history'], { env })
155
+ const result = JSON.parse(stdout)
156
+ assert.equal(result.scope, 'book')
157
+ assert.deepEqual(result.messages.map((message: { id: string; text: string }) => ({ id: message.id, text: message.text })),
158
+ [{ id: firstItem.id, text: literal }])
159
+ assert.equal(result.hasMore, false)
160
+ await assert.rejects(promisify(execFile)(process.execPath,
161
+ ['bin/ezenciel-agents-message.mjs', 'history', '--message-id', '1'], { env }), /Telegram history/)
162
+ await bindings.register('first', null, owner)
163
+ await assert.rejects(deliveredMessages(dir, current.id), /revoked/)
164
+ await new ControlStore(dir, 1000).revokeOwner()
165
+ await assert.rejects(deliveredMessages(dir, otherBinding.id), /owner-mismatch|revoked/)
166
+ })
@@ -12,7 +12,7 @@ import { runCodexSession } from '../src/codex-session.js'
12
12
  import { runDesktopTurn } from '../src/desktop-bridge.js'
13
13
 
14
14
  test('engine defaults stay omitted and explicit native settings survive every adapter', async () => {
15
- for(const cli of ['codex','codex-gui','grok','claude','opencode','agy']) {
15
+ for(const cli of ['codex','codex-gui','grok','claude','opencode']) {
16
16
  assert.deepEqual(executionDefaults(cli,{}),{})
17
17
  const preset={id:'chosen',name:'Chosen',cli,model:'native-model',effort:'ultra'}
18
18
  assert.deepEqual(executionDefaults(cli,preset),preset)
@@ -41,7 +41,7 @@ test('invalid setting syntax never mutates a saved choice', async () => {
41
41
  test('non-Codex catalog defaults survive executor normalization and host revalidation', async () => {
42
42
  const dir = await mkdtemp(join(tmpdir(), 'ez-adapter-defaults-'))
43
43
  try {
44
- const available = async (cli: string) => ['claude', 'opencode', 'agy'].includes(cli)
44
+ const available = async (cli: string) => ['claude', 'opencode'].includes(cli)
45
45
  const catalog = await readModels(dir, available)
46
46
  for (const choice of catalog) {
47
47
  const preset = {id:'selected', name:choice.name, cli:choice.cli, model:choice.model}
@@ -276,6 +276,23 @@ test('isolated broker host binding validates exact mounts without deployment sym
276
276
  if(previous===undefined)delete process.env.EZ_DOCKER_COMPOSE;else process.env.EZ_DOCKER_COMPOSE=previous;
277
277
  }
278
278
  });
279
+ test('isolated broker allows unbound plugins when another plugin has host binding',async t=>{
280
+ const f=await fixture(t),p=await snapshot(f.source),isolated=path.join(f.root,'isolated-other'),workspace=path.join(isolated,'workspace'),control=path.join(isolated,'control'),hostConfig=path.join(isolated,'host-executor.json'),previous=process.env.EZ_DOCKER_COMPOSE;
281
+ await fs.mkdir(f.home,{recursive:true});await fs.mkdir(workspace,{recursive:true});await fs.mkdir(control,{recursive:true});
282
+ await fs.writeFile(hostConfig,JSON.stringify({cli:'codex',isolation:'isolated',agents:[{name:'sample',workspace,controlDir:control,binDir:path.join(f.root,'bin'),toolsHome:f.home}]}));
283
+ const record={source:p.source,project:'ezp-synthetic',revision:p.revision,manifest:p.manifest,deployment:p.deployment};
284
+ const config={workspace:await fs.realpath(workspace),hostConfig:await fs.realpath(hostConfig)};
285
+ await fs.writeFile(hostConfig,JSON.stringify({cli:'codex',isolation:'isolated',agents:[{name:'sample',workspace,controlDir:control,binDir:path.join(f.root,'bin'),toolsHome:f.home,pluginNetworkBindings:{voice:{revisions:[p.revision],bindings:[{service:'sample',network:'voice_default'}]}}}]}));
286
+ process.env.EZ_DOCKER_COMPOSE='standalone';
287
+ try {
288
+ const c=await compose(config,record,{},f.home);
289
+ assert.equal(c.version,'3.8');assert.equal(c.networks,undefined);
290
+ await fs.writeFile(hostConfig,JSON.stringify({cli:'codex',isolation:'isolated',agents:[{name:'sample',workspace,controlDir:control,binDir:path.join(f.root,'bin'),toolsHome:f.home,pluginNetworkBindings:{sample:{revisions:[p.revision],bindings:[{service:'sample',network:'sample_default'}]}}}]}));
291
+ await assert.rejects(compose(config,record,{},f.home),/Isolated broker cannot use host network bindings/);
292
+ } finally {
293
+ if(previous===undefined)delete process.env.EZ_DOCKER_COMPOSE;else process.env.EZ_DOCKER_COMPOSE=previous;
294
+ }
295
+ });
279
296
  test('host-owned private networks survive registered-call Compose regeneration',async t=>{
280
297
  const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);
281
298
  await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
@@ -13,11 +13,11 @@ test('findExecutableInPath locates existing binary and returns null for nonexist
13
13
  assert.equal(missing, null)
14
14
  })
15
15
 
16
- test('readActiveExecutor extracts EZ_EXECUTOR_CLI or defaults to agy', async () => {
16
+ test('readActiveExecutor extracts EZ_EXECUTOR_CLI or defaults to codex', async () => {
17
17
  const dir = await mkdtemp(path.join(tmpdir(), 'ez-setup-test-'))
18
18
  try {
19
19
  const envPath = path.join(dir, '.env')
20
- assert.equal(await readActiveExecutor(envPath), 'agy')
20
+ assert.equal(await readActiveExecutor(envPath), 'codex')
21
21
 
22
22
  await writeFile(envPath, 'EZ_EXECUTOR_CLI=claude\n', 'utf8')
23
23
  assert.equal(await readActiveExecutor(envPath), 'claude')
@@ -37,10 +37,10 @@ test('setExecutorInEnv updates or adds EZ_EXECUTOR_CLI without altering other en
37
37
  assert.match(content, /TELEGRAM_BOT_TOKEN=token123/)
38
38
  assert.match(content, /EZ_EXECUTOR_CLI=claude/)
39
39
 
40
- await setExecutorInEnv(envPath, 'antigravity')
40
+ await setExecutorInEnv(envPath, 'codex')
41
41
  content = await readFile(envPath, 'utf8')
42
42
  assert.match(content, /TELEGRAM_BOT_TOKEN=token123/)
43
- assert.match(content, /EZ_EXECUTOR_CLI=antigravity/)
43
+ assert.match(content, /EZ_EXECUTOR_CLI=codex/)
44
44
  assert.ok(!content.includes('claude'))
45
45
  } finally {
46
46
  await rm(dir, { recursive: true, force: true })
@@ -59,9 +59,9 @@ test('getExecutorsStatus returns all executors and sets isActive correctly', asy
59
59
  assert.ok(claudeItem)
60
60
  assert.equal(claudeItem.isActive, true)
61
61
 
62
- const agyItem = status.items.find((item) => item.id === 'agy')
63
- assert.ok(agyItem)
64
- assert.equal(agyItem.isActive, false)
62
+ const codexItem = status.items.find((item) => item.id === 'codex')
63
+ assert.ok(codexItem)
64
+ assert.equal(codexItem.isActive, false)
65
65
  } finally {
66
66
  await rm(dir, { recursive: true, force: true })
67
67
  }