@jc_stack/ez-agents 0.1.0-beta.33 → 0.1.0-beta.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.env.example +4 -0
  2. package/CHANGELOG.md +25 -0
  3. package/Dockerfile +4 -0
  4. package/README.md +26 -18
  5. package/THIRD_PARTY_NOTICES.md +4 -4
  6. package/compose.yaml +2 -1
  7. package/docker/smoke.mjs +6 -3
  8. package/docs/application-channel.md +6 -0
  9. package/docs/architecture/ai-selection.md +3 -1
  10. package/docs/docker-runtime.md +27 -10
  11. package/docs/host-service.md +17 -6
  12. package/docs/plugins.md +15 -0
  13. package/docs/repair.md +1 -1
  14. package/docs/setup.md +88 -14
  15. package/docs/standalone-cli.md +6 -5
  16. package/package.json +1 -1
  17. package/scripts/smoke-scheduler.ts +2 -1
  18. package/src/agent-install.ts +20 -6
  19. package/src/ai-cli.ts +9 -7
  20. package/src/ai.ts +7 -7
  21. package/src/application-channel.ts +10 -10
  22. package/src/codex-session.ts +5 -3
  23. package/src/config.ts +6 -1
  24. package/src/control-state.ts +12 -1
  25. package/src/executor.ts +91 -9
  26. package/src/host-executor.ts +25 -6
  27. package/src/index.ts +1 -0
  28. package/src/install-config.ts +2 -2
  29. package/src/install-tools.mjs +3 -1
  30. package/src/isolation.ts +25 -0
  31. package/src/menu.ts +9 -6
  32. package/src/plugins/manager.mjs +49 -6
  33. package/src/reply-context.ts +0 -1
  34. package/src/setup.ts +11 -7
  35. package/src/task-executor.ts +4 -1
  36. package/src/updates/runtime.mjs +1 -1
  37. package/src/updates/supervisor.mjs +17 -3
  38. package/src/workspace-confine.ts +38 -0
  39. package/src/workspace.ts +23 -13
  40. package/templates/agent/AGENTS.md +4 -14
  41. package/templates/agent-guidance.md +13 -110
  42. package/templates/agent-purpose.md +1 -0
  43. package/test/agent-guidance.test.ts +8 -12
  44. package/test/agent-install.test.ts +34 -0
  45. package/test/ai-cli.test.ts +18 -0
  46. package/test/ai.test.ts +48 -0
  47. package/test/application-channel.test.ts +5 -2
  48. package/test/application-controls.test.ts +56 -3
  49. package/test/application-only.test.ts +2 -0
  50. package/test/codex-context.test.ts +61 -1
  51. package/test/codex-session.test.ts +6 -1
  52. package/test/config.test.ts +17 -2
  53. package/test/engine-handoff.test.ts +5 -2
  54. package/test/executor.test.ts +38 -1
  55. package/test/failure.test.ts +6 -0
  56. package/test/host-executor.test.ts +6 -0
  57. package/test/install-config.test.ts +17 -6
  58. package/test/install-tools.test.mjs +6 -0
  59. package/test/plugin-manager.test.mjs +24 -3
  60. package/test/scheduler.test.ts +2 -2
  61. package/test/updates.test.mjs +10 -1
  62. package/test/workspace-confine.test.ts +47 -0
  63. package/test/workspace.test.ts +23 -10
  64. package/templates/agent/SOUL.md +0 -11
  65. package/templates/agent/USER.md +0 -5
@@ -17,9 +17,15 @@ test('new agents receive independent projects, secrets and plugin bindings; dupl
17
17
  await assert.rejects(createAgent({...base,name:'different-cli',cli:'codex'}),/already selected/)
18
18
  const host=JSON.parse(await readFile(join(root,'family/host-executor.json'),'utf8'))
19
19
  assert.equal(host.cli,'grok')
20
+ assert.equal(host.isolation,'host-capable')
20
21
  assert.equal(host.agents[0].workspace,'/private/agents/family/mind')
21
22
  const a=parseEnv(await readFile(join(root,'family/docker.env'),'utf8'))
22
23
  const b=parseEnv(await readFile(join(root,'work/docker.env'),'utf8'))
24
+ assert.equal(a.EZ_ISOLATION,'host-capable')
25
+ assert.equal(a.EZ_EXECUTOR_TRANSPORT,'host')
26
+ assert.equal(b.EZ_ISOLATION,'host-capable')
27
+ assert.equal(b.EZ_EXECUTOR_TRANSPORT,'host')
28
+ assert.equal(JSON.parse(await readFile(join(root,'family/agent.json'),'utf8')).isolation,'host-capable')
23
29
  assert.equal(a.EZ_RELAY_IMAGE,'ezenciel-agents:install-candidate')
24
30
  assert.equal(b.EZ_RELAY_IMAGE,'ezenciel-agents:local')
25
31
  await assert.rejects(createAgent({...base,name:'bad-image',image:"bad'\nINJECT=yes"}),/Invalid relay image/)
@@ -30,7 +36,17 @@ test('new agents receive independent projects, secrets and plugin bindings; dupl
30
36
  await assert.rejects(createAgent({...base,name:'family'}),{code:'EEXIST'})
31
37
  assert.equal((await readFile(join(root,'family/relay.env'),'utf8')).includes(base.token),true)
32
38
  await assert.rejects(createAgent({...base,name:'../family'}),/agent name/)
39
+ await assert.rejects(createAgent({...base,name:'bad-class',isolation:'seatbelt'}),/isolated or host-capable/)
40
+ await assert.rejects(createAgent({...base,name:'unsupported-isolated',isolation:'isolated'}),/unavailable for grok/)
41
+ const capable=await createAgent({...base,name:'capable',isolation:'host-capable'})
42
+ assert.equal(capable.isolation,'host-capable')
43
+ const capableEnv=parseEnv(await readFile(join(root,'capable/docker.env'),'utf8'))
44
+ assert.equal(capableEnv.EZ_ISOLATION,'host-capable')
45
+ assert.equal(capableEnv.EZ_EXECUTOR_TRANSPORT,'host')
46
+ assert.equal(JSON.parse(await readFile(join(root,'capable/host-executor.json'),'utf8')).isolation,'host-capable')
47
+ assert.equal(a.EZ_CODEX_SANDBOX,undefined)
33
48
  await assert.rejects(createAgent({...base,name:'bad',token:'do-not-echo'}),e=>!String(e).includes('do-not-echo'))
49
+ await assert.rejects(createAgent({...base,name:'verbose',purpose:'x'.repeat(2001)}),/concise purpose/)
34
50
  } finally { await rm(root,{recursive:true,force:true}) }
35
51
  })
36
52
 
