@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
@@ -6,7 +6,8 @@ import { mkdtemp, mkdir, rm, writeFile, readFile } from 'node:fs/promises'
6
6
  import { spawn } from 'node:child_process'
7
7
  import { tmpdir } from 'node:os'
8
8
  import path from 'node:path'
9
- import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment, grokInvocation, grokJobEnv, opencodeInvocation, resolveExecutor, resolveHostCommand, startExecutorJob, terminateJob, validateCodexProvider } from '../src/executor.js'
9
+ import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment, grokInvocation, grokJobEnv, opencodeDataHome, opencodeInvocation, piAgentDir, resolveExecutor, resolveHostCommand, startExecutorJob, terminateJob, validateCodexProvider } from '../src/executor.js'
10
+ import { requireOwnerExecution } from '../src/execution-authority.js'
10
11
  import { splitTelegramText } from '../src/reply.js'
11
12
  import { matchingProcessIds, processSnapshot } from '../src/process-tree.js'
12
13
 
@@ -60,10 +61,9 @@ test('native CLIs resolve from the host PATH, not a relative package name', () =
60
61
  })
61
62
 
62
63
  test('the executor receives a deliberately small environment', () => {
63
- const environment = executorEnvironment({ PATH: '/bin', HOME: '/tmp/home', TELEGRAM_BOT_TOKEN: 'secret', PAGERDUTY_ROUTING_KEY: 'secret', AWS_SECRET_ACCESS_KEY: 'secret' })
64
+ const environment = executorEnvironment({ PATH: '/bin', HOME: '/tmp/home', TELEGRAM_BOT_TOKEN: 'secret', AWS_SECRET_ACCESS_KEY: 'secret' })
64
65
  assert.deepEqual(environment, { PATH: '/bin', HOME: '/tmp/home' })
65
66
  assert.ok(!('TELEGRAM_BOT_TOKEN' in environment))
66
- assert.ok(!('PAGERDUTY_ROUTING_KEY' in environment))
67
67
  })
68
68
 
