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

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 (157) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +9 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +92 -0
  5. package/Dockerfile +11 -9
  6. package/README.md +32 -27
  7. package/THIRD_PARTY_NOTICES.md +4 -4
  8. package/bin/ez +97 -0
  9. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  10. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  11. package/bin/ezenciel-agents.mjs +1 -1
  12. package/compose.yaml +42 -5
  13. package/docker/entrypoint.sh +1 -1
  14. package/docker/healthcheck.mjs +12 -4
  15. package/docker/run.ts +5 -6
  16. package/docker/smoke.mjs +32 -7
  17. package/docs/application-channel.md +38 -10
  18. package/docs/architecture/ai-selection.md +3 -1
  19. package/docs/architecture/authority-boundaries.md +2 -2
  20. package/docs/channel-backend.md +4 -3
  21. package/docs/docker-runtime.md +106 -21
  22. package/docs/host-service.md +17 -6
  23. package/docs/local-qa.md +2 -2
  24. package/docs/plugin-guide.md +202 -0
  25. package/docs/plugins.md +63 -2
  26. package/docs/releasing.md +13 -0
  27. package/docs/repair.md +1 -1
  28. package/docs/responsive-channels.md +1 -1
  29. package/docs/scheduling.md +4 -4
  30. package/docs/setup.md +96 -16
  31. package/docs/standalone-cli.md +6 -5
  32. package/docs/upgrades.md +46 -23
  33. package/package.json +5 -5
  34. package/scripts/smoke-scheduler.ts +12 -4
  35. package/scripts/smoke.ts +14 -11
  36. package/src/agent-guidance.ts +6 -9
  37. package/src/agent-install.ts +44 -9
  38. package/src/ai-cli.ts +9 -7
  39. package/src/ai.ts +7 -7
  40. package/src/application-channel.ts +88 -17
  41. package/src/application-cli.ts +2 -2
  42. package/src/approval-cli.ts +14 -7
  43. package/src/approval.ts +27 -37
  44. package/src/channel-backend.ts +2 -3
  45. package/src/codex-session.ts +5 -3
  46. package/src/config.ts +18 -28
  47. package/src/control-state.ts +187 -9
  48. package/src/conversation-menu.ts +6 -17
  49. package/src/delivery-socket-client.d.mts +4 -0
  50. package/src/delivery-socket-client.mjs +104 -0
  51. package/src/delivery-socket.ts +354 -0
  52. package/src/execution-authority.ts +21 -5
  53. package/src/executor.ts +110 -31
  54. package/src/files.ts +38 -11
  55. package/src/host-executor.ts +50 -17
  56. package/src/inbox.ts +15 -49
  57. package/src/index.ts +96 -52
  58. package/src/install-config.ts +2 -2
  59. package/src/install-tools.mjs +100 -23
  60. package/src/isolation.ts +25 -0
  61. package/src/ledger-endpoint.d.mts +2 -0
  62. package/src/ledger-endpoint.mjs +17 -0
  63. package/src/menu.ts +16 -8
  64. package/src/message-history.ts +13 -18
  65. package/src/message.ts +35 -18
  66. package/src/plugin-broker.mjs +396 -0
  67. package/src/plugins/connection.mjs +2 -2
  68. package/src/plugins/manager.mjs +178 -26
  69. package/src/plugins/native-tasks.mjs +2 -2
  70. package/src/prompt-suffix.ts +12 -0
  71. package/src/react.ts +6 -4
  72. package/src/runs.ts +156 -243
  73. package/src/schedule-cli.ts +29 -10
  74. package/src/scheduled-tasks.ts +53 -10
  75. package/src/scheduler.ts +20 -7
  76. package/src/service.ts +3 -3
  77. package/src/setup.ts +11 -7
  78. package/src/software-status.ts +36 -4
  79. package/src/task-executor.ts +6 -3
  80. package/src/task-workspace.ts +4 -3
  81. package/src/tasks.ts +1 -1
  82. package/src/telegram-provisioning-cli.ts +22 -0
  83. package/src/telegram-provisioning.ts +214 -0
  84. package/src/updates/control.mjs +77 -7
  85. package/src/updates/runtime.mjs +29 -9
  86. package/src/updates/status.mjs +25 -3
  87. package/src/updates/supervisor.mjs +31 -18
  88. package/src/version.ts +20 -0
  89. package/src/workspace-confine.ts +38 -0
  90. package/src/workspace.ts +24 -20
  91. package/templates/agent-purpose.md +1 -0
  92. package/templates/failure-review.md +1 -1
  93. package/templates/maintainer-purpose.md +1 -1
  94. package/test/agent-guidance.test.ts +18 -88
  95. package/test/agent-install.test.ts +48 -0
  96. package/test/ai-cli.test.ts +18 -0
  97. package/test/ai.test.ts +84 -0
  98. package/test/application-channel.test.ts +66 -15
  99. package/test/application-controls.test.ts +56 -3
  100. package/test/application-only.test.ts +16 -13
  101. package/test/channel-backend.test.ts +3 -7
  102. package/test/channel-delivery.test.ts +4 -1
  103. package/test/channel-owner.test.ts +6 -3
  104. package/test/codex-context.test.ts +63 -3
  105. package/test/codex-session.test.ts +6 -1
  106. package/test/config.test.ts +47 -37
  107. package/test/control-state.test.ts +83 -4
  108. package/test/conversation-menu.test.ts +2 -4
  109. package/test/delivery-transport.test.ts +72 -0
  110. package/test/docker-runtime.test.ts +27 -14
  111. package/test/engine-handoff.test.ts +12 -8
  112. package/test/event-sources.test.ts +2 -2
  113. package/test/execution-authority.test.ts +9 -9
  114. package/test/executor.test.ts +47 -11
  115. package/test/failure.test.ts +18 -5
  116. package/test/files.test.ts +2 -2
  117. package/test/helpers/ledger.ts +18 -0
  118. package/test/host-executor.test.ts +80 -0
  119. package/test/inbox.test.ts +8 -10
  120. package/test/install-config.test.ts +18 -6
  121. package/test/install-tools.test.mjs +80 -7
  122. package/test/intake-relay.test.ts +58 -11
  123. package/test/message-history.test.ts +39 -47
  124. package/test/native-tasks.test.ts +1 -1
  125. package/test/plugin-broker.test.mjs +172 -0
  126. package/test/plugin-manager.test.mjs +143 -5
  127. package/test/reaction.test.ts +4 -0
  128. package/test/relay.test.ts +5 -7
  129. package/test/repair-policy.test.ts +1 -4
  130. package/test/schedule-cli.test.ts +14 -9
  131. package/test/scheduled-tasks.test.ts +29 -6
  132. package/test/scheduler-host.test.ts +5 -5
  133. package/test/scheduler-relay.test.ts +15 -9
  134. package/test/scheduler.test.ts +2 -2
  135. package/test/security.test.ts +6 -3
  136. package/test/software-status.test.ts +21 -3
  137. package/test/telegram-provisioning.test.ts +160 -0
  138. package/test/updates.test.mjs +102 -13
  139. package/test/workspace-confine.test.ts +47 -0
  140. package/test/workspace.test.ts +27 -11
  141. package/bin/ezenciel-agents-watch.mjs +0 -8
  142. package/compose.workforce-watch.yaml +0 -33
  143. package/docker/recovery.ts +0 -11
  144. package/docs/pagerduty.md +0 -42
  145. package/docs/workforce-watch.md +0 -101
  146. package/src/pagerduty.ts +0 -109
  147. package/src/reply-context.ts +0 -44
  148. package/src/update-attention.ts +0 -18
  149. package/src/workforce-watch-cli.ts +0 -14
  150. package/src/workforce-watch.ts +0 -155
  151. package/templates/agent/AGENTS.md +0 -15
  152. package/templates/agent/SOUL.md +0 -11
  153. package/templates/agent/USER.md +0 -5
  154. package/templates/agent-guidance.md +0 -114
  155. package/test/pagerduty.test.ts +0 -104
  156. package/test/update-attention.test.ts +0 -20
  157. package/test/workforce-watch.test.ts +0 -180