@@ -42,7 +58,25 @@ test('package installer records its CLI before any agent exists; creation inheri
42
58
  assert.equal(await installationCli(root,'codex'),'codex')
43
59
  const agent=await createAgent({root,hostRoot:'/private/agents',composeFile:'/opt/ez/compose.yaml',name:'shopper',purpose:'Family shopping',token:'123456789:'+'a'.repeat(30)})
44
60
  assert.equal(agent.executor,'codex')
61
+ assert.equal(agent.isolation,'isolated')
62
+ const env=parseEnv(await readFile(join(root,'shopper/docker.env'),'utf8'))
63
+ assert.equal(env.EZ_ISOLATION,'isolated')
64
+ assert.equal(env.EZ_EXECUTOR_TRANSPORT,'local')
65
+ assert.equal(env.EZ_CODEX_SANDBOX,'external')
45
66
  assert.equal(await installationCli(root),'codex')
46
67
  await assert.rejects(installationCli(root,'grok'),/already selected/)
47
68
  } finally { await rm(root,{recursive:true,force:true}) }
48
69
  })
70
+
71
+ test('agent installation records an isolated Codex provider profile without its key', async () => {
72
+ const root=await mkdtemp(join(tmpdir(),'ez-installer-provider-'))
73
+ try {
74
+ const codexProvider={id:'openrouter',name:'OpenRouter',baseUrl:'https://openrouter.ai/api/v1',envKey:'OPENROUTER_API_KEY',models:['google/gemini-3.8-flash','deepseek/deepseek-v4.1-flash']}
75
+ const agent=await createAgent({root,hostRoot:'/private/agents',composeFile:'/opt/ez/compose.yaml',name:'coach',purpose:'Fitness coach',token:'123456789:'+'a'.repeat(30),cli:'codex',codexProvider})
76
+ const host=JSON.parse(await readFile(join(root,'coach/host-executor.json'),'utf8'))
77
+ assert.deepEqual(host.agents[0].codexProviders,[codexProvider])
78
+ assert.equal(JSON.stringify(agent).includes('OPENROUTER_API_KEY'),false)
79
+ assert.equal(JSON.stringify(host).includes('sk-'),false)
80
+ await assert.rejects(createAgent({root,hostRoot:'/private/agents',composeFile:'/opt/ez/compose.yaml',name:'bad-provider',purpose:'x',token:'123456789:'+'b'.repeat(30),codexProvider:{...codexProvider,baseUrl:'http://openrouter.ai/api/v1'}}),/provider URL/)
81
+ } finally { await rm(root,{recursive:true,force:true}) }
82
+ })
@@ -36,3 +36,21 @@ test('AI help is available without a bound control directory or installed engine
36
36
  assert.equal(result.status,0,result.stderr)
37
37
  assert.match(result.stdout,/select --cli/)
38
38
  })
39
+
40
+ test('an agent can select a declared provider and model without putting the key in control state',async()=>{
41
+ const root=await mkdtemp(path.join(tmpdir(),'ez-ai-provider-'))
42
+ try{
43
+ const controlDir=path.join(root,'control'),binDir=path.join(root,'bin')
44
+ await mkdir(path.join(controlDir,'host-executor'),{recursive:true});await mkdir(binDir)
45
+ await writeFile(path.join(binDir,'codex'),'#!/bin/sh\nexit 0\n',{mode:0o700})
46
+ await writeFile(path.join(controlDir,'host-executor','models.json'),JSON.stringify([{cli:'codex',provider:'openrouter',model:'google/gemini-3.8-flash',name:'Gemini',efforts:[]}]))
47
+ const store=new ControlStore(controlDir,900000);await store.aiState(initialPreset('codex'))
48
+ const env={...process.env,HOME:root,PATH:binDir+path.delimiter+process.env.PATH,EZ_CONTROL_DIR:controlDir,OPENROUTER_API_KEY:'must-not-persist'}
49
+ const bin=fileURLToPath(new URL('../bin/ezenciel-agents-ai.mjs',import.meta.url))
50
+ const result=spawnSync(process.execPath,[bin,'select','--cli','codex','--provider','openrouter','--model','google/gemini-3.8-flash'],{env,encoding:'utf8'})
51
+ assert.equal(result.status,0,result.stderr)
52
+ const state=await store.status(),selected=state.ai?.presets.find(p=>p.id===state.ai?.selectedId)
53
+ assert.equal(selected?.provider,'openrouter');assert.equal(selected?.model,'google/gemini-3.8-flash')
54
+ assert.equal(JSON.stringify(state).includes('must-not-persist'),false)
55
+ }finally{await rm(root,{recursive:true,force:true})}
56
+ })
package/test/ai.test.ts CHANGED
@@ -11,6 +11,54 @@ import { InboxStore } from '../src/inbox.js'
11
11
  import { executionDefaults } from '../src/model-policy.js'
12
12
  import type { Update } from 'grammy/types'
13
13
 
