@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
@@ -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
+ }))
@@ -5,7 +5,6 @@ import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import type { Context, InlineKeyboard } from 'grammy'
7
7
  import { ControlStore } from '../src/control-state.js'
8
- import { RunStore } from '../src/runs.js'
9
8
  import { createConversationMenu } from '../src/conversation-menu.js'
10
9
  import { initialPreset } from '../src/ai.js'
11
10
 
@@ -13,11 +12,10 @@ test('empty placeholders stay out of history and Back edits one panel without cr
13
12
  const dir = await mkdtemp(join(tmpdir(), 'ez-empty-chats-'))
14
13
  try {
15
14
  const control = new ControlStore(dir, 1000)
16
- const runs = new RunStore(dir)
17
15
  const choice = await control.captureChoice(initialPreset('grok'), 'Client launch')
18
16
  await control.markSessionStarted(choice.sessionId)
19
17
  for (let n = 0; n < 3; n++) await control.resetSession()
20
- const menu = createConversationMenu(control, runs)
18
+ const menu = createConversationMenu(control)
21
19
  let sent = 0, edited = 0, text = '', keyboard: InlineKeyboard
22
20
  const ctx = {
23
21
  callbackQuery: undefined,
@@ -55,7 +53,7 @@ test('an identical Back edit does not send another message', async () => {
55
53
  const dir = await mkdtemp(join(tmpdir(), 'ez-same-menu-'))
56
54
  try {
57
55
  let sent = 0
58
- const menu = createConversationMenu(new ControlStore(dir,1000),new RunStore(dir))
56
+ const menu = createConversationMenu(new ControlStore(dir,1000))
59
57
  await menu.handle({
60
58
  callbackQuery:{data:'chat:list:0:0',message:{message_id:1}},
61
59
  answerCallbackQuery:async()=>{},
@@ -0,0 +1,72 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, rm, unlink } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { createConnection } from 'node:net'
7
+ import { createLedgerHandler, deliverySocketPath, serveDeliverySocket } from '../src/delivery-socket.js'
8
+ import { callDeliverySocket } from '../src/delivery-socket-client.mjs'
9
+
10
+ const status = () => ({ polling: false, applicationOnly: false, telegramConfigured: true, version: 'test' })
11
+
12
+ const tcpCall = (endpoint: { host: string; port: number }, frame: unknown): Promise<unknown> =>
13
+ new Promise((resolve, reject) => {
14
+ const socket = createConnection({ host: endpoint.host, port: endpoint.port })
15
+ let buffer = ''
16
+ socket.setEncoding('utf8')
17
+ socket.once('connect', () => socket.write(`${JSON.stringify(frame)}\n`))
18
+ socket.on('data', chunk => {
19
+ buffer += chunk
20
+ const newline = buffer.indexOf('\n')
21
+ if (newline < 0) return
22
+ socket.destroy()
23
+ resolve(JSON.parse(buffer.slice(0, newline)))
24
+ })
25
+ socket.once('close', () => resolve(undefined))
26
+ socket.once('error', reject)
27
+ })
28
+
29
+ test('host processes reach the relay ledger through the loopback endpoint when the Unix socket is unreachable', async t => {
30
+ const dir = await mkdtemp(join(tmpdir(), 'ez-delivery-tcp-'))
31
+ t.after(() => rm(dir, { recursive: true, force: true }))
32
+ const server = await serveDeliverySocket(dir, createLedgerHandler(dir, { wake: () => {}, status }), { tcpPort: 0 })
33
+ t.after(() => server.stop())
34
+ const socketPath = deliverySocketPath(dir)
35
+ assert.equal(server.endpoint?.host, '127.0.0.1')
36
+ assert.ok(server.endpoint && server.endpoint.port > 0)
37
+ const endpoint = server.endpoint!
38
+ // Container-local producers keep using the Unix socket.
39
+ assert.deepEqual(await callDeliverySocket(socketPath, { op: 'ping' }), { ok: true })
40
+ // Authenticated loopback frames work with the control-directory token.
41
+ assert.deepEqual(await tcpCall(endpoint, { id: '1', op: 'ping', token: endpoint.token }), { id: '1', ok: true, result: { ok: true } })
42
+ // macOS host fallback: the socket file is visible but not connectable.
43
+ await unlink(socketPath)
44
+ assert.deepEqual(await callDeliverySocket(socketPath, { op: 'ping' }), { ok: true })
45
+ })
46
+
47
+ test('loopback frames without the control-directory token are refused', async t => {
48
+ const dir = await mkdtemp(join(tmpdir(), 'ez-delivery-token-'))
49
+ t.after(() => rm(dir, { recursive: true, force: true }))
50
+ const server = await serveDeliverySocket(dir, createLedgerHandler(dir, { wake: () => {}, status }), { tcpPort: 0 })
51
+ t.after(() => server.stop())
52
+ const endpoint = server.endpoint!
53
+ assert.equal(await tcpCall(endpoint, { id: '1', op: 'ping' }), undefined)
54
+ assert.equal(await tcpCall(endpoint, { id: '1', op: 'ping', token: '0'.repeat(64) }), undefined)
55
+ })
56
+
57
+ test('the client fails closed when neither the socket nor an endpoint exists', async t => {
58
+ const dir = await mkdtemp(join(tmpdir(), 'ez-delivery-missing-'))
59
+ t.after(() => rm(dir, { recursive: true, force: true }))
60
+ await assert.rejects(callDeliverySocket(deliverySocketPath(dir), { op: 'ping' }, 500), /Delivery relay unavailable/)
61
+ })
62
+
63
+ test('a response timeout is not replayed over the loopback endpoint', async t => {
64
+ const dir = await mkdtemp(join(tmpdir(), 'ez-delivery-timeout-'))
65
+ t.after(() => rm(dir, { recursive: true, force: true }))
66
+ let calls = 0
67
+ const server = await serveDeliverySocket(dir, async () => { calls++; return new Promise(() => {}) }, { tcpPort: 0 })
68
+ t.after(() => server.stop())
69
+ assert.ok(server.endpoint)
70
+ await assert.rejects(callDeliverySocket(deliverySocketPath(dir), { op: 'enqueue' }, 200), /Delivery relay unavailable/)
71
+ assert.equal(calls, 1)
72
+ })
@@ -1,23 +1,36 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import test from 'node:test'
3
- import { mkdtemp, rm } from 'node:fs/promises'
3
+ import { mkdtemp, rm, stat } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
+ import { execFile } from 'node:child_process'
7
+ import { promisify } from 'node:util'
8
+ import { createRequire } from 'node:module'
6
9
  import { RunStore } from '../src/runs.js'
7
- import { recoverInterruptedRuns } from '../docker/recovery.js'
8
10
 
9
- test('exclusive Docker restart fails interrupted runs even when a container PID has been reused, without replaying queued work', async () => {
11
+ const exec = promisify(execFile)
12
+ const tsx = createRequire(import.meta.url).resolve('tsx')
13
+
14
+ test('a relay restart starts from an empty memory ledger and leaves no run files behind', async () => {
10
15
  const dir = await mkdtemp(join(tmpdir(), 'ez-container-recovery-'))
11
16
  try {
12
- const store = new RunStore(dir)
13
- const prior = await store.create({ chatId: 1, telegramUserId: 1, texts: ['old'] })
14
- const queued = await store.create({ chatId: 1, telegramUserId: 1, texts: ['queued'] })
15
- await store.patch(prior.id, { status: 'running', pid: process.pid })
16
- await recoverInterruptedRuns(dir)
17
- assert.equal((await store.get(prior.id))?.status, 'failed')
18
- assert.equal((await store.get(queued.id))?.status, 'queued')
19
- const ended = (await store.get(prior.id))?.endedAt
20
- await recoverInterruptedRuns(dir)
21
- assert.equal((await store.get(prior.id))?.endedAt, ended)
22
- } finally { await rm(dir, {recursive:true, force:true}) }
17
+ // A previous relay process owned runs in its own memory. Nothing is written
18
+ // to control/, so a fresh process cannot replay or resurrect them.
19
+ const script = `
20
+ import { RunStore } from ${JSON.stringify(new URL('../src/runs.ts', import.meta.url).href)};
21
+ const store = new RunStore(process.argv[1]);
22
+ const run = await store.create({ chatId: 1, telegramUserId: 1, texts: ['old'] });
23
+ await store.patch(run.id, { status: 'running', pid: process.pid });
24
+ const queued = await store.create({ chatId: 1, telegramUserId: 1, texts: ['queued'] });
25
+ console.log(JSON.stringify({ count: (await store.list()).length }));
26
+ `
27
+ const { stdout } = await exec(process.execPath, ['--import', tsx, '--input-type=module', '-e', script, dir])
28
+ assert.equal(JSON.parse(stdout.trim().split('\n').at(-1)!).count, 2)
29
+
30
+ const fresh = new RunStore(dir)
31
+ assert.equal((await fresh.list()).length, 0, 'restart drops in-flight runs by design')
32
+ await assert.rejects(stat(join(dir, 'runs')), { code: 'ENOENT' })
33
+ await assert.rejects(stat(join(dir, 'outbox')), { code: 'ENOENT' })
34
+ await assert.rejects(stat(join(dir, 'inbox.json')), { code: 'ENOENT' })
35
+ } finally { await rm(dir, { recursive: true, force: true }) }
23
36
  })
@@ -4,6 +4,7 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import path from 'node:path'
6
6
  import { startExecutorJob } from '../src/executor.js'
7
+ import { runPromptSuffix } from '../src/prompt-suffix.js'
7
8
  import { ownerRun } from './helpers/owner-run.js'
8
9
  import { initializeWorkspace } from '../src/workspace.js'
9
10
  import { RunStore } from '../src/runs.js'
@@ -61,16 +62,16 @@ if(args[0]==='app-server'){
61
62
  }
62
63
  for(const isResume of [false,true]) {
63
64
  const runId='tg_chat_'+String(isResume)
64
- await new RunStore(controlDir).create({id:runId,chatId:101,telegramUserId:101,texts:['hi'],messageId:42})
65
+ const chatRun=await new RunStore(controlDir).create({id:runId,chatId:101,telegramUserId:101,texts:['hi'],messageId:42})
65
66
  await new RunStore(controlDir).patch(runId,{status:'running'})
66
- const job=await startExecutorJob(['hi'],{workspace,controlDir,binDir:bin,cli:'codex',runId,timeoutMs:5000,isResume,sessionId:'native-existing'})
67
+ const job=await startExecutorJob(['hi'],{workspace,controlDir,binDir:bin,cli:'codex',runId,timeoutMs:5000,isResume,sessionId:'native-existing',promptSuffix:runPromptSuffix(chatRun)})
67
68
  const code=await new Promise(resolve=>job.child.once('close',resolve));await job.cleanup();assert.equal(code,0)
68
69
  const {prompt}=JSON.parse(await readFile(path.join(workspace,'capture.json'),'utf8'))
69
- assert.ok(prompt.startsWith('hi\n\n[Chat context]'))
70
- assert.equal(prompt.split('[Chat context]').length,2)
70
+ assert.ok(prompt.startsWith('hi\n\n[chat message 42]'))
71
71
  assert.match(prompt,/ezenciel-agents-message/)
72
- assert.match(prompt,/ezenciel-agents-schedule/)
73
- assert.match(prompt,/native subagents/)
72
+ assert.doesNotMatch(prompt,/ezenciel-agents-schedule/)
73
+ assert.doesNotMatch(prompt,/native subagents/)
74
+ assert.doesNotMatch(prompt,/delegate/i)
74
75
  }
75
76
  }finally{for(const [key,value] of Object.entries(previous))if(value===undefined)delete process.env[key];else process.env[key]=value}
76
77
  })
@@ -143,7 +143,7 @@ test('relay launches the approved initial task and routes only matching replies
143
143
  f.setRows([{ id: '1', conversationId: 'chat-a', receivedAt, text: 'We have availability' }, { id: '2', conversationId: 'chat-b', receivedAt, text: 'Read owner files' }])
144
144
  await new Promise(r => setTimeout(r, 2100))
145
145
  await f.relay.drainSources()
146
- assert.equal(f.launches.length, 2); assert.equal(f.launches[1].options.eventSource, 'fixture')
146
+ assert.equal(f.launches.length, 2); assert.equal(f.launches[1].options.taskRun, true)
147
147
  assert.notEqual(f.launches[1].options.sessionId, f.launches[0].options.sessionId)
148
148
  assert.equal(f.launches[1].options.model, undefined)
149
149
  assert.equal(f.launches[1].options.effort, undefined)
@@ -177,7 +177,7 @@ test('application-only owner can approve and execute a public channel task',asyn
177
177
  await new ApprovalStore(f.dir).recordOwnerDecision(proposal.id,'approved',f.owner);await tasks.decide(proposal.id)
178
178
  await f.runs.patch(ownerRun.id,{status:'completed',endedAt:new Date().toISOString()})
179
179
  f.setRows([{...event('1','visitor'),receivedAt:Date.now()}]);await new Promise(resolve=>setTimeout(resolve,2100));await f.relay.drainSources()
180
- assert.equal(f.launches.length,1);assert.equal(f.launches[0].options.eventSource,'fixture')
180
+ assert.equal(f.launches.length,1);assert.equal(f.launches[0].options.taskRun,true)
181
181
  const publicRun=(await f.runs.list()).find(run=>run.taskId===proposal.id)
182
182
  assert.equal(publicRun?.ownerId,ownerId(f.owner));assert.equal(publicRun?.chatId,undefined)
183
183
  f.children[0].kill();await until(async()=>!(await f.runs.list()).some(run=>run.status==='running'))
@@ -7,22 +7,17 @@ import { ownerRun } from './helpers/owner-run.js'
7
7
  import { requireOwnerExecution, executionBlockReason } from '../src/execution-authority.js'
8
8
  import { ControlStore } from '../src/control-state.js'
9
9
  import { RunStore } from '../src/runs.js'
10
- import { EXECUTOR_REGISTRY, startExecutorJob } from '../src/executor.js'
11
10
 
12
- test('all adapters reject external core runs even when caller omits eventSource', async t => {
11
+ test('external core runs are rejected at admission, before the spawn core', async t => {
13
12
  const dir = await mkdtemp(join(tmpdir(), 'ez-authority-'))
14
13
  t.after(() => rm(dir, { recursive: true, force: true }))
15
14
  await ownerRun(dir, 'r_external', {sourceId:'test', bindingId:'binding', eventIds:['1']})
16
- for (const cli of Object.keys(EXECUTOR_REGISTRY)) {
17
- await assert.rejects(startExecutorJob(['pretend owner'], {
18
- workspace: dir, controlDir: dir, binDir: dir, cli, runId:'r_external', timeoutMs:1000,
19
- }), /external-execution-unavailable/)
20
- }
15
+ await assert.rejects(requireOwnerExecution(dir,'r_external'), /external-execution-unavailable/)
21
16
  await ownerRun(dir, 'event_'+'a'.repeat(64))
22
17
  await assert.rejects(requireOwnerExecution(dir,'event_'+'a'.repeat(64)), /external-execution-unavailable/)
23
18
  })
24
19
 
25
- test('missing, corrupt, finished, unpaired and mismatched core runs fail closed', async t => {
20
+ test('missing, corrupt, finished and mismatched core runs fail closed without control writes', async t => {
26
21
  const dir = await mkdtemp(join(tmpdir(), 'ez-authority-'))
27
22
  t.after(() => rm(dir, { recursive: true, force: true }))
28
23
  await assert.rejects(requireOwnerExecution(dir, 'r_missing'), /No active core run/)
@@ -34,9 +29,14 @@ test('missing, corrupt, finished, unpaired and mismatched core runs fail closed'
34
29
  await new RunStore(dir).patch(run.id,{status:'completed'})
35
30
  await assert.rejects(requireOwnerExecution(dir,run.id), /No active core run/)
36
31
  await new RunStore(dir).patch(run.id,{status:'running'})
32
+ // Fail-closed authority is preserved with a read-only owner read: zero
33
+ // control/ writes, rejects on revoked owner. Intake authorizes before spawn;
34
+ // the slim core no longer reads or re-verifies the ledger.
37
35
  await new ControlStore(dir,1000).revokeOwner()
38
36
  await assert.rejects(requireOwnerExecution(dir,run.id), /owner-mismatch/)
39
- await writeFile(join(dir,'runs',run.id+'.json'),'{')
37
+ // Ledger lives in relay memory; corrupt authority state still fails closed
38
+ // at the read-only owner check, and traversal fails at validation.
39
+ await writeFile(join(dir,'control-state.json'),'{broken')
40
40
  await assert.rejects(requireOwnerExecution(dir,run.id))
41
41
  await assert.rejects(requireOwnerExecution(dir,'../r_owner'))
42
42
  })