@jc_stack/ez-agents 0.1.0-beta.13 → 0.1.0-beta.19

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 (115) hide show
  1. package/.dockerignore +3 -0
  2. package/.env.example +20 -0
  3. package/AGENTS.md +12 -3
  4. package/CHANGELOG.md +63 -0
  5. package/CONTRIBUTING.md +62 -6
  6. package/Dockerfile +6 -0
  7. package/README.md +11 -2
  8. package/bin/ezenciel-agents-watch.mjs +8 -0
  9. package/compose.workforce-watch.yaml +33 -0
  10. package/compose.yaml +8 -1
  11. package/docker/run.ts +1 -1
  12. package/docs/architecture/ai-selection.md +15 -0
  13. package/docs/architecture/authority-boundaries.md +24 -1
  14. package/docs/architecture/telegram-intake.md +1 -1
  15. package/docs/docker-runtime.md +35 -0
  16. package/docs/host-service.md +19 -0
  17. package/docs/pagerduty.md +42 -0
  18. package/docs/plugin-catalog.md +28 -10
  19. package/docs/plugin-contributions.md +9 -0
  20. package/docs/plugins.md +46 -1
  21. package/docs/releasing.md +20 -9
  22. package/docs/repair.md +41 -0
  23. package/docs/responsive-channels.md +57 -0
  24. package/docs/scheduling.md +32 -4
  25. package/docs/selective-monitoring.md +12 -4
  26. package/docs/setup.md +43 -0
  27. package/docs/trusted-publishing.md +140 -0
  28. package/docs/upgrades.md +24 -4
  29. package/docs/workforce-watch.md +101 -0
  30. package/package.json +9 -4
  31. package/scripts/generate-publish-caller.mjs +60 -0
  32. package/scripts/smoke-busy-reply.ts +58 -0
  33. package/scripts/trusted-beta.mjs +289 -0
  34. package/src/agent-guidance.ts +9 -0
  35. package/src/ai-cli.ts +2 -1
  36. package/src/ai.ts +26 -8
  37. package/src/client-defaults.ts +29 -13
  38. package/src/codex-session.ts +4 -2
  39. package/src/config.ts +29 -1
  40. package/src/control-state.ts +26 -7
  41. package/src/desktop-bridge.ts +11 -2
  42. package/src/event-sources.ts +2 -1
  43. package/src/execution-authority.ts +2 -1
  44. package/src/executor.ts +34 -7
  45. package/src/failure.ts +32 -0
  46. package/src/host-executor-client.ts +7 -1
  47. package/src/host-executor.ts +22 -13
  48. package/src/identity.ts +8 -3
  49. package/src/inbox.ts +7 -3
  50. package/src/index.ts +260 -92
  51. package/src/install-tools.mjs +2 -2
  52. package/src/menu.ts +8 -6
  53. package/src/model-policy.ts +18 -0
  54. package/src/owner.ts +3 -3
  55. package/src/pagerduty.ts +109 -0
  56. package/src/plugins/manager.mjs +115 -8
  57. package/src/plugins/shared.mjs +76 -0
  58. package/src/repair-policy.ts +13 -0
  59. package/src/reply-context.ts +71 -0
  60. package/src/reply-executor.ts +55 -0
  61. package/src/reply-mcp.ts +23 -0
  62. package/src/runs.ts +14 -16
  63. package/src/schedule-cli.ts +36 -7
  64. package/src/scheduled-tasks.ts +33 -0
  65. package/src/scheduler.ts +22 -4
  66. package/src/setup.ts +3 -2
  67. package/src/software-status.ts +5 -5
  68. package/src/task-cli.ts +3 -3
  69. package/src/task-executor.ts +9 -6
  70. package/src/tasks.ts +35 -17
  71. package/src/telegram-source.ts +94 -0
  72. package/src/updates/artifact.mjs +16 -0
  73. package/src/updates/binding.mjs +3 -1
  74. package/src/updates/control.mjs +4 -4
  75. package/src/updates/runtime.mjs +5 -2
  76. package/src/workforce-watch-cli.ts +14 -0
  77. package/src/workforce-watch.ts +155 -0
  78. package/templates/agent/AGENTS.md +10 -2
  79. package/templates/agent/TOOLS.md +6 -0
  80. package/templates/agent-guidance.md +24 -0
  81. package/templates/chat-guidance.md +23 -0
  82. package/templates/failure-review.md +9 -0
  83. package/templates/maintainer-purpose.md +15 -0
  84. package/templates/updates.md +2 -2
  85. package/test/agent-guidance.test.ts +125 -0
  86. package/test/ai-cli.test.ts +7 -6
  87. package/test/ai.test.ts +81 -1
  88. package/test/busy-reply-relay.test.ts +41 -0
  89. package/test/client-defaults.test.ts +37 -5
  90. package/test/codex-context.test.ts +5 -2
  91. package/test/codex-session.test.ts +4 -2
  92. package/test/config.test.ts +29 -0
  93. package/test/event-sources.test.ts +4 -0
  94. package/test/executor.test.ts +11 -1
  95. package/test/failure.test.ts +256 -0
  96. package/test/group-owner.test.ts +36 -0
  97. package/test/host-executor.test.ts +54 -7
  98. package/test/intake-relay.test.ts +145 -4
  99. package/test/model-policy.test.ts +69 -0
  100. package/test/pagerduty.test.ts +104 -0
  101. package/test/plugin-manager.test.mjs +52 -2
  102. package/test/relay.test.ts +2 -2
  103. package/test/repair-policy.test.ts +23 -0
  104. package/test/reply.test.ts +153 -0
  105. package/test/runs.test.ts +7 -0
  106. package/test/schedule-cli.test.ts +10 -2
  107. package/test/scheduled-tasks.test.ts +43 -0
  108. package/test/shared-services.test.mjs +98 -0
  109. package/test/software-status.test.ts +5 -5
  110. package/test/task-native.test.ts +2 -2
  111. package/test/tasks.test.ts +14 -6
  112. package/test/telegram-source.test.ts +75 -0
  113. package/test/trusted-beta.test.mjs +224 -0
  114. package/test/updates.test.mjs +35 -3
  115. package/test/workforce-watch.test.ts +180 -0