14
+ test('same-client model switch does not resume the previous native thread', async () => {
15
+ const dir = await mkdtemp(join(tmpdir(), 'ez-ai-model-switch-'))
16
+ try {
17
+ const store = new ControlStore(dir, 1000)
18
+ const deepseek = { id: 'deepseek', name: 'DeepSeek', cli: 'codex', model: 'deepseek/flash', effort: 'max' }
19
+ const luna = { id: 'luna', name: 'Luna', cli: 'codex', model: 'gpt-5.6-luna', effort: 'max' }
20
+ const first = await store.captureChoice(initialPreset('codex'))
21
+ await store.savePreset(deepseek)
22
+ await store.selectPreset(deepseek.id, first.sessionId, false)
23
+ const started = await store.captureChoice(initialPreset('codex'))
24
+ await store.saveNativeSession(started.sessionId, 'native-deepseek')
25
+ const same = await store.captureChoice(initialPreset('codex'))
26
+ assert.equal(same.sessionId, started.sessionId)
27
+ assert.equal((await store.executionSession(same)).nativeSessionId, 'native-deepseek')
28
+ await store.savePreset(luna)
29
+ await store.selectPreset(luna.id, started.sessionId, false)
30
+ const next = await store.captureChoice(initialPreset('codex'))
31
+ assert.notEqual(next.sessionId, started.sessionId)
32
+ assert.equal(next.preset.model, luna.model)
33
+ assert.equal((await store.executionSession(next)).hasStarted, false)
34
+ assert.equal((await store.executionSession(next)).nativeSessionId, undefined)
35
+ assert.equal((await store.executionSession(started)).nativeSessionId, 'native-deepseek')
36
+ await store.saveNativeSession(next.sessionId, 'native-luna')
37
+ const clientDefault = initialPreset('codex')
38
+ await store.savePreset(clientDefault)
39
+ await store.selectPreset(clientDefault.id, next.sessionId, false)
40
+ const defaultChoice = await store.captureChoice(clientDefault)
41
+ assert.notEqual(defaultChoice.sessionId, next.sessionId)
42
+ assert.equal(defaultChoice.preset.model, undefined)
43
+ await store.saveNativeSession(defaultChoice.sessionId, 'native-default')
44
+ await store.selectPreset(deepseek.id, defaultChoice.sessionId, false)
45
+ const explicitAgain = await store.captureChoice(clientDefault)
46
+ assert.notEqual(explicitAgain.sessionId, defaultChoice.sessionId)
47
+ assert.equal(explicitAgain.preset.model, deepseek.model)
48
+ await store.saveNativeSession(explicitAgain.sessionId, 'native-explicit')
49
+ const openrouter = { ...deepseek, id: 'openrouter-deepseek', name: 'OpenRouter DeepSeek', provider: 'openrouter' }
50
+ await store.savePreset(openrouter)
51
+ await store.selectPreset(openrouter.id, explicitAgain.sessionId, false)
52
+ const providerChoice = await store.captureChoice(clientDefault)
53
+ assert.notEqual(providerChoice.sessionId, explicitAgain.sessionId)
54
+ assert.equal(providerChoice.preset.provider, 'openrouter')
55
+ const args = EXECUTOR_REGISTRY.codex.buildArgs({
56
+ workspace: dir, isResume: false, sessionId: next.sessionId,
57
+ }, '', '')
58
+ assert.equal(args.includes('resume'), false)
59
+ } finally { await rm(dir, { recursive: true, force: true }) }
60
+ })
61
+
14
62
  test('AI choices pin model, effort and session; defaults and CLI switches do not reroute old work', async () => {
15
63
  const dir = await mkdtemp(join(tmpdir(), 'ez-ai-'))
16
64
  try {
@@ -84,7 +84,9 @@ test('HTTP auth, idempotency, origin/context isolation, revocation and persisted
84
84
  await runs.claimOutbox(message.id)
85
85
  await channel.deliver(run,message)
86
86
  assert.deepEqual(await runs.waitForDelivery(message.id),{delivered:true})
87
- assert.deepEqual((await channel.snapshot(first.bindingId,run.id)).messages,[{id:message.id,text:'Here is the proposal'}])
87
+ const snapshot=await channel.snapshot(first.bindingId,run.id)
88
+ assert.deepEqual(snapshot.messages,[{id:message.id,text:'Here is the proposal'}])
89
+ assert.deepEqual(snapshot.preset,run.execution?.preset)
88
90
  const approval=await runs.enqueueApproval(run.id,'Allow this channel grant?','task_'+'a'.repeat(32))
89
91
  await runs.claimOutbox(approval.id);await channel.deliver(run,approval)
90
92
  assert.deepEqual((await channel.snapshot(first.bindingId,run.id)).approvals,[{id:'task_'+'a'.repeat(32),prompt:'Allow this channel grant?',state:'delivered'}])
@@ -202,8 +204,9 @@ test('application AI choice retains same-engine history and rejects cross-engine
202
204
  t.after(() => rm(root, { recursive: true, force: true }))
203
205
  const channel = new ApplicationChannel({ controlDir: root, initial: initialPreset('codex'), wake: () => {}, cancel: async () => {} })
204
206
  const binding = (await channel.bindings.register('app', token(), await owner(root)))!
205
- const input = { requestId: 'one', scope: 'main:codex', text: 'Hello', ai: { cli: 'codex', model: 'gpt-5.6-luna', effort: 'high' } }
207
+ const input = { requestId: 'one', scope: 'main:codex', text: 'Hello', ai: { cli: 'codex', provider:'openrouter', model: 'gpt-5.6-luna', effort: 'high' } }
206
208
  const first = await channel.submit(binding.bindingId, input)
209
+ assert.equal(first.execution!.preset.provider,'openrouter')
207
210
  assert.equal(first.execution!.preset.model, input.ai.model)
208
211
  const second = await channel.submit(binding.bindingId, { ...input, requestId: 'two', ai: { ...input.ai, model: 'gpt-5.6-terra' } })
209
212
  assert.equal(first.execution!.sessionId, second.execution!.sessionId)
@@ -1,6 +1,6 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { mkdtemp, rm, open } from 'node:fs/promises'
3
+ import { mkdtemp, rm, open, mkdir, writeFile } from 'node:fs/promises'
4
4
  import { join } from 'node:path'
5
5
  import { tmpdir } from 'node:os'
6
6
  import { randomBytes } from 'node:crypto'
@@ -9,6 +9,41 @@ import { ControlStore } from '../src/control-state.js'
9
9
  import { createAiMenu } from '../src/menu.js'
10
10
  import { RunStore } from '../src/runs.js'
11
11
 
12
+ test('shared controls validate detected host presets against the host catalog', async t => {
13
+ const root = await mkdtemp(join(tmpdir(), 'ez-host-controls-'))
14
+ const previousTransport = process.env.EZ_EXECUTOR_TRANSPORT
15
+ const previousControl = process.env.EZ_CONTROL_DIR
16
+ process.env.EZ_EXECUTOR_TRANSPORT = 'host'
17
+ process.env.EZ_CONTROL_DIR = root
18
+ t.after(async () => {
19
+ if (previousTransport === undefined) delete process.env.EZ_EXECUTOR_TRANSPORT
20
+ else process.env.EZ_EXECUTOR_TRANSPORT = previousTransport
21
+ if (previousControl === undefined) delete process.env.EZ_CONTROL_DIR
22
+ else process.env.EZ_CONTROL_DIR = previousControl
23
+ await rm(root, {recursive:true, force:true})
24
+ })
25
+ await mkdir(join(root, 'host-executor'), {recursive:true})
26
+ await writeFile(join(root, 'host-executor', 'models.json'), JSON.stringify([
27
+ {cli:'grok', model:'grok-4.6', name:'Grok 4.6', efforts:['high']},
28
+ ]))
29
+ const control = new ControlStore(root, 1000)
30
+ await control.requestPairing(42, 42); const owner = await control.approveOwner(42)
31
+ const menu = createAiMenu(control, 'codex')
32
+ const detected = {id:'detected_fixture', name:'Grok 4.6 High', cli:'grok', model:'grok-4.6', effort:'high'}
33
+ await control.aiState(menu.initial)
34
+ await control.savePreset(detected)
35
+ const initial = await control.captureChoice(menu.initial)
36
+ await control.selectPreset(detected.id, initial.sessionId, true)
37
+ const current = (await control.getActiveSession())!
38
+ const channel = new ApplicationChannel({controlDir:root, initial:menu.initial, aiControls:menu, wake:()=>{}, cancel:async()=>{}})
39
+ const binding = (await channel.bindings.register('shared', randomBytes(32).toString('base64url'), owner, true))!
40
+
41
+ const selected = await channel.changeControls(binding.bindingId, {
42
+ action:'select', presetId:detected.id, expectedSession:current.sessionId,
43
+ })
44
+ assert.equal(selected.ai.selectedId, detected.id)
45
+ })
46
+
12
47
  test('private scope reset and model controls preserve running sessions and shared state', async t => {
13
48
  const root = await mkdtemp(join(tmpdir(),'ez-private-controls-'))
14
49
  const control = new ControlStore(root,1000)
@@ -75,6 +110,7 @@ test('application controls share native choices, protect hidden scopes and prese
75
110
  await control.requestPairing(42, 42); const owner = await control.approveOwner(42)
76
111
  const menu = createAiMenu(control, 'codex', async () => [
77
112
  {cli:'codex', model:'gpt-6-astra', name:'Fixture model', efforts:['low','high']},
113
+ {cli:'codex', provider:'openrouter', model:'gpt-6-astra', name:'OpenRouter fixture', efforts:['low','high']},
78
114
  ], root, join(root, 'native-home'), async () => true)
79
115
  const channel = new ApplicationChannel({controlDir:root, initial:menu.initial, aiControls:menu, wake:()=>{}, cancel:async()=>{}})
80
116
  t.after(async()=>{await channel.stop(); await rm(root,{recursive:true,force:true})})
@@ -107,9 +143,11 @@ test('application controls share native choices, protect hidden scopes and prese
107
143
  assert.deepEqual((await control.aiState(menu.initial)).presets,presetsBefore)
108
144
  const selected = await request({action:'model',cli:'codex',model:'gpt-6-astra',effort:'high',expectedSession:old.sessionId})
109
145
  assert.equal(selected.status,200)
110
- assert.equal((await control.captureChoice(menu.initial)).preset.effort,'high')
146
+ const modelChoice = await control.captureChoice(menu.initial)
147
+ assert.equal(modelChoice.preset.effort,'high')
148
+ assert.notEqual(modelChoice.sessionId,old.sessionId)
111
149
  assert.deepEqual((await new RunStore(root).get(run.id))!.execution,old)
112
- const reset = await request({action:'new',expectedSession:old.sessionId})
150
+ const reset = await request({action:'new',expectedSession:modelChoice.sessionId})
113
151
  assert.equal(reset.status,200)
114
152
  const next = (await control.getActiveSession())!
115
153
  assert.notEqual(next.sessionId,old.sessionId)
@@ -119,6 +157,21 @@ test('application controls share native choices, protect hidden scopes and prese
119
157
  assert.equal((await request()).status,401)
120
158
  })
121
159
 
160
+ test('application model controls disambiguate the same model by provider', async t => {
161
+ const root=await mkdtemp(join(tmpdir(),'ez-app-provider-control-')),control=new ControlStore(root,1000)
162
+ await control.requestPairing(42,42);const owner=await control.approveOwner(42)
163
+ const menu=createAiMenu(control,'codex',async()=>[
164
+ {cli:'codex',model:'shared-model',name:'Native',efforts:[]},
165
+ {cli:'codex',provider:'openrouter',model:'shared-model',name:'OpenRouter',efforts:[]},
166
+ ],root,join(root,'native-home'),async()=>true)
167
+ const channel=new ApplicationChannel({controlDir:root,initial:menu.initial,aiControls:menu,wake:()=>{},cancel:async()=>{}})
168
+ t.after(async()=>{await channel.stop();await rm(root,{recursive:true,force:true})})
169
+ const binding=(await channel.bindings.register('shared',randomBytes(32).toString('base64url'),owner,true))!
170
+ const current=await control.captureChoice(menu.initial)
171
+ await channel.changeControls(binding.bindingId,{action:'model',cli:'codex',provider:'openrouter',model:'shared-model',expectedSession:current.sessionId})
172
+ assert.equal((await control.captureChoice(menu.initial)).preset.provider,'openrouter')
173
+ })
174
+
122
175
  test('atomic control mutations reject a replaced owner even when both active sessions are empty', async t => {
123
176
  const root = await mkdtemp(join(tmpdir(),'ez-control-owner-'))
124
177
  t.after(()=>rm(root,{recursive:true,force:true}))
@@ -13,6 +13,7 @@ import { ControlStore } from '../src/control-state.js'
13
13
  import { RunStore } from '../src/runs.js'
14
14
  import { EXECUTOR_REGISTRY } from '../src/executor.js'
15
15
  import { ApplicationBindings } from '../src/application-channel.js'
16
+ import { initializeWorkspace } from '../src/workspace.js'
16
17
 
17
18
  const waitFor = async (condition: () => Promise<boolean>) => {
18
19
  for (let i=0;i<300;i++) { if (await condition()) return; await new Promise(r=>setTimeout(r,10)) }
@@ -60,6 +61,7 @@ test('administrator bootstrap is explicit, local-only and cannot replace identit
60
61
 
61
62
  test('botless daemon executes application turn and rejects Telegram-origin work/outbound', async t => {
62
63
  const root=await mkdtemp(join(tmpdir(),'ez-app-only-runtime-'))
64
+ const purpose=join(root,'purpose.md');await writeFile(purpose,'Fixture application agent\n');await initializeWorkspace(root,purpose)
63
65
  const portServer=createServer();await new Promise<void>(r=>portServer.listen(0,'127.0.0.1',r))
64
66
  const port=(portServer.address() as {port:number}).port;await new Promise<void>(r=>portServer.close(()=>r()))
65
67
  const config=loadConfig({EZ_TELEGRAM_ENABLED:'false',EZ_APPLICATION_PORT:String(port),EZ_CONTROL_DIR:root,EZ_AGENT_WORKSPACE:root,EZ_EXECUTOR_CLI:'codex'})
@@ -6,15 +6,67 @@ import {tmpdir} from 'node:os'
6
6
  import path from 'node:path'
7
7
  import {startExecutorJob} from '../src/executor.js'
8
8
 
9
+ test('a package-bin Codex shim cannot retarget another agent home', async () => {
10
+ const root = await mkdtemp(path.join(tmpdir(), 'ez-codex-host-cli-'))
11
+ const hostBin = path.join(root, 'host-bin')
12
+ const packageBin = path.join(root, 'package-bin')
13
+ const controlDir = path.join(root, 'agent')
14
+ const priorHome = process.env.HOME, priorPath = process.env.PATH
15
+ try {
16
+ await mkdir(hostBin); await mkdir(packageBin); await mkdir(path.join(root, '.codex'))
17
+ await writeFile(path.join(root, '.codex/auth.json'), '{}')
18
+ await writeFile(path.join(packageBin, 'codex'), `#!${process.execPath}
19
+ require('fs').writeFileSync(require('path').join(process.env.HOME, 'hijacked'), 'yes'); process.exit(2)
20
+ `, { mode: 0o700 })
21
+ await writeFile(path.join(hostBin, 'codex'), `#!${process.execPath}\nconsole.log(process.env.CODEX_HOME)`, { mode: 0o700 })
22
+ process.env.HOME = root
23
+ process.env.PATH = hostBin + path.delimiter + (priorPath ?? '')
24
+ await ownerRun(controlDir, 'r_test')
25
+ const job = await startExecutorJob(['hello'], {
26
+ workspace: root, controlDir, binDir: packageBin, cli: 'codex', runId: 'r_test', timeoutMs: 5000,
27
+ })
28
+ let output = ''
29
+ job.child.stdout?.on('data', chunk => output += chunk)
30
+ assert.equal(await new Promise(resolve => job.child.once('close', resolve)), 0)
31
+ await job.cleanup()
32
+ assert.equal(output.trim(), path.join(controlDir, 'cli/codex'))
33
+ await assert.rejects(readFile(path.join(root, 'hijacked')))
34
+ } finally {
35
+ if (priorHome === undefined) delete process.env.HOME; else process.env.HOME = priorHome
36
+ if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath
37
+ await rm(root, { recursive: true, force: true })
38
+ }
39
+ })
40
+
41
+ test('a package-bin Codex shim is not a fallback when the host CLI is missing', async () => {
42
+ const root = await mkdtemp(path.join(tmpdir(), 'ez-codex-host-cli-missing-'))
43
+ const packageBin = path.join(root, 'package-bin')
44
+ const controlDir = path.join(root, 'agent')
45
+ const priorPath = process.env.PATH
46
+ try {
47
+ await mkdir(packageBin)
48
+ await writeFile(path.join(packageBin, 'codex'), `#!${process.execPath}\nprocess.exit(91)\n`, { mode: 0o700 })
49
+ process.env.PATH = '/usr/bin:/bin'
50
+ await ownerRun(controlDir, 'r_test')
51
+ await assert.rejects(startExecutorJob(['hello'], {
52
+ workspace: root, controlDir, binDir: packageBin, cli: 'codex', runId: 'r_test', timeoutMs: 5000,
53
+ }), /Native CLI codex is not executable on the host PATH/)
54
+ } finally {
55
+ if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath
56
+ await rm(root, { recursive: true, force: true })
57
+ }
58
+ })
59
+
9
60
  test('Codex shares only auth through a link and keeps each agent runtime state separate',async()=>{
10
61
  const root=await mkdtemp(path.join(tmpdir(),'ez-codex-context-'))
11
- const priorHome=process.env.HOME,priorPath=process.env.PATH
62
+ const priorHome=process.env.HOME,priorPath=process.env.PATH,priorIsolation=process.env.EZ_ISOLATION
12
63
  try{
13
64
  await mkdir(path.join(root,'.codex'));await mkdir(path.join(root,'bin'))
14
65
  await writeFile(path.join(root,'.codex/auth.json'),'{}')
15
66
  await writeFile(path.join(root,'.codex/config.toml'),'# unrelated personal config')
16
67
  await writeFile(path.join(root,'bin/codex'),`#!${process.execPath}\nconsole.log(process.env.CODEX_HOME)`,{mode:0o700})
17
68
  process.env.HOME=root;process.env.PATH=path.join(root,'bin')+path.delimiter+priorPath
69
+ delete process.env.EZ_ISOLATION
18
70
  for(const agent of ['one','two']){
19
71
  const controlDir=path.join(root,agent)
20
72
  await ownerRun(controlDir, 'r_test')
@@ -27,9 +79,17 @@ test('Codex shares only auth through a link and keeps each agent runtime state s
27
79
  assert.deepEqual(await readdir(home),['auth.json'])
28
80
  assert.equal(await readlink(path.join(home,'auth.json')),path.join(root,'.codex/auth.json'))
29
81
  }
82
+ process.env.EZ_ISOLATION='isolated'
83
+ const isolated=path.join(root,'isolated')
84
+ await ownerRun(isolated, 'r_test')
85
+ const isolatedJob=await startExecutorJob(['hello'],{workspace:root,controlDir:isolated,binDir:path.join(root,'bin'),cli:'codex',runId:'r_test',timeoutMs:5000})
86
+ assert.equal(await new Promise(resolve=>isolatedJob.child.once('close',resolve)),0)
87
+ await isolatedJob.cleanup()
88
+ await assert.rejects(readlink(path.join(isolated,'cli/codex/auth.json')),{code:'ENOENT'})
30
89
  }finally{
31
90
  if(priorHome===undefined)delete process.env.HOME;else process.env.HOME=priorHome
32
91
  if(priorPath===undefined)delete process.env.PATH;else process.env.PATH=priorPath
92
+ if(priorIsolation===undefined)delete process.env.EZ_ISOLATION;else process.env.EZ_ISOLATION=priorIsolation
33
93
  await rm(root,{recursive:true,force:true})
34
94
  }
35
95
  })
@@ -43,7 +43,8 @@ if(q.method==='thread/goal/get'){
43
43
  child.stdin.write=((chunk:any,...args:any[])=>{try{const q=JSON.parse(String(chunk));if(q.method==='turn/start'){turnPrompt=q.params.input[0].text;turnPolicy=q.params.sandboxPolicy}requests.push(q.method);if(q.method==='thread/start'){threadConfig=q.params.config;threadSandbox=q.params.sandbox}}catch{};return (write as any)(chunk,...args)}) as typeof child.stdin.write
44
44
  return child
45
45
  }
46
- const result=await runCodexSession({workspace:'/tmp',controlDir:'/tmp/control',sharedWorkspace:'/canonical',additionalWorkspaces:['/agent/mind/work'],prompt,
46
+ const codexProvider=mode==='tool-goal'?{id:'openrouter',name:'OpenRouter',baseUrl:'https://openrouter.ai/api/v1',envKey:'OPENROUTER_API_KEY',models:['open/model']}:undefined
47
+ const result=await runCodexSession({workspace:'/tmp',controlDir:'/tmp/control',sharedWorkspace:'/canonical',additionalWorkspaces:['/agent/mind/work'],prompt,codexProvider,model:codexProvider?'open/model':undefined,
47
48
  ...(mode==='external'?{codexSandbox:'external' as const}:{})},{launch,emit:line=>output.push(line)})
48
49
  assert.equal(threadSandbox!,mode==='external'?'danger-full-access':'workspace-write')
49
50
  assert.deepEqual(turnPolicy,mode==='external'?{type:'externalSandbox',networkAccess:'enabled'}:undefined)
@@ -52,6 +53,10 @@ if(q.method==='thread/goal/get'){
52
53
  if(mode==='long-goal')assert.ok(prompt.length>4000)
53
54
  assert.ok(threadConfig['sandbox_workspace_write.writable_roots'].includes('/canonical'))
54
55
  assert.ok(threadConfig['sandbox_workspace_write.writable_roots'].includes('/agent/mind/work'))
56
+ if(codexProvider){
57
+ assert.equal(threadConfig.model_provider,'openrouter')
58
+ assert.deepEqual(threadConfig['model_providers.openrouter'],{name:'OpenRouter',base_url:'https://openrouter.ai/api/v1',env_key:'OPENROUTER_API_KEY',wire_api:'responses',supports_websockets:false})
59
+ }
55
60
  assert.equal(result,['external','plain','goal','long-goal','tool-goal'].includes(mode)?0:1)
56
61
  assert.equal(requests.filter(x=>x==='turn/start').length,1,'transport must not send goal continuation prompts')
57
62
  assert.equal(requests.filter(x=>x==='thread/goal/set').length,0)
@@ -65,8 +65,23 @@ test('external Codex isolation requires explicit native local deployment', () =>
65
65
  assert.equal(loadConfig(env).codexSandbox, 'external')
66
66
  assert.equal(loadConfig({...env,EZ_TELEGRAM_ENABLED:'true',TELEGRAM_BOT_TOKEN:'test'}).codexSandbox, 'external')
67
67
  assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test'}).codexSandbox, undefined)
68
- for (const override of [{EZ_EXECUTOR_TRANSPORT:'host'}, {EZ_EXECUTOR_TRANSPORT:''}, {EZ_CODEX_SANDBOX:'danger-full-access'}])
69
- assert.throws(() => loadConfig({...env,...override}), /sandbox|SANDBOX/)
68
+ assert.equal(loadConfig({...env,EZ_EXECUTOR_TRANSPORT:''}).isolation, 'isolated')
69
+ assert.throws(() => loadConfig({...env,EZ_EXECUTOR_TRANSPORT:'host'}), /sandbox|SANDBOX|Isolation/)
70
+ assert.throws(() => loadConfig({...env,EZ_CODEX_SANDBOX:'danger-full-access'}), /sandbox|SANDBOX/)
71
+ })
72
+
73
+ test('isolation class is isolated by default and rejects host transport mismatch', () => {
74
+ assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test'}).isolation, 'isolated')
75
+ assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_ISOLATION:'isolated',EZ_EXECUTOR_TRANSPORT:'local'}).isolation, 'isolated')
76
+ assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_EXECUTOR_TRANSPORT:'host'}).isolation, 'host-capable')
77
+ assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_ISOLATION:'host-capable',EZ_EXECUTOR_TRANSPORT:'host'}).isolation, 'host-capable')
78
+ assert.throws(() => loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_ISOLATION:'isolated',EZ_EXECUTOR_TRANSPORT:'host'}), /Isolation isolated requires EZ_EXECUTOR_TRANSPORT=local/)
79
+ assert.throws(() => loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_ISOLATION:'host-capable',EZ_EXECUTOR_TRANSPORT:'local'}), /Isolation host-capable requires EZ_EXECUTOR_TRANSPORT=host/)
80
+ assert.throws(() => loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_ISOLATION:'seatbelt'}), /isolated or host-capable/)
81
+ assert.throws(() => loadConfig({
82
+ TELEGRAM_BOT_TOKEN:'test',EZ_ISOLATION:'host-capable',EZ_EXECUTOR_TRANSPORT:'host',
83
+ EZ_CHANNEL_BACKEND_URL:'https://backend.example',EZ_CHANNEL_BACKEND_TOKEN:'secret',
84
+ }), /Host-capable isolation requires the host native CLI/)
70
85
  })
71
86
 
72
87
  test('optional web launcher preserves reserved commands and accepts only HTTPS without secrets', () => {
@@ -13,7 +13,7 @@ test('owner, resumed and native scheduled subprocesses receive literal input and
13
13
  const root=await mkdtemp(path.join(tmpdir(),'ez-literal-'))
14
14
  t.after(()=>rm(root,{recursive:true,force:true}))
15
15
  const bin=path.join(root,'bin'),workspace=path.join(root,'mind'),controlDir=path.join(root,'control')
16
- await mkdir(bin);await initializeWorkspace(workspace)
16
+ await mkdir(bin);const purpose=path.join(root,'purpose.md');await writeFile(purpose,'Fixture agent\n');await initializeWorkspace(workspace,purpose)
17
17
  const fixture=`#!${process.execPath}
18
18
  const fs=require('fs'), args=process.argv.slice(2);
19
19
  const capture=prompt=>fs.writeFileSync('capture.json',JSON.stringify({prompt,args,env:{run:process.env.EZ_RUN_ID,control:process.env.EZ_CONTROL_DIR,repair:process.env.EZ_REPAIR_ENABLED,secret:process.env.TELEGRAM_BOT_TOKEN}}));
@@ -44,7 +44,10 @@ if(args[0]==='app-server'){
44
44
  const captured=JSON.parse(await readFile(path.join(workspace,'capture.json'),'utf8'))
45
45
  assert.equal(captured.prompt,text)
46
46
  assert.deepEqual(captured.env,{run:runId,control:controlDir,repair:'false'})
47
- if(cli==='codex')assert.equal(captured.args.at(-1),'-')
47
+ if(cli==='codex'){
48
+ assert.equal(captured.args.at(-1),'-')
49
+ if(process.platform==='darwin')assert.equal(captured.args[captured.args.indexOf('--sandbox')+1],'danger-full-access')
50
+ }
48
51
  if(['codex','claude'].includes(cli))assert.ok(!captured.args.includes('--help'))
49
52
  if(cli==='opencode')assert.equal(captured.args.at(-2),'--')
50
53
  if(cli==='agy')assert.ok(captured.args.includes('--print='+text))
@@ -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, startExecutorJob, terminateJob } from '../src/executor.js'
9
+ import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment, grokInvocation, grokJobEnv, opencodeInvocation, resolveExecutor, resolveHostCommand, startExecutorJob, terminateJob, validateCodexProvider } from '../src/executor.js'
10
10
  import { splitTelegramText } from '../src/reply.js'
11
11
  import { matchingProcessIds, processSnapshot } from '../src/process-tree.js'
12
12
 
@@ -54,6 +54,11 @@ test('Telegram replies are split within the configured message limit', () => {
54
54
  assert.ok(chunks.every((chunk) => chunk.length <= 10))
55
55
  })
56
56
 
57
+ test('native CLIs resolve from the host PATH, not a relative package name', () => {
58
+ assert.equal(resolveHostCommand('/usr/bin/codex'), '/usr/bin/codex')
59
+ assert.throws(() => resolveHostCommand('missing-cli-xyz', '/usr/bin'), /not executable on the host PATH/)
60
+ })
61
+
57
62
  test('the executor receives a deliberately small environment', () => {
58
63
  const environment = executorEnvironment({ PATH: '/bin', HOME: '/tmp/home', TELEGRAM_BOT_TOKEN: 'secret', PAGERDUTY_ROUTING_KEY: 'secret', AWS_SECRET_ACCESS_KEY: 'secret' })
59
64
  assert.deepEqual(environment, { PATH: '/bin', HOME: '/tmp/home' })
@@ -158,6 +163,38 @@ test('Codex jobs disable global memory and host skill discovery', () => {
158
163
  assert.equal(args[args.indexOf('skip_host_skill_discovery')-1],'--enable')
159
164
  })
160
165
 
166
+ test('Codex custom providers use declared runtime state and an environment key reference', () => {
167
+ const provider=validateCodexProvider({id:'openrouter',name:'OpenRouter',baseUrl:'https://openrouter.ai/api/v1/',envKey:'OPENROUTER_API_KEY',models:['google/gemini-3.8-flash']})
168
+ const args=EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent',model:'google/gemini-3.8-flash',codexProvider:provider},'','hello')
169
+ assert.ok(args.includes('model_provider="openrouter"'))
170
+ assert.ok(args.some(value=>value.includes('env_key="OPENROUTER_API_KEY"')))
171
+ assert.ok(args.includes('google/gemini-3.8-flash'))
172
+ assert.equal(JSON.stringify(args).includes('provider-secret'),false)
173
+ assert.throws(()=>validateCodexProvider({...provider,envKey:'TELEGRAM-BOT'}),/environment key/)
174
+ for (const envKey of ['PATH','HOME','CODEX_HOME','NODE_OPTIONS','EZ_CONTROL_DIR','TELEGRAM_BOT_TOKEN','PAGERDUTY_ROUTING_KEY'])
175
+ assert.throws(()=>validateCodexProvider({...provider,envKey}),/Reserved/)
176
+ assert.throws(()=>validateCodexProvider({...provider,models:[]}),/provider models/)
177
+ })
178
+
179
+ test('a configured Codex provider receives only its declared key and runtime selection', async t => {
180
+ const root=await mkdtemp(path.join(tmpdir(),'ez-codex-provider-'));t.after(()=>rm(root,{recursive:true,force:true}))
181
+ const bin=path.join(root,'bin');await mkdir(bin)
182
+ await writeFile(path.join(bin,'codex'),`#!${process.execPath}\nconst fs=require('fs');let text='';process.stdin.on('data',b=>text+=b);process.stdin.on('end',()=>fs.writeFileSync(${JSON.stringify(path.join(root,'observed.json'))},JSON.stringify({args:process.argv.slice(2),key:process.env.OPENROUTER_API_KEY,telegram:process.env.TELEGRAM_BOT_TOKEN,text})));`,{mode:0o755})
183
+ const prior={path:process.env.PATH,key:process.env.OPENROUTER_API_KEY,telegram:process.env.TELEGRAM_BOT_TOKEN}
184
+ const provider=validateCodexProvider({id:'openrouter',name:'OpenRouter',baseUrl:'https://openrouter.ai/api/v1',envKey:'OPENROUTER_API_KEY',models:['google/gemini-3.8-flash']})
185
+ await ownerRun(root,'r_provider')
186
+ try {
187
+ process.env.PATH=bin+path.delimiter+prior.path;process.env.OPENROUTER_API_KEY='provider-secret';process.env.TELEGRAM_BOT_TOKEN='relay-secret'
188
+ const job=await startExecutorJob(['hello'],{workspace:root,controlDir:root,binDir:bin,runId:'r_provider',timeoutMs:0,cli:'codex',provider:'openrouter',model:'google/gemini-3.8-flash',codexProvider:provider})
189
+ assert.equal(await new Promise(resolve=>job.child.once('close',resolve)),0);await job.cleanup()
190
+ const observed=JSON.parse(await readFile(path.join(root,'observed.json'),'utf8'))
191
+ assert.equal(observed.key,'provider-secret');assert.equal(observed.telegram,undefined);assert.equal(observed.text,'hello')
192
+ assert.ok(observed.args.includes('model_provider="openrouter"'));assert.ok(observed.args.includes('google/gemini-3.8-flash'))
193
+ } finally {
194
+ for(const [key,value] of [['PATH',prior.path],['OPENROUTER_API_KEY',prior.key],['TELEGRAM_BOT_TOKEN',prior.telegram]]) if(value===undefined)delete process.env[key!];else process.env[key!]=value
195
+ }
196
+ })
197
+
161
198
  test('Codex plugin access stays scoped to the explicitly bound registry', () => {
162
199
  const args=EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent/mind',controlDir:'/agent/control',toolsHome:'/agent/tools',sharedWorkspace:'/app',additionalWorkspaces:['/agent/mind/work']},'', 'install a plugin')
163
200
  assert.deepEqual(args.flatMap((arg,i)=>arg==='--add-dir'?[args[i+1]]:[]),['/agent/control','/app','/agent/mind/work','/agent/tools'])
@@ -15,12 +15,14 @@ import { initialPreset } from '../src/ai.js'
15
15
  import { createRelay } from '../src/index.js'
16
16
  import { TelegramSource } from '../src/telegram-source.js'
17
17
  import { packageVersion } from '../src/version.js'
18
+ import { initializeWorkspace } from '../src/workspace.js'
18
19
  import type { Update } from 'grammy/types'
19
20
  const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
20
21
  // This integration test starts subprocesses, persists their receipts, and invokes
21
22
  // the schedule CLI. Give a busy CI worker time to settle without changing the
22
23
  // production recovery deadline.
23
24
  const until=async(check:()=>Promise<boolean>,timeoutMs=15_000)=>{const deadline=Date.now()+timeoutMs;while(!(await check())){if(Date.now()>=deadline)throw new Error(`Timed out after ${timeoutMs}ms`);await new Promise(r=>setTimeout(r,20))}}
25
+ const seedWorkspace=async(dir:string)=>{const purpose=join(dir,'purpose.md');await writeFile(purpose,'Fixture relay agent\n');await initializeWorkspace(dir,purpose)}
24
26
 
25
27
  test('failure evidence is bounded and redacts configured credentials, headers, tokens, URLs and keys',()=>{
26
28
  const token='123456789:abcdefghijklmnopqrstuvwxyz123456789'
@@ -142,6 +144,7 @@ test('relay shutdown waits for executor cleanup and final run state', async () =
142
144
 
143
145
  test('a fresh relay initializes its bot identity before polling', async () => {
144
146
  const dir=await mkdtemp(join(tmpdir(),'ez-cold-start-'))
147
+ await seedWorkspace(dir)
145
148
  const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw new Error('No executor expected')})
146
149
  const methods:string[]=[]
147
150
  relay.bot.api.config.use(async(_prev,method,_payload,signal)=>{
@@ -165,6 +168,7 @@ test('a fresh relay initializes its bot identity before polling', async () => {
165
168
 
166
169
  for (const cleanupFails of [false,true]) test(`polling conflict preserves work until an explicit shutdown (cleanup fails: ${cleanupFails})`, async t => {
167
170
  const dir=await mkdtemp(join(tmpdir(),'ez-polling-conflict-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
171
+ await seedWorkspace(dir)
168
172
  let release!:()=>void,entered!:()=>void,releaseDelivery!:()=>void,sending!:()=>void,child:ReturnType<typeof spawn>|undefined,polls=0
169
173
  const gate=new Promise<void>(resolve=>{release=resolve}),cleaning=new Promise<void>(resolve=>{entered=resolve})
170
174
  const deliveryGate=new Promise<void>(resolve=>{releaseDelivery=resolve}),deliveryStarted=new Promise<void>(resolve=>{sending=resolve})
@@ -285,6 +289,7 @@ test('group members can inspect failures and wake review without exposing other
285
289
 
286
290
  for (const failure of [400,401,503,'programming'] as const) test(`setup failure ${failure} retries only transient errors`,async t=>{
287
291
  const dir=await mkdtemp(join(tmpdir(),'ez-setup-failure-'))
292
+ await seedWorkspace(dir)
288
293
  const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw Error('No executor expected')})
289
294
  let commands=0,polls=0
290
295
  relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
@@ -310,6 +315,7 @@ for (const failure of [400,401,503,'programming'] as const) test(`setup failure
310
315
 
311
316
  test('shutdown aborts a pending bot initialization without starting polling',async()=>{
312
317
  const dir=await mkdtemp(join(tmpdir(),'ez-init-stop-'))
318
+ await seedWorkspace(dir)
313
319
  const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw Error('No executor expected')})
314
320
  let initSignal:Parameters<typeof relay.bot.init>[0],polls=0
315
321
  relay.bot.api.config.use(async(_prev,method,_payload,signal)=>{
@@ -41,6 +41,12 @@ test('legacy host lock fails closed while its PID is alive',async()=>{
41
41
  }finally{await rm(root,{recursive:true,force:true})}
42
42
  })
43
43
 
44
+ test('isolated installations cannot start host transport', async () => {
45
+ const abort=new AbortController()
46
+ await assert.rejects(serveHostExecutor({cli:'grok',isolation:'isolated',agents:[]},abort.signal),/Isolated agents run the native CLI in the relay/)
47
+ abort.abort()
48
+ })
49
+
44
50
  test('host restart clears dead native lease only after proving previous CLI stopped',async()=>{
45
51
  const root=await mkdtemp(path.join(tmpdir(),'ez-native-recovery-'));
46
52
  const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),toolsHome=path.join(root,'tools'),directory=path.join(controlDir,'host-executor');
@@ -14,8 +14,10 @@ test('configure preserves custom paths and unknown settings, keeps token private
14
14
  const root = await mkdtemp(path.join(tmpdir(), 'ez-configure-'))
15
15
  try {
16
16
  await writeFile(path.join(root, '.env'), 'EZ_AGENT_WORKSPACE="my mind"\nEZ_CONTROL_DIR="private control"\nEXTRA="keep me"\n')
17
+ const purpose = path.join(root, 'purpose.md')
18
+ await writeFile(purpose, 'Configured assistant\n')
17
19
  const token = '123456789:' + 'a'.repeat(30)
18
- const result = await configureInstallation(root, 'codex', token)
20
+ const result = await configureInstallation(root, 'codex', token, purpose)
19
21
  assert.equal(result.tokenConfigured, true)
20
22
  assert.equal(JSON.stringify(result).includes(token), false)
21
23
  const env = parseEnv(await readFile(result.envFile, 'utf8'))
@@ -24,12 +26,12 @@ test('configure preserves custom paths and unknown settings, keeps token private
24
26
  assert.equal(env.EZ_AGENT_WORKSPACE, path.join(root, 'my mind'))
25
27
  assert.equal((await stat(result.envFile)).mode & 0o777, 0o600)
26
28
  assert.equal(await readActiveExecutor(result.envFile), 'codex')
27
- const soul = path.join(result.workspace, 'SOUL.md')
28
- await writeFile(soul, 'Customized purpose')
29
+ const agents = path.join(result.workspace, 'AGENTS.md')
30
+ await writeFile(agents, 'Customized purpose')
29
31
  const repeated = await configureInstallation(root, 'codex')
30
32
  assert.deepEqual(repeated.created, [])
31
33
  assert.equal(repeated.tokenConfigured, true)
32
- assert.equal(await readFile(soul, 'utf8'), 'Customized purpose')
34
+ assert.match(await readFile(agents, 'utf8'), /Customized purpose/)
33
35
  await assert.rejects(stat(result.controlDir), { code: 'ENOENT' }) // No control/owner mutation.
34
36
  } finally { await rm(root, { recursive: true, force: true }) }
35
37
  })
@@ -56,15 +58,24 @@ test('packaged configure accepts token through stdin without echo or extra initi
56
58
  const root = await mkdtemp(path.join(tmpdir(), 'ez-configure-cli-'))
57
59
  try {
58
60
  const token = '123456789:' + 'b'.repeat(30)
61
+ const purpose = path.join(root, 'purpose.md')
62
+ await writeFile(purpose, 'Packaged assistant\n')
59
63
  const bin = fileURLToPath(new URL('../bin/ezenciel-agents-setup.mjs', import.meta.url))
60
- const result = spawnSync(process.execPath, [bin, 'configure', 'codex', '--token-stdin'], { cwd: root, input: token, encoding: 'utf8', timeout: 10000 })
64
+ const result = spawnSync(process.execPath, [bin, 'configure', 'codex', '--purpose-file', purpose, '--token-stdin'], { cwd: root, input: token, encoding: 'utf8', timeout: 10000 })
61
65
  assert.equal(result.status, 0, result.stderr)
62
66
  assert.equal((result.stdout + result.stderr).includes(token), false)
63
- assert.equal(JSON.parse(result.stdout).created.length, 3)
67
+ assert.deepEqual(JSON.parse(result.stdout).created, ['AGENTS.md'])
64
68
  assert.equal(parseEnv(await readFile(path.join(root, '.env'), 'utf8')).TELEGRAM_BOT_TOKEN, token)
65
69
  } finally { await rm(root, { recursive: true, force: true }) }
66
70
  })
67
71
 
72
+ test('fresh configure refuses to invent a generic purpose', async () => {
73
+ const root = await mkdtemp(path.join(tmpdir(), 'ez-configure-purpose-'))
74
+ try {
75
+ await assert.rejects(configureInstallation(root, 'codex'), /purpose file is required/)
76
+ } finally { await rm(root, { recursive: true, force: true }) }
77
+ })
78
+
68
79
  test('Docker startup uses explicit deployment binding and literal paths without secrets or host supervisors', () => {
69
80
  const definition = serviceDefinition('/tmp/my agent $literal')
70
81
  assert.equal(definition.command, 'docker')