@jc_stack/ez-agents 0.1.0-beta.34 → 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 (137) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +5 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +67 -0
  5. package/Dockerfile +7 -9
  6. package/README.md +10 -13
  7. package/bin/ez +97 -0
  8. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  9. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  10. package/bin/ezenciel-agents.mjs +1 -1
  11. package/compose.yaml +40 -4
  12. package/docker/entrypoint.sh +1 -1
  13. package/docker/healthcheck.mjs +12 -4
  14. package/docker/run.ts +5 -6
  15. package/docker/smoke.mjs +28 -6
  16. package/docs/application-channel.md +32 -10
  17. package/docs/architecture/authority-boundaries.md +2 -2
  18. package/docs/channel-backend.md +4 -3
  19. package/docs/docker-runtime.md +79 -11
  20. package/docs/local-qa.md +2 -2
  21. package/docs/plugin-guide.md +202 -0
  22. package/docs/plugins.md +48 -2
  23. package/docs/releasing.md +13 -0
  24. package/docs/responsive-channels.md +1 -1
  25. package/docs/scheduling.md +4 -4
  26. package/docs/setup.md +11 -5
  27. package/docs/upgrades.md +46 -23
  28. package/package.json +5 -5
  29. package/scripts/smoke-scheduler.ts +10 -3
  30. package/scripts/smoke.ts +14 -11
  31. package/src/agent-guidance.ts +6 -9
  32. package/src/agent-install.ts +28 -7
  33. package/src/application-channel.ts +78 -7
  34. package/src/application-cli.ts +2 -2
  35. package/src/approval-cli.ts +14 -7
  36. package/src/approval.ts +27 -37
  37. package/src/channel-backend.ts +2 -3
  38. package/src/config.ts +12 -27
  39. package/src/control-state.ts +175 -8
  40. package/src/conversation-menu.ts +6 -17
  41. package/src/delivery-socket-client.d.mts +4 -0
  42. package/src/delivery-socket-client.mjs +104 -0
  43. package/src/delivery-socket.ts +354 -0
  44. package/src/execution-authority.ts +21 -5
  45. package/src/executor.ts +20 -23
  46. package/src/files.ts +38 -11
  47. package/src/host-executor.ts +28 -14
  48. package/src/inbox.ts +15 -49
  49. package/src/index.ts +95 -52
  50. package/src/install-tools.mjs +97 -22
  51. package/src/ledger-endpoint.d.mts +2 -0
  52. package/src/ledger-endpoint.mjs +17 -0
  53. package/src/menu.ts +7 -2
  54. package/src/message-history.ts +13 -18
  55. package/src/message.ts +35 -18
  56. package/src/plugin-broker.mjs +396 -0
  57. package/src/plugins/connection.mjs +2 -2
  58. package/src/plugins/manager.mjs +132 -23
  59. package/src/plugins/native-tasks.mjs +2 -2
  60. package/src/prompt-suffix.ts +12 -0
  61. package/src/react.ts +6 -4
  62. package/src/runs.ts +156 -243
  63. package/src/schedule-cli.ts +29 -10
  64. package/src/scheduled-tasks.ts +53 -10
  65. package/src/scheduler.ts +20 -7
  66. package/src/service.ts +3 -3
  67. package/src/software-status.ts +36 -4
  68. package/src/task-executor.ts +2 -2
  69. package/src/task-workspace.ts +4 -3
  70. package/src/tasks.ts +1 -1
  71. package/src/telegram-provisioning-cli.ts +22 -0
  72. package/src/telegram-provisioning.ts +214 -0
  73. package/src/updates/control.mjs +77 -7
  74. package/src/updates/runtime.mjs +28 -8
  75. package/src/updates/status.mjs +25 -3
  76. package/src/updates/supervisor.mjs +15 -16
  77. package/src/version.ts +20 -0
  78. package/src/workspace.ts +3 -9
  79. package/templates/failure-review.md +1 -1
  80. package/templates/maintainer-purpose.md +1 -1
  81. package/test/agent-guidance.test.ts +14 -80
  82. package/test/agent-install.test.ts +14 -0
  83. package/test/ai.test.ts +36 -0
  84. package/test/application-channel.test.ts +61 -13
  85. package/test/application-only.test.ts +14 -13
  86. package/test/channel-backend.test.ts +3 -7
  87. package/test/channel-delivery.test.ts +4 -1
  88. package/test/channel-owner.test.ts +6 -3
  89. package/test/codex-context.test.ts +2 -2
  90. package/test/config.test.ts +38 -43
  91. package/test/control-state.test.ts +83 -4
  92. package/test/conversation-menu.test.ts +2 -4
  93. package/test/delivery-transport.test.ts +72 -0
  94. package/test/docker-runtime.test.ts +27 -14
  95. package/test/engine-handoff.test.ts +7 -6
  96. package/test/event-sources.test.ts +2 -2
  97. package/test/execution-authority.test.ts +9 -9
  98. package/test/executor.test.ts +10 -11
  99. package/test/failure.test.ts +12 -5
  100. package/test/files.test.ts +2 -2
  101. package/test/helpers/ledger.ts +18 -0
  102. package/test/host-executor.test.ts +74 -0
  103. package/test/inbox.test.ts +8 -10
  104. package/test/install-config.test.ts +1 -0
  105. package/test/install-tools.test.mjs +74 -7
  106. package/test/intake-relay.test.ts +58 -11
  107. package/test/message-history.test.ts +39 -47
  108. package/test/native-tasks.test.ts +1 -1
  109. package/test/plugin-broker.test.mjs +172 -0
  110. package/test/plugin-manager.test.mjs +119 -2
  111. package/test/reaction.test.ts +4 -0
  112. package/test/relay.test.ts +5 -7
  113. package/test/repair-policy.test.ts +1 -4
  114. package/test/schedule-cli.test.ts +14 -9
  115. package/test/scheduled-tasks.test.ts +29 -6
  116. package/test/scheduler-host.test.ts +5 -5
  117. package/test/scheduler-relay.test.ts +15 -9
  118. package/test/security.test.ts +6 -3
  119. package/test/software-status.test.ts +21 -3
  120. package/test/telegram-provisioning.test.ts +160 -0
  121. package/test/updates.test.mjs +93 -13
  122. package/test/workspace.test.ts +4 -1
  123. package/bin/ezenciel-agents-watch.mjs +0 -8
  124. package/compose.workforce-watch.yaml +0 -33
  125. package/docker/recovery.ts +0 -11
  126. package/docs/pagerduty.md +0 -42
  127. package/docs/workforce-watch.md +0 -101
  128. package/src/pagerduty.ts +0 -109
  129. package/src/reply-context.ts +0 -43
  130. package/src/update-attention.ts +0 -18
  131. package/src/workforce-watch-cli.ts +0 -14
  132. package/src/workforce-watch.ts +0 -155
  133. package/templates/agent/AGENTS.md +0 -5
  134. package/templates/agent-guidance.md +0 -17
  135. package/test/pagerduty.test.ts +0 -104
  136. package/test/update-attention.test.ts +0 -20
  137. package/test/workforce-watch.test.ts +0 -180
