@jc_stack/ez-agents 0.1.0-beta.13 → 0.1.0-beta.18
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.
- package/.dockerignore +3 -0
- package/.env.example +15 -0
- package/AGENTS.md +6 -3
- package/CHANGELOG.md +49 -0
- package/CONTRIBUTING.md +34 -4
- package/README.md +3 -0
- package/compose.yaml +8 -1
- package/docker/run.ts +1 -1
- package/docs/architecture/ai-selection.md +8 -0
- package/docs/architecture/authority-boundaries.md +24 -1
- package/docs/architecture/telegram-intake.md +1 -1
- package/docs/docker-runtime.md +35 -0
- package/docs/host-service.md +19 -0
- package/docs/pagerduty.md +42 -0
- package/docs/plugin-catalog.md +27 -10
- package/docs/plugin-contributions.md +9 -0
- package/docs/plugins.md +12 -1
- package/docs/releasing.md +20 -9
- package/docs/repair.md +41 -0
- package/docs/scheduling.md +30 -4
- package/docs/selective-monitoring.md +12 -4
- package/docs/setup.md +39 -0
- package/docs/trusted-publishing.md +140 -0
- package/docs/upgrades.md +24 -4
- package/package.json +6 -3
- package/scripts/generate-publish-caller.mjs +60 -0
- package/scripts/smoke-busy-reply.ts +58 -0
- package/scripts/trusted-beta.mjs +289 -0
- package/src/agent-guidance.ts +5 -0
- package/src/ai-cli.ts +2 -1
- package/src/ai.ts +15 -5
- package/src/client-defaults.ts +29 -13
- package/src/codex-session.ts +4 -2
- package/src/config.ts +29 -1
- package/src/control-state.ts +24 -7
- package/src/desktop-bridge.ts +8 -1
- package/src/event-sources.ts +2 -1
- package/src/execution-authority.ts +2 -1
- package/src/executor.ts +31 -6
- package/src/failure.ts +32 -0
- package/src/host-executor.ts +22 -13
- package/src/identity.ts +8 -3
- package/src/inbox.ts +7 -3
- package/src/index.ts +207 -79
- package/src/install-tools.mjs +2 -2
- package/src/menu.ts +6 -4
- package/src/model-policy.ts +15 -0
- package/src/owner.ts +3 -3
- package/src/pagerduty.ts +109 -0
- package/src/plugins/manager.mjs +47 -8
- package/src/plugins/shared.mjs +76 -0
- package/src/repair-policy.ts +13 -0
- package/src/reply-context.ts +67 -0
- package/src/reply-executor.ts +54 -0
- package/src/reply-mcp.ts +23 -0
- package/src/runs.ts +15 -4
- package/src/schedule-cli.ts +36 -7
- package/src/scheduler.ts +12 -3
- package/src/setup.ts +2 -1
- package/src/software-status.ts +5 -5
- package/src/task-cli.ts +3 -3
- package/src/task-executor.ts +7 -5
- package/src/tasks.ts +35 -17
- package/src/telegram-source.ts +94 -0
- package/src/updates/artifact.mjs +16 -0
- package/src/updates/binding.mjs +3 -1
- package/src/updates/control.mjs +4 -4
- package/src/updates/runtime.mjs +3 -1
- package/templates/agent/AGENTS.md +10 -2
- package/templates/agent/TOOLS.md +6 -0
- package/templates/agent-guidance.md +13 -0
- package/templates/failure-review.md +9 -0
- package/templates/maintainer-purpose.md +15 -0
- package/templates/updates.md +2 -2
- package/test/agent-guidance.test.ts +110 -0
- package/test/ai-cli.test.ts +7 -6
- package/test/ai.test.ts +41 -0
- package/test/busy-reply-relay.test.ts +41 -0
- package/test/client-defaults.test.ts +37 -5
- package/test/codex-context.test.ts +5 -2
- package/test/codex-session.test.ts +4 -2
- package/test/config.test.ts +29 -0
- package/test/executor.test.ts +11 -1
- package/test/failure.test.ts +250 -0
- package/test/group-owner.test.ts +36 -0
- package/test/host-executor.test.ts +38 -7
- package/test/intake-relay.test.ts +141 -4
- package/test/model-policy.test.ts +61 -0
- package/test/pagerduty.test.ts +104 -0
- package/test/plugin-manager.test.mjs +3 -2
- package/test/relay.test.ts +2 -2
- package/test/repair-policy.test.ts +23 -0
- package/test/reply.test.ts +131 -0
- package/test/schedule-cli.test.ts +8 -2
- package/test/shared-services.test.mjs +98 -0
- package/test/software-status.test.ts +5 -5
- package/test/task-native.test.ts +2 -2
- package/test/tasks.test.ts +14 -6
- package/test/telegram-source.test.ts +75 -0
- package/test/trusted-beta.test.mjs +224 -0
- package/test/updates.test.mjs +35 -3
|
@@ -0,0 +1,250 @@
|
|
|
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(`fatal polling conflict waits for shared shutdown without retrying (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=assert.rejects(relay.start(),/409.*Conflict/).then(()=>{finished=true})
|
|
174
|
+
await cleaning
|
|
175
|
+
const stopping=relay.stop()
|
|
176
|
+
assert.equal(relay.stop(),stopping,'concurrent stop calls share one promise')
|
|
177
|
+
const stopped=cleanupFails?assert.rejects(stopping,/Synthetic shutdown failure/):stopping
|
|
178
|
+
assert.equal(finished,false,'polling failure must wait for executor cleanup')
|
|
179
|
+
release()
|
|
180
|
+
await until(async()=>(await runs.get('tg_92'))?.status!=='running')
|
|
181
|
+
assert.equal(finished,false,'polling failure must wait for the in-flight delivery receipt')
|
|
182
|
+
releaseDelivery();await delivery;await start;await stopped
|
|
183
|
+
assert.equal(relay.stop(),stopping,'finished shutdown remains idempotent')
|
|
184
|
+
assert.equal(sourceStops,1)
|
|
185
|
+
assert.deepEqual(JSON.parse(await readFile(join(dir,'outbox',`${item.id}.sent.json`),'utf8')).receipt.messageIds,[1])
|
|
186
|
+
assert.equal(polls,1,'a conflict must not start another polling loop')
|
|
187
|
+
assert.equal(relay.bot.isRunning(),false)
|
|
188
|
+
assert.ok(child && (child.exitCode!==null || child.signalCode!==null))
|
|
189
|
+
assert.notEqual((await runs.get('tg_92'))?.status,'running')
|
|
190
|
+
await assert.rejects(readFile(join(dir,'control-state.lock')), {code:'ENOENT'})
|
|
191
|
+
assert.equal((await control.status()).owner?.telegramUserId,101)
|
|
192
|
+
} finally {release();releaseDelivery();child?.kill();await relay.stop().catch(()=>{});await rm(dir,{recursive:true,force:true})}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
for (const intake of [true,false]) test(`relay shutdown terminates an in-flight ${intake?'intake':'scheduled'} launch`, async () => {
|
|
196
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-stop-launch-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
|
|
197
|
+
let release!:()=>void,entered!:()=>void,child:ReturnType<typeof spawn>|undefined,cleaned=false
|
|
198
|
+
const gate=new Promise<void>(resolve=>{release=resolve}),launching=new Promise<void>(resolve=>{entered=resolve})
|
|
199
|
+
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{
|
|
200
|
+
entered();await gate
|
|
201
|
+
child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:['pipe','pipe','pipe']})
|
|
202
|
+
await once(child,'spawn')
|
|
203
|
+
return {child,cleanup:async()=>{cleaned=true},stdout:''}
|
|
204
|
+
})
|
|
205
|
+
relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
|
|
206
|
+
relay.bot.api.config.use(async()=>({ok:true,result:{message_id:1}} as any))
|
|
207
|
+
try {
|
|
208
|
+
await control.requestPairing(101,101);const owner=await control.approveOwner(101)
|
|
209
|
+
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'}}})
|
|
210
|
+
else {
|
|
211
|
+
const scheduler=new Scheduler(dir),at=Date.now()+1000,execution=await control.captureChoice(initialPreset('grok'))
|
|
212
|
+
await scheduler.save({id:'fixture',name:'Fixture',text:'fixture',trigger:{at:new Date(at).toISOString()},enabled:true,owner,execution})
|
|
213
|
+
await scheduler.tick(owner,runs,at)
|
|
214
|
+
}
|
|
215
|
+
const drain=intake?relay.drainInbox(true):relay.drainSources()
|
|
216
|
+
await launching
|
|
217
|
+
let stopped=false
|
|
218
|
+
const stop=relay.stop().then(()=>{stopped=true})
|
|
219
|
+
await new Promise(resolve=>setImmediate(resolve))
|
|
220
|
+
assert.equal(stopped,false,'stop must wait for the pending launch')
|
|
221
|
+
release();await drain
|
|
222
|
+
// Bound regressions without leaving a real child running on a failed check.
|
|
223
|
+
await until(async()=>stopped)
|
|
224
|
+
await stop
|
|
225
|
+
assert.equal(cleaned,true)
|
|
226
|
+
assert.ok(child && (child.exitCode!==null || child.signalCode!==null))
|
|
227
|
+
assert.ok((await runs.list()).every(run=>run.status!=='running'))
|
|
228
|
+
} finally {release();child?.kill();await relay.stop();await rm(dir,{recursive:true,force:true})}
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('group members can inspect failures and wake review without exposing other chats', async t => {
|
|
232
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-group-failure-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
233
|
+
const runs=new RunStore(dir),control=new ControlStore(dir,900000),scheduler=new Scheduler(dir)
|
|
234
|
+
await control.requestPairing(101,-123,'Fixture');const owner=await control.approveOwner(-123,true)
|
|
235
|
+
const execution=await control.captureChoice(initialPreset('grok'))
|
|
236
|
+
await runs.create({id:'tg_1',chatId:-123,telegramUserId:202,texts:['failed'],execution})
|
|
237
|
+
await runs.patch('tg_1',{status:'failed',endedAt:new Date().toISOString()})
|
|
238
|
+
await runs.create({id:'tg_2',chatId:-124,telegramUserId:202,texts:['private'],execution})
|
|
239
|
+
await runs.patch('tg_2',{status:'failed'})
|
|
240
|
+
await runs.create({id:'tg_3',chatId:-123,telegramUserId:303,texts:['review'],execution})
|
|
241
|
+
await runs.patch('tg_3',{status:'running'})
|
|
242
|
+
const env={...process.env,EZ_CONTROL_DIR:dir,EZ_EXECUTOR_CLI:'grok',EZ_RUN_ID:'tg_3'}
|
|
243
|
+
const result=JSON.parse((await exec(process.execPath,[bin,'failures'],{env})).stdout)
|
|
244
|
+
assert.deepEqual(result.runs.map((r:any)=>r.id),['tg_1'])
|
|
245
|
+
const at=Date.now()+1000
|
|
246
|
+
await scheduler.save({id:'review',name:'Review',text:'Review failures',trigger:{at:new Date(at).toISOString()},when:'unreviewed-failures',enabled:true,owner,execution})
|
|
247
|
+
await scheduler.tick(owner,runs,at)
|
|
248
|
+
assert.equal((await runs.list()).filter(r=>r.scheduled).length,1)
|
|
249
|
+
await assert.rejects(exec(process.execPath,[bin,'run','tg_2'],{env}),/Unknown owner/)
|
|
250
|
+
})
|
|
@@ -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
|
|
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,'
|
|
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,'
|
|
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
|
|
@@ -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 => ({
|
|
@@ -188,7 +295,7 @@ test('slow voice normalization preserves instruction order and leaves controls r
|
|
|
188
295
|
await downloading
|
|
189
296
|
await f.relay.bot.handleUpdate(message(3, '/status'))
|
|
190
297
|
assert.ok(f.replies.some((text) => text.includes('2 incoming messages')))
|
|
191
|
-
assert.ok(f.replies.some((text) => text.includes(`
|
|
298
|
+
assert.ok(f.replies.some((text) => text.includes(`Relay: running · v${packageVersion}`)))
|
|
192
299
|
await f.relay.bot.handleUpdate(message(4, 'Next instruction'))
|
|
193
300
|
assert.equal(f.launched.length, 0)
|
|
194
301
|
release()
|
|
@@ -218,8 +325,8 @@ test('cancel clears accepted and queued work, never spawns a cancelled run, and
|
|
|
218
325
|
assert.equal(f.launched.length, 0)
|
|
219
326
|
assert.equal((await runs.get(run.id))?.status, 'cancelled')
|
|
220
327
|
await f.relay.bot.handleUpdate(message(3, '/status'))
|
|
221
|
-
assert.match(f.replies.at(-1)!, /
|
|
222
|
-
assert.match(f.replies.at(-1)!, /1
|
|
328
|
+
assert.match(f.replies.at(-1)!, /Queue: empty/)
|
|
329
|
+
assert.match(f.replies.at(-1)!, /1 delivery is awaiting confirmation/)
|
|
223
330
|
} finally {
|
|
224
331
|
await f.close()
|
|
225
332
|
}
|
|
@@ -335,3 +442,33 @@ test('media failure quarantines its instruction batch instead of executing incom
|
|
|
335
442
|
await f.close()
|
|
336
443
|
}
|
|
337
444
|
})
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
test('approved family messages enter restricted task runs, never the owner session', async()=>{
|
|
448
|
+
const f=await fixture()
|
|
449
|
+
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'}}})
|
|
450
|
+
try {
|
|
451
|
+
await f.relay.bot.handleUpdate(group(100)) // Registers transport, grants nothing.
|
|
452
|
+
assert.equal(f.launched.length,0)
|
|
453
|
+
await ownerRun(f.dir,'setup')
|
|
454
|
+
const tasks=new Tasks(f.dir),runs=new RunStore(f.dir)
|
|
455
|
+
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}
|
|
456
|
+
await new ApprovalStore(f.dir).recordDecision(proposal.id,'approved',101)
|
|
457
|
+
await tasks.decide(proposal.id)
|
|
458
|
+
await runs.patch('setup',{status:'completed'})
|
|
459
|
+
await f.relay.bot.handleUpdate(group(101))
|
|
460
|
+
const anonymous=group(102);anonymous.message!.sender_chat=anonymous.message!.chat
|
|
461
|
+
await f.relay.bot.handleUpdate(anonymous)
|
|
462
|
+
const bot=group(103);bot.message!.from!.is_bot=true;await f.relay.bot.handleUpdate(bot)
|
|
463
|
+
await new Promise(r=>setTimeout(r,3100))
|
|
464
|
+
await f.relay.drainSources()
|
|
465
|
+
const launched=(await runs.list()).filter(r=>r.taskId)
|
|
466
|
+
assert.equal(launched.length,1)
|
|
467
|
+
assert.equal(launched[0].taskId,proposal.id)
|
|
468
|
+
assert.equal(launched[0].external!.eventIds.length,1)
|
|
469
|
+
assert.equal(launched[0].external!.eventIds[0],'tg_n101_101')
|
|
470
|
+
assert.equal(f.launched.length,1)
|
|
471
|
+
assert.match(f.launched[0][0],/Member/)
|
|
472
|
+
await tasks.ownerCall('setup','list',{}).then(()=>assert.fail('completed run cannot change grants'),()=>{})
|
|
473
|
+
} finally {await f.close()}
|
|
474
|
+
})
|