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

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 (142) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +5 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +96 -0
  5. package/Dockerfile +20 -11
  6. package/README.md +10 -13
  7. package/THIRD_PARTY_NOTICES.md +7 -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 +41 -4
  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 +28 -6
  17. package/docs/application-channel.md +32 -10
  18. package/docs/architecture/ai-selection.md +10 -2
  19. package/docs/architecture/authority-boundaries.md +2 -2
  20. package/docs/channel-backend.md +4 -3
  21. package/docs/docker-runtime.md +98 -11
  22. package/docs/local-qa.md +2 -2
  23. package/docs/plugin-guide.md +202 -0
  24. package/docs/plugins.md +48 -2
  25. package/docs/releasing.md +13 -0
  26. package/docs/responsive-channels.md +1 -1
  27. package/docs/scheduling.md +4 -4
  28. package/docs/setup.md +11 -5
  29. package/docs/upgrades.md +46 -23
  30. package/package.json +5 -5
  31. package/scripts/smoke-scheduler.ts +10 -3
  32. package/scripts/smoke.ts +14 -11
  33. package/src/agent-guidance.ts +6 -9
  34. package/src/agent-install.ts +28 -7
  35. package/src/ai-cli.ts +1 -1
  36. package/src/ai.ts +131 -5
  37. package/src/application-channel.ts +78 -7
  38. package/src/application-cli.ts +2 -2
  39. package/src/approval-cli.ts +14 -7
  40. package/src/approval.ts +27 -37
  41. package/src/channel-backend.ts +2 -3
  42. package/src/config.ts +12 -27
  43. package/src/control-state.ts +175 -8
  44. package/src/conversation-menu.ts +6 -17
  45. package/src/delivery-socket-client.d.mts +4 -0
  46. package/src/delivery-socket-client.mjs +104 -0
  47. package/src/delivery-socket.ts +354 -0
  48. package/src/execution-authority.ts +21 -5
  49. package/src/executor.ts +81 -24
  50. package/src/files.ts +38 -11
  51. package/src/host-executor.ts +33 -17
  52. package/src/inbox.ts +15 -49
  53. package/src/index.ts +98 -54
  54. package/src/install-tools.mjs +97 -22
  55. package/src/ledger-endpoint.d.mts +2 -0
  56. package/src/ledger-endpoint.mjs +17 -0
  57. package/src/menu.ts +8 -3
  58. package/src/message-history.ts +13 -18
  59. package/src/message.ts +35 -18
  60. package/src/plugin-broker.mjs +396 -0
  61. package/src/plugins/connection.mjs +2 -2
  62. package/src/plugins/manager.mjs +131 -23
  63. package/src/plugins/native-tasks.mjs +2 -2
  64. package/src/prompt-suffix.ts +12 -0
  65. package/src/react.ts +6 -4
  66. package/src/runs.ts +156 -243
  67. package/src/schedule-cli.ts +29 -10
  68. package/src/scheduled-tasks.ts +53 -10
  69. package/src/scheduler.ts +20 -7
  70. package/src/service.ts +3 -3
  71. package/src/setup.ts +1 -0
  72. package/src/software-status.ts +36 -4
  73. package/src/task-executor.ts +2 -2
  74. package/src/task-workspace.ts +4 -3
  75. package/src/tasks.ts +1 -1
  76. package/src/telegram-provisioning-cli.ts +22 -0
  77. package/src/telegram-provisioning.ts +214 -0
  78. package/src/updates/control.mjs +77 -7
  79. package/src/updates/runtime.mjs +28 -8
  80. package/src/updates/status.mjs +25 -3
  81. package/src/updates/supervisor.mjs +15 -16
  82. package/src/version.ts +20 -0
  83. package/src/workspace.ts +3 -9
  84. package/templates/failure-review.md +1 -1
  85. package/templates/maintainer-purpose.md +1 -1
  86. package/test/agent-guidance.test.ts +14 -80
  87. package/test/agent-install.test.ts +14 -0
  88. package/test/ai.test.ts +220 -1
  89. package/test/application-channel.test.ts +61 -13
  90. package/test/application-only.test.ts +14 -13
  91. package/test/channel-backend.test.ts +3 -7
  92. package/test/channel-delivery.test.ts +4 -1
  93. package/test/channel-owner.test.ts +6 -3
  94. package/test/codex-context.test.ts +2 -2
  95. package/test/config.test.ts +38 -43
  96. package/test/control-state.test.ts +83 -4
  97. package/test/conversation-menu.test.ts +2 -4
  98. package/test/delivery-transport.test.ts +72 -0
  99. package/test/docker-runtime.test.ts +27 -14
  100. package/test/engine-handoff.test.ts +7 -6
  101. package/test/event-sources.test.ts +2 -2
  102. package/test/execution-authority.test.ts +9 -9
  103. package/test/executor.test.ts +120 -12
  104. package/test/failure.test.ts +12 -5
  105. package/test/files.test.ts +2 -2
  106. package/test/helpers/ledger.ts +18 -0
  107. package/test/host-executor.test.ts +87 -3
  108. package/test/inbox.test.ts +8 -10
  109. package/test/install-config.test.ts +1 -0
  110. package/test/install-tools.test.mjs +74 -7
  111. package/test/intake-relay.test.ts +58 -11
  112. package/test/message-history.test.ts +39 -47
  113. package/test/native-tasks.test.ts +1 -1
  114. package/test/plugin-broker.test.mjs +172 -0
  115. package/test/plugin-manager.test.mjs +136 -2
  116. package/test/reaction.test.ts +4 -0
  117. package/test/relay.test.ts +5 -7
  118. package/test/repair-policy.test.ts +1 -4
  119. package/test/schedule-cli.test.ts +14 -9
  120. package/test/scheduled-tasks.test.ts +29 -6
  121. package/test/scheduler-host.test.ts +5 -5
  122. package/test/scheduler-relay.test.ts +15 -9
  123. package/test/security.test.ts +6 -3
  124. package/test/software-status.test.ts +21 -3
  125. package/test/telegram-provisioning.test.ts +160 -0
  126. package/test/updates.test.mjs +93 -13
  127. package/test/workspace.test.ts +4 -1
  128. package/bin/ezenciel-agents-watch.mjs +0 -8
  129. package/compose.workforce-watch.yaml +0 -33
  130. package/docker/recovery.ts +0 -11
  131. package/docs/pagerduty.md +0 -42
  132. package/docs/workforce-watch.md +0 -101
  133. package/src/pagerduty.ts +0 -109
  134. package/src/reply-context.ts +0 -43
  135. package/src/update-attention.ts +0 -18
  136. package/src/workforce-watch-cli.ts +0 -14
  137. package/src/workforce-watch.ts +0 -155
  138. package/templates/agent/AGENTS.md +0 -5
  139. package/templates/agent-guidance.md +0 -17
  140. package/test/pagerduty.test.ts +0 -104
  141. package/test/update-attention.test.ts +0 -20
  142. package/test/workforce-watch.test.ts +0 -180