@@ -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,18 +13,17 @@ 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)) }
19
20
  throw new Error('Timed out')
20
21
  }
21
22
 
22
- test('application-only mode explicitly requires native listener and ignores bot credentials', () => {
23
- assert.throws(()=>loadConfig({}), /TELEGRAM_BOT_TOKEN/)
24
- assert.throws(()=>loadConfig({EZ_TELEGRAM_ENABLED:'false'}), /EZ_APPLICATION_PORT/)
25
- assert.throws(()=>loadConfig({EZ_TELEGRAM_ENABLED:'no'}), /true or false/)
26
- assert.throws(()=>loadConfig({EZ_TELEGRAM_ENABLED:'false',EZ_APPLICATION_PORT:'8110',EZ_CHANNEL_BACKEND_URL:'http://backend',EZ_CHANNEL_BACKEND_TOKEN:'secret'}), /native Ez executor/)
27
- const config=loadConfig({EZ_TELEGRAM_ENABLED:'false',EZ_APPLICATION_PORT:'8110',TELEGRAM_BOT_TOKEN:'unused-secret'})
23
+ test('application-only mode is inferred from the absence of a bot token and requires a native listener', () => {
24
+ assert.throws(()=>loadConfig({}), /EZ_APPLICATION_PORT/)
25
+ assert.throws(()=>loadConfig({EZ_APPLICATION_PORT:'8110',EZ_CHANNEL_BACKEND_URL:'http://backend',EZ_CHANNEL_BACKEND_TOKEN:'secret'}), /native Ez executor/)
26
+ const config=loadConfig({EZ_APPLICATION_PORT:'8110'})
28
27
  assert.equal(config.telegramBotToken,'')
29
28
  assert.equal(config.telegramEnabled,false)
30
29
  })
@@ -35,8 +34,8 @@ test('administrator bootstrap is explicit, local-only and cannot replace identit
35
34
  const token=join(root,'token'); await writeFile(token,'x'.repeat(48),{mode:0o600})
36
35
  const cli=fileURLToPath(new URL('../bin/ezenciel-agents-application.mjs',import.meta.url))
37
36
  const args=[cli,'--id','app','--token-file',token,'--owner','42']
38
- const env={...process.env,EZ_CONTROL_DIR:root,EZ_RUN_ID:'',EZ_TELEGRAM_ENABLED:'false'}
39
- assert.notEqual(spawnSync(process.execPath,args,{env:{...env,EZ_TELEGRAM_ENABLED:'true'}}).status,0)
37
+ const env={...process.env,EZ_CONTROL_DIR:root,EZ_RUN_ID:'',TELEGRAM_BOT_TOKEN:''}
38
+ assert.notEqual(spawnSync(process.execPath,args,{env:{...env,TELEGRAM_BOT_TOKEN:'fixture'}}).status,0)
40
39
  assert.notEqual(spawnSync(process.execPath,args,{env:{...env,EZ_RUN_ID:'r_agent'}}).status,0)
41
40
  assert.equal((await new ControlStore(root,1000).status()).owner,null)
42
41
  const invalidToken=join(root,'invalid-token');await writeFile(invalidToken,'short')
@@ -60,9 +59,10 @@ test('administrator bootstrap is explicit, local-only and cannot replace identit
60
59
 
61
60
  test('botless daemon executes application turn and rejects Telegram-origin work/outbound', async t => {
62
61
  const root=await mkdtemp(join(tmpdir(),'ez-app-only-runtime-'))
62
+ const purpose=join(root,'purpose.md');await writeFile(purpose,'Fixture application agent\n');await initializeWorkspace(root,purpose)
63
63
  const portServer=createServer();await new Promise<void>(r=>portServer.listen(0,'127.0.0.1',r))
64
64
  const port=(portServer.address() as {port:number}).port;await new Promise<void>(r=>portServer.close(()=>r()))
65
- const config=loadConfig({EZ_TELEGRAM_ENABLED:'false',EZ_APPLICATION_PORT:String(port),EZ_CONTROL_DIR:root,EZ_AGENT_WORKSPACE:root,EZ_EXECUTOR_CLI:'codex'})
65
+ const config=loadConfig({EZ_APPLICATION_PORT:String(port),EZ_CONTROL_DIR:root,EZ_AGENT_WORKSPACE:root,EZ_EXECUTOR_CLI:'codex'})
66
66
  const native='11111111-1111-1111-1111-111111111111', fixture=join(root,'engine.mjs')
67
67
  await writeFile(fixture,`import {spawnSync} from 'node:child_process';if(process.env.TELEGRAM_BOT_TOKEN)throw Error('secret leak');console.log(JSON.stringify({type:'thread.started',thread_id:${JSON.stringify(native)}}));const r=spawnSync(process.execPath,[${JSON.stringify(fileURLToPath(new URL('../bin/ezenciel-agents-message.mjs',import.meta.url)))},'--text','Application reply'],{env:process.env});process.exit(r.status);`)
68
68
  const original=EXECUTOR_REGISTRY.codex,require=createRequire(import.meta.url)
@@ -89,12 +89,15 @@ test('botless daemon executes application turn and rejects Telegram-origin work/
89
89
  assert.equal((await runs.get(legacy.id))?.status,'queued')
90
90
  })
91
91
 
92
- test('Docker health accepts application readiness only with explicit botless configuration', async t => {
92
+ test('Docker health accepts application readiness when the agent has no bot', async t => {
93
93
  const root=await mkdtemp(join(tmpdir(),'ez-app-only-health-'))
94
94
  t.after(()=>rm(root,{recursive:true,force:true}))
95
- await writeFile(join(root,'heartbeat.json'),JSON.stringify({at:Date.now(),polling:false,applicationOnly:true}))
95
+ await writeFile(join(root,'heartbeat.json'),JSON.stringify({at:Date.now(),polling:false,applicationOnly:true,telegramConfigured:false}))
96
96
  const command=fileURLToPath(new URL('../docker/healthcheck.mjs',import.meta.url))
97
97
  const env={...process.env,EZ_HEALTH_RELAY_CONTROL_DIR:root,EZ_EXECUTOR_TRANSPORT:'local'}
98
- assert.equal(spawnSync(process.execPath,[command],{env:{...env,EZ_TELEGRAM_ENABLED:'false'}}).status,0)
99
- assert.notEqual(spawnSync(process.execPath,[command],{env:{...env,EZ_TELEGRAM_ENABLED:'true'}}).status,0)
98
+ assert.equal(spawnSync(process.execPath,[command],{env}).status,0)
99
+ await writeFile(join(root,'heartbeat.json'),JSON.stringify({at:Date.now(),polling:false,applicationOnly:false,telegramConfigured:false}))
100
+ assert.notEqual(spawnSync(process.execPath,[command],{env}).status,0)
101
+ await writeFile(join(root,'heartbeat.json'),JSON.stringify({at:Date.now(),polling:false,applicationOnly:true,telegramConfigured:true}))
102
+ assert.notEqual(spawnSync(process.execPath,[command],{env}).status,0)
100
103
  })
@@ -43,7 +43,6 @@ test('structured photo transport and deterministic reply recovery', async () =>
43
43
  test('backend dispatch keeps owner/private gate and never starts a CLI', async () => {
44
44
  const { createRelay } = await import('../src/index.js')
45
45
  const { ControlStore } = await import('../src/control-state.js')
46
- const { recoverInterruptedRuns } = await import('../docker/recovery.js')
47
46
  const root = await mkdtemp(join(tmpdir(), 'channel-gate-'))
48
47
  const originalFetch = globalThis.fetch
49
48
  let calls = 0
@@ -68,16 +67,14 @@ test('backend dispatch keeps owner/private gate and never starts a CLI', async (
68
67
  assert.equal(calls, 1)
69
68
  const store = new RunStore(root)
70
69
  assert.equal((await store.get('tg_3'))?.status, 'completed')
71
- await store.patch('tg_3', { status: 'running' })
72
- await recoverInterruptedRuns(root, true)
73
- assert.equal((await store.get('tg_3'))?.status, 'queued')
70
+ await store.patch('tg_3', { status: 'running', backendSubmitted: true })
74
71
  const cancel = update(4, 42); cancel.message.text = '/cancel'
75
72
  await relay.bot.handleUpdate(cancel)
76
- assert.equal((await store.get('tg_3'))?.status, 'queued', 'cancel must retain submitted operation polling')
73
+ assert.equal((await store.get('tg_3'))?.status, 'running', 'cancel must retain submitted operation polling')
77
74
  } finally { await relay.stop(); globalThis.fetch = originalFetch; await rm(root, { recursive: true, force: true }) }
78
75
  })
79
76
 
80
- test('backend mode cannot dispatch restricted, external, scheduled or update runs', async () => {
77
+ test('backend mode cannot dispatch restricted, external or scheduled runs', async () => {
81
78
  const { createRelay } = await import('../src/index.js')
82
79
  const { ControlStore } = await import('../src/control-state.js')
83
80
  const root = await mkdtemp(join(tmpdir(), 'channel-authority-'))
@@ -92,7 +89,6 @@ test('backend mode cannot dispatch restricted, external, scheduled or update run
92
89
  await runs.create({ ...common, id: 'restricted', taskId: `task_${'a'.repeat(32)}` })
93
90
  await runs.create({ ...common, id: 'external', external: { sourceId: 'fixture', bindingId: 'binding', eventIds: ['1'] } })
94
91
  await runs.create({ ...common, id: 'scheduled', scheduled: { id: 's', revision: 'r', dueAt: new Date().toISOString(), pairedAt: owner.pairedAt } })
95
- await runs.create({ ...common, id: 'r_update_fixture' })
96
92
  await relay.drainSources()
97
93
  assert.equal(calls, 0); assert.equal(launches, 0)
98
94
  assert.ok((await runs.list()).every(run => run.status === 'failed'))
@@ -8,11 +8,14 @@ import {ControlStore} from '../src/control-state.js'
8
8
  import {RunStore} from '../src/runs.js'
9
9
  import {authorizeDeliveryContext,captureDeliveryContext} from '../src/delivery-context.mjs'
10
10
  import {nativeTasks,nativeTaskBinding} from '../src/plugins/native-tasks.mjs'
11
+ import {serveTestLedger} from './helpers/ledger.js'
11
12
 
12
13
  test('authenticated connection sends text and files through ordinary outbox receipts without a native run',async()=>{
13
14
  const root=await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(),'ez-channel-delivery-'))),workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),home=path.join(root,'tools'),hostConfig=path.join(root,'host-executor.json')
14
15
  for(const dir of [workspace,controlDir,home])await fs.mkdir(dir)
15
16
  const relay=createRelay({controlDir,workspace,pairingTtlMs:1000,executorTimeoutMs:1000,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw Error('Must not launch native execution')})
17
+ // The message CLI child reaches the relay memory ledger through the delivery socket.
18
+ const ledger=await serveTestLedger(controlDir)
16
19
  const calls:string[]=[];relay.bot.api.config.use(async(_prev,method)=>{calls.push(method);return {ok:true,result:{message_id:42}} as never})
17
20
  try {
18
21
  await fs.writeFile(path.join(home,'config.json'),JSON.stringify({schemaVersion:1,workspace,hostConfig}))
@@ -38,7 +41,7 @@ test('authenticated connection sends text and files through ordinary outbox rece
38
41
  await relay.drainOutbox();assert.equal(calls.length,2);await assert.rejects(store.waitForDelivery(queued.id),/revoked/)
39
42
  await assert.rejects(nativeTasks(home,['--text','denied'],{command:'message',deliveryContext:context}),/revoked/)
40
43
  await assert.rejects(store.ownerDeliveryReceipt(context,queued.id),/revoked/)
41
- } finally {await relay.stop();await fs.rm(root,{recursive:true,force:true})}
44
+ } finally {await relay.stop();await ledger.stop();await fs.rm(root,{recursive:true,force:true})}
42
45
  })
43
46
  test('delivery authority cannot revive after Telegram relink or same-time owner replacement',async t=>{
44
47
  const root=await fs.mkdtemp(path.join(os.tmpdir(),'ez-channel-delivery-epoch-'))
@@ -16,6 +16,7 @@ import { ownsRun, isOwner } from '../src/identity.js'
16
16
  import { createRelay } from '../src/index.js'
17
17
  import { EXECUTOR_REGISTRY } from '../src/executor.js'
18
18
  import { loadConfig } from '../src/config.js'
19
+ import { serveTestLedger } from './helpers/ledger.js'
19
20
 
20
21
  const secret = () => randomBytes(32).toString('base64url')
21
22
  const waitFor = async (check: () => Promise<boolean>) => {
@@ -87,13 +88,15 @@ test('web owner uses the standard schedule CLI, executor and outbox with Telegra
87
88
  if(!scheduled){const r=spawnSync(process.execPath,[${JSON.stringify(scheduleCli)},'create','followup','--now','--name','Followup','--text','Finish the requested work'],{env:process.env,encoding:'utf8'});if(r.status)throw Error(r.stderr)}
88
89
  const r=spawnSync(process.execPath,[${JSON.stringify(messageCli)},'--text',scheduled?'Scheduled reply':'Chat reply'],{env:process.env,encoding:'utf8'});if(r.status)throw Error(r.stderr);`)
89
90
  EXECUTOR_REGISTRY.grok={...original,command:process.execPath,buildArgs:()=>['--import',require.resolve('tsx'),fixture]}
90
- const config=loadConfig({EZ_TELEGRAM_ENABLED:'false',EZ_APPLICATION_PORT:'8787',EZ_CONTROL_DIR:root,EZ_AGENT_WORKSPACE:root,EZ_EXECUTOR_CLI:'grok'})
91
+ const config=loadConfig({EZ_APPLICATION_PORT:'8787',EZ_CONTROL_DIR:root,EZ_AGENT_WORKSPACE:root,EZ_EXECUTOR_CLI:'grok'})
91
92
  const relay=createRelay(config), control=new ControlStore(root,60000), runs=new RunStore(root)
93
+ // Engine children reach the relay memory ledger through the delivery socket.
94
+ const ledger=await serveTestLedger(root)
92
95
  const owner=await control.registerOwner('web-owner'),token=secret()
93
96
  const binding=(await relay.applicationChannel.bindings.register('web',token,owner))!
94
97
  const address=await relay.applicationChannel.listen(0) as {port:number}
95
98
  const timer=setInterval(()=>{void relay.drainSources();void relay.drainOutbox()},25)
96
- t.after(async()=>{clearInterval(timer);await relay.stop();EXECUTOR_REGISTRY.grok=original;await rm(root,{recursive:true,force:true})})
99
+ t.after(async()=>{clearInterval(timer);await relay.stop();EXECUTOR_REGISTRY.grok=original;await ledger.stop();await rm(root,{recursive:true,force:true})})
97
100
  const first=await relay.applicationChannel.submit(binding.bindingId,{requestId:'chat',scope:'main',text:'Schedule a followup'})
98
101
  await waitFor(async()=> (await runs.get(first.id))?.status==='completed')
99
102
  await waitFor(async()=> (await runs.list()).some(r=>r.scheduled && r.status==='completed'))
@@ -141,7 +144,7 @@ for (const mode of ['owner','web','telegram']) test(`${mode} revocation stops it
141
144
  const control=new ControlStore(root,60000),runs=new RunStore(root),scheduler=new Scheduler(root)
142
145
  await control.requestPairing(42,42)
143
146
  const owner=await control.approveOwner(42)
144
- const relay=createRelay(loadConfig({TELEGRAM_BOT_TOKEN:'synthetic',EZ_CONTROL_DIR:root,EZ_AGENT_WORKSPACE:root,EZ_EXECUTOR_CLI:'grok'}),async()=>({child:spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:'pipe'}),stdout:'',cleanup:async()=>{}}))
147
+ const relay=createRelay(loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_CONTROL_DIR:root,EZ_AGENT_WORKSPACE:root,EZ_EXECUTOR_CLI:'grok'}),async()=>({child:spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:'pipe'}),stdout:'',cleanup:async()=>{}}))
145
148
  relay.bot.api.config.use(async()=>({ok:true,result:true}) as never)
146
149
  t.after(async()=>{await relay.stop();await rm(root,{recursive:true,force:true})})
147
150
  if (mode==='telegram') {
@@ -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
  })
@@ -50,11 +110,11 @@ if(q.method==='turn/start'){send({id:q.id,result:{turn:{id:'one'}}});send({metho
50
110
  send({id:q.id,result:q.method==='thread/goal/get'?{goal:null}:{}});});setInterval(()=>{},1000);
51
111
  `,{mode:0o700})
52
112
  process.env.HOME=root;process.env.PATH=bin+path.delimiter+priorPath
53
- await assert.rejects(startExecutorJob(['test'],{workspace:root,controlDir,binDir:bin,cli:'codex',runId:'r_schedule_/../../escape',timeoutMs:0}),/Invalid native task run ID/)
113
+ await assert.rejects(startExecutorJob(['test'],{workspace:root,controlDir,binDir:bin,cli:'codex',runId:'r_schedule_/../../escape',timeoutMs:0,nativeSession:true}),/Invalid native task run ID/)
54
114
  await ownerRun(controlDir, 'r_pair_fixture')
55
115
  await Promise.all(['r_schedule_one','r_schedule_two'].map(async runId=>{
56
116
  await ownerRun(controlDir, runId)
57
- const job=await startExecutorJob(['test'],{workspace:root,controlDir,binDir:bin,cli:'codex',runId,timeoutMs:0})
117
+ const job=await startExecutorJob(['test'],{workspace:root,controlDir,binDir:bin,cli:'codex',runId,timeoutMs:0,nativeSession:true})
58
118
  assert.equal(await new Promise(resolve=>job.child.once('close',resolve)),0);await job.cleanup()
59
119
  const home=path.join(controlDir,'cli/codex/tasks',runId)
60
120
  assert.equal(JSON.parse(await readFile(path.join(home,'observed.json'),'utf8')).home,home)
@@ -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)
@@ -2,13 +2,13 @@ import assert from 'node:assert/strict'
2
2
  import test from 'node:test'
3
3
  import { loadConfig } from '../src/config.js'
4
4
 
5
- test('requires a Telegram token', () => {
6
- assert.throws(() => loadConfig({}), /TELEGRAM_BOT_TOKEN is required/)
5
+ test('requires an application listener when no Telegram bot token is present', () => {
6
+ assert.throws(() => loadConfig({}), /EZ_APPLICATION_PORT/)
7
7
  })
8
8
 
9
9
  test('uses a relative agent workspace and protected control state defaults', () => {
10
10
  const config = loadConfig({
11
- TELEGRAM_BOT_TOKEN: 'test-token',
11
+ TELEGRAM_BOT_TOKEN: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',
12
12
  EZ_AGENT_WORKSPACE: './fixture-agent',
13
13
  EZ_CONTROL_DIR: './fixture-control',
14
14
  })
@@ -19,58 +19,68 @@ test('uses a relative agent workspace and protected control state defaults', ()
19
19
  })
20
20
 
21
21
  test('rejects malformed timeouts', () => {
22
- assert.equal(loadConfig({ TELEGRAM_BOT_TOKEN: 'test', EZ_EXECUTOR_TIMEOUT_SECONDS: '300' }).executorTimeoutMs, 0)
23
- assert.throws(() => loadConfig({ TELEGRAM_BOT_TOKEN: 'test', EZ_PAIRING_TTL_SECONDS: 'bad' }), /positive integer/)
22
+ assert.equal(loadConfig({ TELEGRAM_BOT_TOKEN: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY', EZ_EXECUTOR_TIMEOUT_SECONDS: '300' }).executorTimeoutMs, 0)
23
+ assert.throws(() => loadConfig({ TELEGRAM_BOT_TOKEN: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY', EZ_PAIRING_TTL_SECONDS: 'bad' }), /positive integer/)
24
24
  })
25
25
 
26
26
  test('loads executor CLI configuration with agy fallback', () => {
27
- const defaultConfig = loadConfig({ TELEGRAM_BOT_TOKEN: 'test' })
27
+ const defaultConfig = loadConfig({ TELEGRAM_BOT_TOKEN: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY' })
28
28
  assert.equal(defaultConfig.executorCli, 'agy')
29
29
 
30
- const customConfig = loadConfig({ TELEGRAM_BOT_TOKEN: 'test', EZ_EXECUTOR_CLI: 'claude' })
30
+ const customConfig = loadConfig({ TELEGRAM_BOT_TOKEN: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY', EZ_EXECUTOR_CLI: 'claude' })
31
31
  assert.equal(customConfig.executorCli, 'claude')
32
32
  })
33
33
 
34
34
  test('Codex context limit is configurable and rejects invalid values', () => {
35
- assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test'}).codexAutoCompactTokens,undefined)
36
- assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_CODEX_AUTO_COMPACT_TOKENS:'32000'}).codexAutoCompactTokens,32000)
35
+ assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY'}).codexAutoCompactTokens,undefined)
36
+ assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_CODEX_AUTO_COMPACT_TOKENS:'32000'}).codexAutoCompactTokens,32000)
37
37
  for(const value of ['0','-1','bad','1.5','9007199254740992'])
38
- assert.throws(()=>loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_CODEX_AUTO_COMPACT_TOKENS:value}),/positive integer/)
38
+ assert.throws(()=>loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_CODEX_AUTO_COMPACT_TOKENS:value}),/positive integer/)
39
39
  })
40
40
 
41
- test('PagerDuty Stocks monitoring requires a routing key and validates its target', () => {
42
- assert.throws(
43
- () => loadConfig({ TELEGRAM_BOT_TOKEN: 'test', EZ_PAGERDUTY_STOCKS_HEALTH_URL: 'http://stocks.test/health/critical' }),
44
- /PAGERDUTY_ROUTING_KEY is required/,
45
- )
46
- assert.throws(
47
- () => loadConfig({ TELEGRAM_BOT_TOKEN: 'test', PAGERDUTY_ROUTING_KEY: 'key', EZ_PAGERDUTY_STOCKS_HEALTH_URL: 'file:///private/health' }),
48
- /absolute HTTP\(S\) URL/,
49
- )
50
- const config = loadConfig({
51
- TELEGRAM_BOT_TOKEN: 'test',
52
- PAGERDUTY_ROUTING_KEY: 'pagerduty-key',
53
- EZ_PAGERDUTY_STOCKS_HEALTH_URL: 'http://stocks.test/health/critical',
54
- EZ_PAGERDUTY_POLL_SECONDS: '45',
55
- EZ_PAGERDUTY_FAILURE_THRESHOLD: '4',
56
- })
57
- assert.equal(config.pagerDutyRoutingKey, 'pagerduty-key')
58
- assert.equal(config.pagerDutyStocksHealthUrl, 'http://stocks.test/health/critical')
59
- assert.equal(config.pagerDutyPollMs, 45_000)
60
- assert.equal(config.pagerDutyFailureThreshold, 4)
61
- })
62
41
 
63
42
  test('external Codex isolation requires explicit native local deployment', () => {
64
- const env = {EZ_TELEGRAM_ENABLED:'false', EZ_APPLICATION_PORT:'8110', EZ_EXECUTOR_TRANSPORT:'local', EZ_CODEX_SANDBOX:'external'}
43
+ const env = {EZ_APPLICATION_PORT:'8110', EZ_EXECUTOR_TRANSPORT:'local', EZ_CODEX_SANDBOX:'external'}
65
44
  assert.equal(loadConfig(env).codexSandbox, 'external')
66
- assert.equal(loadConfig({...env,EZ_TELEGRAM_ENABLED:'true',TELEGRAM_BOT_TOKEN:'test'}).codexSandbox, 'external')
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/)
45
+ assert.equal(loadConfig({...env,TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY'}).codexSandbox, 'external')
46
+ assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY'}).codexSandbox, undefined)
47
+ assert.equal(loadConfig({...env,EZ_EXECUTOR_TRANSPORT:''}).isolation, 'isolated')
48
+ assert.throws(() => loadConfig({...env,EZ_EXECUTOR_TRANSPORT:'host'}), /sandbox|SANDBOX|Isolation/)
49
+ assert.throws(() => loadConfig({...env,EZ_CODEX_SANDBOX:'danger-full-access'}), /sandbox|SANDBOX/)
50
+ })
51
+
52
+ test('isolation class is isolated by default and rejects host transport mismatch', () => {
53
+ assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY'}).isolation, 'isolated')
54
+ assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_ISOLATION:'isolated',EZ_EXECUTOR_TRANSPORT:'local'}).isolation, 'isolated')
55
+ assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_EXECUTOR_TRANSPORT:'host'}).isolation, 'host-capable')
56
+ assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_ISOLATION:'host-capable',EZ_EXECUTOR_TRANSPORT:'host'}).isolation, 'host-capable')
57
+ assert.throws(() => loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_ISOLATION:'isolated',EZ_EXECUTOR_TRANSPORT:'host'}), /Isolation isolated requires EZ_EXECUTOR_TRANSPORT=local/)
58
+ assert.throws(() => loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_ISOLATION:'host-capable',EZ_EXECUTOR_TRANSPORT:'local'}), /Isolation host-capable requires EZ_EXECUTOR_TRANSPORT=host/)
59
+ assert.throws(() => loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_ISOLATION:'seatbelt'}), /isolated or host-capable/)
60
+ assert.throws(() => loadConfig({
61
+ TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_ISOLATION:'host-capable',EZ_EXECUTOR_TRANSPORT:'host',
62
+ EZ_CHANNEL_BACKEND_URL:'https://backend.example',EZ_CHANNEL_BACKEND_TOKEN:'secret',
63
+ }), /Host-capable isolation requires the host native CLI/)
70
64
  })
71
65
 
72
66
  test('optional web launcher preserves reserved commands and accepts only HTTPS without secrets', () => {
73
- const config = (value: unknown) => loadConfig({ TELEGRAM_BOT_TOKEN: 'test', EZ_TELEGRAM_WEB_APP: JSON.stringify(value) })
67
+ const config = (value: unknown) => loadConfig({ TELEGRAM_BOT_TOKEN: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY', EZ_TELEGRAM_WEB_APP: JSON.stringify(value) })
74
68
  assert.deepEqual(config({command:'voice',label:'Voice',url:'https://voice.example/'}).webLauncher,{command:'voice',label:'Voice',url:'https://voice.example/'})
75
69
  for (const value of [{command:'stop',label:'Voice',url:'https://voice.example/'},{command:'voice',label:'Voice',url:'http://voice.example/'},{command:'voice',label:'Voice',url:'https://voice.example/#secret'},{command:'voice',label:'Voice',url:'https://user:pass@voice.example/'},{command:'voice',label:'Voice',url:'https://voice.example/?token=secret'}]) assert.throws(()=>config(value))
76
70
  })
71
+
72
+ test('Telegram transport is inferred from a valid token; legacy flag contradictions fail closed', () => {
73
+ const token = '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY'
74
+ const enabled = loadConfig({ TELEGRAM_BOT_TOKEN: token, EZ_APPLICATION_PORT: '8110' })
75
+ assert.equal(enabled.telegramEnabled, true)
76
+ assert.equal(enabled.telegramBotToken, token)
77
+ const appOnly = loadConfig({ TELEGRAM_BOT_TOKEN: ' ', EZ_APPLICATION_PORT: '8110' })
78
+ assert.equal(appOnly.telegramEnabled, false)
79
+ assert.equal(appOnly.telegramBotToken, '')
80
+ assert.throws(() => loadConfig({ TELEGRAM_BOT_TOKEN: 'garbage', EZ_APPLICATION_PORT: '8110' }), /malformed/)
81
+ assert.throws(() => loadConfig({ TELEGRAM_BOT_TOKEN: token, EZ_TELEGRAM_ENABLED: 'false', EZ_APPLICATION_PORT: '8110' }), /contradicts/)
82
+ assert.throws(() => loadConfig({ EZ_TELEGRAM_ENABLED: 'true', EZ_APPLICATION_PORT: '8110' }), /contradicts/)
83
+ assert.throws(() => loadConfig({ TELEGRAM_BOT_TOKEN: token, EZ_TELEGRAM_ENABLED: 'sometimes' }), /must be true or false/)
84
+ assert.equal(loadConfig({ EZ_TELEGRAM_ENABLED: 'false', EZ_APPLICATION_PORT: '8110' }).telegramEnabled, false)
85
+ assert.equal(loadConfig({ TELEGRAM_BOT_TOKEN: token, EZ_TELEGRAM_ENABLED: 'true' }).telegramEnabled, true)
86
+ })