@@ -0,0 +1,256 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { tmpdir } from 'node:os'
6
+ import { execFile, spawn } from 'node:child_process'
7
+ import { promisify } from 'node:util'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { once } from 'node:events'
10
+ import { redactFailure, failureStamp, needsFailureReview } from '../src/failure.js'
11
+ import { RunStore } from '../src/runs.js'
12
+ import { ControlStore } from '../src/control-state.js'
13
+ import { Scheduler } from '../src/scheduler.js'
14
+ import { initialPreset } from '../src/ai.js'
15
+ import { createRelay } from '../src/index.js'
16
+ import { TelegramSource } from '../src/telegram-source.js'
17
+ import { packageVersion } from '../src/version.js'
18
+ import type { Update } from 'grammy/types'
19
+ const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
20
+ const until=async(check:()=>Promise<boolean>)=>{for(let n=0;n<200;n++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Timed out')}
21
+
22
+ test('failure evidence is bounded and redacts configured credentials, headers, tokens, URLs and keys',()=>{
23
+ const token='123456789:abcdefghijklmnopqrstuvwxyz123456789'
24
+ const text=redactFailure('x'.repeat(5000)+'\nMissing input\nAuthorization: Bearer sensitive123\n{"api_key":"api-credential"}\npassword=private123\nhttps://alice:pass@example.com/file?token=abc#secret\nhttps://api.telegram.org/bot'+token+'/sendMessage\nCustomSecret\nsk-abcdefghijk\neyJabc.def.ghi\n-----BEGIN RSA PRIVATE KEY-----\nABC\n-----END RSA PRIVATE KEY-----',['CustomSecret'])
25
+ for(const value of ['sensitive123','api-credential','private123','alice','pass@','token=abc',token,'CustomSecret','sk-abcdefghijk','eyJabc.def.ghi','ABC'])assert.ok(!text.includes(value),value)
26
+ assert.ok(text.includes('Missing input'));assert.ok(text.length<=4096)
27
+ })
28
+
29
+ test('failure review CLI is owner-bound, rejects stale reviews, and keeps failure evidence immutable',async t=>{
30
+ const dir=await mkdtemp(join(tmpdir(),'ez-failure-cli-'));t.after(()=>rm(dir,{recursive:true,force:true}))
31
+ const runs=new RunStore(dir),control=new ControlStore(dir,1000)
32
+ const env={...process.env,EZ_CONTROL_DIR:dir,EZ_EXECUTOR_CLI:'grok',EZ_RUN_ID:''}
33
+ const cli=(args:string[],overrides={})=>exec(process.execPath,[bin,...args],{env:{...env,...overrides}})
34
+ await assert.rejects(cli(['failures']),/Pair an owner/)
35
+ await control.requestPairing(101,101);await control.approveOwner(101)
36
+ const execution=await control.captureChoice(initialPreset('grok'))
37
+ await runs.create({id:'r_failed',chatId:101,telegramUserId:101,texts:['test'],execution})
38
+ await runs.patch('r_failed',{status:'failed',endedAt:'2026-09-10T07:00:00.000Z',exitCode:7,failureReason:'executor-exit'})
39
+ await runs.create({id:'r_other',chatId:202,telegramUserId:202,texts:['private']});await runs.patch('r_other',{status:'failed'})
40
+ const failures=JSON.parse((await cli(['failures'])).stdout)
41
+ assert.deepEqual(failures.runs.map((r:any)=>r.id),['r_failed'])
42
+ assert.equal(failures.runs[0].failure.relayVersion,packageVersion)
43
+ const review=['review','r_failed','--failed-at','2026-09-10T07:00:00.000Z','--status','resolved','--diagnosis','Missing input','--recovery','Created fixture','--outcome','Check exited zero']
44
+ const stale=[...review];stale[3]='2026-09-10T06:00:00Z';await assert.rejects(cli(stale),/Failure changed/)
45
+ await assert.rejects(cli(['run','r_other']),/Unknown owner/)
46
+ await assert.rejects(cli(['run','../escape']),/Invalid/)
47
+ await cli(review)
48
+ assert.equal(JSON.parse((await cli(['failures'])).stdout).total,0)
49
+ const record=JSON.parse((await cli(['run','r_failed'])).stdout)
50
+ assert.equal(record.status,'failed');assert.equal(record.exitCode,7);assert.equal(record.failureReview.status,'resolved')
51
+ assert.equal(JSON.parse((await cli(['failures','--all'])).stdout).total,1)
52
+ await runs.patch('r_failed',{status:'failed',endedAt:'2026-09-10T08:00:00.000Z'})
53
+ assert.equal(JSON.parse((await cli(['failures'])).stdout).total,1)
54
+ for(const [id,extra] of [['r_external',{external:{sourceId:'s',bindingId:'b',eventIds:['e']}}],['r_task',{taskId:'task_'+'a'.repeat(32)}],['tg_1',{replyOnly:true}]] as const){
55
+ await runs.create({id,chatId:101,telegramUserId:101,texts:['test'],...('taskId' in extra?{taskId:extra.taskId}:{}),...('external' in extra?{external:{...extra.external,eventIds:[...extra.external.eventIds]}}:{})})
56
+ await runs.patch(id,{status:'running',...('replyOnly' in extra?{replyOnly:true}:{})})
57
+ await assert.rejects(cli(review,{EZ_RUN_ID:id}),/owner-authorized/)
58
+ }
59
+ await writeFile(join(dir,'runs','r_bad.json'),'{broken')
60
+ assert.equal(JSON.parse((await cli(['failures'])).stdout).total,1)
61
+ })
62
+
63
+ test('failure capture, diagnosis, verified recovery and conditional quiet next tick through real CLI and relay',async t=>{
64
+ const dir=await mkdtemp(join(tmpdir(),'ez-failure-loop-')),runs=new RunStore(dir),control=new ControlStore(dir,1000),scheduler=new Scheduler(dir)
65
+ const replies:string[]=[],children:ReturnType<typeof spawn>[]=[],fixture=join(dir,'fixture.txt')
66
+ let reviews=0
67
+ const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture-token'},async(_texts,options)=>{
68
+ const scheduled=options.runId.startsWith('r_schedule_')
69
+ if(scheduled){
70
+ reviews++
71
+ const env={...process.env,EZ_CONTROL_DIR:dir,EZ_RUN_ID:options.runId,EZ_EXECUTOR_CLI:'grok'}
72
+ const pending=JSON.parse((await exec(process.execPath,[bin,'failures'],{env})).stdout)
73
+ assert.equal(pending.total,1);assert.match(pending.runs[0].failure.error,/Missing fixture/)
74
+ await writeFile(fixture,'ready')
75
+ await exec(process.execPath,['-e',`if(require('fs').readFileSync(process.argv[1],'utf8')!=='ready')process.exit(9)`,fixture])
76
+ await runs.enqueueMessage(options.runId,'Fixture recovery verified')
77
+ await exec(process.execPath,[bin,'review',pending.runs[0].id,'--failed-at',pending.runs[0].failedAt,'--status','resolved','--diagnosis','Missing fixture','--recovery','Created expected fixture','--outcome','Independent check exited zero'],{env})
78
+ }
79
+ const child=spawn(process.execPath,['-e',scheduled?'setTimeout(()=>{},30)':`setTimeout(()=>{console.error('Missing fixture; authorization: Bearer secret-value');process.exit(7)},30)`],{stdio:['pipe','pipe','pipe'],detached:true})
80
+ children.push(child);await once(child,'spawn');return {child,cleanup:async()=>{if(options.runId==='tg_11')throw new Error('Cleanup after stop')},stdout:''}
81
+ })
82
+ relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
83
+ 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})
84
+ const originalPatch=RunStore.prototype.patch
85
+ t.mock.method(RunStore.prototype,'patch',async function(this:RunStore,...args:Parameters<RunStore['patch']>){
86
+ // Hold the PID write until the fast child has closed, reproducing slow disk
87
+ // without a timer or depending on runner load. Stderr must already be captured.
88
+ if(args[0]==='tg_10' && args[1].pid && children[0].exitCode===null) await once(children[0],'close')
89
+ return originalPatch.apply(this,args)
90
+ })
91
+ try{
92
+ await control.requestPairing(101,101);await control.approveOwner(101)
93
+ const owner=(await control.status()).owner!,execution=await control.captureChoice(initialPreset('grok')),start=Date.now()+2000
94
+ await scheduler.save({id:'review',name:'Review failures',text:'Review failures',trigger:{everySeconds:60,start:new Date(start).toISOString()},when:'unreviewed-failures',enabled:true,owner,execution})
95
+ await scheduler.tick(owner,runs,start)
96
+ assert.equal((await runs.list()).length,0,'no model job for an empty inbox')
97
+ const update:Update={update_id:10,message:{message_id:10,date:0,text:'Run fixture',from:{id:101,is_bot:false,first_name:'Fixture'},chat:{id:101,type:'private',first_name:'Fixture'}}}
98
+ await relay.bot.handleUpdate(update);await relay.drainInbox(true)
99
+ await until(async()=>(await runs.get('tg_10'))?.status==='failed')
100
+ const failed=(await runs.get('tg_10'))!
101
+ assert.equal(failed.exitCode,7);assert.equal(failed.failure?.relayVersion,packageVersion);assert.ok(!failed.failure?.error.includes('secret-value'))
102
+ await scheduler.tick(owner,runs,start+60000);await relay.drainSources()
103
+ await until(async()=>(await runs.list()).some(r=>r.scheduled && r.status==='completed'))
104
+ await relay.drainOutbox();assert.deepEqual(replies,['Fixture recovery verified'])
105
+ assert.equal((await runs.get('tg_10'))?.status,'failed');assert.equal(needsFailureReview((await runs.get('tg_10'))!),false)
106
+ await scheduler.tick(owner,runs,start+120000);await relay.drainSources();await relay.drainOutbox()
107
+ assert.equal(reviews,1);assert.equal(replies.length,1)
108
+ await relay.bot.handleUpdate({...update,update_id:11,message:{...update.message!,message_id:11}});await relay.drainInbox(true)
109
+ await relay.bot.handleUpdate({...update,update_id:12,message:{...update.message!,message_id:12,text:'/stop'}})
110
+ await until(async()=>(await runs.get('tg_11'))?.status==='cancelled')
111
+ assert.equal(needsFailureReview((await runs.get('tg_11'))!),false)
112
+ }finally{await relay.stop();for(const child of children)child.kill();await rm(dir,{recursive:true,force:true})}
113
+ })
114
+
115
+
116
+ test('relay shutdown waits for executor cleanup and final run state', async () => {
117
+ const dir=await mkdtemp(join(tmpdir(),'ez-stop-finalize-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
118
+ let release!:()=>void,entered!:()=>void
119
+ const gate=new Promise<void>(resolve=>{release=resolve}),cleaning=new Promise<void>(resolve=>{entered=resolve})
120
+ const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{
121
+ const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:['pipe','pipe','pipe']})
122
+ await once(child,'spawn')
123
+ return {child,cleanup:async()=>{entered();await gate},stdout:''}
124
+ })
125
+ relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
126
+ relay.bot.api.config.use(async()=>({ok:true,result:{message_id:1}} as any))
127
+ try {
128
+ await control.requestPairing(101,101);await control.approveOwner(101)
129
+ await relay.bot.handleUpdate({update_id:90,message:{message_id:90,date:0,text:'fixture',from:{id:101,is_bot:false,first_name:'Fixture'},chat:{id:101,type:'private',first_name:'Fixture'}}})
130
+ await relay.drainInbox(true)
131
+ let stopped=false
132
+ const stop=relay.stop().then(()=>{stopped=true})
133
+ await cleaning
134
+ assert.equal(stopped,false,'stop must not finish before cleanup')
135
+ release();await stop
136
+ assert.notEqual((await runs.get('tg_90'))?.status,'running')
137
+ } finally {release();await relay.stop();await rm(dir,{recursive:true,force:true})}
138
+ })
139
+
140
+ for (const cleanupFails of [false,true]) test(`polling conflict preserves work until an explicit shutdown (cleanup fails: ${cleanupFails})`, async t => {
141
+ const dir=await mkdtemp(join(tmpdir(),'ez-polling-conflict-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
142
+ let release!:()=>void,entered!:()=>void,releaseDelivery!:()=>void,sending!:()=>void,child:ReturnType<typeof spawn>|undefined,polls=0
143
+ const gate=new Promise<void>(resolve=>{release=resolve}),cleaning=new Promise<void>(resolve=>{entered=resolve})
144
+ const deliveryGate=new Promise<void>(resolve=>{releaseDelivery=resolve}),deliveryStarted=new Promise<void>(resolve=>{sending=resolve})
145
+ const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{
146
+ child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:['pipe','pipe','pipe']})
147
+ await once(child,'spawn')
148
+ return {child,cleanup:async()=>{entered();await gate;await control.status()},stdout:''}
149
+ })
150
+ const stopSource=TelegramSource.prototype.stop
151
+ let sourceStops=0
152
+ t.mock.method(TelegramSource.prototype,'stop',async function(this:TelegramSource){
153
+ sourceStops++;await stopSource.call(this)
154
+ if(cleanupFails) throw new Error('Synthetic shutdown failure')
155
+ })
156
+ relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
157
+ relay.bot.api.config.use(async(_prev,method)=>{
158
+ if(method==='getUpdates') {
159
+ polls++
160
+ return {ok:false,error_code:409,description:'Conflict: another getUpdates request'} as any
161
+ }
162
+ if(method==='sendMessage') {sending();await deliveryGate}
163
+ return {ok:true,result:{message_id:1}} as any
164
+ })
165
+ try {
166
+ await control.requestPairing(101,101);await control.approveOwner(101)
167
+ await relay.bot.handleUpdate({update_id:92,message:{message_id:92,date:0,text:'fixture',from:{id:101,is_bot:false,first_name:'Fixture'},chat:{id:101,type:'private',first_name:'Fixture'}}})
168
+ await relay.drainInbox(true)
169
+ const item=await runs.enqueueMessage('tg_92','Fixture reply')
170
+ const delivery=relay.drainOutbox()
171
+ await deliveryStarted
172
+ let finished=false
173
+ const start=relay.start().finally(()=>{finished=true})
174
+ await until(async()=>polls===1)
175
+ assert.equal(sourceStops,0,'a polling conflict must not stop the relay')
176
+ assert.equal((await runs.get('tg_92'))?.status,'running')
177
+ assert.ok(child && child.exitCode===null && child.signalCode===null)
178
+ const stopping=relay.stop()
179
+ assert.equal(relay.stop(),stopping,'concurrent stop calls share one promise')
180
+ const stopped=cleanupFails?assert.rejects(stopping,/Synthetic shutdown failure/):stopping
181
+ assert.equal(finished,false,'relay start must wait for the explicit shutdown')
182
+ await cleaning
183
+ release()
184
+ await until(async()=>(await runs.get('tg_92'))?.status!=='running')
185
+ assert.equal(finished,false,'shutdown must wait for the in-flight delivery receipt')
186
+ releaseDelivery();await delivery
187
+ if(cleanupFails) await assert.rejects(start,/Synthetic shutdown failure/);else await start
188
+ await stopped
189
+ assert.equal(relay.stop(),stopping,'finished shutdown remains idempotent')
190
+ assert.equal(sourceStops,1)
191
+ assert.deepEqual(JSON.parse(await readFile(join(dir,'outbox',`${item.id}.sent.json`),'utf8')).receipt.messageIds,[1])
192
+ assert.equal(polls,1,'a conflict must not start another polling loop before the retry delay')
193
+ assert.equal(relay.bot.isRunning(),false)
194
+ assert.ok(child && (child.exitCode!==null || child.signalCode!==null))
195
+ assert.notEqual((await runs.get('tg_92'))?.status,'running')
196
+ await assert.rejects(readFile(join(dir,'control-state.lock')), {code:'ENOENT'})
197
+ assert.equal((await control.status()).owner?.telegramUserId,101)
198
+ } finally {release();releaseDelivery();child?.kill();await relay.stop().catch(()=>{});await rm(dir,{recursive:true,force:true})}
199
+ })
200
+
201
+ for (const intake of [true,false]) test(`relay shutdown terminates an in-flight ${intake?'intake':'scheduled'} launch`, async () => {
202
+ const dir=await mkdtemp(join(tmpdir(),'ez-stop-launch-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
203
+ let release!:()=>void,entered!:()=>void,child:ReturnType<typeof spawn>|undefined,cleaned=false
204
+ const gate=new Promise<void>(resolve=>{release=resolve}),launching=new Promise<void>(resolve=>{entered=resolve})
205
+ const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{
206
+ entered();await gate
207
+ child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:['pipe','pipe','pipe']})
208
+ await once(child,'spawn')
209
+ return {child,cleanup:async()=>{cleaned=true},stdout:''}
210
+ })
211
+ relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
212
+ relay.bot.api.config.use(async()=>({ok:true,result:{message_id:1}} as any))
213
+ try {
214
+ await control.requestPairing(101,101);const owner=await control.approveOwner(101)
215
+ if(intake) await relay.bot.handleUpdate({update_id:91,message:{message_id:91,date:0,text:'fixture',from:{id:101,is_bot:false,first_name:'Fixture'},chat:{id:101,type:'private',first_name:'Fixture'}}})
216
+ else {
217
+ const scheduler=new Scheduler(dir),at=Date.now()+1000,execution=await control.captureChoice(initialPreset('grok'))
218
+ await scheduler.save({id:'fixture',name:'Fixture',text:'fixture',trigger:{at:new Date(at).toISOString()},enabled:true,owner,execution})
219
+ await scheduler.tick(owner,runs,at)
220
+ }
221
+ const drain=intake?relay.drainInbox(true):relay.drainSources()
222
+ await launching
223
+ let stopped=false
224
+ const stop=relay.stop().then(()=>{stopped=true})
225
+ await new Promise(resolve=>setImmediate(resolve))
226
+ assert.equal(stopped,false,'stop must wait for the pending launch')
227
+ release();await drain
228
+ // Bound regressions without leaving a real child running on a failed check.
229
+ await until(async()=>stopped)
230
+ await stop
231
+ assert.equal(cleaned,true)
232
+ assert.ok(child && (child.exitCode!==null || child.signalCode!==null))
233
+ assert.ok((await runs.list()).every(run=>run.status!=='running'))
234
+ } finally {release();child?.kill();await relay.stop();await rm(dir,{recursive:true,force:true})}
235
+ })
236
+
237
+ test('group members can inspect failures and wake review without exposing other chats', async t => {
238
+ const dir=await mkdtemp(join(tmpdir(),'ez-group-failure-'));t.after(()=>rm(dir,{recursive:true,force:true}))
239
+ const runs=new RunStore(dir),control=new ControlStore(dir,900000),scheduler=new Scheduler(dir)
240
+ await control.requestPairing(101,-123,'Fixture');const owner=await control.approveOwner(-123,true)
241
+ const execution=await control.captureChoice(initialPreset('grok'))
242
+ await runs.create({id:'tg_1',chatId:-123,telegramUserId:202,texts:['failed'],execution})
243
+ await runs.patch('tg_1',{status:'failed',endedAt:new Date().toISOString()})
244
+ await runs.create({id:'tg_2',chatId:-124,telegramUserId:202,texts:['private'],execution})
245
+ await runs.patch('tg_2',{status:'failed'})
246
+ await runs.create({id:'tg_3',chatId:-123,telegramUserId:303,texts:['review'],execution})
247
+ await runs.patch('tg_3',{status:'running'})
248
+ const env={...process.env,EZ_CONTROL_DIR:dir,EZ_EXECUTOR_CLI:'grok',EZ_RUN_ID:'tg_3'}
249
+ const result=JSON.parse((await exec(process.execPath,[bin,'failures'],{env})).stdout)
250
+ assert.deepEqual(result.runs.map((r:any)=>r.id),['tg_1'])
251
+ const at=Date.now()+1000
252
+ await scheduler.save({id:'review',name:'Review',text:'Review failures',trigger:{at:new Date(at).toISOString()},when:'unreviewed-failures',enabled:true,owner,execution})
253
+ await scheduler.tick(owner,runs,at)
254
+ assert.equal((await runs.list()).filter(r=>r.scheduled).length,1)
255
+ await assert.rejects(exec(process.execPath,[bin,'run','tg_2'],{env}),/Unknown owner/)
256
+ })
@@ -0,0 +1,36 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, rm } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { ControlStore } from '../src/control-state.js'
7
+ import { isOwner, ownsRun } from '../src/identity.js'
8
+ import { executionBlockReason } from '../src/execution-authority.js'
9
+ import { RunStore } from '../src/runs.js'
10
+
11
+ test('group pairing requires explicit group approval and grants only the exact group', async () => {
12
+ const dir = await mkdtemp(join(tmpdir(), 'ez-group-owner-'))
13
+ try {
14
+ const store = new ControlStore(dir, 900000)
15
+ await store.requestPairing(101, -123, 'Test group')
16
+ assert.equal((await store.status()).owner, null)
17
+ await assert.rejects(store.approveOwner(101), /No active/)
18
+ await assert.rejects(store.approveOwner(-124, true), /No active/)
19
+ const owner = await store.approveOwner(-123, true)
20
+ assert.equal(owner.kind, 'group')
21
+ const ctx = (id: number, chat = -123, bot = false) => ({from: {id, is_bot: bot, first_name: 'Member'}, chat: {id: chat, type: 'supergroup' as const, title: 'Test'}})
22
+ assert.equal(isOwner(ctx(101), owner), true)
23
+ assert.equal(isOwner(ctx(202), owner), true)
24
+ assert.equal(isOwner(ctx(202, -124), owner), false)
25
+ assert.equal(isOwner(ctx(202, -123, true), owner), false)
26
+ assert.equal(isOwner({...ctx(101), chat: {id: 101, type: 'private', first_name: 'User'}}, owner), false)
27
+ const run = await new RunStore(dir).create({id: 'tg_1', chatId: -123, telegramUserId: 202, texts: ['Hi']})
28
+ assert.equal(executionBlockReason(run, owner), undefined)
29
+ assert.equal(executionBlockReason({...run, chatId: -124}, owner), 'owner-mismatch')
30
+ assert.equal(ownsRun(owner, {...run, telegramUserId: 0}), false)
31
+ const restored = (await new ControlStore(dir, 900000).status()).owner
32
+ assert.deepEqual(restored, owner)
33
+ await store.revokeOwner()
34
+ assert.equal(ownsRun((await store.status()).owner, run), false)
35
+ } finally { await rm(dir, {recursive: true, force: true}) }
36
+ })
@@ -1,7 +1,7 @@
1
1
  import { ownerRun } from './helpers/owner-run.js'