package/test/ai.test.ts CHANGED
@@ -242,6 +242,42 @@ test('model catalog can read an agent-bound Codex home', async () => {
242
242
  }
243
243
  })
244
244
 
245
+ test('Choose AI lists the agent-bound Codex home instead of only the desktop fallback', async () => {
246
+ const dir = await mkdtemp(join(tmpdir(), 'ez-ai-menu-codex-home-'))
247
+ const codexHome = await mkdtemp(join(tmpdir(), 'ez-ai-menu-cache-'))
248
+ try {
249
+ await writeFile(join(codexHome, 'models_cache.json'), JSON.stringify({ models: [
250
+ { slug: 'fixture-bound-model', display_name: 'Bound Fixture', visibility: 'list',
251
+ supported_reasoning_levels: [{ effort: 'medium' }] },
252
+ ] }))
253
+ const store = new ControlStore(dir, 1000)
254
+ await store.aiState(initialPreset('codex'))
255
+ // Default catalog (no explicit override): the menu must bind it to codexHome,
256
+ // otherwise an empty process-home cache drops codex and only codex-gui lists.
257
+ const menu = createAiMenu(store, 'codex', undefined, dir, codexHome,
258
+ async (cli) => cli === 'codex' || cli === 'codex-gui')
259
+ const replies: Array<{ text: string; buttons: Array<{ text: string; callback_data: string }> }> = []
260
+ const context = (data?: string) => ({
261
+ callbackQuery: data ? { data } : undefined,
262
+ answerCallbackQuery: async () => ({}),
263
+ reply: async (text: string, options?: { reply_markup?: { inline_keyboard?: Array<Array<{ text: string; callback_data: string }>> } }) => {
264
+ replies.push({ text, buttons: options?.reply_markup?.inline_keyboard?.flat() ?? [] })
265
+ return {} as never
266
+ },
267
+ })
268
+ await menu.list(context() as never)
269
+ assert.deepEqual(replies.at(-1)!.buttons.map((button) => button.text), [
270
+ 'codex', 'codex-gui (desktop)', 'Refresh available AIs',
271
+ ])
272
+ const client = replies.at(-1)!.buttons.find((button) => button.text === 'codex')!
273
+ await menu.handle(context(client.callback_data) as never)
274
+ assert.deepEqual(replies.at(-1)!.buttons.map((button) => button.text), ['Bound Fixture', 'Back to clients'])
275
+ } finally {
276
+ await rm(dir, { recursive: true, force: true })
277
+ await rm(codexHome, { recursive: true, force: true })
278
+ }
279
+ })
280
+
245
281
  test('native executor flags carry the exact model and effort; only structured metadata binds sessions', () => {
246
282
  const opts = { workspace: '/tmp/fixture', sessionId: crypto.randomUUID(), isResume: true,
247
283
  model: 'fixture-model', effort: 'medium' }
@@ -9,12 +9,13 @@ import { randomBytes } from 'node:crypto'
9
9
  import { fileURLToPath } from 'node:url'
10
10
  import { createRequire } from 'node:module'
11
11
  import { ApplicationChannel, ApplicationBindings, applicationScope } from '../src/application-channel.js'
12
- import { ControlStore, ownerId } from '../src/control-state.js'
12
+ import { ControlStore, ownerId, ownerEpoch } from '../src/control-state.js'
13
13
  import { RunStore } from '../src/runs.js'
14
14
  import { initialPreset } from '../src/ai.js'
15
15
  import { requireOwnerExecution } from '../src/execution-authority.js'
16
16
  import { createRelay } from '../src/index.js'
17
17
  import { EXECUTOR_REGISTRY, executorEnvironment } from '../src/executor.js'
18
+ import { serveTestLedger } from './helpers/ledger.js'
18
19
 
19
20
  const token = () => randomBytes(32).toString('base64url')
20
21
  const owner = async (dir: string) => {
@@ -31,7 +32,7 @@ test('HTTP auth, idempotency, origin/context isolation, revocation and persisted
31
32
  const root=await mkdtemp(join(tmpdir(),'ez-app-channel-'))
32
33
  const owned=await owner(root), runs=new RunStore(root), control=new ControlStore(root,1000)
33
34
  let wakes=0
34
- const channel=new ApplicationChannel({controlDir:root,initial:initialPreset('codex'),wake:()=>{wakes++},cancel:async id=>{await runs.patch(id,{status:'cancelled'})}})
35
+ const channel=new ApplicationChannel({controlDir:root,initial:initialPreset('codex'),wake:()=>{wakes++},cancel:async id=>{await runs.patch(id,{status:'cancelled'})},createTelegramPairing:async()=>{throw new Error('unavailable')},telegramAvailable:()=>false})
35
36
  t.after(async()=>{await channel.stop();await rm(root,{recursive:true,force:true})})
36
37
  const firstToken=token(), secondToken=token()
37
38
  const first=(await channel.bindings.register('first',firstToken,owned))!
@@ -124,12 +125,14 @@ if(result.status!==0){console.error(result.stderr);process.exit(1)};
124
125
  `)
125
126
  EXECUTOR_REGISTRY.codex={...original,command:process.execPath,buildArgs:(options,_file,prompt)=>{prompts.push(prompt);resumeIds.push(options.isResume?options.sessionId:undefined);return ['--import',require.resolve('tsx'),fixture]}}
126
127
  const relay=createRelay({controlDir:root,workspace:root,pairingTtlMs:1000,telegramBotToken:'fixture',executorTimeoutMs:0,executorCli:'codex'})
128
+ // The engine child reaches the relay memory ledger through the delivery socket.
129
+ const ledger=await serveTestLedger(root)
127
130
  let telegramCalls=0
128
131
  relay.bot.api.config.use(async()=>{telegramCalls++;return {ok:true,result:{message_id:1}} as never})
129
132
  const owned=await owner(root), secret=token()
130
133
  const binding=(await relay.applicationChannel.bindings.register('app',secret,owned))!
131
134
  const drain=setInterval(()=>void relay.drainOutbox(),10)
132
- t.after(async()=>{clearInterval(drain);await relay.stop();EXECUTOR_REGISTRY.codex=original;await rm(root,{recursive:true,force:true})})
135
+ t.after(async()=>{clearInterval(drain);await relay.stop();EXECUTOR_REGISTRY.codex=original;await ledger.stop();await rm(root,{recursive:true,force:true})})
133
136
  const first=await relay.applicationChannel.submit(binding.bindingId,{requestId:'one',scope:'program',text:'Hello from the app',context:{secret:'must-stay-out-of-prompt'}})
134
137
  await waitFor(async()=> (await new RunStore(root).get(first.id))?.status==='completed')
135
138
  const second=await relay.applicationChannel.submit(binding.bindingId,{requestId:'two',scope:'program',text:'Continue'})
@@ -137,8 +140,9 @@ if(result.status!==0){console.error(result.stderr);process.exit(1)};
137
140
  assert.deepEqual(resumeIds,[undefined,nativeId])
138
141
  assert.equal(telegramCalls,0)
139
142
  assert.ok(prompts[0].startsWith('Hello from the app'))
140
- assert.ok(prompts[0].includes('[Application channel]'))
143
+ assert.ok(prompts[0].includes('[application scope "program"]'))
141
144
  assert.ok(!prompts[0].includes('must-stay-out-of-prompt'))
145
+ assert.ok(!prompts[0].includes('delegate'))
142
146
  assert.equal((await relay.applicationChannel.snapshot(binding.bindingId,second.id)).messages[0].text,'Fixture engine reply')
143
147
  })
144
148
 
@@ -302,7 +306,7 @@ test('generic attachments stage after auth, preserve literal comments and reject
302
306
  const input={requestId:'image',scope:'chat',followOwner:true,text:' /goal literal\n comment ',attachment:{name:'image.png',data:Buffer.from('89504e470d0a1a0a','hex').toString('base64')}}
303
307
  assert.equal((await post(input,'bad')).status,401)
304
308
  const {readdir,stat}=await import('node:fs/promises')
305
- await assert.rejects(readdir(join(root,'inbox')),/ENOENT/)
309
+ await assert.rejects(readdir(join(root,'attachments')),/ENOENT/)
306
310
  for (const [name,bytes] of [['image.png',Buffer.from('89504e470d0a1a0a','hex')],['file.pdf',Buffer.from('%PDF-1.4\nfixture')],['notes.md',Buffer.from('# Fixture')]] as const) {
307
311
  const body={...input,requestId:name,attachment:{name,data:bytes.toString('base64')}}
308
312
  const response=await post(body);assert.equal(response.status,202)
@@ -311,12 +315,12 @@ test('generic attachments stage after auth, preserve literal comments and reject
311
315
  assert.equal(run.ownerId,ownerId(owned))
312
316
  assert.equal(run.application?.inputText,input.text)
313
317
  assert.ok(run.texts[0].endsWith(`Caption: ${input.text}`))
314
- const path=run.texts[0].match(/staged at (inbox\/[^ ]+)/)![1]
315
- assert.deepEqual(await readFile(join(root,path)),bytes)
316
- assert.equal((await stat(join(root,path))).mode & 0o777,0o600)
317
- const before=(await readdir(join(root,'inbox'))).length
318
+ const path=run.texts[0].match(/staged at (\/[^ ]+)/)![1]
319
+ assert.deepEqual(await readFile(path),bytes)
320
+ assert.equal((await stat(path)).mode & 0o777,0o600)
321
+ const before=(await readdir(join(root,'attachments'))).length
318
322
  assert.equal((await post(body)).status,202)
319
- assert.equal((await readdir(join(root,'inbox'))).length,before)
323
+ assert.equal((await readdir(join(root,'attachments'))).length,before)
320
324
  assert.equal((await post({...body,text:'changed'})).status,409)
321
325
  assert.equal((await post({...body,attachment:{name,data:Buffer.from('changed').toString('base64')}})).status,409)
322
326
  assert.equal((await fetch(`http://127.0.0.1:${address.port}/v1/runs/${snapshot.id}`,{headers:{Authorization:`Bearer ${other}`}})).status,404)
@@ -326,12 +330,56 @@ test('generic attachments stage after auth, preserve literal comments and reject
326
330
  const invalidActivation=await post({...input,requestId:'invalid-activation',followOwner:false,activateTelegram:true,scope:'new-scope',attachment:{name:'x.exe',data:Buffer.from('text').toString('base64')}})
327
331
  assert.equal(invalidActivation.status,400)
328
332
  assert.deepEqual((await control.status()).activeSession,priorSession)
329
- const before=(await readdir(join(root,'inbox'))).length
333
+ const before=(await readdir(join(root,'attachments'))).length
330
334
  for (const attachment of [{name:'x.exe',data:Buffer.from('text').toString('base64')},{name:'x.txt',data:'%%%invalid'},{name:'x.txt',data:Buffer.alloc(10*1024*1024+1,65).toString('base64')}]) {
331
335
  assert.equal((await post({...input,requestId:'bad',attachment})).status,400)
332
336
  }
333
- assert.equal((await readdir(join(root,'inbox'))).length,before)
337
+ assert.equal((await readdir(join(root,'attachments'))).length,before)
334
338
  await channel.bindings.register('web',null,owned)
335
339
  await assert.rejects(channel.submit(binding.bindingId,{...input,requestId:'revoked'}),/revoked/)
336
- assert.equal((await readdir(join(root,'inbox'))).length,before)
340
+ assert.equal((await readdir(join(root,'attachments'))).length,before)
341
+ })
342
+
343
+ test('shareTelegram bindings list Telegram intake and admit one run with context', async t => {
344
+ const root=await mkdtemp(join(tmpdir(),'ez-app-telegram-'))
345
+ const control=new ControlStore(root,1000), runs=new RunStore(root)
346
+ await control.requestPairing(42,42); await control.approveOwner(42)
347
+ const owned=(await control.status()).owner!
348
+ const channel=new ApplicationChannel({controlDir:root,initial:initialPreset('codex'),wake:()=>{},cancel:async()=>{},createTelegramPairing:async()=>{throw new Error('unavailable')},telegramAvailable:()=>false})
349
+ t.after(async()=>{await channel.stop();await rm(root,{recursive:true,force:true})})
350
+ const sharedToken=token(), plainToken=token()
351
+ await channel.bindings.register('tg-app',sharedToken,owned,true)
352
+ await channel.bindings.register('plain',plainToken,owned)
353
+ const address=await channel.listen(0) as {port:number}
354
+ const url=`http://127.0.0.1:${address.port}`
355
+ const get=(bearer:string)=>fetch(url+'/v1/runs',{headers:{Authorization:`Bearer ${bearer}`}})
356
+ const attach=(id:string,bearer:string,body:unknown)=>fetch(url+`/v1/runs/${id}/application`,{method:'POST',headers:{Authorization:`Bearer ${bearer}`},body:JSON.stringify(body)})
357
+ const telegram=await runs.create({id:'tg_intake_1',chatId:42,telegramUserId:42,texts:['Attach the session'],ownerId:ownerId(owned),ownerEpoch:ownerEpoch(owned)})
358
+ assert.equal(telegram.application,undefined)
359
+ const listed=await (await get(sharedToken)).json() as {runs:{id:string;scope:string}[]}
360
+ assert.ok(listed.runs.some(run=>run.id==='tg_intake_1' && run.scope==='telegram'))
361
+ const hidden=await (await get(plainToken)).json() as {runs:{id:string}[]}
362
+ assert.ok(!hidden.runs.some(run=>run.id==='tg_intake_1'))
363
+ assert.equal((await attach('tg_intake_1',plainToken,{context:{aifit:{capability:'x'}}})).status,403)
364
+ assert.equal((await attach('tg_intake_1',sharedToken,{context:'nope'})).status,400)
365
+ assert.equal((await attach('tg_nope',sharedToken,{context:{}})).status,404)
366
+ const admitted=await attach('tg_intake_1',sharedToken,{context:{aifit:{capability:'granted'}}})
367
+ assert.equal(admitted.status,200)
368
+ assert.deepEqual(await admitted.json(),{id:'tg_intake_1',scope:'telegram',status:'queued'})
369
+ const stored=await runs.get('tg_intake_1')
370
+ const storedContext=stored?.telegramApplication?.context as {aifit?:{capability?:string}}|undefined
371
+ assert.equal(storedContext?.aifit?.capability,'granted')
372
+ assert.equal(stored?.application,undefined,'owner delivery routing is untouched')
373
+ const retrySame=await attach('tg_intake_1',sharedToken,{context:{aifit:{capability:'granted'}}})
374
+ assert.equal(retrySame.status,200)
375
+ assert.equal((await attach('tg_intake_1',sharedToken,{context:{aifit:{capability:'changed'}}})).status,409)
376
+ const stranger=await runs.create({id:'tg_stranger_1',chatId:43,telegramUserId:43,texts:['Not the owner'],ownerId:ownerId(owned),ownerEpoch:ownerEpoch(owned)})
377
+ const stillHidden=await (await get(sharedToken)).json() as {runs:{id:string}[]}
378
+ assert.ok(!stillHidden.runs.some(run=>run.id===stranger.id))
379
+ assert.equal((await attach(stranger.id,sharedToken,{context:{}})).status,400)
380
+ const plain=await runs.create({id:'r_plain_1',texts:['No telegram origin'],ownerId:ownerId(owned),ownerEpoch:ownerEpoch(owned)})
381
+ assert.equal((await attach(plain.id,sharedToken,{context:{}})).status,404)
382
+ const finished=await runs.create({id:'tg_finished_1',chatId:42,telegramUserId:42,texts:['Done already'],ownerId:ownerId(owned),ownerEpoch:ownerEpoch(owned)})
383
+ await runs.patch(finished.id,{status:'completed'})
384
+ assert.equal((await attach(finished.id,sharedToken,{context:{}})).status,409)
337
385
  })
@@ -20,12 +20,10 @@ const waitFor = async (condition: () => Promise<boolean>) => {
20
20
  throw new Error('Timed out')
21
21
  }
22
22
 
23
- test('application-only mode explicitly requires native listener and ignores bot credentials', () => {
24
- assert.throws(()=>loadConfig({}), /TELEGRAM_BOT_TOKEN/)
25
- assert.throws(()=>loadConfig({EZ_TELEGRAM_ENABLED:'false'}), /EZ_APPLICATION_PORT/)
26
- assert.throws(()=>loadConfig({EZ_TELEGRAM_ENABLED:'no'}), /true or false/)
27
- 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/)
28
- 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'})
29
27
  assert.equal(config.telegramBotToken,'')
30
28
  assert.equal(config.telegramEnabled,false)
31
29
  })
@@ -36,8 +34,8 @@ test('administrator bootstrap is explicit, local-only and cannot replace identit
36
34
  const token=join(root,'token'); await writeFile(token,'x'.repeat(48),{mode:0o600})
37
35
  const cli=fileURLToPath(new URL('../bin/ezenciel-agents-application.mjs',import.meta.url))
38
36
  const args=[cli,'--id','app','--token-file',token,'--owner','42']
39
- const env={...process.env,EZ_CONTROL_DIR:root,EZ_RUN_ID:'',EZ_TELEGRAM_ENABLED:'false'}
40
- 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)
41
39
  assert.notEqual(spawnSync(process.execPath,args,{env:{...env,EZ_RUN_ID:'r_agent'}}).status,0)
42
40
  assert.equal((await new ControlStore(root,1000).status()).owner,null)
43
41
  const invalidToken=join(root,'invalid-token');await writeFile(invalidToken,'short')
@@ -64,7 +62,7 @@ test('botless daemon executes application turn and rejects Telegram-origin work/
64
62
  const purpose=join(root,'purpose.md');await writeFile(purpose,'Fixture application agent\n');await initializeWorkspace(root,purpose)
65
63
  const portServer=createServer();await new Promise<void>(r=>portServer.listen(0,'127.0.0.1',r))
66
64
  const port=(portServer.address() as {port:number}).port;await new Promise<void>(r=>portServer.close(()=>r()))
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'})
65
+ const config=loadConfig({EZ_APPLICATION_PORT:String(port),EZ_CONTROL_DIR:root,EZ_AGENT_WORKSPACE:root,EZ_EXECUTOR_CLI:'codex'})
68
66
  const native='11111111-1111-1111-1111-111111111111', fixture=join(root,'engine.mjs')
69
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);`)
70
68
  const original=EXECUTOR_REGISTRY.codex,require=createRequire(import.meta.url)
@@ -91,12 +89,15 @@ test('botless daemon executes application turn and rejects Telegram-origin work/
91
89
  assert.equal((await runs.get(legacy.id))?.status,'queued')
92
90
  })
93
91
 
94
- 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 => {
95
93
  const root=await mkdtemp(join(tmpdir(),'ez-app-only-health-'))
96
94
  t.after(()=>rm(root,{recursive:true,force:true}))
97
- 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}))
98
96
  const command=fileURLToPath(new URL('../docker/healthcheck.mjs',import.meta.url))
99
97
  const env={...process.env,EZ_HEALTH_RELAY_CONTROL_DIR:root,EZ_EXECUTOR_TRANSPORT:'local'}
100
- assert.equal(spawnSync(process.execPath,[command],{env:{...env,EZ_TELEGRAM_ENABLED:'false'}}).status,0)
101
- 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)
102
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') {
@@ -110,11 +110,11 @@ if(q.method==='turn/start'){send({id:q.id,result:{turn:{id:'one'}}});send({metho
110
110
  send({id:q.id,result:q.method==='thread/goal/get'?{goal:null}:{}});});setInterval(()=>{},1000);
111
111
  `,{mode:0o700})
112
112
  process.env.HOME=root;process.env.PATH=bin+path.delimiter+priorPath
113
- 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/)
114
114
  await ownerRun(controlDir, 'r_pair_fixture')