package/test/ai.test.ts CHANGED
@@ -4,7 +4,7 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import { ControlStore } from '../src/control-state.js'
7
- import { initialPreset, chatPreset, readModels, isPreset } from '../src/ai.js'
7
+ import { initialPreset, chatPreset, readModels, isPreset, readOpencodeModels, readCuratedModels, validateSelection, opencodeProviderAllowlist } from '../src/ai.js'
8
8
  import { createAiMenu } from '../src/menu.js'
9
9
  import { EXECUTOR_REGISTRY, nativeSessionId } from '../src/executor.js'
10
10
  import { InboxStore } from '../src/inbox.js'
@@ -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' }
@@ -251,6 +287,12 @@ test('native executor flags carry the exact model and effort; only structured me
251
287
  const codex = EXECUTOR_REGISTRY.codex.buildArgs(opts, '', 'fixture')
252
288
  assert.ok(codex.includes('model_reasoning_effort="medium"'))
253
289
  assert.deepEqual(codex.slice(-3), ['resume', opts.sessionId, '-'])
290
+ // Opencode variants pass through verbatim on every platform; there is no
291
+ // OS-specific effort mapping between the catalog and `run --variant`.
292
+ const opencode = EXECUTOR_REGISTRY.opencode.buildArgs(
293
+ { workspace: '/tmp/fixture', model: 'opencode/muse-spark-1.3-contributor-free', effort: 'xhigh' }, '', 'fixture')
294
+ assert.equal(opencode[opencode.indexOf('-m') + 1], 'opencode/muse-spark-1.3-contributor-free')
295
+ assert.equal(opencode[opencode.indexOf('--variant') + 1], 'xhigh')
254
296
  assert.equal(nativeSessionId('codex', JSON.stringify({ type: 'thread.started', thread_id: opts.sessionId })), opts.sessionId)
255
297
  assert.equal(nativeSessionId('opencode', JSON.stringify({ type: 'step_start', sessionID: 'ses_fixture' })), 'ses_fixture')
256
298
  assert.equal(nativeSessionId('codex', JSON.stringify({ type: 'text', thread_id: opts.sessionId })), undefined)
@@ -317,3 +359,180 @@ test('upgrades expose responsive chat without replacing an existing default or q
317
359
  assert.ok(state.presets.some(p => p.id === 'chat-default'))
318
360
  } finally { await rm(dir, { recursive: true, force: true }) }
319
361
  })
