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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +9 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +92 -0
  5. package/Dockerfile +11 -9
  6. package/README.md +32 -27
  7. package/THIRD_PARTY_NOTICES.md +4 -4
  8. package/bin/ez +97 -0
  9. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  10. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  11. package/bin/ezenciel-agents.mjs +1 -1
  12. package/compose.yaml +42 -5
  13. package/docker/entrypoint.sh +1 -1
  14. package/docker/healthcheck.mjs +12 -4
  15. package/docker/run.ts +5 -6
  16. package/docker/smoke.mjs +32 -7
  17. package/docs/application-channel.md +38 -10
  18. package/docs/architecture/ai-selection.md +3 -1
  19. package/docs/architecture/authority-boundaries.md +2 -2
  20. package/docs/channel-backend.md +4 -3
  21. package/docs/docker-runtime.md +106 -21
  22. package/docs/host-service.md +17 -6
  23. package/docs/local-qa.md +2 -2
  24. package/docs/plugin-guide.md +202 -0
  25. package/docs/plugins.md +63 -2
  26. package/docs/releasing.md +13 -0
  27. package/docs/repair.md +1 -1
  28. package/docs/responsive-channels.md +1 -1
  29. package/docs/scheduling.md +4 -4
  30. package/docs/setup.md +96 -16
  31. package/docs/standalone-cli.md +6 -5
  32. package/docs/upgrades.md +46 -23
  33. package/package.json +5 -5
  34. package/scripts/smoke-scheduler.ts +12 -4
  35. package/scripts/smoke.ts +14 -11
  36. package/src/agent-guidance.ts +6 -9
  37. package/src/agent-install.ts +44 -9
  38. package/src/ai-cli.ts +9 -7
  39. package/src/ai.ts +7 -7
  40. package/src/application-channel.ts +88 -17
  41. package/src/application-cli.ts +2 -2
  42. package/src/approval-cli.ts +14 -7
  43. package/src/approval.ts +27 -37
  44. package/src/channel-backend.ts +2 -3
  45. package/src/codex-session.ts +5 -3
  46. package/src/config.ts +18 -28
  47. package/src/control-state.ts +187 -9
  48. package/src/conversation-menu.ts +6 -17
  49. package/src/delivery-socket-client.d.mts +4 -0
  50. package/src/delivery-socket-client.mjs +104 -0
  51. package/src/delivery-socket.ts +354 -0
  52. package/src/execution-authority.ts +21 -5
  53. package/src/executor.ts +110 -31
  54. package/src/files.ts +38 -11
  55. package/src/host-executor.ts +50 -17
  56. package/src/inbox.ts +15 -49
  57. package/src/index.ts +96 -52
  58. package/src/install-config.ts +2 -2
  59. package/src/install-tools.mjs +100 -23
  60. package/src/isolation.ts +25 -0
  61. package/src/ledger-endpoint.d.mts +2 -0
  62. package/src/ledger-endpoint.mjs +17 -0
  63. package/src/menu.ts +16 -8
  64. package/src/message-history.ts +13 -18
  65. package/src/message.ts +35 -18
  66. package/src/plugin-broker.mjs +396 -0
  67. package/src/plugins/connection.mjs +2 -2
  68. package/src/plugins/manager.mjs +178 -26
  69. package/src/plugins/native-tasks.mjs +2 -2
  70. package/src/prompt-suffix.ts +12 -0
  71. package/src/react.ts +6 -4
  72. package/src/runs.ts +156 -243
  73. package/src/schedule-cli.ts +29 -10
  74. package/src/scheduled-tasks.ts +53 -10
  75. package/src/scheduler.ts +20 -7
  76. package/src/service.ts +3 -3
  77. package/src/setup.ts +11 -7
  78. package/src/software-status.ts +36 -4
  79. package/src/task-executor.ts +6 -3
  80. package/src/task-workspace.ts +4 -3
  81. package/src/tasks.ts +1 -1
  82. package/src/telegram-provisioning-cli.ts +22 -0
  83. package/src/telegram-provisioning.ts +214 -0
  84. package/src/updates/control.mjs +77 -7
  85. package/src/updates/runtime.mjs +29 -9
  86. package/src/updates/status.mjs +25 -3
  87. package/src/updates/supervisor.mjs +31 -18
  88. package/src/version.ts +20 -0
  89. package/src/workspace-confine.ts +38 -0
  90. package/src/workspace.ts +24 -20
  91. package/templates/agent-purpose.md +1 -0
  92. package/templates/failure-review.md +1 -1
  93. package/templates/maintainer-purpose.md +1 -1
  94. package/test/agent-guidance.test.ts +18 -88
  95. package/test/agent-install.test.ts +48 -0
  96. package/test/ai-cli.test.ts +18 -0
  97. package/test/ai.test.ts +84 -0
  98. package/test/application-channel.test.ts +66 -15
  99. package/test/application-controls.test.ts +56 -3
  100. package/test/application-only.test.ts +16 -13
  101. package/test/channel-backend.test.ts +3 -7
  102. package/test/channel-delivery.test.ts +4 -1
  103. package/test/channel-owner.test.ts +6 -3
  104. package/test/codex-context.test.ts +63 -3
  105. package/test/codex-session.test.ts +6 -1
  106. package/test/config.test.ts +47 -37
  107. package/test/control-state.test.ts +83 -4
  108. package/test/conversation-menu.test.ts +2 -4
  109. package/test/delivery-transport.test.ts +72 -0
  110. package/test/docker-runtime.test.ts +27 -14
  111. package/test/engine-handoff.test.ts +12 -8
  112. package/test/event-sources.test.ts +2 -2
  113. package/test/execution-authority.test.ts +9 -9
  114. package/test/executor.test.ts +47 -11
  115. package/test/failure.test.ts +18 -5
  116. package/test/files.test.ts +2 -2
  117. package/test/helpers/ledger.ts +18 -0
  118. package/test/host-executor.test.ts +80 -0
  119. package/test/inbox.test.ts +8 -10
  120. package/test/install-config.test.ts +18 -6
  121. package/test/install-tools.test.mjs +80 -7
  122. package/test/intake-relay.test.ts +58 -11
  123. package/test/message-history.test.ts +39 -47
  124. package/test/native-tasks.test.ts +1 -1
  125. package/test/plugin-broker.test.mjs +172 -0
  126. package/test/plugin-manager.test.mjs +143 -5
  127. package/test/reaction.test.ts +4 -0
  128. package/test/relay.test.ts +5 -7
  129. package/test/repair-policy.test.ts +1 -4
  130. package/test/schedule-cli.test.ts +14 -9
  131. package/test/scheduled-tasks.test.ts +29 -6
  132. package/test/scheduler-host.test.ts +5 -5
  133. package/test/scheduler-relay.test.ts +15 -9
  134. package/test/scheduler.test.ts +2 -2
  135. package/test/security.test.ts +6 -3
  136. package/test/software-status.test.ts +21 -3
  137. package/test/telegram-provisioning.test.ts +160 -0
  138. package/test/updates.test.mjs +102 -13
  139. package/test/workspace-confine.test.ts +47 -0
  140. package/test/workspace.test.ts +27 -11
  141. package/bin/ezenciel-agents-watch.mjs +0 -8
  142. package/compose.workforce-watch.yaml +0 -33
  143. package/docker/recovery.ts +0 -11
  144. package/docs/pagerduty.md +0 -42
  145. package/docs/workforce-watch.md +0 -101
  146. package/src/pagerduty.ts +0 -109
  147. package/src/reply-context.ts +0 -44
  148. package/src/update-attention.ts +0 -18
  149. package/src/workforce-watch-cli.ts +0 -14
  150. package/src/workforce-watch.ts +0 -155
  151. package/templates/agent/AGENTS.md +0 -15
  152. package/templates/agent/SOUL.md +0 -11
  153. package/templates/agent/USER.md +0 -5
  154. package/templates/agent-guidance.md +0 -114
  155. package/test/pagerduty.test.ts +0 -104
  156. package/test/update-attention.test.ts +0 -20
  157. package/test/workforce-watch.test.ts +0 -180