115
115
  await Promise.all(['r_schedule_one','r_schedule_two'].map(async runId=>{
116
116
  await ownerRun(controlDir, runId)
117
- 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})
118
118
  assert.equal(await new Promise(resolve=>job.child.once('close',resolve)),0);await job.cleanup()
119
119
  const home=path.join(controlDir,'cli/codex/tasks',runId)
120
120
  assert.equal(JSON.parse(await readFile(path.join(home,'observed.json'),'utf8')).home,home)
@@ -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,73 +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)
45
+ assert.equal(loadConfig({...env,TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY'}).codexSandbox, 'external')
46
+ assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY'}).codexSandbox, undefined)
68
47
  assert.equal(loadConfig({...env,EZ_EXECUTOR_TRANSPORT:''}).isolation, 'isolated')
69
48
  assert.throws(() => loadConfig({...env,EZ_EXECUTOR_TRANSPORT:'host'}), /sandbox|SANDBOX|Isolation/)
70
49
  assert.throws(() => loadConfig({...env,EZ_CODEX_SANDBOX:'danger-full-access'}), /sandbox|SANDBOX/)
71
50
  })
72
51
 
73
52
  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/)
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/)
81
60
  assert.throws(() => loadConfig({
82
- TELEGRAM_BOT_TOKEN:'test',EZ_ISOLATION:'host-capable',EZ_EXECUTOR_TRANSPORT:'host',
61
+ TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_ISOLATION:'host-capable',EZ_EXECUTOR_TRANSPORT:'host',
83
62
  EZ_CHANNEL_BACKEND_URL:'https://backend.example',EZ_CHANNEL_BACKEND_TOKEN:'secret',
84
63
  }), /Host-capable isolation requires the host native CLI/)