2
2
  import assert from 'node:assert/strict'
3
3
  import test from 'node:test'
4
- import { mkdtemp, mkdir, readFile, writeFile, rm, realpath } from 'node:fs/promises'
4
+ import { mkdtemp, mkdir, readFile, writeFile, rm, realpath, symlink } from 'node:fs/promises'
5
5
  import path from 'node:path'
6
6
  import { tmpdir } from 'node:os'
7
7
  import { serveHostExecutor } from '../src/host-executor.js'
@@ -9,7 +9,9 @@ import { spawn } from 'node:child_process'
9
9
  import { fileURLToPath } from 'node:url'
10
10
  import { isHostRunId } from '../src/host-executor-protocol.js'
11
11
  import { EXECUTOR_REGISTRY } from '../src/executor.js'
12
+ import { RunStore } from '../src/runs.js'
12
13
  import { packageVersion } from '../src/version.js'
14
+ import { executionDefaults } from '../src/model-policy.js'
13
15
 
14
16
  test('one installed CLI executes two agent bindings with separate minds and sanitized environment', async () => {
15
17
  const root=await mkdtemp(path.join(tmpdir(),'ez-host-'))
@@ -23,16 +25,19 @@ test('one installed CLI executes two agent bindings with separate minds and sani
23
25
  const binary=path.join(root,'cli')
24
26
  await writeFile(binary,`#!${process.execPath}\nif(process.env.EZ_RUN_ID==='r_hold')setInterval(()=>{},1000);console.log(JSON.stringify({cwd:process.cwd(),home:process.env.HOME,token:process.env.TELEGRAM_BOT_TOKEN,control:process.env.EZ_CONTROL_DIR,run:process.env.EZ_RUN_ID,args:process.argv.slice(2)}));\n`,{mode:0o700})
25
27
  await writeFile(path.join(root,'claude'),await readFile(binary),{mode:0o700})
28
+ await writeFile(path.join(root,'codex'),await readFile(binary),{mode:0o700})
26
29
  process.env.PATH=root+path.delimiter+oldPath
27
30
  EXECUTOR_REGISTRY.grok.command=binary
28
31
  EXECUTOR_REGISTRY.grok.buildArgs=EXECUTOR_REGISTRY.codex.buildArgs
29
32
  process.env.TELEGRAM_BOT_TOKEN='must-not-reach-host-cli'
33
+ const sharedAlias=path.join(root,'shared-alias')
34
+ await symlink(root,sharedAlias)
30
35
  const agents=await Promise.all(['one','two'].map(async name=>{
31
36
  const workspace=path.join(root,name,'mind'),controlDir=path.join(root,name,'control')
32
37
  await mkdir(workspace,{recursive:true});await mkdir(controlDir,{recursive:true})
33
38
  const toolsHome=path.join(root,name,'tools');await mkdir(toolsHome)
34
39
  await writeFile(path.join(toolsHome,'config.json'),JSON.stringify({schemaVersion:1,workspace:await realpath(workspace)}))
35
- return {name,workspace,controlDir,binDir:path.join(root,'bin'),toolsHome}
40
+ return {name,workspace,controlDir,binDir:path.join(root,'bin'),toolsHome,sharedWorkspace:name==='two'?sharedAlias:root}
36
41
  }))
37
42
  server=serveHostExecutor({cli:'grok',agents},abort.signal)
38
43
  for(const agent of agents){
@@ -51,7 +56,9 @@ test('one installed CLI executes two agent bindings with separate minds and sani
51
56
  assert.equal(result.cwd,await realpath(agent.workspace))
52
57
  assert.equal(result.control,agent.controlDir)
53
58
  assert.equal(result.token,undefined)
59
+ assert.match(result.args.join(' '),/you are its repairer/)
54
60
  assert.ok(result.args.includes(agent.toolsHome))
61
+ assert.ok(result.args.includes(await realpath(root)))
55
62
  assert.ok(!result.args.includes('/wrong'))
56
63
  assert.equal(result.home,process.env.HOME)
57
64
  }
@@ -62,9 +69,12 @@ test('one installed CLI executes two agent bindings with separate minds and sani
62
69
  let stdout='',stderr=''
63
70
  client.stdout.on('data',chunk=>stdout+=chunk)
64
71
  client.stderr.on('data',chunk=>stderr+=chunk)
65
- client.stdin.end(JSON.stringify({texts:['Telegram message'],options:{cli:'grok',timeoutMs:5000}}))
72
+ client.stdin.end(JSON.stringify({texts:['Telegram message'],options:{cli:'grok',timeoutMs:5000,codexAutoCompactTokens:32000,repairEnabled:false}}))
66
73
  assert.equal(await new Promise(resolve=>client.once('close',resolve)),0,stderr)
67
74
  assert.equal(JSON.parse(stdout).run,'tg_6293305')
75
+ assert.match(JSON.parse(stdout).args.join(' '),/Automatic repair is disabled/)
76
+ assert.doesNotMatch(JSON.parse(stdout).args.join(' '),/you are its repairer/)
77
+ assert.ok(JSON.parse(stdout).args.includes('model_auto_compact_token_limit=32000'))
68
78
  const eventId='event_'+'a'.repeat(64)
69
79
  await ownerRun(agents[0].controlDir, eventId, {sourceId:'fixture',bindingId:'binding',eventIds:['1']})
70
80
  const eventClient=spawn(process.execPath,['--import',fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs',import.meta.url)),fileURLToPath(new URL('../src/host-executor-client.ts',import.meta.url)),agents[0].controlDir,eventId],{stdio:['pipe','pipe','pipe']})
@@ -80,22 +90,43 @@ test('one installed CLI executes two agent bindings with separate minds and sani
80
90
  let switchedOutput=''
81
91
  switched.stdout.on('data',chunk=>switchedOutput+=chunk)
82
92
  switched.stderr.resume()
83
- switched.stdin.end(JSON.stringify({texts:['Explicit CLI change'],options:{cli:'claude',timeoutMs:5000}}))
93
+ switched.stdin.end(JSON.stringify({texts:['Explicit CLI change'],options:executionDefaults('claude',{cli:'claude',timeoutMs:5000,effort:undefined})}))
84
94
  assert.equal(await new Promise(resolve=>switched.once('close',resolve)),0)
85
95
  assert.ok(JSON.parse(switchedOutput).args.includes('--print'))
96
+ // The bound cache may advertise a model absent from the host's cache.
97
+ const codexHome=path.join(agents[0].controlDir,'cli','codex')
98
+ await mkdir(codexHome,{recursive:true})
99
+ await writeFile(path.join(codexHome,'models_cache.json'),JSON.stringify({models:[{slug:'agent-only-fixture',visibility:'list',display_name:'Agent model',supported_reasoning_levels:[]}]}))
100
+ await ownerRun(agents[0].controlDir,'tg_6293307')
101
+ const bound=spawn(process.execPath,['--import',fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs',import.meta.url)),fileURLToPath(new URL('../src/host-executor-client.ts',import.meta.url)),agents[0].controlDir,'tg_6293307'],{stdio:['pipe','pipe','pipe']})
102
+ let boundOutput='',boundError=''
103
+ bound.stdout.on('data',chunk=>boundOutput+=chunk)
104
+ bound.stderr.on('data',chunk=>boundError+=chunk)
105
+ bound.stdin.end(JSON.stringify({texts:['Switch to agent model'],options:{cli:'codex',model:'agent-only-fixture',timeoutMs:5000}}))
106
+ assert.equal(await new Promise(resolve=>bound.once('close',resolve)),0,boundError)
107
+ assert.ok(JSON.parse(boundOutput).args.includes('agent-only-fixture'))
86
108
  await assert.rejects(serveHostExecutor({cli:'grok',agents},new AbortController().signal),/already running/)
87
109
  const directory=path.join(agents[0].controlDir,'host-executor')
88
110
  const submit=async(id:string)=>{await ownerRun(agents[0].controlDir,id);await writeFile(path.join(directory,id+'.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok',timeoutMs:5000}}))}
89
111
  await submit('r_hold')
90
112
  for(let n=0;n<100;n++){try{await readFile(path.join(directory,'r_hold.process.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
91
- await submit('r_queued')
113
+ await new RunStore(agents[0].controlDir).create({id:'r_schedule_queued',chatId:101,telegramUserId:101,texts:['test'],scheduled:{id:'shared',revision:'v1',dueAt:new Date().toISOString(),pairedAt:new Date().toISOString()}})
114
+ await new RunStore(agents[0].controlDir).patch('r_schedule_queued',{status:'running'})
115
+ await writeFile(path.join(directory,'r_schedule_queued.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok',sharedWorkspace:'/wrong'}}))
116
+ const otherDirectory=path.join(agents[1].controlDir,'host-executor')
117
+ await ownerRun(agents[1].controlDir,'r_other_shared')
118
+ await writeFile(path.join(otherDirectory,'r_other_shared.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok'}}))
92
119
  await new Promise(r=>setTimeout(r,350))
93
- await assert.rejects(readFile(path.join(directory,'r_queued.running.json')),{code:'ENOENT'})
120
+ await assert.rejects(readFile(path.join(directory,'r_schedule_queued.running.json')),{code:'ENOENT'})
121
+ await assert.rejects(readFile(path.join(otherDirectory,'r_other_shared.running.json')),{code:'ENOENT'})
122
+ await assert.rejects(readFile(path.join(otherDirectory,'r_other_shared.events')),{code:'ENOENT'})
94
123
  await writeFile(path.join(directory,'r_hold.cancel'),'')
95
124
  let output=''
96
- for(let n=0;n<200;n++){try{output=await readFile(path.join(directory,'r_queued.events'),'utf8');if(output.includes('"stream":"exit"'))break}catch{}await new Promise(r=>setTimeout(r,20))}
125
+ for(let n=0;n<200;n++){try{output=await readFile(path.join(directory,'r_schedule_queued.events'),'utf8');if(output.includes('"stream":"exit"'))break}catch{}await new Promise(r=>setTimeout(r,20))}
97
126
  assert.match(output, /"stream":"exit","code":0/)
98
127
  assert.match(await readFile(path.join(directory,'r_hold.events'),'utf8'), /"stream":"exit","code":1/)
128
+ for(let n=0;n<200;n++){try{output=await readFile(path.join(otherDirectory,'r_other_shared.events'),'utf8');if(output.includes('"stream":"exit"'))break}catch{}await new Promise(r=>setTimeout(r,20))}
129
+ assert.match(output, /"stream":"exit","code":0/)
99
130
  } finally {
100
131
  abort.abort();await server
101
132
  EXECUTOR_REGISTRY.grok.command=old
@@ -129,6 +160,22 @@ test('client tolerates missing heartbeat and consumes completion before checking
129
160
  } finally {client.kill();await closed;await rm(root,{recursive:true,force:true})}
130
161
  })
131
162
 
163
+ test('client records a relay interruption before exiting 130',async()=>{
164
+ const root=await mkdtemp(path.join(tmpdir(),'ez-host-interrupt-'))
165
+ const directory=path.join(root,'host-executor');await mkdir(directory)
166
+ const client=spawn(process.execPath,['--import',fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs',import.meta.url)),fileURLToPath(new URL('../src/host-executor-client.ts',import.meta.url)),root,'tg_97'],{stdio:['pipe','pipe','pipe']})
167
+ let stderr='';client.stderr.on('data',chunk=>stderr+=chunk);client.stdout.resume()
168
+ client.stdin.end(JSON.stringify({texts:['test'],options:{}}))
169
+ try {
170
+ for(let n=0;n<100;n++){try{await readFile(path.join(directory,'tg_97.request.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
171
+ const closed=new Promise<number|null>(resolve=>client.once('close',resolve))
172
+ client.kill('SIGTERM')
173
+ assert.equal(await closed,130)
174
+ assert.equal(await readFile(path.join(directory,'tg_97.cancel'),'utf8'),'')
175
+ assert.match(stderr,/Host executor client interrupted by SIGTERM/)
176
+ } finally {if(client.exitCode===null && client.signalCode===null)client.kill();await rm(root,{recursive:true,force:true})}
177
+ })
178
+
132
179
  test('client cancels on stale or invalid heartbeat instead of waiting indefinitely',async()=>{
133
180
  for(const heartbeat of [{at:Date.now()-60000},{at:'invalid'}]) {
134
181
  const root=await mkdtemp(path.join(tmpdir(),'ez-heartbeat-invalid-'))
@@ -11,6 +11,8 @@ import { ControlStore } from '../src/control-state.js'
11
11
  import { RunStore } from '../src/runs.js'
12
12
  import { InboxStore } from '../src/inbox.js'
13
13
  import { ApprovalStore } from '../src/approval.js'
14
+ import { Tasks } from '../src/tasks.js'
15
+ import { ownerRun } from './helpers/owner-run.js'
14
16
  import { packageVersion } from '../src/version.js'
15
17
 
16
18
  const message = (id: number, text = 'hello'): Update => ({
@@ -27,6 +29,7 @@ const fixture = async () => {
27
29
  const dir = await mkdtemp(join(tmpdir(), 'ez-intake-relay-'))
28
30
  const launched: string[][] = []
29
31
  const replies: string[] = []
32
+ const members = new Map<number, string>()
30
33
  const keyboards: { text: string; callback_data: string }[][][] = []
31
34
  const children: ReturnType<typeof spawn>[] = []
32
35
  const config = {
@@ -53,12 +56,13 @@ const fixture = async () => {
53
56
  username: 'fixture_bot',
54
57
  } as typeof relay.bot.botInfo
55
58
  relay.bot.api.config.use(async (_previous, method, payload) => {
59
+ if (method === 'getChatMember' && members.get((payload as {user_id: number}).user_id) === 'error') throw new Error('Fixture membership unavailable')
56
60
  if (method === 'sendMessage') replies.push((payload as { text: string }).text)
57
61
  const keyboard = (payload as { reply_markup?: { inline_keyboard?: { text: string; callback_data: string }[][] } }).reply_markup?.inline_keyboard
58
62
  if (keyboard) keyboards.push(keyboard)
59
63
  return {
60
64
  ok: true,
61
- result: method === 'getFile' ? { file_path: 'fixture.ogg' } : { message_id: 42 },
65
+ result: method === 'getFile' ? { file_path: 'fixture.ogg' } : method === 'getChatMember' ? {status: members.get((payload as {user_id: number}).user_id) ?? 'member'} : { message_id: 42 },
62
66
  } as never
63
67
  })
64
68
  return relay
@@ -71,6 +75,7 @@ const fixture = async () => {
71
75
  dir,
72
76
  launched,
73
77
  replies,
78
+ members,
74
79
  keyboards,
75
80
  get relay() {
76
81
  return relay
@@ -94,6 +99,108 @@ const fixture = async () => {
94
99
  }
95
100
  }
96
101
 
102
+ test('approved owner group accepts different members, controls and group delivery; rejects other chats and anonymous posts', async () => {
103
+ const f = await fixture()
104
+ const control = new ControlStore(f.dir, 900000)
105
+ const group = (id: number, sender = 202, chatId = -101): Update => ({update_id: id, message: {
106
+ message_id: id, date: 0, text: 'Hello', from: {id: sender, is_bot: false, first_name: 'Member'},
107
+ chat: {id: chatId, type: 'supergroup', title: 'Team'},
108
+ }})
109
+ try {
110
+ await control.revokeOwner()
111
+ await f.relay.bot.handleUpdate(group(1))
112
+ assert.equal(f.launched.length, 0)
113
+ assert.equal((await control.status()).pending[0].title, 'Team')
114
+ await control.approveOwner(-101, true)
115
+ await f.relay.bot.handleUpdate(group(2, 303))
116
+ await f.relay.bot.handleUpdate(group(3, 404))
117
+ await f.relay.drainInbox(true)
118
+ const run = (await new RunStore(f.dir).list())[0]
119
+ assert.equal(run.chatId, -101)
120
+ assert.equal(run.telegramUserId, 303)
121
+ assert.equal(run.texts.length, 2)
122
+ assert.match(run.texts[0], /303/)
123
+ assert.match(run.texts[1], /404/)
124
+ assert.equal(f.launched.length, 1)
125
+ await new RunStore(f.dir).enqueueMessage(run.id, 'Group reply')
126
+ await f.relay.drainOutbox()
127
+ assert.ok(f.replies.includes('Group reply'))
128
+ const approval = new ApprovalStore(f.dir)
129
+ await approval.requestApproval('group_action', 'Approve this?', run.id)
130
+ f.members.set(707, 'left')
131
+ await f.relay.bot.handleUpdate({update_id: 12, callback_query: {
132
+ id: 'outsider', chat_instance: 'test', from: {id: 707, is_bot: false, first_name: 'Outsider'},
133
+ message: group(12).message!, data: 'approval:group_action:approve',
134
+ }})
135
+ assert.equal((await approval.getDecision('group_action'))?.decision, 'pending')
136
+ f.members.set(707, 'error')
137
+ await assert.rejects(f.relay.bot.handleUpdate({update_id: 13, callback_query: {
138
+ id: 'unavailable', chat_instance: 'test', from: {id: 707, is_bot: false, first_name: 'Member'},
139
+ message: group(13).message!, data: 'approval:group_action:approve',
140
+ }}), /membership verification unavailable/)
141
+ await f.relay.bot.handleUpdate({update_id: 4, callback_query: {
142
+ id: 'callback', chat_instance: 'test', from: {id: 404, is_bot: false, first_name: 'Second'},
143
+ message: group(4).message!, data: 'approval:group_action:approve',
144
+ }})
145
+ await f.relay.drainInbox(true)
146
+ assert.equal((await approval.getDecision('group_action'))?.decidedBy, 404)
147
+ await f.relay.bot.handleUpdate(group(5, 202, -102))
148
+ await f.relay.bot.handleUpdate(message(6))
149
+ const anonymous = group(7); anonymous.message!.sender_chat = anonymous.message!.chat
150
+ await f.relay.bot.handleUpdate(anonymous)
151
+ const bot = group(8); bot.message!.from!.is_bot = true
152
+ await f.relay.bot.handleUpdate(bot)
153
+ assert.equal((await new InboxStore(f.dir).status()).pending, 0)
154
+ const status = group(9); status.message!.text = '/status'
155
+ await f.relay.bot.handleUpdate(status)
156
+ assert.ok(f.replies.some(text => text.includes('🟢 Ez is online')))
157
+ assert.ok(f.replies.some(text => text.includes('Queue:')))
158
+ // Seed the retry fixture only after the relay writer and its timer are idle.
159
+ await f.relay.stop()
160
+ await f.relay.drainInbox()
161
+ const inbox = new InboxStore(f.dir)
162
+ await inbox.accept(group(10, 505))
163
+ const failed = await inbox.next(true)
164
+ await inbox.finish(failed!.id, true)
165
+ await f.relay.bot.handleUpdate({update_id: 11, callback_query: {
166
+ id: 'retry', chat_instance: 'test', from: {id: 606, is_bot: false, first_name: 'Third'},
167
+ message: group(11).message!, data: 'menu:retry',
168
+ }})
169
+ assert.equal(await inbox.pending(failed!.id), true)
170
+ await inbox.cancel()
171
+ await control.revokeOwner()
172
+ await assert.rejects(new RunStore(f.dir).enqueueMessage('not_a_run', 'no'))
173
+ } finally { await f.close() }
174
+ })
175
+
176
+ test('owner group discovery routes only to the private chat and rechecks identity', async () => {
177
+ const f = await fixture()
178
+ const group = (id: number, sender = 101): Update => ({update_id: id, message: {
179
+ message_id: id, date: 0, text: 'Hi from the group',
180
+ from: {id: sender, is_bot: false, first_name: 'Fixture'},
181
+ chat: {id: -101, type: 'supergroup', title: 'Family'},
182
+ }})
183
+ try {
184
+ await f.relay.bot.handleUpdate(group(1, 202))
185
+ const anonymous = group(2)
186
+ anonymous.message!.sender_chat = anonymous.message!.chat
187
+ await f.relay.bot.handleUpdate(anonymous)
188
+ assert.equal((await new InboxStore(f.dir).status()).pending, 0)
189
+ await f.relay.bot.handleUpdate(group(3))
190
+ await f.relay.drainInbox(true)
191
+ const run = (await new RunStore(f.dir).list())[0]
192
+ assert.equal(run.chatId, 101)
193
+ assert.equal(run.messageId, undefined)
194
+ assert.match(run.texts[0], /Reply privately/)
195
+ assert.match(run.texts[0], /"chatId":-101/)
196
+ assert.equal(f.launched.length, 1)
197
+ await f.relay.bot.handleUpdate(group(4))
198
+ await new ControlStore(f.dir, 1000).revokeOwner()
199
+ await f.relay.drainInbox(true)
200
+ assert.equal(f.launched.length, 1)
201
+ } finally { await f.close() }
202
+ })
203
+
97
204
  test('four-item menu is owner-only; saved AI buttons work and forged/stale buttons cannot change settings', async () => {
98
205
  const f = await fixture()
99
206
  const callback = (id: number, data: string, user = 101): Update => ({
@@ -118,6 +225,10 @@ test('four-item menu is owner-only; saved AI buttons work and forged/stale butto
118
225
  assert.match(f.replies.at(-1)!, /Menu expired/)
119
226
  await f.relay.bot.handleUpdate(message(7, '/settings'))
120
227
  assert.match(f.replies.at(-1)!, /Default for new conversations/)
228
+ await f.relay.bot.handleUpdate(message(8, '/status'))
229
+ assert.ok(f.keyboards.at(-1)!.flat().some((button) => button.text === 'Scheduled tasks'))
230
+ await f.relay.bot.handleUpdate(callback(9, 'menu:scheduled-tasks'))
231
+ assert.match(f.replies.at(-1)!, /No scheduled tasks for this owner/)
121
232
  assert.equal(f.launched.length, 0)
122
233
  } finally { await f.close() }
123
234
  })
@@ -188,7 +299,7 @@ test('slow voice normalization preserves instruction order and leaves controls r
188
299
  await downloading
189
300
  await f.relay.bot.handleUpdate(message(3, '/status'))
190
301
  assert.ok(f.replies.some((text) => text.includes('2 incoming messages')))
191
- assert.ok(f.replies.some((text) => text.includes(`Ez relay: ${packageVersion} (running)`)))
302
+ assert.ok(f.replies.some((text) => text.includes(`Relay: running · v${packageVersion}`)))
192
303
  await f.relay.bot.handleUpdate(message(4, 'Next instruction'))
193
304
  assert.equal(f.launched.length, 0)
194
305
  release()
@@ -218,8 +329,8 @@ test('cancel clears accepted and queued work, never spawns a cancelled run, and
218
329
  assert.equal(f.launched.length, 0)
219
330
  assert.equal((await runs.get(run.id))?.status, 'cancelled')
220
331
  await f.relay.bot.handleUpdate(message(3, '/status'))
221
- assert.match(f.replies.at(-1)!, /0 runs; 0 incoming messages/)
222
- assert.match(f.replies.at(-1)!, /1 unknown/)
332
+ assert.match(f.replies.at(-1)!, /Queue: empty/)
333
+ assert.match(f.replies.at(-1)!, /1 delivery is awaiting confirmation/)
223
334
  } finally {
224
335
  await f.close()
225
336
  }
@@ -335,3 +446,33 @@ test('media failure quarantines its instruction batch instead of executing incom
335
446
  await f.close()
336
447
  }
337
448
  })
449
+
450
+
451
+ test('approved family messages enter restricted task runs, never the owner session', async()=>{
452
+ const f=await fixture()
453
+ const group=(id:number,sender=202):Update=>({update_id:id,message:{message_id:id,date:Math.ceil(Date.now()/1000),text:'hello Annie',from:{id:sender,is_bot:false,first_name:'Member'},chat:{id:-101,type:'group',title:'Family'}}})
454
+ try {
455
+ await f.relay.bot.handleUpdate(group(100)) // Registers transport, grants nothing.
456
+ assert.equal(f.launched.length,0)
457
+ await ownerRun(f.dir,'setup')
458
+ const tasks=new Tasks(f.dir),runs=new RunStore(f.dir)
459
+ const proposal=await tasks.ownerCall('setup','propose',{sourceId:'telegram',conversationId:'-101',purpose:'Family conversation',context:'Only group context',hours:24,waitForIncoming:true,untilRevoked:true}) as {id:string}
460
+ await new ApprovalStore(f.dir).recordDecision(proposal.id,'approved',101)
461
+ await tasks.decide(proposal.id)
462
+ await runs.patch('setup',{status:'completed'})
463
+ await f.relay.bot.handleUpdate(group(101))
464
+ const anonymous=group(102);anonymous.message!.sender_chat=anonymous.message!.chat
465
+ await f.relay.bot.handleUpdate(anonymous)
466
+ const bot=group(103);bot.message!.from!.is_bot=true;await f.relay.bot.handleUpdate(bot)
467
+ await new Promise(r=>setTimeout(r,3100))
468
+ await f.relay.drainSources()
469
+ const launched=(await runs.list()).filter(r=>r.taskId)
470
+ assert.equal(launched.length,1)
471
+ assert.equal(launched[0].taskId,proposal.id)
472
+ assert.equal(launched[0].external!.eventIds.length,1)
473
+ assert.equal(launched[0].external!.eventIds[0],'tg_n101_101')
474
+ assert.equal(f.launched.length,1)
475
+ assert.match(f.launched[0][0],/Member/)
476
+ await tasks.ownerCall('setup','list',{}).then(()=>assert.fail('completed run cannot change grants'),()=>{})
477
+ } finally {await f.close()}
478
+ })