@@ -2,10 +2,9 @@ import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import { loadConfig } from '../src/config.js'
4
4
  import { executorJobEnv } from '../src/executor.js'
5
- import { agentGuidance } from '../src/agent-guidance.js'
6
5
 
7
6
  test('repair preference is bound in the environment, never rewritten into the request', () => {
8
- const config = (value?:string) => loadConfig({TELEGRAM_BOT_TOKEN:'fixture', ...(value===undefined ? {} : {EZ_REPAIR_ENABLED:value})})
7
+ const config = (value?:string) => loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY', ...(value===undefined ? {} : {EZ_REPAIR_ENABLED:value})})
9
8
  assert.equal(config().repairEnabled,true)
10
9
  assert.equal(config('false').repairEnabled,false)
11
10
  assert.throws(()=>config('disabled'),/EZ_REPAIR_ENABLED/)
@@ -14,6 +13,4 @@ test('repair preference is bound in the environment, never rewritten into the re
14
13
  assert.equal(env.EZ_REPAIR_ENABLED,String(enabled))
15
14
  assert.equal(env.TELEGRAM_BOT_TOKEN,undefined)
16
15
  }
17
- assert.match(agentGuidance(),/EZ_REPAIR_ENABLED/)
18
- assert.match(agentGuidance(),/explicit owner request or saved maintenance mandate/)
19
16
  })
@@ -9,9 +9,12 @@ import { fileURLToPath } from 'node:url'
9
9
  import { ControlStore } from '../src/control-state.js'
10
10
  import { RunStore } from '../src/runs.js'
11
11
  import { initialPreset } from '../src/ai.js'
12
+ import { serveTestLedger } from './helpers/ledger.js'
12
13
  const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