85
64
  })
86
65
 
87
66
  test('optional web launcher preserves reserved commands and accepts only HTTPS without secrets', () => {
88
- 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) })
89
68
  assert.deepEqual(config({command:'voice',label:'Voice',url:'https://voice.example/'}).webLauncher,{command:'voice',label:'Voice',url:'https://voice.example/'})
90
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))
91
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
+ })
@@ -1,16 +1,16 @@
1
1
  import assert from 'node:assert/strict'
2
- import { mkdtemp, rm } from 'node:fs/promises'
2
+ import { mkdtemp, readFile, rm } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import path from 'node:path'
5
5
  import test from 'node:test'
6
6
  import { ControlStore } from '../src/control-state.js'
7
7
  import { parseOwnerArgs } from '../src/owner-args.js'
8
8
 
9
- const fixture = async (run: (store: ControlStore, advance: (milliseconds: number) => void) => Promise<void>): Promise<void> => {
9
+ const fixture = async (run: (store: ControlStore, advance: (milliseconds: number) => void, directory: string) => Promise<void>): Promise<void> => {
10
10
  const directory = await mkdtemp(path.join(tmpdir(), 'ez-control-test-'))
11
11
  let now = Date.parse('2026-01-01T00:00:00.000Z')
12
12
  try {
13
- await run(new ControlStore(directory, 60_000, () => now), (milliseconds) => { now += milliseconds })
13
+ await run(new ControlStore(directory, 60_000, () => now), (milliseconds) => { now += milliseconds }, directory)
14
14
  } finally {
15
15
  await rm(directory, { recursive: true, force: true })
16
16
  }
@@ -35,7 +35,9 @@ test('cannot approve a telegram id that did not request pairing', async () => fi
35
35
  await store.requestPairing(101, 101)
36
36
  await assert.rejects(store.approveOwner(202), /No active pairing request/)
37
37
  assert.equal((await store.status()).owner, null)
38
- assert.equal((await store.status()).pending[0]?.telegramUserId, 101)
38
+ const request = (await store.status()).pending[0]
39
+ if (!request || !('telegramUserId' in request)) throw new Error('Telegram pairing request missing')
40
+ assert.equal(request.telegramUserId, 101)
39
41
  }))