362
+
363
+ test('opencode catalog projects installed models with variants as efforts', async () => {
364
+ const verbose = [
365
+ 'opencode/big-pickle',
366
+ JSON.stringify({ id: 'big-pickle', providerID: 'opencode', name: 'Big Pickle', variants: {} }),
367
+ 'opencode/ling-free',
368
+ JSON.stringify({ id: 'ling-free', providerID: 'opencode', name: 'Ling Free',
369
+ variants: { low: { reasoningEffort: 'low' }, medium: { reasoningEffort: 'medium' }, 'bad option': {} } }),
370
+ 'openrouter/~aliased/model',
371
+ JSON.stringify({ id: 'model', providerID: 'openrouter', name: 'Aliased', variants: {} }),
372
+ ].join('\n')
373
+ const catalog = await readOpencodeModels(async (args) => {
374
+ assert.deepEqual(args, ['models', '--verbose'])
375
+ return verbose
376
+ })
377
+ assert.deepEqual(catalog, [
378
+ { cli: 'opencode', model: 'opencode/big-pickle', name: 'Big Pickle · opencode/big-pickle', efforts: [] },
379
+ { cli: 'opencode', model: 'opencode/ling-free', name: 'Ling Free · opencode/ling-free', efforts: ['low', 'medium'] },
380
+ ])
381
+ })
382
+
383
+ test('opencode catalog falls back to the plain list, then to the client default', async () => {
384
+ const plain = await readOpencodeModels(async (args) => {
385
+ if (args.includes('--verbose')) throw new Error('no verbose metadata')
386
+ return 'opencode/alpha\nopenrouter/~aliased/model\nnot a model line\n'
387
+ })
388
+ assert.deepEqual(plain, [{ cli: 'opencode', model: 'opencode/alpha', name: 'opencode/alpha', efforts: [] }])
389
+ assert.deepEqual(await readOpencodeModels(async () => { throw new Error('opencode unavailable') }), [])
390
+ const fallback = await readModels(undefined, async (cli) => cli === 'opencode', undefined as never,
391
+ async () => { throw new Error('opencode unavailable') })
392
+ assert.deepEqual(fallback, [{ cli: 'opencode', name: 'opencode · client default', efforts: [] }])
393
+ const listed = await readModels(undefined, async (cli) => cli === 'opencode', undefined as never,
394
+ async () => 'opencode/alpha\n')
395
+ assert.deepEqual(listed, [{ cli: 'opencode', model: 'opencode/alpha', name: 'opencode/alpha', efforts: [] }])
396
+ })
397
+
398
+ test('opencode provider allowlist scopes the catalog to Go and never falls back outside it', async () => {
399
+ assert.equal(opencodeProviderAllowlist({}), undefined)
400
+ assert.equal(opencodeProviderAllowlist({ EZ_OPENCODE_PROVIDERS: ' ' }), undefined)
401
+ assert.deepEqual(opencodeProviderAllowlist({ EZ_OPENCODE_PROVIDERS: 'opencode-go' }), ['opencode-go'])
402
+ assert.deepEqual(opencodeProviderAllowlist({ EZ_OPENCODE_PROVIDERS: ' opencode-go ,openrouter,opencode-go ' }), ['opencode-go', 'openrouter'])
403
+ for (const bad of ['Bad Name!', 'has space', 'UPPER', 'a'.repeat(33), ',,,', 'ok,,bad name'])
404
+ assert.throws(() => opencodeProviderAllowlist({ EZ_OPENCODE_PROVIDERS: bad }), /one to sixteen unique provider IDs/)
405
+ const verbose = [
406
+ 'opencode/mimo-free',
407
+ JSON.stringify({ id: 'mimo-free', providerID: 'opencode', name: 'Mimo Free', variants: {} }),
408
+ 'opencode-go/muse-spark-1.3-contributor',
409
+ JSON.stringify({ id: 'muse-spark-1.3-contributor', providerID: 'opencode-go', name: 'Muse Spark 1.3 Contributor',
410
+ variants: { high: {}, xhigh: {} } }),
411
+ 'openrouter/some/model',
412
+ JSON.stringify({ id: 'some/model', providerID: 'openrouter', name: 'Some', variants: {} }),
413
+ ].join('\n')
414
+ const runner = async () => verbose
415
+ const prior = process.env.EZ_OPENCODE_PROVIDERS
416
+ try {
417
+ process.env.EZ_OPENCODE_PROVIDERS = 'opencode-go'
418
+ const scoped = await readModels(undefined, async (cli) => cli === 'opencode', undefined as never, runner)
419
+ assert.deepEqual(scoped.map((m) => m.model), ['opencode-go/muse-spark-1.3-contributor'])
420
+ await assert.rejects(
421
+ validateSelection({ id: 'free', name: 'Free', cli: 'opencode', model: 'opencode/mimo-free' }, scoped, async () => true),
422
+ /installed client catalog/)
423
+ process.env.EZ_OPENCODE_PROVIDERS = 'missing-provider'
424
+ assert.deepEqual(await readModels(undefined, async (cli) => cli === 'opencode', undefined as never, runner), [])
425
+ delete process.env.EZ_OPENCODE_PROVIDERS
426
+ assert.equal((await readModels(undefined, async (cli) => cli === 'opencode', undefined as never, runner)).length, 3)
427
+ } finally {
428
+ if (prior === undefined) delete process.env.EZ_OPENCODE_PROVIDERS
429
+ else process.env.EZ_OPENCODE_PROVIDERS = prior
430
+ }
431
+ })
432
+ test('explicit host binding allowlist overrides the process environment', async () => {
433
+ const runner = async () => 'opencode/a\nopencode-go/b\n'
434
+ const only = (models: { cli: string; model?: string }[]) => models.filter((m) => m.cli === 'opencode').map((m) => m.model)
435
+ const prior = process.env.EZ_OPENCODE_PROVIDERS
436
+ try {
437
+ process.env.EZ_OPENCODE_PROVIDERS = 'opencode'
438
+ assert.deepEqual(only(await readModels(undefined, async () => true, undefined as never, runner)), ['opencode/a'])
439
+ assert.deepEqual(only(await readModels(undefined, async () => true, undefined as never, runner, undefined, ['opencode-go'])), ['opencode-go/b'])
440
+ assert.deepEqual(only(await readModels(undefined, async () => true, undefined as never, runner, undefined, undefined)), ['opencode/a'])
441
+ } finally {
442
+ if (prior === undefined) delete process.env.EZ_OPENCODE_PROVIDERS
443
+ else process.env.EZ_OPENCODE_PROVIDERS = prior
444
+ }
445
+ })
446
+
447
+ test('opencode model and variant selections validate against the installed catalog', async () => {
448
+ const catalog = [
449
+ { cli: 'opencode', model: 'opencode/ling-free', name: 'Ling Free · opencode/ling-free', efforts: ['low', 'medium'] },
450
+ { cli: 'opencode', model: 'opencode/big-pickle', name: 'Big Pickle · opencode/big-pickle', efforts: [] },
451
+ ]
452
+ const available = async (cli: string) => cli === 'opencode'
453
+ await validateSelection({ id: 'choice', name: 'Ling', cli: 'opencode', model: 'opencode/ling-free', effort: 'low' }, catalog, available)
454
+ await validateSelection({ id: 'default', name: 'Default', cli: 'opencode', model: 'opencode/big-pickle' }, catalog, available)
455
+ await assert.rejects(
456
+ validateSelection({ id: 'bad', name: 'Bad', cli: 'opencode', model: 'opencode/ling-free', effort: 'max' }, catalog, available),
457
+ /installed client catalog/)
458
+ await assert.rejects(
459
+ validateSelection({ id: 'missing', name: 'Missing', cli: 'opencode', model: 'opencode/unknown' }, catalog, available),
460
+ /installed client catalog/)
461
+ })
462
+
463
+ test('pi does not borrow an unrelated OpenCode catalog', async () => {
464
+ const runner = async () => [
465
+ 'opencode-go/muse-spark-1.3-contributor',
466
+ JSON.stringify({ id: 'muse-spark-1.3-contributor', providerID: 'opencode-go', name: 'Muse Spark 1.3 Contributor', variants: { high: {}, xhigh: {} } }),
467
+ ].join('\n')
468
+ const catalog = await readModels(undefined, async (cli) => cli === 'pi', undefined as never, runner)
469
+ assert.deepEqual(catalog, [{ cli: 'pi', name: 'Pi · client default', efforts: [] }])
470
+ assert.equal(isPreset({ id: 'x', name: 'Pi', cli: 'pi' }), true)
471
+ assert.equal(isPreset({ id: 'x', name: 'Unavailable', cli: 'unreal-agent' }), false)
472
+ await validateSelection({ id: 'choice', name: 'Default', cli: 'pi' }, catalog, async (cli) => cli === 'pi')
473
+ await assert.rejects(
474
+ validateSelection(
475
+ { id: 'bad', name: 'Bad', cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', effort: 'max' },
476
+ catalog, async (cli) => cli === 'pi'),
477
+ /installed client catalog/)
478
+ })
479
+
480
+ test('model catalog merges user-curated entries ahead of discovered ones', async () => {
481
+ const control = await mkdtemp(join(tmpdir(), 'ez-catalog-curated-'))
482
+ const home = await mkdtemp(join(tmpdir(), 'ez-catalog-curated-home-'))
483
+ try {
484
+ await mkdir(join(home, '.codex'))
485
+ await writeFile(join(home, '.codex/models_cache.json'), JSON.stringify({ models: [
486
+ { slug: 'fixture-model', display_name: 'Fixture', visibility: 'list',
487
+ supported_reasoning_levels: [{ effort: 'medium' }] },
488
+ ] }))
489
+ await writeFile(join(control, 'ai-models.json'), JSON.stringify([
490
+ { cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', name: 'Pi · Muse Spark 1.3', efforts: ['xhigh'] },
491
+ { cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', name: 'Dupe', efforts: [] },
492
+ { cli: 'codex', model: 'fixture-model', name: 'Curated Fixture', efforts: ['medium'] },
493
+ { cli: 'nope', model: 'x' },
494
+ 'not-an-entry',
495
+ ]))
496
+ const installedOnly = async (cli: string) => cli === 'codex' || cli === 'pi'
497
+ const noRunner = async (_args: string[]): Promise<string> => { throw new Error('no native CLI in test') }
498
+ const catalog = await readModels(home, installedOnly, join(home, '.codex'),
499
+ noRunner, undefined, undefined, control)
500
+ assert.deepEqual(catalog.filter((m) => m.cli === 'pi'), [
501
+ { cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', name: 'Pi · Muse Spark 1.3', efforts: ['xhigh'] },
502
+ { cli: 'pi', name: 'Pi · client default', efforts: [] },
503
+ ])
504
+ // Curated entries win over the same discovered id and validate for selection.
505
+ assert.deepEqual(catalog.filter((m) => m.cli === 'codex'), [
506
+ { cli: 'codex', model: 'fixture-model', name: 'Curated Fixture', efforts: ['medium'] },
507
+ ])
508
+ await validateSelection(
509
+ { id: 'choice', name: 'Spark', cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', effort: 'xhigh' },
510
+ catalog, async (cli) => cli === 'pi')
511
+ // Missing or malformed curation files fall back to discovery alone.
512
+ assert.deepEqual(await readCuratedModels(join(control, 'missing-dir')), [])
513
+ await writeFile(join(control, 'ai-models.json'), 'not json')
514
+ assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', join(home, '.codex'),
515
+ noRunner, undefined, undefined, control), [
516
+ { cli: 'codex', model: 'fixture-model', name: 'Fixture', efforts: ['medium'] },
517
+ ])
518
+ } finally {
519
+ await rm(control, { recursive: true, force: true })
520
+ await rm(home, { recursive: true, force: true })
521
+ }
522
+ })
523
+
524
+ test('curated models respect installed clients and provider scope', async () => {
525
+ const control = await mkdtemp(join(tmpdir(), 'ez-catalog-scope-'))
526
+ try {
527
+ await writeFile(join(control, 'ai-models.json'), JSON.stringify([
528
+ { cli: 'pi', model: 'opencode-go/allowed', efforts: ['xhigh'] },
529
+ { cli: 'pi', model: 'other/blocked' },
530
+ { cli: 'opencode', model: 'other/blocked' },
531
+ { cli: 'claude', model: 'uninstalled' },
532
+ ]))
533
+ const catalog = await readModels(undefined, async (cli) => cli === 'pi', undefined as never,
534
+ async () => { throw new Error('no OpenCode catalog') }, undefined, ['opencode-go'], control)
535
+ assert.deepEqual(catalog.filter(entry => entry.model).map(entry => entry.model), ['opencode-go/allowed'])
536
+ assert.equal(catalog.some(entry => entry.cli === 'pi' && !entry.model), false)
537
+ } finally { await rm(control, { recursive: true, force: true }) }
538
+ })
@@ -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)