69
69
  test('the Grok job env binds the run and still strips the bot token', () => {
@@ -73,6 +73,7 @@ test('the Grok job env binds the run and still strips the bot token', () => {
73
73
  )
74
74
  assert.equal(environment.EZ_RUN_ID, 'r_1')
75
75
  assert.equal(environment.EZ_CONTROL_DIR, '/tmp/control')
76
+ assert.equal(environment.EZ_DELIVERY_SOCKET, '/tmp/control/delivery.sock')
76
77
  assert.match(environment.PATH ?? '', /^\/tmp\/bin/)
77
78
  assert.ok(!('TELEGRAM_BOT_TOKEN' in environment))
78
79
  })
@@ -124,6 +125,7 @@ test('resolveExecutor correctly maps keys and aliases', () => {
124
125
  assert.equal(resolveExecutor('grok').command, 'grok')
125
126
  assert.equal(resolveExecutor('opencode').command, 'opencode')
126
127
  assert.equal(resolveExecutor('oc').command, 'opencode')
128
+ assert.throws(() => resolveExecutor('unreal-agent'), /Unsupported executor CLI/)
127
129
  assert.equal(resolveExecutor('codex-gui').name, 'codex-gui')
128
130
  assert.notEqual(resolveExecutor('codex-gui').name, resolveExecutor('codex').name)
129
131
  assert.equal(resolveExecutor(undefined).command, 'agy')
@@ -171,11 +173,119 @@ test('Codex custom providers use declared runtime state and an environment key r
171
173
  assert.ok(args.includes('google/gemini-3.8-flash'))
172
174
  assert.equal(JSON.stringify(args).includes('provider-secret'),false)
173
175
  assert.throws(()=>validateCodexProvider({...provider,envKey:'TELEGRAM-BOT'}),/environment key/)
174
- for (const envKey of ['PATH','HOME','CODEX_HOME','NODE_OPTIONS','EZ_CONTROL_DIR','TELEGRAM_BOT_TOKEN','PAGERDUTY_ROUTING_KEY'])
176
+ for (const envKey of ['PATH','HOME','CODEX_HOME','NODE_OPTIONS','EZ_CONTROL_DIR','TELEGRAM_BOT_TOKEN'])
175
177
  assert.throws(()=>validateCodexProvider({...provider,envKey}),/Reserved/)
176
178
  assert.throws(()=>validateCodexProvider({...provider,models:[]}),/provider models/)
177
179
  })
178
180
 
181
+ test('opencode jobs use the agent-bound data home only when its auth binding exists', async t => {
182
+ const root=await mkdtemp(path.join(tmpdir(),'ez-opencode-auth-'));t.after(()=>rm(root,{recursive:true,force:true}))
183
+ const bin=path.join(root,'bin');await mkdir(bin)
184
+ await writeFile(path.join(bin,'opencode'),`#!${process.execPath}\nconst fs=require('fs');fs.writeFileSync(${JSON.stringify(path.join(root,'observed.json'))},JSON.stringify({args:process.argv.slice(2),xdg:process.env.XDG_DATA_HOME,telegram:process.env.TELEGRAM_BOT_TOKEN}));`,{mode:0o755})
185
+ assert.equal(opencodeDataHome(root),undefined)
186
+ await mkdir(path.join(root,'cli','opencode'),{recursive:true})
187
+ assert.equal(opencodeDataHome(root),undefined)
188
+ await writeFile(path.join(root,'cli','opencode','auth.json'),'{}',{mode:0o600})
189
+ assert.equal(opencodeDataHome(root),path.join(root,'cli'))
190
+ const prior={path:process.env.PATH,telegram:process.env.TELEGRAM_BOT_TOKEN,transport:process.env.EZ_EXECUTOR_TRANSPORT}
191
+ const launch=async(runId:string)=>{
192
+ await ownerRun(root,runId)
193
+ process.env.PATH=bin+path.delimiter+prior.path;process.env.TELEGRAM_BOT_TOKEN='relay-secret';delete process.env.EZ_EXECUTOR_TRANSPORT
194
+ const job=await startExecutorJob(['hello'],{workspace:root,controlDir:root,binDir:bin,runId,timeoutMs:0,cli:'opencode',model:'opencode-go/muse-spark-1.3-contributor',effort:'xhigh'})
195
+ assert.equal(await new Promise(resolve=>job.child.once('close',resolve)),0);await job.cleanup()
196
+ return JSON.parse(await readFile(path.join(root,'observed.json'),'utf8'))
197
+ }
198
+ try {
199
+ const bound=await launch('r_ocbound')
200
+ assert.equal(bound.xdg,path.join(root,'cli'))
201
+ assert.equal(bound.telegram,undefined)
202
+ assert.equal(bound.args[bound.args.indexOf('-m')+1],'opencode-go/muse-spark-1.3-contributor')
203
+ assert.equal(bound.args[bound.args.indexOf('--variant')+1],'xhigh')
204
+ await rm(path.join(root,'cli','opencode','auth.json'))
205
+ const free=await launch('r_ocfree')
206
+ assert.equal(free.xdg,undefined)
207
+ assert.equal(free.telegram,undefined)
208
+ } finally {
209
+ if(prior.path===undefined)delete process.env.PATH;else process.env.PATH=prior.path
210
+ if(prior.telegram===undefined)delete process.env.TELEGRAM_BOT_TOKEN;else process.env.TELEGRAM_BOT_TOKEN=prior.telegram
211
+ if(prior.transport===undefined)delete process.env.EZ_EXECUTOR_TRANSPORT;else process.env.EZ_EXECUTOR_TRANSPORT=prior.transport
212
+ }
213
+ })
214
+
215
+ test('pi jobs use the agent-bound config dir only when its auth binding exists', async t => {
216
+ const root=await mkdtemp(path.join(tmpdir(),'ez-pi-auth-'));t.after(()=>rm(root,{recursive:true,force:true}))
217
+ const bin=path.join(root,'bin');await mkdir(bin)
218
+ await writeFile(path.join(bin,'pi'),`#!${process.execPath}\nconst fs=require('fs');fs.writeFileSync(${JSON.stringify(path.join(root,'observed.json'))},JSON.stringify({args:process.argv.slice(2),agentDir:process.env.PI_CODING_AGENT_DIR,telegram:process.env.TELEGRAM_BOT_TOKEN}));`,{mode:0o755})
219
+ assert.equal(piAgentDir(root),undefined)
220
+ await mkdir(path.join(root,'cli','pi','agent'),{recursive:true})
221
+ assert.equal(piAgentDir(root),undefined)
222
+ await writeFile(path.join(root,'cli','pi','agent','auth.json'),'{}',{mode:0o600})
223
+ assert.equal(piAgentDir(root),path.join(root,'cli','pi','agent'))
224
+ const prior={path:process.env.PATH,telegram:process.env.TELEGRAM_BOT_TOKEN,transport:process.env.EZ_EXECUTOR_TRANSPORT,agentDir:process.env.PI_CODING_AGENT_DIR}
225
+ const launch=async(runId:string)=>{
226
+ await ownerRun(root,runId)
227
+ process.env.PATH=bin+path.delimiter+prior.path;process.env.TELEGRAM_BOT_TOKEN='relay-secret';delete process.env.EZ_EXECUTOR_TRANSPORT;delete process.env.PI_CODING_AGENT_DIR
228
+ const job=await startExecutorJob(['hello'],{workspace:root,controlDir:root,binDir:bin,runId,timeoutMs:0,cli:'pi',model:'opencode-go/muse-spark-1.3-contributor',effort:'xhigh'})
229
+ assert.equal(await new Promise(resolve=>job.child.once('close',resolve)),0);await job.cleanup()
230
+ return JSON.parse(await readFile(path.join(root,'observed.json'),'utf8'))
231
+ }
232
+ try {
233
+ const bound=await launch('r_pibound')
234
+ assert.equal(bound.agentDir,path.join(root,'cli','pi','agent'))
235
+ assert.equal(bound.telegram,undefined)
236
+ assert.equal(bound.args[bound.args.indexOf('--model')+1],'opencode-go/muse-spark-1.3-contributor')
237
+ assert.equal(bound.args[bound.args.indexOf('--thinking')+1],'xhigh')
238
+ await rm(path.join(root,'cli','pi','agent','auth.json'))
239
+ const free=await launch('r_pifree')
240
+ assert.equal(free.agentDir,undefined)
241
+ assert.equal(free.telegram,undefined)
242
+ // Owner-provisioned delivery skill loads by path; absence keeps prior args.
243
+ assert.ok(!bound.args.includes('--skill'))
244
+ await mkdir(path.join(root,'cli','pi','skills','ez-delivery'),{recursive:true})
245
+ await writeFile(path.join(root,'cli','pi','skills','ez-delivery','SKILL.md'),'# delivery',{mode:0o644})
246
+ const skilled=await launch('r_piskilled')
247
+ assert.equal(skilled.args[skilled.args.indexOf('--skill')+1],path.join(root,'cli','pi','skills','ez-delivery'))
248
+ } finally {
249
+ if(prior.path===undefined)delete process.env.PATH;else process.env.PATH=prior.path
250
+ if(prior.telegram===undefined)delete process.env.TELEGRAM_BOT_TOKEN;else process.env.TELEGRAM_BOT_TOKEN=prior.telegram
251
+ if(prior.transport===undefined)delete process.env.EZ_EXECUTOR_TRANSPORT;else process.env.EZ_EXECUTOR_TRANSPORT=prior.transport
252
+ if(prior.agentDir===undefined)delete process.env.PI_CODING_AGENT_DIR;else process.env.PI_CODING_AGENT_DIR=prior.agentDir
253
+ }
254
+ })
255
+
256
+ test('the pi invocation pins control sessions, model, and thinking level', () => {
257
+ assert.equal(resolveExecutor('pi').command, 'pi')
258
+ const args = EXECUTOR_REGISTRY.pi.buildArgs(
259
+ { workspace: '/agent/mind', controlDir: '/agent/control', sessionId: 'ez-session', isResume: true, model: 'opencode-go/muse-spark-1.3-contributor', effort: 'xhigh' },
260
+ '/prompt', 'review CAMT')
261
+ assert.deepEqual(args, ['-p', '--mode', 'json', '--session-dir', '/agent/control/cli/pi/sessions', '--session-id', 'ez-session',
262
+ '--model', 'opencode-go/muse-spark-1.3-contributor', '--thinking', 'xhigh', '--', 'review CAMT'])
263
+ const other = EXECUTOR_REGISTRY.pi.buildArgs({ workspace: '/agent/mind', controlDir: '/agent/control', sessionId: 'other-session', isResume: true }, '/prompt', 'other')
264
+ assert.equal(other[other.indexOf('--session-id') + 1], 'other-session')
265
+ assert.ok(!other.includes('--continue'))
266
+ assert.throws(() => EXECUTOR_REGISTRY.pi.buildArgs({ workspace: '/agent' }, '/prompt', 'hi'), /bound control directory/)
267
+ })
268
+
269
+ test('pi jobs run with control-bound sessions and no secret leakage', async t => {
270
+ const root = await mkdtemp(path.join(tmpdir(), 'ez-pi-auth-')); t.after(() => rm(root, { recursive: true, force: true }))
271
+ const bin = path.join(root, 'bin'); await mkdir(bin)
272
+ await writeFile(path.join(bin, 'pi'), `#!${process.execPath}\nconst fs=require('fs');fs.writeFileSync(${JSON.stringify(path.join(root, 'observed.json'))},JSON.stringify({args:process.argv.slice(2),telegram:process.env.TELEGRAM_BOT_TOKEN}));`, { mode: 0o755 })
273
+ const prior = { path: process.env.PATH, telegram: process.env.TELEGRAM_BOT_TOKEN, transport: process.env.EZ_EXECUTOR_TRANSPORT }
274
+ try {
275
+ await ownerRun(root, 'r_pibound')
276
+ process.env.PATH = bin + path.delimiter + prior.path; process.env.TELEGRAM_BOT_TOKEN = 'relay-secret'; delete process.env.EZ_EXECUTOR_TRANSPORT
277
+ const job = await startExecutorJob(['review CAMT'], { workspace: root, controlDir: root, binDir: bin, runId: 'r_pibound', timeoutMs: 0, cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', effort: 'xhigh' })
278
+ assert.equal(await new Promise(resolve => job.child.once('close', resolve)), 0); await job.cleanup()
279
+ const observed = JSON.parse(await readFile(path.join(root, 'observed.json'), 'utf8'))
280
+ assert.deepEqual(observed.args, ['-p', '--mode', 'json', '--session-dir', path.join(root, 'cli', 'pi', 'sessions'),
281
+ '--model', 'opencode-go/muse-spark-1.3-contributor', '--thinking', 'xhigh', '--', 'review CAMT'])
282
+ assert.equal(observed.telegram, undefined)
283
+ } finally {
284
+ if (prior.path === undefined) delete process.env.PATH; else process.env.PATH = prior.path
285
+ if (prior.telegram === undefined) delete process.env.TELEGRAM_BOT_TOKEN; else process.env.TELEGRAM_BOT_TOKEN = prior.telegram
286
+ if (prior.transport === undefined) delete process.env.EZ_EXECUTOR_TRANSPORT; else process.env.EZ_EXECUTOR_TRANSPORT = prior.transport
287
+ }
288
+ })
179
289
  test('a configured Codex provider receives only its declared key and runtime selection', async t => {
180
290
  const root=await mkdtemp(path.join(tmpdir(),'ez-codex-provider-'));t.after(()=>rm(root,{recursive:true,force:true}))
181
291
  const bin=path.join(root,'bin');await mkdir(bin)
@@ -230,21 +340,19 @@ test('external Codex isolation cannot launch host runs', async t => {
230
340
  const root = await mkdtemp(path.join(tmpdir(), 'ez-external-sandbox-'))
231
341
  t.after(() => rm(root,{recursive:true,force:true}))
232
342
  await ownerRun(root,'r_sandbox')
233
- const previous = {transport:process.env.EZ_EXECUTOR_TRANSPORT,telegram:process.env.EZ_TELEGRAM_ENABLED}
343
+ const previous = process.env.EZ_EXECUTOR_TRANSPORT
234
344
  try {
235
- process.env.EZ_TELEGRAM_ENABLED='false'
236
345
  for (const transport of ['host','']) {
237
346
  process.env.EZ_EXECUTOR_TRANSPORT=transport
238
347
  await assert.rejects(startExecutorJob(['Hello'],{workspace:root,controlDir:root,binDir:root,runId:'r_sandbox',timeoutMs:0,cli:'codex',codexSandbox:'external'}), /owner-authorized native local/)
239
348
  }
240
349
  } finally {
241
- for (const [key,value] of [['EZ_EXECUTOR_TRANSPORT',previous.transport],['EZ_TELEGRAM_ENABLED',previous.telegram]]) {
242
- if (value === undefined) delete process.env[key!]; else process.env[key!]=value
243
- }
350
+ if (previous === undefined) delete process.env.EZ_EXECUTOR_TRANSPORT
351
+ else process.env.EZ_EXECUTOR_TRANSPORT=previous
244
352
  }
245
353
  })
246
354
 
247
- test('external local owner chat preserves authorization and literal input', async t => {
355
+ test('external local owner chat preserves literal input and admission rejects foreign or restricted runs', async t => {
248
356
  const root=await mkdtemp(path.join(tmpdir(),'ez-external-owner-'))
249
357
  t.after(()=>rm(root,{recursive:true,force:true}))
250
358
  const prior={path:process.env.PATH,transport:process.env.EZ_EXECUTOR_TRANSPORT}
@@ -264,10 +372,10 @@ test('external local owner chat preserves authorization and literal input', asyn
264
372
  const runs=new RunStore(root)
265
373
  await runs.create({id:'r_foreign',chatId:999,telegramUserId:999,texts:['no']})
266
374
  await runs.patch('r_foreign',{status:'running'})
267
- await assert.rejects(startExecutorJob(['no'],{...opts,runId:'r_foreign'}),/blocked|owner/i)
375
+ await assert.rejects(requireOwnerExecution(root,'r_foreign'),/blocked: owner-mismatch/i)
268
376
  await runs.create({id:'r_restricted',chatId:101,telegramUserId:101,texts:['no'],taskId:'task_'+'a'.repeat(32)})
269
377
  await runs.patch('r_restricted',{status:'running'})
270
- await assert.rejects(startExecutorJob(['no'],{...opts,runId:'r_restricted'}),/owner-authorized native local/)
378
+ await assert.rejects(requireOwnerExecution(root,'r_restricted'),/external-execution-unavailable/)
271
379
  } finally {
272
380
  if(prior.path===undefined)delete process.env.PATH;else process.env.PATH=prior.path
273
381
  if(prior.transport===undefined)delete process.env.EZ_EXECUTOR_TRANSPORT;else process.env.EZ_EXECUTOR_TRANSPORT=prior.transport
@@ -1,6 +1,6 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'
3
+ import { mkdtemp, mkdir, readFile, writeFile, rm, stat } from 'node:fs/promises'
4
4
  import { join } from 'node:path'
5
5
  import { tmpdir } from 'node:os'
6
6
  import { execFile, spawn } from 'node:child_process'
@@ -8,11 +8,12 @@ import { promisify } from 'node:util'
8
8
  import { fileURLToPath } from 'node:url'
9
9
  import { once } from 'node:events'
10
10
  import { redactFailure, failureStamp, needsFailureReview } from '../src/failure.js'
11
- import { RunStore } from '../src/runs.js'
11
+ import { RunStore, sentOutbox } from '../src/runs.js'
12
12
  import { ControlStore } from '../src/control-state.js'
13
13
  import { Scheduler } from '../src/scheduler.js'
14
14
  import { initialPreset } from '../src/ai.js'
15
15
  import { createRelay } from '../src/index.js'
16
+ import { serveTestLedger } from './helpers/ledger.js'
16
17
  import { TelegramSource } from '../src/telegram-source.js'
17
18
  import { packageVersion } from '../src/version.js'
18
19
  import { initializeWorkspace } from '../src/workspace.js'
@@ -33,6 +34,8 @@ test('failure evidence is bounded and redacts configured credentials, headers, t
33
34
 
34
35
  test('failure review CLI is owner-bound, rejects stale reviews, and keeps failure evidence immutable',async t=>{
35
36
  const dir=await mkdtemp(join(tmpdir(),'ez-failure-cli-'));t.after(()=>rm(dir,{recursive:true,force:true}))
37
+ // The schedule CLI child reaches the relay memory ledger through the socket.
38
+ const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
36
39
  const runs=new RunStore(dir),control=new ControlStore(dir,1000)
37
40
  const env={...process.env,EZ_CONTROL_DIR:dir,EZ_EXECUTOR_CLI:'grok',EZ_RUN_ID:''}
38
41
  const cli=(args:string[],overrides={})=>exec(process.execPath,[bin,...args],{env:{...env,...overrides}})
@@ -61,7 +64,7 @@ test('failure review CLI is owner-bound, rejects stale reviews, and keeps failur
61
64
  await runs.patch(id,{status:'running',...('replyOnly' in extra?{replyOnly:true}:{})})
62
65
  await assert.rejects(cli(review,{EZ_RUN_ID:id}),/owner-authorized/)
63
66
  }
64
- await writeFile(join(dir,'runs','r_bad.json'),'{broken')
67
+ await assert.rejects(stat(join(dir,'runs')),{code:'ENOENT'})
65
68
  assert.equal(JSON.parse((await cli(['failures'])).stdout).total,1)
66
69
  })
67
70
 
@@ -86,6 +89,8 @@ test('failure capture, diagnosis, verified recovery and conditional quiet next t
86
89
  })
87
90
  relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
88
91
  relay.bot.api.config.use(async(_p,method,payload)=>{if(method==='sendMessage')replies.push((payload as any).text);return {ok:true,result:{message_id:replies.length}} as any})
92
+ // The schedule CLI children reach the relay memory ledger through the socket.
93
+ const ledger=await serveTestLedger(dir)
89
94
  const originalPatch=RunStore.prototype.patch
90
95
  t.mock.method(RunStore.prototype,'patch',async function(this:RunStore,...args:Parameters<RunStore['patch']>){
91
96
  // Hold the PID write until the fast child has closed, reproducing slow disk
@@ -114,7 +119,7 @@ test('failure capture, diagnosis, verified recovery and conditional quiet next t
114
119
  await relay.bot.handleUpdate({...update,update_id:12,message:{...update.message!,message_id:12,text:'/stop'}})
115
120
  await until(async()=>(await runs.get('tg_11'))?.status==='cancelled')
116
121
  assert.equal(needsFailureReview((await runs.get('tg_11'))!),false)
117
- }finally{await relay.stop();for(const child of children)child.kill();await rm(dir,{recursive:true,force:true})}
122
+ }finally{await relay.stop();for(const child of children)child.kill();await ledger.stop();await rm(dir,{recursive:true,force:true})}
118
123
  })
119
124
 
120
125
 
@@ -220,7 +225,7 @@ for (const cleanupFails of [false,true]) test(`polling conflict preserves work u
220
225
  await stopped
221
226
  assert.equal(relay.stop(),stopping,'finished shutdown remains idempotent')
222
227
  assert.equal(sourceStops,1)
223
- assert.deepEqual(JSON.parse(await readFile(join(dir,'outbox',`${item.id}.sent.json`),'utf8')).receipt.messageIds,[1])
228
+ assert.deepEqual((sentOutbox(dir).find(entry=>entry.id===item.id)?.receipt as {messageIds:number[]}).messageIds,[1])
224
229
  assert.equal(polls,1,'a conflict must not start another polling loop before the retry delay')
225
230
  assert.equal(relay.bot.isRunning(),false)
226
231
  assert.ok(child && (child.exitCode!==null || child.signalCode!==null))
@@ -268,6 +273,7 @@ for (const intake of [true,false]) test(`relay shutdown terminates an in-flight
268
273
 
269
274
  test('group members can inspect failures and wake review without exposing other chats', async t => {
270
275
  const dir=await mkdtemp(join(tmpdir(),'ez-group-failure-'));t.after(()=>rm(dir,{recursive:true,force:true}))
276
+ const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
271
277
  const runs=new RunStore(dir),control=new ControlStore(dir,900000),scheduler=new Scheduler(dir)
272
278
  await control.requestPairing(101,-123,'Fixture');const owner=await control.approveOwner(-123,true)
273
279
  const execution=await control.captureChoice(initialPreset('grok'))
@@ -334,6 +340,7 @@ test('shutdown aborts a pending bot initialization without starting polling',asy
334
340
 
335
341
  test('failed reviewer readback exposes its stop through show, list and resume until explicit edit',async t=>{
336
342
  const dir=await mkdtemp(join(tmpdir(),'ez-review-stop-'));t.after(()=>rm(dir,{recursive:true,force:true}))
343
+ const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
337
344
  const control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
338
345
  await control.requestPairing(101,101);const owner=await control.approveOwner(101)
339
346
  const execution=await control.captureChoice(initialPreset('grok')),now=Date.now()+1000
@@ -35,13 +35,13 @@ test('sanitizes dangerous file names', () => {
35
35
  assert.equal(sanitizeFileName('report 2026!@#.pdf'), 'report_2026___.pdf')
36
36
  })
37
37
 
38
- test('stages file into workspace inbox cleanly', async () => {
38
+ test('stages file into control attachments, never the workspace', async () => {
39
39
  const tmp = await mkdtemp(path.join(tmpdir(), 'ez-files-'))
40
40
  try {
41
41
  const data = Buffer.from('%PDF-1.4 sample content')
42
42
  const staged = await stageIncomingFile(tmp, 'sample.pdf', data)
43
43
  assert.equal(staged.fileType, 'pdf')
44
- assert.match(staged.relativePath, /^inbox[/\\][a-f0-9-]+_sample\.pdf$/)
44
+ assert.match(staged.relativePath, /^attachments[/\\][a-f0-9-]+_sample\.pdf$/)
45
45
  const read = await readFile(staged.fullPath)
46
46
  assert.deepEqual(read, data)
47
47
  } finally {
@@ -0,0 +1,18 @@
1
+ import { createLedgerHandler, serveDeliverySocket } from '../../src/delivery-socket.js'
2
+
3
+ // Serves the relay memory ledger for child-process CLIs (message, react,
4
+ // approval, schedule) in tests that do not start a full relay. Handlers run
5
+ // in this process, so test-created runs are visible to CLI children through
6
+ // the shared-volume socket. Fixtures that start a relay must NOT also serve:
7
+ // the relay serves the same path.
8
+ export const serveTestLedger = async (
9
+ controlDir: string,
10
+ status: () => { polling: boolean; applicationOnly: boolean; telegramConfigured: boolean; version: string } =
11
+ () => ({ polling: false, applicationOnly: false, telegramConfigured: true, version: 'test' }),
12
+ ) => {
13
+ const server = await serveDeliverySocket(
14
+ controlDir,
15
+ createLedgerHandler(controlDir, { wake: () => {}, status }),
16
+ )
17
+ return server
18
+ }
@@ -22,7 +22,7 @@ test('host restart replaces a lock whose PID was reused by another process',asyn
22
22
  await mkdir(workspace);await mkdir(directory,{recursive:true})
23
23
  await writeFile(path.join(directory,'worker.lock'),JSON.stringify({pid:process.pid,started:'reused-pid'}))
24
24
  server=serveHostExecutor({cli:'grok',agents:[{name:'test',workspace,controlDir,binDir:root}]},abort.signal)
25
- for(let n=0;n<100;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
25
+ for(let n=0;n<300;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
26
26
  await readFile(path.join(directory,'heartbeat.json'))
27
27
  const lock=JSON.parse(await readFile(path.join(directory,'worker.lock'),'utf8'))
28
28
  assert.equal(lock.pid,process.pid)
@@ -47,6 +47,16 @@ test('isolated installations cannot start host transport', async () => {
47
47
  abort.abort()
48
48
  })
49
49
 
50
+ test('host bindings reject invalid opencode provider scopes before touching state', async () => {
51
+ const abort=new AbortController()
52
+ for (const opencodeProviders of [['BAD NAME'], [], ['ok', 'ok'], new Array(17).fill('ok')]) {
53
+ await assert.rejects(serveHostExecutor({cli:'grok',agents:[{
54
+ name:'t',workspace:'/tmp/ez-scope-x',controlDir:'/tmp/ez-scope-y',binDir:'/tmp/ez-scope-z',opencodeProviders} as never]},abort.signal),
55
+ /one to sixteen unique provider IDs/)
56
+ }
57
+ abort.abort()
58
+ })
59
+
50
60
  test('host restart clears dead native lease only after proving previous CLI stopped',async()=>{
51
61
  const root=await mkdtemp(path.join(tmpdir(),'ez-native-recovery-'));
52
62
  const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),toolsHome=path.join(root,'tools'),directory=path.join(controlDir,'host-executor');
@@ -63,12 +73,86 @@ test('host restart clears dead native lease only after proving previous CLI stop
63
73
  await readFile(path.join(toolsHome,'workspace-writer.lock'));
64
74
  await writeFile(path.join(directory,'r_old.process.json'),JSON.stringify({pid:deadPid}));
65
75
  server=serveHostExecutor(installation,abort.signal);
66
- for(let n=0;n<100;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
76
+ for(let n=0;n<300;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
67
77
  await readFile(path.join(directory,'heartbeat.json'));
68
78
  await assert.rejects(readFile(path.join(toolsHome,'workspace-writer.lock')),{code:'ENOENT'});
69
79
  }finally{abort.abort();await server;await rm(root,{recursive:true,force:true});}
70
80
  });
71
81
 
82
+ test('host execution resolves a declared provider from the bound agent, not a launcher wrapper', async () => {
83
+ const root = await mkdtemp(path.join(tmpdir(), 'ez-host-provider-'))
84
+ const workspace = path.join(root, 'mind')
85
+ const controlDir = path.join(root, 'control')
86
+ const directory = path.join(controlDir, 'host-executor')
87
+ const provider = {
88
+ id: 'openrouter', name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1',
89
+ envKey: 'OPENROUTER_API_KEY', models: ['deepseek/deepseek-v4.1-flash'],
90
+ }
91
+ const installation = {
92
+ cli: 'codex' as const,
93
+ agents: [{name: 'test', workspace, controlDir, binDir: path.join(root, 'bin'), codexProviders: [provider]}],
94
+ }
95
+ const abort = new AbortController()
96
+ let server: Promise<void> | undefined
97
+ let captured: any
98
+ const previousPath = process.env.PATH
99
+ try {
100
+ await mkdir(workspace, {recursive: true})
101
+ await mkdir(directory, {recursive: true})
102
+ const fakeBin = path.join(root, 'bin')
103
+ await mkdir(fakeBin, {recursive: true})
104
+ await mkdir(path.join(controlDir, 'cli', 'codex'), {recursive: true})
105
+ await writeFile(path.join(fakeBin, 'codex'), '#!/bin/sh\nexit 0\n', {mode: 0o700})
106
+ await writeFile(path.join(controlDir, 'cli', 'codex', 'models_cache.json'), JSON.stringify({models: [
107
+ {slug: provider.models[0], visibility: 'list', display_name: 'DeepSeek V4.1 Flash', supported_reasoning_levels: [{effort: 'max'}]},
108
+ ]}))
109
+ process.env.PATH = `${fakeBin}${path.delimiter}${previousPath ?? ''}`
110
+ await ownerRun(controlDir, 'r_provider')
111
+ server = serveHostExecutor(installation, abort.signal, async (_texts, options) => {
112
+ captured = options
113
+ const child = spawn(process.execPath, ['-e', 'process.exit(0)'])
114
+ return {child, cleanup: async () => {}, stdout: ''}
115
+ })
116
+ for (let n = 0; n < 100; n++) {
117
+ try { await readFile(path.join(directory, 'heartbeat.json')); break }
118
+ catch { await new Promise(resolve => setTimeout(resolve, 20)) }
119
+ }
120
+ await writeFile(path.join(directory, 'r_provider.request.json'), JSON.stringify({
121
+ texts: ['hello'], options: {cli: 'codex', provider: 'openrouter', model: provider.models[0], effort: 'max'},
122
+ }))
123
+ let events = ''
124
+ for (let n = 0; n < 100; n++) {
125
+ try {
126
+ events = await readFile(path.join(directory, 'r_provider.events'), 'utf8')
127
+ if (events.includes('"stream":"exit"')) break
128
+ } catch {}
129
+ await new Promise(resolve => setTimeout(resolve, 20))
130
+ }
131
+ assert.match(events, /"stream":"exit","code":0/)
132
+ const catalog = JSON.parse(await readFile(path.join(directory, 'models.json'), 'utf8'))
133
+ assert.deepEqual(catalog.filter((model: any) => model.model === provider.models[0]), [
134
+ {
135
+ cli: 'codex-gui', model: provider.models[0],
136
+ name: 'codex-gui · DeepSeek V4.1 Flash', efforts: ['max'],
137
+ },
138
+ {
139
+ cli: 'codex', provider: 'openrouter', model: provider.models[0],
140
+ name: `OpenRouter · ${provider.models[0]}`, efforts: ['max'],
141
+ },
142
+ ])
143
+ assert.deepEqual(catalog.filter((model: any) => model.cli === 'codex' && !model.provider), [])
144
+ assert.equal(captured.provider, 'openrouter')
145
+ assert.deepEqual(captured.codexProvider, provider)
146
+ assert.equal(captured.model, provider.models[0])
147
+ } finally {
148
+ abort.abort()
149
+ await server
150
+ if (previousPath === undefined) delete process.env.PATH
151
+ else process.env.PATH = previousPath
152
+ await rm(root, {recursive: true, force: true})
153
+ }
154
+ })
155
+
72
156
  test('one installed CLI executes two agent bindings with separate minds and sanitized environment', async () => {
73
157
  const root=await mkdtemp(path.join(tmpdir(),'ez-host-'))
74
158
  const abort=new AbortController()
@@ -99,7 +183,7 @@ test('one installed CLI executes two agent bindings with separate minds and sani
99
183
  server=serveHostExecutor({cli:'grok',agents},abort.signal)
100
184
  for(const agent of agents){
101
185
  const dir=path.join(agent.controlDir,'host-executor')
102
- for(let n=0;n<100;n++){try{await readFile(path.join(dir,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
186
+ for(let n=0;n<300;n++){try{await readFile(path.join(dir,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
103
187
  assert.equal(JSON.parse(await readFile(path.join(dir,'heartbeat.json'),'utf8')).version,packageVersion)
104
188
  const release = await workspaceLease(agent.toolsHome)
105
189
  await ownerRun(agent.controlDir, `r_${agent.name}`)
@@ -8,12 +8,11 @@ import { InboxStore } from '../src/inbox.js'
8
8
  import { RunStore } from '../src/runs.js'
9
9
 
10
10
  const update = (id: number): Update => ({ update_id: id })
11
- test('intake survives restart, deduplicates IDs, seals membership and retains cancellation tombstones', async () => {
11
+ test('intake deduplicates IDs, seals membership and retains cancellation tombstones (memory only)', async () => {
12
12
  const dir = await mkdtemp(join(tmpdir(), 'ez-inbox-'))
13
13
  try {
14
- let inbox = new InboxStore(dir)
14
+ const inbox = new InboxStore(dir)
15
15
  assert.equal(await inbox.accept(update(1)), true)
16
- inbox = new InboxStore(dir)
17
16
  assert.equal(await inbox.accept(update(1)), false)
18
17
  await inbox.accept(update(2))
19
18
  assert.equal(await inbox.next(), undefined)
@@ -23,12 +22,11 @@ test('intake survives restart, deduplicates IDs, seals membership and retains ca
23
22
  [1, 2],
24
23
  )
25
24
  await inbox.accept(update(3))
26
- inbox = new InboxStore(dir)
27
25
  assert.deepEqual(await inbox.next(true), batch)
28
26
  const runs = new RunStore(dir)
29
27
  const input = { id: batch.id, chatId: 101, telegramUserId: 101, texts: ['hello'] }
30
28
  await runs.create(input)
31
- // Crash between run creation and inbox completion must not produce a second run.
29
+ // Retry with the same run ID returns the existing run; no second run.
32
30
  await runs.create(input)
33
31
  assert.equal((await runs.list()).length, 1)
34
32
  await inbox.finish(batch.id)
@@ -36,19 +34,19 @@ test('intake survives restart, deduplicates IDs, seals membership and retains ca
36
34
  assert.equal(await inbox.cancel(), 1)
37
35
  assert.equal(await inbox.accept(update(3)), false)
38
36
  assert.equal(await inbox.next(true), undefined)
39
- assert.equal((await stat(join(dir, 'inbox.json'))).mode & 0o777, 0o600)
37
+ // Stateless pipe: no inbox.json exists on disk; restart drops by design.
38
+ await assert.rejects(stat(join(dir, 'inbox.json')))
40
39
  } finally {
41
40
  await rm(dir, { recursive: true, force: true })
42
41
  }
43
42
  })
44
43
 
45
- test('corrupt intake fails closed without overwriting evidence; invalid identifiers are rejected', async () => {
44
+ test('restart loses nothing by design; invalid identifiers are rejected', async () => {
46
45
  const dir = await mkdtemp(join(tmpdir(), 'ez-inbox-corrupt-'))
47
46
  try {
48
- await writeFile(join(dir, 'inbox.json'), '{broken')
47
+ // A fresh directory starts empty: no durable journal survives a restart.
49
48
  const inbox = new InboxStore(dir)
50
- await assert.rejects(inbox.accept(update(4)), /Inbox unreadable/)
51
- assert.equal(await readFile(join(dir, 'inbox.json'), 'utf8'), '{broken')
49
+ assert.deepEqual(await inbox.status(), { pending: 0, failed: 0 })
52
50
  assert.throws(() => inbox.accept(update(-1)), /Invalid update ID/)
53
51
  await assert.rejects(
54
52
  new RunStore(dir).create({ id: '../escape', chatId: 101, telegramUserId: 101, texts: [] }),
@@ -81,6 +81,7 @@ test('Docker startup uses explicit deployment binding and literal paths without
81
81
  assert.equal(definition.command, 'docker')
82
82
  assert.ok(definition.args.includes('/tmp/my agent $literal/docker.env'))
83
83
  assert.deepEqual(definition.args.slice(-4), ['up', '-d', '--wait', 'relay'])
84
+ assert.deepEqual(serviceDefinition('/tmp/isolated', true).args.slice(-5), ['up', '-d', '--wait', 'relay', 'plugin-broker'])
84
85
  assert.doesNotMatch(JSON.stringify(definition), /TELEGRAM_BOT_TOKEN|launchctl|systemctl/)
85
86
  assert.throws(() => serviceDefinition('/tmp/\nbad'), /single-line/)
86
87
  })
@@ -3,7 +3,10 @@ import assert from 'node:assert/strict';
3
3
  import * as fs from 'node:fs/promises';
4
4
  import {tmpdir} from 'node:os';
5
5
  import path from 'node:path';
6
- import {preflight,installationStatus,build,hostEnvironment} from '../src/install-tools.mjs';
6
+ import {preflight,installationStatus,build,hostEnvironment,migrateLedger} from '../src/install-tools.mjs';
7
+ import {parseEnv} from 'node:util';
8
+ import {RunStore} from '../src/runs.js';
9
+ import {serveTestLedger} from './helpers/ledger.js';
7
10
 
8
11
  async function fixture(t){const root=await fs.mkdtemp(path.join(tmpdir(),'ez-install-tools-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));return root;}
9
12
  test('preflight checks the selected executable and reports missing prerequisites without credentials',async t=>{
@@ -19,10 +22,12 @@ test('preflight checks the selected executable and reports missing prerequisites
19
22
  test('installation status separates files, pairing and current-owner Telegram delivery evidence',async t=>{
20
23
  const root=await fixture(t),control=path.join(root,'control');
21
24
  assert.equal((await installationStatus(root)).stage,'not-configured');
22
- for(const dir of ['host-executor','outbox','runs'])await fs.mkdir(path.join(control,dir),{recursive:true});
25
+ await fs.mkdir(path.join(control,'host-executor'),{recursive:true});
23
26
  for(const name of ['agent.json','host-executor.json','docker.env','relay.env'])await fs.writeFile(path.join(root,name),'private');
24
27
  const write=(name,obj)=>fs.writeFile(path.join(control,name),JSON.stringify(obj));
25
28
  await write('heartbeat.json',{at:Date.now(),polling:true});await write('host-executor/heartbeat.json',{at:Date.now()});
29
+ // Delivery evidence comes from the running relay's memory ledger.
30
+ const ledger=await serveTestLedger(control,()=>({polling:true,applicationOnly:false,telegramConfigured:true,version:'test'}));t.after(()=>ledger.stop());
26
31
  assert.equal((await installationStatus(root)).stage,'awaiting-owner');
27
32
  await fs.writeFile(path.join(root,'agent.json'),JSON.stringify({isolation:'isolated'}));
28
33
  await fs.rm(path.join(control,'host-executor/heartbeat.json'));
@@ -32,15 +37,46 @@ test('installation status separates files, pairing and current-owner Telegram de
32
37
  assert.equal((await installationStatus(root)).stage,'awaiting-owner');
33
38
  const owner={telegramUserId:123,telegramChatId:123,pairedAt:new Date(Date.now()-10000).toISOString()};await write('control-state.json',{owner});
34
39
  assert.equal((await installationStatus(root)).stage,'awaiting-telegram-reply');
35
- const item={runId:'tg_12',chatId:123,receipt:{messageIds:[42],deliveredAt:new Date().toISOString()}};
36
- await write('outbox/test.sent.json',item);await write('runs/tg_12.json',{status:'completed',chatId:123,telegramUserId:999});
40
+ const runs=new RunStore(control),deliver=async(id,text,chatId,telegramUserId,ids,status='completed')=>{
41
+ const run=await runs.create({id,chatId,telegramUserId,texts:[text]});
42
+ await runs.patch(run.id,{status:'running'});
43
+ const item=await runs.enqueueMessage(run.id,text);
44
+ await runs.claimOutbox(item.id);await runs.markOutboxSent(item.id,ids);
45
+ await runs.patch(run.id,{status});
46
+ };
47
+ // Another chat member's completed delivery is not the owner's reply evidence.
48
+ await deliver('tg_13','other member',123,999,[42]);
37
49
  assert.equal((await installationStatus(root)).telegramReplyVerified,false);
38
- await write('runs/tg_12.json',{status:'completed',chatId:123,telegramUserId:123});
50
+ // A failed run never counts, even with a receipt.
51
+ await deliver('tg_14','failed',123,123,[43],'failed');
52
+ assert.equal((await installationStatus(root)).telegramReplyVerified,false);
53
+ await deliver('tg_12','hello',123,123,[42]);
39
54
  assert.equal((await installationStatus(root)).stage,'ready-for-telegram-plugin-request');
40
- await write('heartbeat.json',{at:0,polling:true});assert.equal((await installationStatus(root)).stage,'runtime-offline');
41
- await write('outbox/test.sent.json',{...item,runId:'../../escape'});assert.equal((await installationStatus(root)).telegramReplyVerified,false);
55
+ // With the relay down, status falls back to the legacy heartbeat file.
56
+ await ledger.stop();await write('heartbeat.json',{at:0,polling:true});assert.equal((await installationStatus(root)).stage,'runtime-offline');
42
57
  await fs.writeFile(path.join(control,'control-state.json'),'{');await assert.rejects(installationStatus(root));
43
58
  });
59
+ test('host-capable deployments migrate once to a loopback ledger endpoint',async t=>{
60
+ const root=await fixture(t),deployment=path.join(root,'agent');await fs.mkdir(deployment);
61
+ const envFile=path.join(deployment,'docker.env');
62
+ await fs.writeFile(envFile,"COMPOSE_PROJECT_NAME='ez-agent-canary'\nCOMPOSE_FILE='/opt/ez/compose.yaml:/opt/ez/extra.compose.yaml'\n",{mode:0o600});
63
+ const first=await migrateLedger({deployment});
64
+ assert.equal(first.changed,true);assert.ok(Number.isSafeInteger(first.port)&&first.port>=1024);
65
+ const values=parseEnv(await fs.readFile(envFile,'utf8'));
66
+ assert.equal(values.EZ_DELIVERY_TCP_PORT,String(first.port));
67
+ assert.equal(values.COMPOSE_FILE,`/opt/ez/compose.yaml:/opt/ez/extra.compose.yaml:${path.join(deployment,'ledger.compose.yaml')}`);
68
+ assert.match(await fs.readFile(path.join(deployment,'ledger.compose.yaml'),'utf8'),/127\.0\.0\.1:\$\{EZ_DELIVERY_TCP_PORT:\?\}/);
69
+ assert.equal((await fs.stat(envFile)).mode&0o777,0o600);
70
+ const second=await migrateLedger({deployment});
71
+ assert.equal(second.changed,false);assert.equal(second.port,first.port);
72
+ await assert.rejects(migrateLedger({deployment:path.join(root,'missing')}),/docker.env not found/);
73
+ });
74
+ test('isolated deployments need no host ledger endpoint',async t=>{
75
+ const root=await fixture(t),deployment=path.join(root,'isolated');await fs.mkdir(deployment);
76
+ await fs.writeFile(path.join(deployment,'docker.env'),"COMPOSE_PROJECT_NAME='ez-agent-isolated'\nCOMPOSE_FILE='/opt/ez/compose.yaml'\nEZ_ISOLATION='isolated'\nEZ_EXECUTOR_TRANSPORT='local'\n",{mode:0o600});
77
+ const result=await migrateLedger({deployment});
78
+ assert.equal(result.changed,false);assert.match(result.note,/Isolated/);
79
+ });
44
80
  test('same-artifact build retries do not spawn a second Docker build and reuse verified completed image',async t=>{
45
81
  const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
46
82
  let started,finish,count=0;const running=new Promise(r=>started=r),held=new Promise(r=>finish=r);
@@ -51,6 +87,37 @@ test('same-artifact build retries do not spawn a second Docker build and reuse v
51
87
  assert.equal((await build({home,source},invoke)).reused,true);assert.equal(count,1);
52
88
  await fs.writeFile(path.join(source,'package.json'),'{"changed":true}');await build({home,source},invoke);assert.equal(count,2);
53
89
  });
90
+ test('source checkouts build RCs only: clean reviewed commit plus increasing label',async t=>{
91
+ const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
92
+ let inside=true,status='',built=[],toplevel=source;const sha='a'.repeat(40);
93
+ const invoke=async(cmd,args)=>{
94
+ if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);
95
+ if(cmd==='git'){
96
+ if(!inside)throw Error('not a git repository');
97
+ if(args[2]==='rev-parse'&&args[3]==='--show-toplevel')return toplevel;
98
+ if(args[2]==='status')return status;
99
+ if(args[2]==='rev-parse'&&args[3]==='HEAD')return sha;
100
+ return '';
101
+ }
102
+ if(args[0]==='build'){built.push(args);return 'image';}
103
+ return 'sha256:fixture';
104
+ };
105
+ await assert.rejects(build({home,source},invoke),/requires --label/);
106
+ await assert.rejects(build({home,source,label:'0.1.0-beta.36'},invoke),/RC label/);
107
+ status=' M src/executor.ts';
108
+ await assert.rejects(build({home,source,label:'0.1.0-beta.36.rc.2'},invoke),/Commit the reviewed source/);
109
+ status='';
110
+ const result=await build({home,source,label:'0.1.0-beta.36.rc.2'},invoke);
111
+ assert.equal(result.label,'0.1.0-beta.36.rc.2');assert.equal(result.sha,sha);
112
+ const args=built.at(-1);
113
+ assert.ok(args.includes('BUILD_TAG=0.1.0-beta.36.rc.2'));assert.ok(args.includes(`BUILD_SHA=${sha}`));
114
+ assert.ok(args.includes('ezenciel-agents:0.1.0-beta.36.rc.2'));
115
+ inside=false;
116
+ await assert.rejects(build({home,source,label:'0.1.0-beta.36.rc.2'},invoke),/--label requires a git checkout/);
117
+ // A tarball unpacked inside an unrelated repository is not a source checkout.
118
+ inside=true;toplevel=home;
119
+ assert.equal((await build({home,source},invoke)).label,undefined);
120
+ });
54
121
  test('a failed build releases its own lock and leaves a failure receipt for diagnosis',async t=>{
55
122
  const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
56
123
  const invoke=async(cmd)=>{if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);throw Error('Synthetic build failed');};