13
14
  test('public scheduler CLI saves literal text, reads back, edits, pauses, and rejects external or finished callers',async t=>{
14
15
  const dir=await mkdtemp(join(tmpdir(),'ez-schedule-cli-'));t.after(()=>rm(dir,{recursive:true,force:true}))
16
+ // The schedule CLI child reaches the relay memory ledger through the socket.
17
+ const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
15
18
  const control=new ControlStore(dir,1000),runs=new RunStore(dir)
16
19
  const env={...process.env,EZ_CONTROL_DIR:dir,EZ_RUN_ID:'',EZ_EXECUTOR_CLI:'grok'}
17
20
  await assert.rejects(exec(process.execPath,[bin,'list'],{env}),/Pair an owner/)
@@ -19,8 +22,8 @@ test('public scheduler CLI saves literal text, reads back, edits, pauses, and re
19
22
  const execution=await control.captureChoice(initialPreset('grok'))
20
23
  const run=await runs.create({chatId:101,telegramUserId:101,texts:['owner request'],execution})
21
24
  await runs.patch(run.id,{status:'running'});env.EZ_RUN_ID=run.id
22
- const context=JSON.parse((await exec(process.execPath,[bin,'context'],{env})).stdout)
23
- assert.deepEqual(context.run.texts,['owner request']);assert.deepEqual(context.busyReplies,[])
25
+ const context=JSON.parse((await exec(process.execPath,[bin,'context'],{env})).stdout)
26
+ assert.deepEqual(context.run.texts,['owner request']);assert.equal(context.busyReplies,undefined)
24
27
  await assert.rejects(exec(process.execPath,[bin,'context'],{env:{...env,EZ_RUN_ID:''}}),/active owner run/)
25
28
  const args=['create','test','--at','2027-09-09T09:00:00+04:00','--text','Literal $(do-not-execute) /goal objective']
26
29
  const saved=JSON.parse((await exec(process.execPath,[bin,...args],{env})).stdout)
@@ -59,13 +62,14 @@ test('public scheduler CLI saves literal text, reads back, edits, pauses, and re
59
62
 
60
63
  test('executor PATH exposes the extensionless scheduler command',async()=>{
61
64
  const command=fileURLToPath(new URL('../bin/ezenciel-agents-schedule',import.meta.url))
62
- assert.match((await exec(command,['--help'])).stdout,/durable, asynchronous CLI task/)
65
+ assert.match((await exec(command,['--help'])).stdout,/Creates a scheduled task/)
63
66
  })
64
67
 
65
68
 
66
69
  test('deferred literal input retains owner-scoped conversation through source metadata',async t=>{
67
70
  const {Scheduler}=await import('../src/scheduler.js')
68
71
  const dir=await mkdtemp(join(tmpdir(),'ez-origin-context-'));t.after(()=>rm(dir,{recursive:true,force:true}))
72
+ const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
69
73
  const control=new ControlStore(dir,1000),runs=new RunStore(dir)
70
74
  await control.requestPairing(101,101);await control.approveOwner(101)
71
75
  const execution=await control.captureChoice(initialPreset('codex'))
@@ -76,13 +80,14 @@ test('deferred literal input retains owner-scoped conversation through source me
76
80
  await new Scheduler(dir).save({id:'legacy-deferred',name:'Owner request',text:'Do the same for March',originRunId:'tg_2',owner:(await control.status()).owner!,execution,enabled:true,trigger:{at:new Date(Date.now()+1000).toISOString()}},true)
77
81
  await new Scheduler(dir).tick((await control.status()).owner!,runs,Date.now()+2000)
78
82
  const worker=(await runs.list()).find(r=>r.scheduled)!
79
- assert.deepEqual(worker.texts,['Do the same for March']);assert.equal(worker.scheduled?.originRunId,'tg_2')
83
+ assert.match(worker.texts[0],/^\[schedule legacy-deferred due .+\]$/);assert.equal(worker.texts[1],'Do the same for March');assert.equal(worker.scheduled?.originRunId,'tg_2')
80
84
  await runs.patch(worker.id,{status:'running'})
81
85
  const env={...process.env,EZ_CONTROL_DIR:dir,EZ_RUN_ID:worker.id}
82
- const result=JSON.parse((await exec(process.execPath,[bin,'context'],{env})).stdout)
83
- assert.ok(result.origin.recent.some((r:any)=>r.texts==='Use the blue ledger'))
86
+ const result=JSON.parse((await exec(process.execPath,[bin,'context'],{env})).stdout)
87
+ assert.equal(result.run.texts[1],'Do the same for March')
88
+ assert.equal(result.origin.id,'tg_2')
84
89
  await runs.create({id:'other',chatId:999,telegramUserId:999,texts:['PRIVATE OTHER OWNER']})
85
- const {writeFile}=await import('node:fs/promises')
86
- await writeFile(join(dir,'runs',worker.id+'.json'),JSON.stringify({...worker,status:'running',scheduled:{...worker.scheduled,originRunId:'other'}}))
87
- await assert.rejects(exec(process.execPath,[bin,'context'],{env}),/outside this owner binding/)
90
+ const tampered=await runs.create({id:'tg_tampered',chatId:101,telegramUserId:101,texts:worker.texts,execution,scheduled:{...worker.scheduled!,originRunId:'other'}})
91
+ await runs.patch(tampered.id,{status:'running'})
92
+ await assert.rejects(exec(process.execPath,[bin,'context'],{env:{...env,EZ_RUN_ID:tampered.id}}),/outside this owner binding/)
88
93
  })
@@ -5,7 +5,8 @@ import { randomUUID } from 'node:crypto'
5
5
  import { join } from 'node:path'
6
6
  import { tmpdir } from 'node:os'
7
7
  import { Scheduler } from '../src/scheduler.js'
8
- import { scheduledTasksText } from '../src/scheduled-tasks.js'
8
+ import { scheduledTaskDetailText, scheduledTasksText } from '../src/scheduled-tasks.js'
9
+ import { RunStore } from '../src/runs.js'
9
10
 
10
11
  const owner = { telegramUserId: 101, telegramChatId: 101, pairedAt: '2026-09-11T00:00:00.000Z' }
11
12
  const execution = { sessionId: randomUUID(), preset: { id: 'fixture', name: 'Fixture', cli: 'codex' } }
@@ -18,7 +19,7 @@ test('scheduled task view is read-only, owner-bound, and shows active task promp
18
19
  assert.deepEqual(await scheduler.listReadOnly(), [])
19
20
  await assert.rejects(access(join(dir, 'schedules')), /ENOENT/)
20
21
 
21
- await scheduler.save({
22
+ const saved = await scheduler.save({
22
23
  id: 'owner-task', name: 'Daily report', text: 'Read the ledger and send the owner a concise report.', owner, execution, enabled: true,
23
24
  trigger: { cron: '0 9 * * 1-5', timezone: 'Asia/Dubai', start: '2026-01-01T00:00:00.000Z' },
24
25
  })
@@ -30,9 +31,17 @@ test('scheduled task view is read-only, owner-bound, and shows active task promp
30
31
  const scheduleDir = join(dir, 'schedules')
31
32
  const before = await readFile(join(scheduleDir, 'owner-task.json'), 'utf8')
32
33
  const entries = await readdir(scheduleDir)
33
- const text = scheduledTasksText(await scheduler.listActiveReadOnly([]), owner)
34
+ const runs = new RunStore(dir)
35
+ const run = await runs.create({
36
+ id: 'owner-task-run', ownerId: 'telegram:101:101', ownerEpoch: owner.pairedAt, chatId: 101, telegramUserId: 101, texts: [],
37
+ scheduled: { id: saved.id, revision: saved.revision, dueAt: '2026-01-01T05:00:00.000Z', pairedAt: owner.pairedAt },
38
+ })
39
+ await runs.patch(run.id, { status: 'completed', endedAt: '2026-01-01T05:04:00.000Z' })
40
+ const active = await scheduler.listActiveReadOnly(await runs.list())
41
+ const text = scheduledTasksText(active, owner)
34
42
 
35
- assert.equal(text, 'Active scheduled tasks\n\n• Daily report\n codex · client default · default effort\n Next: 2026-01-01 05:00:00 UTC\n Read the ledger and send the owner a concise report.')
43
+ assert.equal(text, '📅 Scheduled tasks · 1 active\n\nAll times UTC. Use /tasks 1 for task details.\n\n1 · Daily report\n ● Active\n Next · Thu, Jan 1 · 05:00 UTC\n Last · ✓ Completed · Thu, Jan 1 · 05:04 UTC\n codex · client default · default effort\n Read the ledger and send the owner a concise report.')
44
+ assert.match(scheduledTaskDetailText(active.find(schedule => schedule.id === saved.id)!, 1), /Last run\n✓ Completed · Thu, Jan 1 · 05:04 UTC/)
36
45
  assert.doesNotMatch(text, /Other owner task|This must never be visible/)
37
46
  assert.equal(await readFile(join(scheduleDir, 'owner-task.json'), 'utf8'), before)
38
47
  assert.deepEqual(await readdir(scheduleDir), entries)
@@ -42,7 +51,6 @@ test('active view follows existing cursor and run state without changing schedul
42
51
  const dir = await mkdtemp(join(tmpdir(),'ez-active-schedules-'))
43
52
  t.after(()=>rm(dir,{recursive:true,force:true}))
44
53
  const scheduler = new Scheduler(dir)
45
- const { RunStore } = await import('../src/runs.js')
46
54
  const runs = new RunStore(dir)
47
55
  const due = Date.now()+60_000
48
56
  const common = {text:'/goal List files.',owner,execution,enabled:true}
@@ -88,7 +96,7 @@ test('prompt preview is one bounded Unicode sentence and preserves stored input'
88
96
  enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}})
89
97
  const output=scheduledTasksText(await scheduler.listActiveReadOnly([]),owner)
90
98
  assert.match(output,/gpt-5.6-terra · high/)
91
- assert.match(output,/Next: 2027-01-01 00:00:00 UTC/)
99
+ assert.match(output,/Next · Fri, Jan 1 · 00:00 UTC/)
92
100
  assert.equal(Array.from(output.split('\n').at(-1)!.trim()).length,140)
93
101
  assert.ok(output.endsWith('…'))
94
102
  assert.doesNotMatch(output,/Do not display/)
@@ -112,3 +120,18 @@ test('a legacy invalid selection does not prevent the active menu from rendering
112
120
  const active = await scheduler.listActiveReadOnly([])
113
121
  assert.match(scheduledTasksText(active,owner),/codex · gpt-5.6-terra · max/)
114
122
  })
123
+
124
+ test('detail view truncates long instructions and tolerates invalid timestamps', async t => {
125
+ const dir = await mkdtemp(join(tmpdir(),'ez-schedule-detail-bounds-'))
126
+ t.after(()=>rm(dir,{recursive:true,force:true}))
127
+ const scheduler = new Scheduler(dir)
128
+ const saved = await scheduler.save({id:'long',name:'Long task',text:'/goal '+'x'.repeat(5000),owner,
129
+ execution,enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}})
130
+ const active = await scheduler.listActiveReadOnly([])
131
+ const detail = scheduledTaskDetailText(active.find(schedule => schedule.id === saved.id)!,1,1)
132
+ assert.match(detail,/📅 1 of 1 · Long task/)
133
+ assert.match(detail,/truncated; full text lives in the workspace/)
134
+ assert.ok(detail.length < 3000)
135
+ const broken = {...active.find(schedule => schedule.id === saved.id)!,nextAt:'not-a-date',lastRun:{status:'completed',at:'also-bad',currentRevision:true}} as never
136
+ assert.doesNotMatch(scheduledTaskDetailText(broken,1,1),/NaN/)
137
+ })
@@ -10,6 +10,7 @@ import { EXECUTOR_REGISTRY } from '../src/executor.js'
10
10
  import { RunStore } from '../src/runs.js'
11
11
 
12
12
  const until=async(check:()=>Promise<boolean>)=>{for(let n=0;n<250;n++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Host probe timed out')}
13
+ const stdout=(events:string) => events.trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)).filter(event=>event.stream==='stdout').map(event=>event.text).join('')
13
14
  test('host transport reserves separate task and main lanes, pins directories, and cancels only its target',async()=>{
14
15
  const root=await mkdtemp(join(tmpdir(),'ez-scheduler-host-')),workspace=join(root,'agent'),controlDir=join(root,'control')
15
16
  await mkdir(workspace);await mkdir(controlDir)
@@ -32,12 +33,11 @@ test('host transport reserves separate task and main lanes, pins directories, an
32
33
  await submit('tg_1')
33
34
  await until(async()=>(await exists('tg_1.events')).includes('"stream":"exit","code":0'))
34
35
  assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
35
- const scheduled=JSON.parse(JSON.parse((await exists(id+'.events')).trim().split('\n')[0]).text)
36
- const main=JSON.parse(JSON.parse((await exists('tg_1.events')).trim().split('\n')[0]).text)
37
- assert.equal(scheduled.cwd,await realpath(join(workspace,'work/tasks',id)));assert.equal(main.cwd,await realpath(workspace))
36
+ const scheduled=JSON.parse(stdout(await exists(id+'.events')))
37
+ const main=JSON.parse(stdout(await exists('tg_1.events')))
38
+ assert.equal(scheduled.cwd,await realpath(join(controlDir,'work/tasks',id)));assert.equal(main.cwd,await realpath(workspace))
38
39
  assert.equal(scheduled.token,undefined);assert.equal(main.token,undefined)
39
- // A malformed scheduled request must not unwind the shared host service.
40
- await writeFile(join(controlDir,'runs/r_schedule_corrupt.json'),'{')
40
+ // An unknown scheduled run must not unwind the shared host service.
41
41
  await submit('r_schedule_corrupt')
42
42
  await until(async()=>(await exists('r_schedule_corrupt.events')).includes('"stream":"exit","code":1'))
43
43
  assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
@@ -11,16 +11,18 @@ import { ControlStore } from '../src/control-state.js'
11
11
  import { RunStore } from '../src/runs.js'
12
12
  import { Scheduler } from '../src/scheduler.js'
13
13
  import { initialPreset } from '../src/ai.js'
14
- import { randomUUID } from 'node:crypto'
15
14
 
16
15
  const until=async(check:()=>Promise<boolean>)=>{for(let i=0;i<200;i++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Timed out')}
17
16
 
18
- test('maintenance wakeups use an independent ephemeral session after a relay restart',async()=>{
19
- const dir=await mkdtemp(join(tmpdir(),'ez-maintenance-session-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
20
- let launchSession='',onSessionCalls=0
17
+ // Adversarial: the relay must not reserve wrapper semantics for an
18
+ // update-shaped run id or a stale attention notice.
19
+ test('a wrapper-shaped run id has no maintenance treatment and a stale notice is inert',async()=>{
20
+ const dir=await mkdtemp(join(tmpdir(),'ez-no-maintenance-session-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
21
+ let launchSession='',onSessionCalls=0,launches=0
21
22
  const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'codex',telegramBotToken:'fixture'},async(_texts,options)=>{
23
+ launches++
22
24
  launchSession=options.sessionId ?? ''
23
- if(options.onSession){onSessionCalls++;await options.onSession('native-maintenance')}
25
+ if(options.onSession){onSessionCalls++;await options.onSession('native-review')}
24
26
  const child=spawn(process.execPath,['-e','setTimeout(()=>{},10)'],{detached:process.platform!=='win32'})
25
27
  await once(child,'spawn')
26
28
  return {child,cleanup:async()=>{},stdout:''}
@@ -29,13 +31,17 @@ test('maintenance wakeups use an independent ephemeral session after a relay res
29
31
  relay.bot.api.config.use(async()=>({ok:true,result:{message_id:42}}) as never)
30
32
  try{
31
33
  await control.requestPairing(101,101);await control.approveOwner(101)
32
- const execution={sessionId:randomUUID(),preset:initialPreset('codex')}
34
+ const execution=await control.captureChoice(initialPreset('codex'))
33
35
  await runs.create({id:'r_update_fixture',chatId:101,telegramUserId:101,texts:['maintenance'],execution})
34
36
  await relay.drainSources()
35
37
  await until(async()=> (await runs.get('r_update_fixture'))?.status==='completed')
36
- assert.notEqual(launchSession,execution.sessionId)
37
- assert.equal(onSessionCalls,0)
38
- }finally{await relay.stop();await rm(dir,{recursive:true,force:true})}
38
+ assert.equal(launchSession,execution.sessionId)
39
+ assert.equal(onSessionCalls,1)
40
+ await writeFile(join(dir,'update-attention.json'),JSON.stringify({id:'a'.repeat(64)}))
41
+ await relay.drainSources()
42
+ assert.equal(launches,1)
43
+ assert.equal((await runs.list()).length,1)
44
+ }finally{await relay.stop();await rm(dir,{recursive:true,force:true})}
39
45
  })
40
46
 
41
47
  test('chat replies through the real ingress/outbox while scheduled CLI remains alive; targeted cancellation',async()=>{
@@ -78,10 +78,10 @@ test('pause, edit, removal, owner revocation, corrupt records and traversal fail
78
78
  })
79
79
  test('task workspaces are distinct and cannot escape through symlinks',async t=>{
80
80
  const f=await fixture(t)
81
- await writeFile(join(f.dir,'SOUL.md'),'Owner context')
81
+ await writeFile(join(f.dir,'private.md'),'Owner context')
82
82
  const first=await taskWorkspace(f.dir,'r_one'),second=await taskWorkspace(f.dir,'r_two')
83
83
  assert.notEqual(first,second)
84
- await assert.rejects(readFile(join(first,'SOUL.md'),'utf8'),{code:'ENOENT'})
84
+ await assert.rejects(readFile(join(first,'private.md'),'utf8'),{code:'ENOENT'})
85
85
  await assert.rejects(readFile(join(first,'AGENTS.md'),'utf8'),{code:'ENOENT'})
86
86
  await assert.rejects(taskWorkspace(f.dir,'../escape'))
87
87
  const other=join(f.dir,'other');await mkdir(other)
@@ -1,6 +1,6 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { mkdtemp, rm, writeFile, symlink, mkdir } from 'node:fs/promises'
3
+ import { mkdtemp, rm, writeFile, symlink, mkdir, stat } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import { isOwner } from '../src/identity.js'
@@ -26,7 +26,7 @@ test('owner gate denies groups, bots, missing identity, and a different sender o
26
26
  assert.equal(isOwner({ from, chat }, null), false)
27
27
  })
28
28
 
29
- test('store identifiers reject traversal; corrupt records cannot authorize or block healthy runs', async () => {
29
+ test('store identifiers reject traversal; corrupt control state fails closed and the ledger writes nothing', async () => {
30
30
  const dir = await mkdtemp(join(tmpdir(), 'ez-negative-'))
31
31
  try {
32
32
  const runs = new RunStore(dir)
@@ -34,8 +34,11 @@ test('store identifiers reject traversal; corrupt records cannot authorize or bl
34
34
  await assert.rejects(runs.claimOutbox('../control-state'), /Invalid/)
35
35
  await assert.rejects(new ApprovalStore(dir).getDecision('../escape'), /Invalid/)
36
36
  const run = await runs.create({ chatId: 101, telegramUserId: 101, texts: ['test'] })
37
- await writeFile(join(dir, 'runs', 'broken.json'), '{')
38
37
  assert.equal((await runs.nextQueued())?.id, run.id)
38
+ // Run/outbox/inbox bookkeeping is memory-only: no control/ files exist to corrupt.
39
+ await assert.rejects(stat(join(dir, 'runs')), { code: 'ENOENT' })
40
+ await assert.rejects(stat(join(dir, 'outbox')), { code: 'ENOENT' })
41
+ await assert.rejects(stat(join(dir, 'inbox.json')), { code: 'ENOENT' })
39
42
  await writeFile(join(dir, 'control-state.json'), '{')
40
43
  await assert.rejects(new ControlStore(dir, 1000).status())
41
44
  } finally {
@@ -4,7 +4,13 @@ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import path from 'node:path'
6
6
  import { installedPluginVersions, softwareStatus } from '../src/software-status.js'
7
- import { packageVersion } from '../src/version.js'
7
+ import { formatRelayLabel, packageVersion } from '../src/version.js'
8
+
9
+ test('relay label shows the running RC tag first', () => {
10
+ assert.equal(formatRelayLabel('0.1.0-beta.35', 'rc2', 'abc123def4567890'), 'rc2 (v0.1.0-beta.35 · abc123def456)')
11
+ assert.equal(formatRelayLabel('0.1.0-beta.35', 'rc2'), 'rc2 (v0.1.0-beta.35)')
12
+ assert.equal(formatRelayLabel('1.0.0'), 'v1.0.0')
13
+ })
8
14
 
9
15
  test('Telegram software status uses loaded version and only fresh host plugin metadata', async t => {
10
16
  const root=await mkdtemp(path.join(tmpdir(),'ez-software-status-'))
@@ -14,9 +20,10 @@ test('Telegram software status uses loaded version and only fresh host plugin me
14
20
  const plugins=await installedPluginVersions(root)
15
21
  assert.deepEqual(plugins,[{id:'whatsapp',version:'0.1.0-beta.3'}])
16
22
  const heartbeat=path.join(root,'host-executor/heartbeat.json')
17
- await writeFile(heartbeat,JSON.stringify({at:Date.now(),version:'0.1.0-beta.4',plugins}))
18
- const lines=await softwareStatus(root)
23
+ await writeFile(heartbeat,JSON.stringify({at:Date.now(),version:'0.1.0-beta.4',platform:'darwin',arch:'arm64',plugins}))
24
+ const lines=await softwareStatus(root,'host-capable')
19
25
  assert.equal(lines[0],`Relay: running · v${packageVersion}`)
26
+ assert(lines.includes('Env: mac (darwin/arm64) · host-capable · host'))
20
27
  assert(lines.includes('Host transport: running · v0.1.0-beta.4'))
21
28
  assert(lines.includes('Plugins: whatsapp 0.1.0-beta.3'))
22
29
  assert(!JSON.stringify(lines).includes('/private'))
@@ -28,4 +35,15 @@ test('Telegram software status uses loaded version and only fresh host plugin me
28
35
  assert((await softwareStatus(root)).includes('Host transport: running · version unknown'))
29
36
  await writeFile(path.join(root,'registry.json'),'broken')
30
37
  assert.equal(await installedPluginVersions(root),null)
38
+ await rm(heartbeat)
39
+ assert((await softwareStatus(root)).includes('Host transport: unavailable'))
40
+ assert((await softwareStatus(root)).includes('Plugins: unknown'))
41
+ assert((await softwareStatus(root)).some(l => l.startsWith('Env:') && l.includes('unknown · unknown')))
42
+ assert((await softwareStatus(root,'isolated')).includes('Host transport: n/a · isolated execution'))
43
+ assert((await softwareStatus(root,'isolated')).some(l => l.startsWith('Env:') && l.includes('isolated · local')))
44
+ assert((await softwareStatus(root,'isolated')).includes('Plugins: isolated broker · versions via `ez tools list`'))
45
+ await writeFile(path.join(root,'plugin-broker-plugins.json'),JSON.stringify({version:1,at:new Date().toISOString(),plugins:[{id:'voice',version:'0.2.0'}]}))
46
+ assert((await softwareStatus(root,'isolated')).includes('Plugins: voice 0.2.0'))
47
+ await writeFile(path.join(root,'plugin-broker-plugins.json'),'broken')
48
+ assert((await softwareStatus(root,'isolated')).includes('Plugins: isolated broker · versions via `ez tools list`'))
31
49
  })
@@ -0,0 +1,160 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import path from 'node:path'
6
+ import { provisionTelegramBot } from '../src/telegram-provisioning.js'
7
+
8
+ const token = '123456:abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMN'
9
+
10
+ const fixture = async () => {
11
+ const root = await mkdtemp(path.join(tmpdir(), 'ez-telegram-provisioning-'))
12
+ const composeFile = path.join(root, 'compose.yml')
13
+ const configFile = path.join(root, 'telegram-provisioning.json')
14
+ const relayEnvFile = path.join(root, 'secrets', 'relay.env')
15
+ const overrideFile = path.join(root, 'telegram.compose.yml')
16
+ const deploymentEnvFile = path.join(root, 'docker.env')
17
+ await writeFile(composeFile, 'services: {}\n')
18
+ await writeFile(deploymentEnvFile, "EZ_RELAY_IMAGE='ezenciel-agents:0.1.0-beta.36.rc.4'\n", { mode: 0o600 })
19
+ await writeFile(configFile, JSON.stringify({
20
+ version: 1,
21
+ composeFile,
22
+ projectDirectory: root,
23
+ projectName: 'tenant-aifit',
24
+ service: 'relay',
25
+ relayEnvFile,
26
+ overrideFile,
27
+ }), { mode: 0o600 })
28
+ return { root, composeFile, configFile, relayEnvFile, overrideFile, deploymentEnvFile }
29
+ }
30
+
31
+ test('Telegram provisioning writes only a private relay secret and restarts the exact relay', async (t) => {
32
+ const value = await fixture()
33
+ t.after(async () => { await import('node:fs/promises').then(({ rm }) => rm(value.root, { recursive: true, force: true })) })
34
+ const calls: string[][] = []
35
+ const environments: NodeJS.ProcessEnv[] = []
36
+ const priorImage = process.env.EZ_RELAY_IMAGE
37
+ process.env.EZ_RELAY_IMAGE = 'stale:shell-export'
38
+ try {
39
+ await provisionTelegramBot(value.configFile, token, async (args, environment) => { calls.push(args); environments.push(environment) })
40
+ } finally {
41
+ if (priorImage === undefined) delete process.env.EZ_RELAY_IMAGE; else process.env.EZ_RELAY_IMAGE = priorImage
42
+ }
43
+
44
+ // The deployment's docker.env is the only source for its runtime values:
45
+ // shell exports never override the installed image or mounts.
46
+ assert.equal('EZ_RELAY_IMAGE' in environments[0], false)
47
+ assert.equal('TELEGRAM_BOT_TOKEN' in environments[0], false)
48
+ assert.equal(environments[0].PATH, process.env.PATH)
49
+ assert.equal(await readFile(value.relayEnvFile, 'utf8'), `TELEGRAM_BOT_TOKEN=${token}\n`)
50
+ assert.equal((await stat(value.relayEnvFile)).mode & 0o777, 0o600)
51
+ const override = await readFile(value.overrideFile, 'utf8')
52
+ // The installed deployment supplies the relay image; the override never sets one.
53
+ assert.ok(!override.includes('image:'))
54
+ assert.match(override, /relay_env/)
55
+ assert.match(override, /EZ_TELEGRAM_ENABLED: "true"/)
56
+ assert.ok(!override.includes(token))
57
+ assert.equal((await stat(value.overrideFile)).mode & 0o777, 0o600)
58
+ assert.deepEqual(calls, [[
59
+ '--project-directory', value.root,
60
+ '--project-name', 'tenant-aifit',
61
+ '--env-file', value.deploymentEnvFile,
62
+ '-f', value.composeFile,
63
+ '-f', value.overrideFile,
64
+ 'up', '-d', '--wait', 'relay',
65
+ ]])
66
+ })
67
+
68
+ test('provisioning rejects a self-set image and custom deployments without a Docker env file still start', async (t) => {
69
+ const value = await fixture()
70
+ t.after(async () => { await rm(value.root, { recursive: true, force: true }) })
71
+ const configured = JSON.parse(await readFile(value.configFile, 'utf8'))
72
+ await writeFile(value.configFile, JSON.stringify({ ...configured, image: 'eizenciel-agents:0.1.0-beta.36.rc.3' }))
73
+ await assert.rejects(provisionTelegramBot(value.configFile, token, async () => {}), /Invalid Telegram provisioning configuration/)
74
+ await writeFile(value.configFile, JSON.stringify(configured))
75
+ await rm(value.deploymentEnvFile)
76
+ const calls: string[][] = []
77
+ await provisionTelegramBot(value.configFile, token, async (args) => { calls.push(args) })
78
+ assert.ok(!calls[0].includes('--env-file'))
79
+ })
80
+
81
+ test('provisioning fails closed when the deployment docker.env has no installed image', async (t) => {
82
+ const value = await fixture()
83
+ t.after(async () => { await rm(value.root, { recursive: true, force: true }) })
84
+ await writeFile(value.deploymentEnvFile, "EZ_AGENT_WORKSPACE='/private/agents/tenant/mind'\n", { mode: 0o600 })
85
+ await assert.rejects(provisionTelegramBot(value.configFile, token, async () => {}), /must define EZ_RELAY_IMAGE/)
86
+ await assert.rejects(readFile(value.relayEnvFile, 'utf8'), { code: 'ENOENT' })
87
+ })
88
+
89
+ test('Telegram provisioning restores the botless relay when startup fails', async (t) => {
90
+ const value = await fixture()
91
+ t.after(async () => { await import('node:fs/promises').then(({ rm }) => rm(value.root, { recursive: true, force: true })) })
92
+ const calls: string[][] = []
93
+ await assert.rejects(
94
+ provisionTelegramBot(value.configFile, token, async (args) => {
95
+ calls.push(args)
96
+ if (calls.length === 1) throw new Error('Bot rejected')
97
+ }),
98
+ /Bot rejected/,
99
+ )
100
+ await assert.rejects(readFile(value.relayEnvFile, 'utf8'), { code: 'ENOENT' })
101
+ await assert.rejects(readFile(value.overrideFile, 'utf8'), { code: 'ENOENT' })
102
+ assert.equal(calls.length, 2)
103
+ assert.ok(!calls[1].includes(value.overrideFile))
104
+ })
105
+
106
+ test('Telegram provisioning rejects invalid tokens and configs before touching the deployment', async (t) => {
107
+ const value = await fixture()
108
+ t.after(async () => { await import('node:fs/promises').then(({ rm }) => rm(value.root, { recursive: true, force: true })) })
109
+ await assert.rejects(provisionTelegramBot(value.configFile, 'not-a-token', async () => {}), /Invalid Telegram bot token/)
110
+ await assert.rejects(provisionTelegramBot(value.configFile, `${'1'.repeat(600)}:x`, async () => {}), /Invalid Telegram bot token/)
111
+ await assert.rejects(readFile(value.relayEnvFile, 'utf8'), { code: 'ENOENT' })
112
+ await writeFile(value.configFile, JSON.stringify({ version: 1 }))
113
+ await assert.rejects(provisionTelegramBot(value.configFile, token, async () => {}), /Invalid Telegram provisioning configuration/)
114
+ })
115
+
116
+ test('Telegram rotation failure restores both files and restarts with the previous override', async (t) => {
117
+ const value = await fixture()
118
+ t.after(async () => { await import('node:fs/promises').then(({ rm }) => rm(value.root, { recursive: true, force: true })) })
119
+ const { mkdir } = await import('node:fs/promises')
120
+ await mkdir(path.dirname(value.relayEnvFile), { recursive: true })
121
+ await writeFile(value.relayEnvFile, 'TELEGRAM_BOT_TOKEN=111111:old-old-old-old-old-old-old-old\n', { mode: 0o600 })
122
+ await writeFile(value.overrideFile, 'previous-override\n')
123
+ const calls: string[][] = []
124
+ await assert.rejects(
125
+ provisionTelegramBot(value.configFile, token, async (args) => {
126
+ calls.push(args)
127
+ if (calls.length === 1) throw new Error('Bot rejected rotation')
128
+ }),
129
+ /Bot rejected rotation/,
130
+ )
131
+ assert.equal(await readFile(value.relayEnvFile, 'utf8'), 'TELEGRAM_BOT_TOKEN=111111:old-old-old-old-old-old-old-old\n')
132
+ assert.equal(await readFile(value.overrideFile, 'utf8'), 'previous-override\n')
133
+ assert.equal(calls.length, 2)
134
+ assert.ok(calls[1].includes(value.overrideFile))
135
+ })
136
+
137
+ test('Telegram provisioning refuses a live lock but recovers a stale one', async (t) => {
138
+ const value = await fixture()
139
+ t.after(async () => { await import('node:fs/promises').then(({ rm }) => rm(value.root, { recursive: true, force: true })) })
140
+ const calls: string[][] = []
141
+ await writeFile(path.join(value.root, 'telegram-provisioning.lock'), JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }))
142
+ await assert.rejects(provisionTelegramBot(value.configFile, token, async (args) => { calls.push(args) }), /already in progress/)
143
+ assert.equal(calls.length, 0)
144
+ await writeFile(path.join(value.root, 'telegram-provisioning.lock'), JSON.stringify({ pid: 2147483647, startedAt: '2020-01-01T00:00:00.000Z' }))
145
+ await provisionTelegramBot(value.configFile, token, async (args) => { calls.push(args) })
146
+ assert.equal(calls.length, 1)
147
+ })
148
+
149
+ test('Telegram provisioning CLI rejects TTY input and oversized stdin', async () => {
150
+ const { runTelegramProvisioningCli } = await import('../src/telegram-provisioning-cli.js')
151
+ await assert.rejects(runTelegramProvisioningCli(['--config', 'x'], { isTTY: true } as never), /stdin/)
152
+ let destroyed = false
153
+ const oversized = {
154
+ isTTY: false,
155
+ destroy() { destroyed = true },
156
+ async *[Symbol.asyncIterator]() { yield 'x'.repeat(600) },
157
+ }
158
+ await assert.rejects(runTelegramProvisioningCli(['--config', 'x'], oversized as never), /too long/)
159
+ assert.equal(destroyed, true)
160
+ })