40
42
 
41
43
  test('expires pairing requests before an owner can approve them', async () => fixture(async (store, advance) => {
@@ -51,8 +53,85 @@ test('supports local owner recovery by revocation', async () => fixture(async (s
51
53
  assert.equal((await store.status()).owner, null)
52
54
  }))
53
55
 
56
+ test('application-owned Telegram link is hashed, one-time, and bound to the current owner', async () => fixture(async (store, advance, directory) => {
57
+ const owner = await store.registerOwner('account:one')
58
+ const bindingId = '11111111-1111-1111-1111-111111111111'
59
+ const pairing = await store.createApplicationTelegramPairing(bindingId, owner)
60
+ assert.ok(pairing)
61
+ assert.doesNotMatch(await readFile(path.join(directory, 'control-state.json'), 'utf8'), new RegExp(pairing.token))
62
+ const flipped = pairing.token.slice(0, -1) + (pairing.token.endsWith('A') ? 'B' : 'A')
63
+ assert.equal(await store.claimApplicationTelegramPairing(flipped, 101, 101, async () => true), null)
64
+ assert.equal(await store.claimApplicationTelegramPairing(pairing.token, 101, 101, async () => false), null)
65
+ assert.equal((await store.status()).owner?.telegramUserId, undefined)
66
+
67
+ const replacement = await store.createApplicationTelegramPairing(bindingId, owner)
68
+ assert.ok(replacement)
69
+ advance(60_001)
70
+ assert.equal(await store.claimApplicationTelegramPairing(replacement.token, 101, 101, async () => true), null)
71
+
72
+ const active = await store.createApplicationTelegramPairing(bindingId, owner)
73
+ assert.ok(active)
74
+ const linked = await store.claimApplicationTelegramPairing(active.token, 101, 101, async (requestedBinding, expectedOwner) =>
75
+ requestedBinding === bindingId && expectedOwner.id === owner.id)
76
+ assert.equal(linked?.id, owner.id)
77
+ assert.equal(linked?.telegramUserId, 101)
78
+ assert.equal(linked?.telegramChatId, 101)
79
+ assert.ok(linked?.telegramLinkedAt)
80
+ assert.equal(await store.claimApplicationTelegramPairing(active.token, 101, 101, async () => true), null)
81
+ assert.equal(await store.createApplicationTelegramPairing(bindingId, linked!), null)
82
+ await store.unlinkTelegram()
83
+ const relink = await store.createApplicationTelegramPairing(bindingId, (await store.status()).owner!)
84
+ assert.ok(relink)
85
+ assert.equal(await store.claimApplicationTelegramPairing(relink.token, 101, 101, async () => false), null)
86
+ }))
87
+
54
88
  test('owner CLI treats pnpm -- as a separator, not a command', () => {
55
89
  assert.deepEqual(parseOwnerArgs(['--', 'status']), { command: 'status', value: undefined })
56
90
  assert.deepEqual(parseOwnerArgs(['--', 'approve', '101']), { command: 'approve', value: '101' })
57
91
  assert.deepEqual(parseOwnerArgs(['revoke']), { command: 'revoke', value: undefined })
58
92
  })
93
+
94
+ test('provider catalog upgrades a persisted providerless Codex selection', async () => fixture(async (store) => {
95
+ const model = 'deepseek/deepseek-v4.1-flash'
96
+ await store.captureChoice({ id: 'deepseek', name: `${model} · max`, cli: 'codex', model, effort: 'max' })
97
+ const catalog = [{ cli: 'codex', provider: 'openrouter', model, name: `OpenRouter · ${model}`, efforts: ['max'] }]
98
+
99
+ assert.equal(await store.normalizeProviderBindings(catalog), true)
100
+ const state = await store.status()
101
+ const preset = state.ai!.presets.find((item) => item.id === state.ai!.selectedId)!
102
+ assert.equal(preset.provider, 'openrouter')
103
+ assert.equal(preset.name, `${model} · max`)
104
+ assert.equal(state.activeSession?.preset?.provider, 'openrouter')
105
+ assert.equal(await store.normalizeProviderBindings(catalog), false)
106
+ }))
107
+
108
+ test('provider catalog leaves ambiguous and effort-mismatched selections untouched', async () => fixture(async (store) => {
109
+ const model = 'deepseek/deepseek-v4.1-flash'
110
+ await store.captureChoice({ id: 'deepseek', name: 'My deepseek', cli: 'codex', model, effort: 'max' })
111
+ const ambiguous = [
112
+ { cli: 'codex', provider: 'openrouter', model, name: `OpenRouter · ${model}`, efforts: ['max'] },
113
+ { cli: 'codex', provider: 'direct', model, name: `Direct · ${model}`, efforts: ['max'] },
114
+ ]
115
+ assert.equal(await store.normalizeProviderBindings(ambiguous), false)
116
+ assert.equal((await store.status()).ai!.presets.find((item) => item.id === 'deepseek')!.provider, undefined)
117
+ const mismatch = [{ cli: 'codex', provider: 'openrouter', model, name: `OpenRouter · ${model}`, efforts: ['low'] }]
118
+ assert.equal(await store.normalizeProviderBindings(mismatch), false)
119
+ assert.equal((await store.status()).ai!.presets.find((item) => item.id === 'deepseek')!.provider, undefined)
120
+ }))
121
+
122
+ test('steady-state owner checks and session pins write nothing to control/', async () => fixture(async (store, _advance, directory) => {
123
+ const { stat } = await import('node:fs/promises')
124
+ const { initialPreset } = await import('../src/ai.js')
125
+ await store.requestPairing(101, 101)
126
+ await store.approveOwner(101)
127
+ await store.aiState(initialPreset('codex'))
128
+ // First pin may persist the session binding; everything after must be quiet.
129
+ await store.captureChoice(initialPreset('codex'), 'hello')
130
+ const before = await stat(path.join(directory, 'control-state.json'))
131
+ await store.status()
132
+ await store.captureChoice(initialPreset('codex'), 'hello again')
133
+ await store.status()
134
+ const after = await stat(path.join(directory, 'control-state.json'))
135
+ assert.equal(after.mtimeMs, before.mtimeMs)
136
+ assert.equal(after.size, before.size)
137
+ }))