@jc_stack/ez-agents 0.1.0-beta.34 → 0.1.0-beta.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +1 -0
- package/.env.example +5 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +67 -0
- package/Dockerfile +7 -9
- package/README.md +10 -13
- package/bin/ez +97 -0
- package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
- package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
- package/bin/ezenciel-agents.mjs +1 -1
- package/compose.yaml +40 -4
- package/docker/entrypoint.sh +1 -1
- package/docker/healthcheck.mjs +12 -4
- package/docker/run.ts +5 -6
- package/docker/smoke.mjs +28 -6
- package/docs/application-channel.md +32 -10
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +79 -11
- package/docs/local-qa.md +2 -2
- package/docs/plugin-guide.md +202 -0
- package/docs/plugins.md +48 -2
- package/docs/releasing.md +13 -0
- package/docs/responsive-channels.md +1 -1
- package/docs/scheduling.md +4 -4
- package/docs/setup.md +11 -5
- package/docs/upgrades.md +46 -23
- package/package.json +5 -5
- package/scripts/smoke-scheduler.ts +10 -3
- package/scripts/smoke.ts +14 -11
- package/src/agent-guidance.ts +6 -9
- package/src/agent-install.ts +28 -7
- package/src/application-channel.ts +78 -7
- package/src/application-cli.ts +2 -2
- package/src/approval-cli.ts +14 -7
- package/src/approval.ts +27 -37
- package/src/channel-backend.ts +2 -3
- package/src/config.ts +12 -27
- package/src/control-state.ts +175 -8
- package/src/conversation-menu.ts +6 -17
- package/src/delivery-socket-client.d.mts +4 -0
- package/src/delivery-socket-client.mjs +104 -0
- package/src/delivery-socket.ts +354 -0
- package/src/execution-authority.ts +21 -5
- package/src/executor.ts +20 -23
- package/src/files.ts +38 -11
- package/src/host-executor.ts +28 -14
- package/src/inbox.ts +15 -49
- package/src/index.ts +95 -52
- package/src/install-tools.mjs +97 -22
- package/src/ledger-endpoint.d.mts +2 -0
- package/src/ledger-endpoint.mjs +17 -0
- package/src/menu.ts +7 -2
- package/src/message-history.ts +13 -18
- package/src/message.ts +35 -18
- package/src/plugin-broker.mjs +396 -0
- package/src/plugins/connection.mjs +2 -2
- package/src/plugins/manager.mjs +132 -23
- package/src/plugins/native-tasks.mjs +2 -2
- package/src/prompt-suffix.ts +12 -0
- package/src/react.ts +6 -4
- package/src/runs.ts +156 -243
- package/src/schedule-cli.ts +29 -10
- package/src/scheduled-tasks.ts +53 -10
- package/src/scheduler.ts +20 -7
- package/src/service.ts +3 -3
- package/src/software-status.ts +36 -4
- package/src/task-executor.ts +2 -2
- package/src/task-workspace.ts +4 -3
- package/src/tasks.ts +1 -1
- package/src/telegram-provisioning-cli.ts +22 -0
- package/src/telegram-provisioning.ts +214 -0
- package/src/updates/control.mjs +77 -7
- package/src/updates/runtime.mjs +28 -8
- package/src/updates/status.mjs +25 -3
- package/src/updates/supervisor.mjs +15 -16
- package/src/version.ts +20 -0
- package/src/workspace.ts +3 -9
- package/templates/failure-review.md +1 -1
- package/templates/maintainer-purpose.md +1 -1
- package/test/agent-guidance.test.ts +14 -80
- package/test/agent-install.test.ts +14 -0
- package/test/ai.test.ts +36 -0
- package/test/application-channel.test.ts +61 -13
- package/test/application-only.test.ts +14 -13
- package/test/channel-backend.test.ts +3 -7
- package/test/channel-delivery.test.ts +4 -1
- package/test/channel-owner.test.ts +6 -3
- package/test/codex-context.test.ts +2 -2
- package/test/config.test.ts +38 -43
- package/test/control-state.test.ts +83 -4
- package/test/conversation-menu.test.ts +2 -4
- package/test/delivery-transport.test.ts +72 -0
- package/test/docker-runtime.test.ts +27 -14
- package/test/engine-handoff.test.ts +7 -6
- package/test/event-sources.test.ts +2 -2
- package/test/execution-authority.test.ts +9 -9
- package/test/executor.test.ts +10 -11
- package/test/failure.test.ts +12 -5
- package/test/files.test.ts +2 -2
- package/test/helpers/ledger.ts +18 -0
- package/test/host-executor.test.ts +74 -0
- package/test/inbox.test.ts +8 -10
- package/test/install-config.test.ts +1 -0
- package/test/install-tools.test.mjs +74 -7
- package/test/intake-relay.test.ts +58 -11
- package/test/message-history.test.ts +39 -47
- package/test/native-tasks.test.ts +1 -1
- package/test/plugin-broker.test.mjs +172 -0
- package/test/plugin-manager.test.mjs +119 -2
- package/test/reaction.test.ts +4 -0
- package/test/relay.test.ts +5 -7
- package/test/repair-policy.test.ts +1 -4
- package/test/schedule-cli.test.ts +14 -9
- package/test/scheduled-tasks.test.ts +29 -6
- package/test/scheduler-host.test.ts +5 -5
- package/test/scheduler-relay.test.ts +15 -9
- package/test/security.test.ts +6 -3
- package/test/software-status.test.ts +21 -3
- package/test/telegram-provisioning.test.ts +160 -0
- package/test/updates.test.mjs +93 -13
- package/test/workspace.test.ts +4 -1
- package/bin/ezenciel-agents-watch.mjs +0 -8
- package/compose.workforce-watch.yaml +0 -33
- package/docker/recovery.ts +0 -11
- package/docs/pagerduty.md +0 -42
- package/docs/workforce-watch.md +0 -101
- package/src/pagerduty.ts +0 -109
- package/src/reply-context.ts +0 -43
- package/src/update-attention.ts +0 -18
- package/src/workforce-watch-cli.ts +0 -14
- package/src/workforce-watch.ts +0 -155
- package/templates/agent/AGENTS.md +0 -5
- package/templates/agent-guidance.md +0 -17
- package/test/pagerduty.test.ts +0 -104
- package/test/update-attention.test.ts +0 -20
- package/test/workforce-watch.test.ts +0 -180
package/test/ai.test.ts
CHANGED
|
@@ -242,6 +242,42 @@ test('model catalog can read an agent-bound Codex home', async () => {
|
|
|
242
242
|
}
|
|
243
243
|
})
|
|
244
244
|
|
|
245
|
+
test('Choose AI lists the agent-bound Codex home instead of only the desktop fallback', async () => {
|
|
246
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-ai-menu-codex-home-'))
|
|
247
|
+
const codexHome = await mkdtemp(join(tmpdir(), 'ez-ai-menu-cache-'))
|
|
248
|
+
try {
|
|
249
|
+
await writeFile(join(codexHome, 'models_cache.json'), JSON.stringify({ models: [
|
|
250
|
+
{ slug: 'fixture-bound-model', display_name: 'Bound Fixture', visibility: 'list',
|
|
251
|
+
supported_reasoning_levels: [{ effort: 'medium' }] },
|
|
252
|
+
] }))
|
|
253
|
+
const store = new ControlStore(dir, 1000)
|
|
254
|
+
await store.aiState(initialPreset('codex'))
|
|
255
|
+
// Default catalog (no explicit override): the menu must bind it to codexHome,
|
|
256
|
+
// otherwise an empty process-home cache drops codex and only codex-gui lists.
|
|
257
|
+
const menu = createAiMenu(store, 'codex', undefined, dir, codexHome,
|
|
258
|
+
async (cli) => cli === 'codex' || cli === 'codex-gui')
|
|
259
|
+
const replies: Array<{ text: string; buttons: Array<{ text: string; callback_data: string }> }> = []
|
|
260
|
+
const context = (data?: string) => ({
|
|
261
|
+
callbackQuery: data ? { data } : undefined,
|
|
262
|
+
answerCallbackQuery: async () => ({}),
|
|
263
|
+
reply: async (text: string, options?: { reply_markup?: { inline_keyboard?: Array<Array<{ text: string; callback_data: string }>> } }) => {
|
|
264
|
+
replies.push({ text, buttons: options?.reply_markup?.inline_keyboard?.flat() ?? [] })
|
|
265
|
+
return {} as never
|
|
266
|
+
},
|
|
267
|
+
})
|
|
268
|
+
await menu.list(context() as never)
|
|
269
|
+
assert.deepEqual(replies.at(-1)!.buttons.map((button) => button.text), [
|
|
270
|
+
'codex', 'codex-gui (desktop)', 'Refresh available AIs',
|
|
271
|
+
])
|
|
272
|
+
const client = replies.at(-1)!.buttons.find((button) => button.text === 'codex')!
|
|
273
|
+
await menu.handle(context(client.callback_data) as never)
|
|
274
|
+
assert.deepEqual(replies.at(-1)!.buttons.map((button) => button.text), ['Bound Fixture', 'Back to clients'])
|
|
275
|
+
} finally {
|
|
276
|
+
await rm(dir, { recursive: true, force: true })
|
|
277
|
+
await rm(codexHome, { recursive: true, force: true })
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
|
|
245
281
|
test('native executor flags carry the exact model and effort; only structured metadata binds sessions', () => {
|
|
246
282
|
const opts = { workspace: '/tmp/fixture', sessionId: crypto.randomUUID(), isResume: true,
|
|
247
283
|
model: 'fixture-model', effort: 'medium' }
|
|
@@ -9,12 +9,13 @@ import { randomBytes } from 'node:crypto'
|
|
|
9
9
|
import { fileURLToPath } from 'node:url'
|
|
10
10
|
import { createRequire } from 'node:module'
|
|
11
11
|
import { ApplicationChannel, ApplicationBindings, applicationScope } from '../src/application-channel.js'
|
|
12
|
-
import { ControlStore, ownerId } from '../src/control-state.js'
|
|
12
|
+
import { ControlStore, ownerId, ownerEpoch } from '../src/control-state.js'
|
|
13
13
|
import { RunStore } from '../src/runs.js'
|
|
14
14
|
import { initialPreset } from '../src/ai.js'
|
|
15
15
|
import { requireOwnerExecution } from '../src/execution-authority.js'
|
|
16
16
|
import { createRelay } from '../src/index.js'
|
|
17
17
|
import { EXECUTOR_REGISTRY, executorEnvironment } from '../src/executor.js'
|
|
18
|
+
import { serveTestLedger } from './helpers/ledger.js'
|
|
18
19
|
|
|
19
20
|
const token = () => randomBytes(32).toString('base64url')
|
|
20
21
|
const owner = async (dir: string) => {
|
|
@@ -31,7 +32,7 @@ test('HTTP auth, idempotency, origin/context isolation, revocation and persisted
|
|
|
31
32
|
const root=await mkdtemp(join(tmpdir(),'ez-app-channel-'))
|
|
32
33
|
const owned=await owner(root), runs=new RunStore(root), control=new ControlStore(root,1000)
|
|
33
34
|
let wakes=0
|
|
34
|
-
const channel=new ApplicationChannel({controlDir:root,initial:initialPreset('codex'),wake:()=>{wakes++},cancel:async id=>{await runs.patch(id,{status:'cancelled'})}})
|
|
35
|
+
const channel=new ApplicationChannel({controlDir:root,initial:initialPreset('codex'),wake:()=>{wakes++},cancel:async id=>{await runs.patch(id,{status:'cancelled'})},createTelegramPairing:async()=>{throw new Error('unavailable')},telegramAvailable:()=>false})
|
|
35
36
|
t.after(async()=>{await channel.stop();await rm(root,{recursive:true,force:true})})
|
|
36
37
|
const firstToken=token(), secondToken=token()
|
|
37
38
|
const first=(await channel.bindings.register('first',firstToken,owned))!
|
|
@@ -124,12 +125,14 @@ if(result.status!==0){console.error(result.stderr);process.exit(1)};
|
|
|
124
125
|
`)
|
|
125
126
|
EXECUTOR_REGISTRY.codex={...original,command:process.execPath,buildArgs:(options,_file,prompt)=>{prompts.push(prompt);resumeIds.push(options.isResume?options.sessionId:undefined);return ['--import',require.resolve('tsx'),fixture]}}
|
|
126
127
|
const relay=createRelay({controlDir:root,workspace:root,pairingTtlMs:1000,telegramBotToken:'fixture',executorTimeoutMs:0,executorCli:'codex'})
|
|
128
|
+
// The engine child reaches the relay memory ledger through the delivery socket.
|
|
129
|
+
const ledger=await serveTestLedger(root)
|
|
127
130
|
let telegramCalls=0
|
|
128
131
|
relay.bot.api.config.use(async()=>{telegramCalls++;return {ok:true,result:{message_id:1}} as never})
|
|
129
132
|
const owned=await owner(root), secret=token()
|
|
130
133
|
const binding=(await relay.applicationChannel.bindings.register('app',secret,owned))!
|
|
131
134
|
const drain=setInterval(()=>void relay.drainOutbox(),10)
|
|
132
|
-
t.after(async()=>{clearInterval(drain);await relay.stop();EXECUTOR_REGISTRY.codex=original;await rm(root,{recursive:true,force:true})})
|
|
135
|
+
t.after(async()=>{clearInterval(drain);await relay.stop();EXECUTOR_REGISTRY.codex=original;await ledger.stop();await rm(root,{recursive:true,force:true})})
|
|
133
136
|
const first=await relay.applicationChannel.submit(binding.bindingId,{requestId:'one',scope:'program',text:'Hello from the app',context:{secret:'must-stay-out-of-prompt'}})
|
|
134
137
|
await waitFor(async()=> (await new RunStore(root).get(first.id))?.status==='completed')
|
|
135
138
|
const second=await relay.applicationChannel.submit(binding.bindingId,{requestId:'two',scope:'program',text:'Continue'})
|
|
@@ -137,8 +140,9 @@ if(result.status!==0){console.error(result.stderr);process.exit(1)};
|
|
|
137
140
|
assert.deepEqual(resumeIds,[undefined,nativeId])
|
|
138
141
|
assert.equal(telegramCalls,0)
|
|
139
142
|
assert.ok(prompts[0].startsWith('Hello from the app'))
|
|
140
|
-
assert.ok(prompts[0].includes('[
|
|
143
|
+
assert.ok(prompts[0].includes('[application scope "program"]'))
|
|
141
144
|
assert.ok(!prompts[0].includes('must-stay-out-of-prompt'))
|
|
145
|
+
assert.ok(!prompts[0].includes('delegate'))
|
|
142
146
|
assert.equal((await relay.applicationChannel.snapshot(binding.bindingId,second.id)).messages[0].text,'Fixture engine reply')
|
|
143
147
|
})
|
|
144
148
|
|
|
@@ -302,7 +306,7 @@ test('generic attachments stage after auth, preserve literal comments and reject
|
|
|
302
306
|
const input={requestId:'image',scope:'chat',followOwner:true,text:' /goal literal\n comment ',attachment:{name:'image.png',data:Buffer.from('89504e470d0a1a0a','hex').toString('base64')}}
|
|
303
307
|
assert.equal((await post(input,'bad')).status,401)
|
|
304
308
|
const {readdir,stat}=await import('node:fs/promises')
|
|
305
|
-
await assert.rejects(readdir(join(root,'
|
|
309
|
+
await assert.rejects(readdir(join(root,'attachments')),/ENOENT/)
|
|
306
310
|
for (const [name,bytes] of [['image.png',Buffer.from('89504e470d0a1a0a','hex')],['file.pdf',Buffer.from('%PDF-1.4\nfixture')],['notes.md',Buffer.from('# Fixture')]] as const) {
|
|
307
311
|
const body={...input,requestId:name,attachment:{name,data:bytes.toString('base64')}}
|
|
308
312
|
const response=await post(body);assert.equal(response.status,202)
|
|
@@ -311,12 +315,12 @@ test('generic attachments stage after auth, preserve literal comments and reject
|
|
|
311
315
|
assert.equal(run.ownerId,ownerId(owned))
|
|
312
316
|
assert.equal(run.application?.inputText,input.text)
|
|
313
317
|
assert.ok(run.texts[0].endsWith(`Caption: ${input.text}`))
|
|
314
|
-
const path=run.texts[0].match(/staged at (
|
|
315
|
-
assert.deepEqual(await readFile(
|
|
316
|
-
assert.equal((await stat(
|
|
317
|
-
const before=(await readdir(join(root,'
|
|
318
|
+
const path=run.texts[0].match(/staged at (\/[^ ]+)/)![1]
|
|
319
|
+
assert.deepEqual(await readFile(path),bytes)
|
|
320
|
+
assert.equal((await stat(path)).mode & 0o777,0o600)
|
|
321
|
+
const before=(await readdir(join(root,'attachments'))).length
|
|
318
322
|
assert.equal((await post(body)).status,202)
|
|
319
|
-
assert.equal((await readdir(join(root,'
|
|
323
|
+
assert.equal((await readdir(join(root,'attachments'))).length,before)
|
|
320
324
|
assert.equal((await post({...body,text:'changed'})).status,409)
|
|
321
325
|
assert.equal((await post({...body,attachment:{name,data:Buffer.from('changed').toString('base64')}})).status,409)
|
|
322
326
|
assert.equal((await fetch(`http://127.0.0.1:${address.port}/v1/runs/${snapshot.id}`,{headers:{Authorization:`Bearer ${other}`}})).status,404)
|
|
@@ -326,12 +330,56 @@ test('generic attachments stage after auth, preserve literal comments and reject
|
|
|
326
330
|
const invalidActivation=await post({...input,requestId:'invalid-activation',followOwner:false,activateTelegram:true,scope:'new-scope',attachment:{name:'x.exe',data:Buffer.from('text').toString('base64')}})
|
|
327
331
|
assert.equal(invalidActivation.status,400)
|
|
328
332
|
assert.deepEqual((await control.status()).activeSession,priorSession)
|
|
329
|
-
const before=(await readdir(join(root,'
|
|
333
|
+
const before=(await readdir(join(root,'attachments'))).length
|
|
330
334
|
for (const attachment of [{name:'x.exe',data:Buffer.from('text').toString('base64')},{name:'x.txt',data:'%%%invalid'},{name:'x.txt',data:Buffer.alloc(10*1024*1024+1,65).toString('base64')}]) {
|
|
331
335
|
assert.equal((await post({...input,requestId:'bad',attachment})).status,400)
|
|
332
336
|
}
|
|
333
|
-
assert.equal((await readdir(join(root,'
|
|
337
|
+
assert.equal((await readdir(join(root,'attachments'))).length,before)
|
|
334
338
|
await channel.bindings.register('web',null,owned)
|
|
335
339
|
await assert.rejects(channel.submit(binding.bindingId,{...input,requestId:'revoked'}),/revoked/)
|
|
336
|
-
assert.equal((await readdir(join(root,'
|
|
340
|
+
assert.equal((await readdir(join(root,'attachments'))).length,before)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
test('shareTelegram bindings list Telegram intake and admit one run with context', async t => {
|
|
344
|
+
const root=await mkdtemp(join(tmpdir(),'ez-app-telegram-'))
|
|
345
|
+
const control=new ControlStore(root,1000), runs=new RunStore(root)
|
|
346
|
+
await control.requestPairing(42,42); await control.approveOwner(42)
|
|
347
|
+
const owned=(await control.status()).owner!
|
|
348
|
+
const channel=new ApplicationChannel({controlDir:root,initial:initialPreset('codex'),wake:()=>{},cancel:async()=>{},createTelegramPairing:async()=>{throw new Error('unavailable')},telegramAvailable:()=>false})
|
|
349
|
+
t.after(async()=>{await channel.stop();await rm(root,{recursive:true,force:true})})
|
|
350
|
+
const sharedToken=token(), plainToken=token()
|
|
351
|
+
await channel.bindings.register('tg-app',sharedToken,owned,true)
|
|
352
|
+
await channel.bindings.register('plain',plainToken,owned)
|
|
353
|
+
const address=await channel.listen(0) as {port:number}
|
|
354
|
+
const url=`http://127.0.0.1:${address.port}`
|
|
355
|
+
const get=(bearer:string)=>fetch(url+'/v1/runs',{headers:{Authorization:`Bearer ${bearer}`}})
|
|
356
|
+
const attach=(id:string,bearer:string,body:unknown)=>fetch(url+`/v1/runs/${id}/application`,{method:'POST',headers:{Authorization:`Bearer ${bearer}`},body:JSON.stringify(body)})
|
|
357
|
+
const telegram=await runs.create({id:'tg_intake_1',chatId:42,telegramUserId:42,texts:['Attach the session'],ownerId:ownerId(owned),ownerEpoch:ownerEpoch(owned)})
|
|
358
|
+
assert.equal(telegram.application,undefined)
|
|
359
|
+
const listed=await (await get(sharedToken)).json() as {runs:{id:string;scope:string}[]}
|
|
360
|
+
assert.ok(listed.runs.some(run=>run.id==='tg_intake_1' && run.scope==='telegram'))
|
|
361
|
+
const hidden=await (await get(plainToken)).json() as {runs:{id:string}[]}
|
|
362
|
+
assert.ok(!hidden.runs.some(run=>run.id==='tg_intake_1'))
|
|
363
|
+
assert.equal((await attach('tg_intake_1',plainToken,{context:{aifit:{capability:'x'}}})).status,403)
|
|
364
|
+
assert.equal((await attach('tg_intake_1',sharedToken,{context:'nope'})).status,400)
|
|
365
|
+
assert.equal((await attach('tg_nope',sharedToken,{context:{}})).status,404)
|
|
366
|
+
const admitted=await attach('tg_intake_1',sharedToken,{context:{aifit:{capability:'granted'}}})
|
|
367
|
+
assert.equal(admitted.status,200)
|
|
368
|
+
assert.deepEqual(await admitted.json(),{id:'tg_intake_1',scope:'telegram',status:'queued'})
|
|
369
|
+
const stored=await runs.get('tg_intake_1')
|
|
370
|
+
const storedContext=stored?.telegramApplication?.context as {aifit?:{capability?:string}}|undefined
|
|
371
|
+
assert.equal(storedContext?.aifit?.capability,'granted')
|
|
372
|
+
assert.equal(stored?.application,undefined,'owner delivery routing is untouched')
|
|
373
|
+
const retrySame=await attach('tg_intake_1',sharedToken,{context:{aifit:{capability:'granted'}}})
|
|
374
|
+
assert.equal(retrySame.status,200)
|
|
375
|
+
assert.equal((await attach('tg_intake_1',sharedToken,{context:{aifit:{capability:'changed'}}})).status,409)
|
|
376
|
+
const stranger=await runs.create({id:'tg_stranger_1',chatId:43,telegramUserId:43,texts:['Not the owner'],ownerId:ownerId(owned),ownerEpoch:ownerEpoch(owned)})
|
|
377
|
+
const stillHidden=await (await get(sharedToken)).json() as {runs:{id:string}[]}
|
|
378
|
+
assert.ok(!stillHidden.runs.some(run=>run.id===stranger.id))
|
|
379
|
+
assert.equal((await attach(stranger.id,sharedToken,{context:{}})).status,400)
|
|
380
|
+
const plain=await runs.create({id:'r_plain_1',texts:['No telegram origin'],ownerId:ownerId(owned),ownerEpoch:ownerEpoch(owned)})
|
|
381
|
+
assert.equal((await attach(plain.id,sharedToken,{context:{}})).status,404)
|
|
382
|
+
const finished=await runs.create({id:'tg_finished_1',chatId:42,telegramUserId:42,texts:['Done already'],ownerId:ownerId(owned),ownerEpoch:ownerEpoch(owned)})
|
|
383
|
+
await runs.patch(finished.id,{status:'completed'})
|
|
384
|
+
assert.equal((await attach(finished.id,sharedToken,{context:{}})).status,409)
|
|
337
385
|
})
|
|
@@ -20,12 +20,10 @@ const waitFor = async (condition: () => Promise<boolean>) => {
|
|
|
20
20
|
throw new Error('Timed out')
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
test('application-only mode
|
|
24
|
-
assert.throws(()=>loadConfig({}), /
|
|
25
|
-
assert.throws(()=>loadConfig({
|
|
26
|
-
|
|
27
|
-
assert.throws(()=>loadConfig({EZ_TELEGRAM_ENABLED:'false',EZ_APPLICATION_PORT:'8110',EZ_CHANNEL_BACKEND_URL:'http://backend',EZ_CHANNEL_BACKEND_TOKEN:'secret'}), /native Ez executor/)
|
|
28
|
-
const config=loadConfig({EZ_TELEGRAM_ENABLED:'false',EZ_APPLICATION_PORT:'8110',TELEGRAM_BOT_TOKEN:'unused-secret'})
|
|
23
|
+
test('application-only mode is inferred from the absence of a bot token and requires a native listener', () => {
|
|
24
|
+
assert.throws(()=>loadConfig({}), /EZ_APPLICATION_PORT/)
|
|
25
|
+
assert.throws(()=>loadConfig({EZ_APPLICATION_PORT:'8110',EZ_CHANNEL_BACKEND_URL:'http://backend',EZ_CHANNEL_BACKEND_TOKEN:'secret'}), /native Ez executor/)
|
|
26
|
+
const config=loadConfig({EZ_APPLICATION_PORT:'8110'})
|
|
29
27
|
assert.equal(config.telegramBotToken,'')
|
|
30
28
|
assert.equal(config.telegramEnabled,false)
|
|
31
29
|
})
|
|
@@ -36,8 +34,8 @@ test('administrator bootstrap is explicit, local-only and cannot replace identit
|
|
|
36
34
|
const token=join(root,'token'); await writeFile(token,'x'.repeat(48),{mode:0o600})
|
|
37
35
|
const cli=fileURLToPath(new URL('../bin/ezenciel-agents-application.mjs',import.meta.url))
|
|
38
36
|
const args=[cli,'--id','app','--token-file',token,'--owner','42']
|
|
39
|
-
const env={...process.env,EZ_CONTROL_DIR:root,EZ_RUN_ID:'',
|
|
40
|
-
assert.notEqual(spawnSync(process.execPath,args,{env:{...env,
|
|
37
|
+
const env={...process.env,EZ_CONTROL_DIR:root,EZ_RUN_ID:'',TELEGRAM_BOT_TOKEN:''}
|
|
38
|
+
assert.notEqual(spawnSync(process.execPath,args,{env:{...env,TELEGRAM_BOT_TOKEN:'fixture'}}).status,0)
|
|
41
39
|
assert.notEqual(spawnSync(process.execPath,args,{env:{...env,EZ_RUN_ID:'r_agent'}}).status,0)
|
|
42
40
|
assert.equal((await new ControlStore(root,1000).status()).owner,null)
|
|
43
41
|
const invalidToken=join(root,'invalid-token');await writeFile(invalidToken,'short')
|
|
@@ -64,7 +62,7 @@ test('botless daemon executes application turn and rejects Telegram-origin work/
|
|
|
64
62
|
const purpose=join(root,'purpose.md');await writeFile(purpose,'Fixture application agent\n');await initializeWorkspace(root,purpose)
|
|
65
63
|
const portServer=createServer();await new Promise<void>(r=>portServer.listen(0,'127.0.0.1',r))
|
|
66
64
|
const port=(portServer.address() as {port:number}).port;await new Promise<void>(r=>portServer.close(()=>r()))
|
|
67
|
-
const config=loadConfig({
|
|
65
|
+
const config=loadConfig({EZ_APPLICATION_PORT:String(port),EZ_CONTROL_DIR:root,EZ_AGENT_WORKSPACE:root,EZ_EXECUTOR_CLI:'codex'})
|
|
68
66
|
const native='11111111-1111-1111-1111-111111111111', fixture=join(root,'engine.mjs')
|
|
69
67
|
await writeFile(fixture,`import {spawnSync} from 'node:child_process';if(process.env.TELEGRAM_BOT_TOKEN)throw Error('secret leak');console.log(JSON.stringify({type:'thread.started',thread_id:${JSON.stringify(native)}}));const r=spawnSync(process.execPath,[${JSON.stringify(fileURLToPath(new URL('../bin/ezenciel-agents-message.mjs',import.meta.url)))},'--text','Application reply'],{env:process.env});process.exit(r.status);`)
|
|
70
68
|
const original=EXECUTOR_REGISTRY.codex,require=createRequire(import.meta.url)
|
|
@@ -91,12 +89,15 @@ test('botless daemon executes application turn and rejects Telegram-origin work/
|
|
|
91
89
|
assert.equal((await runs.get(legacy.id))?.status,'queued')
|
|
92
90
|
})
|
|
93
91
|
|
|
94
|
-
test('Docker health accepts application readiness
|
|
92
|
+
test('Docker health accepts application readiness when the agent has no bot', async t => {
|
|
95
93
|
const root=await mkdtemp(join(tmpdir(),'ez-app-only-health-'))
|
|
96
94
|
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
97
|
-
await writeFile(join(root,'heartbeat.json'),JSON.stringify({at:Date.now(),polling:false,applicationOnly:true}))
|
|
95
|
+
await writeFile(join(root,'heartbeat.json'),JSON.stringify({at:Date.now(),polling:false,applicationOnly:true,telegramConfigured:false}))
|
|
98
96
|
const command=fileURLToPath(new URL('../docker/healthcheck.mjs',import.meta.url))
|
|
99
97
|
const env={...process.env,EZ_HEALTH_RELAY_CONTROL_DIR:root,EZ_EXECUTOR_TRANSPORT:'local'}
|
|
100
|
-
assert.equal(spawnSync(process.execPath,[command],{env
|
|
101
|
-
|
|
98
|
+
assert.equal(spawnSync(process.execPath,[command],{env}).status,0)
|
|
99
|
+
await writeFile(join(root,'heartbeat.json'),JSON.stringify({at:Date.now(),polling:false,applicationOnly:false,telegramConfigured:false}))
|
|
100
|
+
assert.notEqual(spawnSync(process.execPath,[command],{env}).status,0)
|
|
101
|
+
await writeFile(join(root,'heartbeat.json'),JSON.stringify({at:Date.now(),polling:false,applicationOnly:true,telegramConfigured:true}))
|
|
102
|
+
assert.notEqual(spawnSync(process.execPath,[command],{env}).status,0)
|
|
102
103
|
})
|
|
@@ -43,7 +43,6 @@ test('structured photo transport and deterministic reply recovery', async () =>
|
|
|
43
43
|
test('backend dispatch keeps owner/private gate and never starts a CLI', async () => {
|
|
44
44
|
const { createRelay } = await import('../src/index.js')
|
|
45
45
|
const { ControlStore } = await import('../src/control-state.js')
|
|
46
|
-
const { recoverInterruptedRuns } = await import('../docker/recovery.js')
|
|
47
46
|
const root = await mkdtemp(join(tmpdir(), 'channel-gate-'))
|
|
48
47
|
const originalFetch = globalThis.fetch
|
|
49
48
|
let calls = 0
|
|
@@ -68,16 +67,14 @@ test('backend dispatch keeps owner/private gate and never starts a CLI', async (
|
|
|
68
67
|
assert.equal(calls, 1)
|
|
69
68
|
const store = new RunStore(root)
|
|
70
69
|
assert.equal((await store.get('tg_3'))?.status, 'completed')
|
|
71
|
-
await store.patch('tg_3', { status: 'running' })
|
|
72
|
-
await recoverInterruptedRuns(root, true)
|
|
73
|
-
assert.equal((await store.get('tg_3'))?.status, 'queued')
|
|
70
|
+
await store.patch('tg_3', { status: 'running', backendSubmitted: true })
|
|
74
71
|
const cancel = update(4, 42); cancel.message.text = '/cancel'
|
|
75
72
|
await relay.bot.handleUpdate(cancel)
|
|
76
|
-
assert.equal((await store.get('tg_3'))?.status, '
|
|
73
|
+
assert.equal((await store.get('tg_3'))?.status, 'running', 'cancel must retain submitted operation polling')
|
|
77
74
|
} finally { await relay.stop(); globalThis.fetch = originalFetch; await rm(root, { recursive: true, force: true }) }
|
|
78
75
|
})
|
|
79
76
|
|
|
80
|
-
test('backend mode cannot dispatch restricted, external
|
|
77
|
+
test('backend mode cannot dispatch restricted, external or scheduled runs', async () => {
|
|
81
78
|
const { createRelay } = await import('../src/index.js')
|
|
82
79
|
const { ControlStore } = await import('../src/control-state.js')
|
|
83
80
|
const root = await mkdtemp(join(tmpdir(), 'channel-authority-'))
|
|
@@ -92,7 +89,6 @@ test('backend mode cannot dispatch restricted, external, scheduled or update run
|
|
|
92
89
|
await runs.create({ ...common, id: 'restricted', taskId: `task_${'a'.repeat(32)}` })
|
|
93
90
|
await runs.create({ ...common, id: 'external', external: { sourceId: 'fixture', bindingId: 'binding', eventIds: ['1'] } })
|
|
94
91
|
await runs.create({ ...common, id: 'scheduled', scheduled: { id: 's', revision: 'r', dueAt: new Date().toISOString(), pairedAt: owner.pairedAt } })
|
|
95
|
-
await runs.create({ ...common, id: 'r_update_fixture' })
|
|
96
92
|
await relay.drainSources()
|
|
97
93
|
assert.equal(calls, 0); assert.equal(launches, 0)
|
|
98
94
|
assert.ok((await runs.list()).every(run => run.status === 'failed'))
|
|
@@ -8,11 +8,14 @@ import {ControlStore} from '../src/control-state.js'
|
|
|
8
8
|
import {RunStore} from '../src/runs.js'
|
|
9
9
|
import {authorizeDeliveryContext,captureDeliveryContext} from '../src/delivery-context.mjs'
|
|
10
10
|
import {nativeTasks,nativeTaskBinding} from '../src/plugins/native-tasks.mjs'
|
|
11
|
+
import {serveTestLedger} from './helpers/ledger.js'
|
|
11
12
|
|
|
12
13
|
test('authenticated connection sends text and files through ordinary outbox receipts without a native run',async()=>{
|
|
13
14
|
const root=await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(),'ez-channel-delivery-'))),workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),home=path.join(root,'tools'),hostConfig=path.join(root,'host-executor.json')
|
|
14
15
|
for(const dir of [workspace,controlDir,home])await fs.mkdir(dir)
|
|
15
16
|
const relay=createRelay({controlDir,workspace,pairingTtlMs:1000,executorTimeoutMs:1000,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw Error('Must not launch native execution')})
|
|
17
|
+
// The message CLI child reaches the relay memory ledger through the delivery socket.
|
|
18
|
+
const ledger=await serveTestLedger(controlDir)
|
|
16
19
|
const calls:string[]=[];relay.bot.api.config.use(async(_prev,method)=>{calls.push(method);return {ok:true,result:{message_id:42}} as never})
|
|
17
20
|
try {
|
|
18
21
|
await fs.writeFile(path.join(home,'config.json'),JSON.stringify({schemaVersion:1,workspace,hostConfig}))
|
|
@@ -38,7 +41,7 @@ test('authenticated connection sends text and files through ordinary outbox rece
|
|
|
38
41
|
await relay.drainOutbox();assert.equal(calls.length,2);await assert.rejects(store.waitForDelivery(queued.id),/revoked/)
|
|
39
42
|
await assert.rejects(nativeTasks(home,['--text','denied'],{command:'message',deliveryContext:context}),/revoked/)
|
|
40
43
|
await assert.rejects(store.ownerDeliveryReceipt(context,queued.id),/revoked/)
|
|
41
|
-
} finally {await relay.stop();await fs.rm(root,{recursive:true,force:true})}
|
|
44
|
+
} finally {await relay.stop();await ledger.stop();await fs.rm(root,{recursive:true,force:true})}
|
|
42
45
|
})
|
|
43
46
|
test('delivery authority cannot revive after Telegram relink or same-time owner replacement',async t=>{
|
|
44
47
|
const root=await fs.mkdtemp(path.join(os.tmpdir(),'ez-channel-delivery-epoch-'))
|
|
@@ -16,6 +16,7 @@ import { ownsRun, isOwner } from '../src/identity.js'
|
|
|
16
16
|
import { createRelay } from '../src/index.js'
|
|
17
17
|
import { EXECUTOR_REGISTRY } from '../src/executor.js'
|
|
18
18
|
import { loadConfig } from '../src/config.js'
|
|
19
|
+
import { serveTestLedger } from './helpers/ledger.js'
|
|
19
20
|
|
|
20
21
|
const secret = () => randomBytes(32).toString('base64url')
|
|
21
22
|
const waitFor = async (check: () => Promise<boolean>) => {
|
|
@@ -87,13 +88,15 @@ test('web owner uses the standard schedule CLI, executor and outbox with Telegra
|
|
|
87
88
|
if(!scheduled){const r=spawnSync(process.execPath,[${JSON.stringify(scheduleCli)},'create','followup','--now','--name','Followup','--text','Finish the requested work'],{env:process.env,encoding:'utf8'});if(r.status)throw Error(r.stderr)}
|
|
88
89
|
const r=spawnSync(process.execPath,[${JSON.stringify(messageCli)},'--text',scheduled?'Scheduled reply':'Chat reply'],{env:process.env,encoding:'utf8'});if(r.status)throw Error(r.stderr);`)
|
|
89
90
|
EXECUTOR_REGISTRY.grok={...original,command:process.execPath,buildArgs:()=>['--import',require.resolve('tsx'),fixture]}
|
|
90
|
-
const config=loadConfig({
|
|
91
|
+
const config=loadConfig({EZ_APPLICATION_PORT:'8787',EZ_CONTROL_DIR:root,EZ_AGENT_WORKSPACE:root,EZ_EXECUTOR_CLI:'grok'})
|
|
91
92
|
const relay=createRelay(config), control=new ControlStore(root,60000), runs=new RunStore(root)
|
|
93
|
+
// Engine children reach the relay memory ledger through the delivery socket.
|
|
94
|
+
const ledger=await serveTestLedger(root)
|
|
92
95
|
const owner=await control.registerOwner('web-owner'),token=secret()
|
|
93
96
|
const binding=(await relay.applicationChannel.bindings.register('web',token,owner))!
|
|
94
97
|
const address=await relay.applicationChannel.listen(0) as {port:number}
|
|
95
98
|
const timer=setInterval(()=>{void relay.drainSources();void relay.drainOutbox()},25)
|
|
96
|
-
t.after(async()=>{clearInterval(timer);await relay.stop();EXECUTOR_REGISTRY.grok=original;await rm(root,{recursive:true,force:true})})
|
|
99
|
+
t.after(async()=>{clearInterval(timer);await relay.stop();EXECUTOR_REGISTRY.grok=original;await ledger.stop();await rm(root,{recursive:true,force:true})})
|
|
97
100
|
const first=await relay.applicationChannel.submit(binding.bindingId,{requestId:'chat',scope:'main',text:'Schedule a followup'})
|
|
98
101
|
await waitFor(async()=> (await runs.get(first.id))?.status==='completed')
|
|
99
102
|
await waitFor(async()=> (await runs.list()).some(r=>r.scheduled && r.status==='completed'))
|
|
@@ -141,7 +144,7 @@ for (const mode of ['owner','web','telegram']) test(`${mode} revocation stops it
|
|
|
141
144
|
const control=new ControlStore(root,60000),runs=new RunStore(root),scheduler=new Scheduler(root)
|
|
142
145
|
await control.requestPairing(42,42)
|
|
143
146
|
const owner=await control.approveOwner(42)
|
|
144
|
-
const relay=createRelay(loadConfig({TELEGRAM_BOT_TOKEN:'
|
|
147
|
+
const relay=createRelay(loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_CONTROL_DIR:root,EZ_AGENT_WORKSPACE:root,EZ_EXECUTOR_CLI:'grok'}),async()=>({child:spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:'pipe'}),stdout:'',cleanup:async()=>{}}))
|
|
145
148
|
relay.bot.api.config.use(async()=>({ok:true,result:true}) as never)
|
|
146
149
|
t.after(async()=>{await relay.stop();await rm(root,{recursive:true,force:true})})
|
|
147
150
|
if (mode==='telegram') {
|
|
@@ -110,11 +110,11 @@ if(q.method==='turn/start'){send({id:q.id,result:{turn:{id:'one'}}});send({metho
|
|
|
110
110
|
send({id:q.id,result:q.method==='thread/goal/get'?{goal:null}:{}});});setInterval(()=>{},1000);
|
|
111
111
|
`,{mode:0o700})
|
|
112
112
|
process.env.HOME=root;process.env.PATH=bin+path.delimiter+priorPath
|
|
113
|
-
await assert.rejects(startExecutorJob(['test'],{workspace:root,controlDir,binDir:bin,cli:'codex',runId:'r_schedule_/../../escape',timeoutMs:0}),/Invalid native task run ID/)
|
|
113
|
+
await assert.rejects(startExecutorJob(['test'],{workspace:root,controlDir,binDir:bin,cli:'codex',runId:'r_schedule_/../../escape',timeoutMs:0,nativeSession:true}),/Invalid native task run ID/)
|
|
114
114
|
await ownerRun(controlDir, 'r_pair_fixture')
|
|
115
115
|
await Promise.all(['r_schedule_one','r_schedule_two'].map(async runId=>{
|
|
116
116
|
await ownerRun(controlDir, runId)
|
|
117
|
-
const job=await startExecutorJob(['test'],{workspace:root,controlDir,binDir:bin,cli:'codex',runId,timeoutMs:0})
|
|
117
|
+
const job=await startExecutorJob(['test'],{workspace:root,controlDir,binDir:bin,cli:'codex',runId,timeoutMs:0,nativeSession:true})
|
|
118
118
|
assert.equal(await new Promise(resolve=>job.child.once('close',resolve)),0);await job.cleanup()
|
|
119
119
|
const home=path.join(controlDir,'cli/codex/tasks',runId)
|
|
120
120
|
assert.equal(JSON.parse(await readFile(path.join(home,'observed.json'),'utf8')).home,home)
|
package/test/config.test.ts
CHANGED
|
@@ -2,13 +2,13 @@ import assert from 'node:assert/strict'
|
|
|
2
2
|
import test from 'node:test'
|
|
3
3
|
import { loadConfig } from '../src/config.js'
|
|
4
4
|
|
|
5
|
-
test('requires
|
|
6
|
-
assert.throws(() => loadConfig({}), /
|
|
5
|
+
test('requires an application listener when no Telegram bot token is present', () => {
|
|
6
|
+
assert.throws(() => loadConfig({}), /EZ_APPLICATION_PORT/)
|
|
7
7
|
})
|
|
8
8
|
|
|
9
9
|
test('uses a relative agent workspace and protected control state defaults', () => {
|
|
10
10
|
const config = loadConfig({
|
|
11
|
-
TELEGRAM_BOT_TOKEN: '
|
|
11
|
+
TELEGRAM_BOT_TOKEN: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',
|
|
12
12
|
EZ_AGENT_WORKSPACE: './fixture-agent',
|
|
13
13
|
EZ_CONTROL_DIR: './fixture-control',
|
|
14
14
|
})
|
|
@@ -19,73 +19,68 @@ test('uses a relative agent workspace and protected control state defaults', ()
|
|
|
19
19
|
})
|
|
20
20
|
|
|
21
21
|
test('rejects malformed timeouts', () => {
|
|
22
|
-
assert.equal(loadConfig({ TELEGRAM_BOT_TOKEN: '
|
|
23
|
-
assert.throws(() => loadConfig({ TELEGRAM_BOT_TOKEN: '
|
|
22
|
+
assert.equal(loadConfig({ TELEGRAM_BOT_TOKEN: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY', EZ_EXECUTOR_TIMEOUT_SECONDS: '300' }).executorTimeoutMs, 0)
|
|
23
|
+
assert.throws(() => loadConfig({ TELEGRAM_BOT_TOKEN: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY', EZ_PAIRING_TTL_SECONDS: 'bad' }), /positive integer/)
|
|
24
24
|
})
|
|
25
25
|
|
|
26
26
|
test('loads executor CLI configuration with agy fallback', () => {
|
|
27
|
-
const defaultConfig = loadConfig({ TELEGRAM_BOT_TOKEN: '
|
|
27
|
+
const defaultConfig = loadConfig({ TELEGRAM_BOT_TOKEN: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY' })
|
|
28
28
|
assert.equal(defaultConfig.executorCli, 'agy')
|
|
29
29
|
|
|
30
|
-
const customConfig = loadConfig({ TELEGRAM_BOT_TOKEN: '
|
|
30
|
+
const customConfig = loadConfig({ TELEGRAM_BOT_TOKEN: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY', EZ_EXECUTOR_CLI: 'claude' })
|
|
31
31
|
assert.equal(customConfig.executorCli, 'claude')
|
|
32
32
|
})
|
|
33
33
|
|
|
34
34
|
test('Codex context limit is configurable and rejects invalid values', () => {
|
|
35
|
-
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'
|
|
36
|
-
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'
|
|
35
|
+
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY'}).codexAutoCompactTokens,undefined)
|
|
36
|
+
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_CODEX_AUTO_COMPACT_TOKENS:'32000'}).codexAutoCompactTokens,32000)
|
|
37
37
|
for(const value of ['0','-1','bad','1.5','9007199254740992'])
|
|
38
|
-
assert.throws(()=>loadConfig({TELEGRAM_BOT_TOKEN:'
|
|
38
|
+
assert.throws(()=>loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_CODEX_AUTO_COMPACT_TOKENS:value}),/positive integer/)
|
|
39
39
|
})
|
|
40
40
|
|
|
41
|
-
test('PagerDuty Stocks monitoring requires a routing key and validates its target', () => {
|
|
42
|
-
assert.throws(
|
|
43
|
-
() => loadConfig({ TELEGRAM_BOT_TOKEN: 'test', EZ_PAGERDUTY_STOCKS_HEALTH_URL: 'http://stocks.test/health/critical' }),
|
|
44
|
-
/PAGERDUTY_ROUTING_KEY is required/,
|
|
45
|
-
)
|
|
46
|
-
assert.throws(
|
|
47
|
-
() => loadConfig({ TELEGRAM_BOT_TOKEN: 'test', PAGERDUTY_ROUTING_KEY: 'key', EZ_PAGERDUTY_STOCKS_HEALTH_URL: 'file:///private/health' }),
|
|
48
|
-
/absolute HTTP\(S\) URL/,
|
|
49
|
-
)
|
|
50
|
-
const config = loadConfig({
|
|
51
|
-
TELEGRAM_BOT_TOKEN: 'test',
|
|
52
|
-
PAGERDUTY_ROUTING_KEY: 'pagerduty-key',
|
|
53
|
-
EZ_PAGERDUTY_STOCKS_HEALTH_URL: 'http://stocks.test/health/critical',
|
|
54
|
-
EZ_PAGERDUTY_POLL_SECONDS: '45',
|
|
55
|
-
EZ_PAGERDUTY_FAILURE_THRESHOLD: '4',
|
|
56
|
-
})
|
|
57
|
-
assert.equal(config.pagerDutyRoutingKey, 'pagerduty-key')
|
|
58
|
-
assert.equal(config.pagerDutyStocksHealthUrl, 'http://stocks.test/health/critical')
|
|
59
|
-
assert.equal(config.pagerDutyPollMs, 45_000)
|
|
60
|
-
assert.equal(config.pagerDutyFailureThreshold, 4)
|
|
61
|
-
})
|
|
62
41
|
|
|
63
42
|
test('external Codex isolation requires explicit native local deployment', () => {
|
|
64
|
-
const env = {
|
|
43
|
+
const env = {EZ_APPLICATION_PORT:'8110', EZ_EXECUTOR_TRANSPORT:'local', EZ_CODEX_SANDBOX:'external'}
|
|
65
44
|
assert.equal(loadConfig(env).codexSandbox, 'external')
|
|
66
|
-
assert.equal(loadConfig({...env,
|
|
67
|
-
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'
|
|
45
|
+
assert.equal(loadConfig({...env,TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY'}).codexSandbox, 'external')
|
|
46
|
+
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY'}).codexSandbox, undefined)
|
|
68
47
|
assert.equal(loadConfig({...env,EZ_EXECUTOR_TRANSPORT:''}).isolation, 'isolated')
|
|
69
48
|
assert.throws(() => loadConfig({...env,EZ_EXECUTOR_TRANSPORT:'host'}), /sandbox|SANDBOX|Isolation/)
|
|
70
49
|
assert.throws(() => loadConfig({...env,EZ_CODEX_SANDBOX:'danger-full-access'}), /sandbox|SANDBOX/)
|
|
71
50
|
})
|
|
72
51
|
|
|
73
52
|
test('isolation class is isolated by default and rejects host transport mismatch', () => {
|
|
74
|
-
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'
|
|
75
|
-
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'
|
|
76
|
-
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'
|
|
77
|
-
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'
|
|
78
|
-
assert.throws(() => loadConfig({TELEGRAM_BOT_TOKEN:'
|
|
79
|
-
assert.throws(() => loadConfig({TELEGRAM_BOT_TOKEN:'
|
|
80
|
-
assert.throws(() => loadConfig({TELEGRAM_BOT_TOKEN:'
|
|
53
|
+
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY'}).isolation, 'isolated')
|
|
54
|
+
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_ISOLATION:'isolated',EZ_EXECUTOR_TRANSPORT:'local'}).isolation, 'isolated')
|
|
55
|
+
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_EXECUTOR_TRANSPORT:'host'}).isolation, 'host-capable')
|
|
56
|
+
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_ISOLATION:'host-capable',EZ_EXECUTOR_TRANSPORT:'host'}).isolation, 'host-capable')
|
|
57
|
+
assert.throws(() => loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_ISOLATION:'isolated',EZ_EXECUTOR_TRANSPORT:'host'}), /Isolation isolated requires EZ_EXECUTOR_TRANSPORT=local/)
|
|
58
|
+
assert.throws(() => loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_ISOLATION:'host-capable',EZ_EXECUTOR_TRANSPORT:'local'}), /Isolation host-capable requires EZ_EXECUTOR_TRANSPORT=host/)
|
|
59
|
+
assert.throws(() => loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_ISOLATION:'seatbelt'}), /isolated or host-capable/)
|
|
81
60
|
assert.throws(() => loadConfig({
|
|
82
|
-
TELEGRAM_BOT_TOKEN:'
|
|
61
|
+
TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY',EZ_ISOLATION:'host-capable',EZ_EXECUTOR_TRANSPORT:'host',
|
|
83
62
|
EZ_CHANNEL_BACKEND_URL:'https://backend.example',EZ_CHANNEL_BACKEND_TOKEN:'secret',
|
|
84
63
|
}), /Host-capable isolation requires the host native CLI/)
|
|
85
64
|
})
|
|
86
65
|
|
|
87
66
|
test('optional web launcher preserves reserved commands and accepts only HTTPS without secrets', () => {
|
|
88
|
-
const config = (value: unknown) => loadConfig({ TELEGRAM_BOT_TOKEN: '
|
|
67
|
+
const config = (value: unknown) => loadConfig({ TELEGRAM_BOT_TOKEN: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY', EZ_TELEGRAM_WEB_APP: JSON.stringify(value) })
|
|
89
68
|
assert.deepEqual(config({command:'voice',label:'Voice',url:'https://voice.example/'}).webLauncher,{command:'voice',label:'Voice',url:'https://voice.example/'})
|
|
90
69
|
for (const value of [{command:'stop',label:'Voice',url:'https://voice.example/'},{command:'voice',label:'Voice',url:'http://voice.example/'},{command:'voice',label:'Voice',url:'https://voice.example/#secret'},{command:'voice',label:'Voice',url:'https://user:pass@voice.example/'},{command:'voice',label:'Voice',url:'https://voice.example/?token=secret'}]) assert.throws(()=>config(value))
|
|
91
70
|
})
|
|
71
|
+
|
|
72
|
+
test('Telegram transport is inferred from a valid token; legacy flag contradictions fail closed', () => {
|
|
73
|
+
const token = '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY'
|
|
74
|
+
const enabled = loadConfig({ TELEGRAM_BOT_TOKEN: token, EZ_APPLICATION_PORT: '8110' })
|
|
75
|
+
assert.equal(enabled.telegramEnabled, true)
|
|
76
|
+
assert.equal(enabled.telegramBotToken, token)
|
|
77
|
+
const appOnly = loadConfig({ TELEGRAM_BOT_TOKEN: ' ', EZ_APPLICATION_PORT: '8110' })
|
|
78
|
+
assert.equal(appOnly.telegramEnabled, false)
|
|
79
|
+
assert.equal(appOnly.telegramBotToken, '')
|
|
80
|
+
assert.throws(() => loadConfig({ TELEGRAM_BOT_TOKEN: 'garbage', EZ_APPLICATION_PORT: '8110' }), /malformed/)
|
|
81
|
+
assert.throws(() => loadConfig({ TELEGRAM_BOT_TOKEN: token, EZ_TELEGRAM_ENABLED: 'false', EZ_APPLICATION_PORT: '8110' }), /contradicts/)
|
|
82
|
+
assert.throws(() => loadConfig({ EZ_TELEGRAM_ENABLED: 'true', EZ_APPLICATION_PORT: '8110' }), /contradicts/)
|
|
83
|
+
assert.throws(() => loadConfig({ TELEGRAM_BOT_TOKEN: token, EZ_TELEGRAM_ENABLED: 'sometimes' }), /must be true or false/)
|
|
84
|
+
assert.equal(loadConfig({ EZ_TELEGRAM_ENABLED: 'false', EZ_APPLICATION_PORT: '8110' }).telegramEnabled, false)
|
|
85
|
+
assert.equal(loadConfig({ TELEGRAM_BOT_TOKEN: token, EZ_TELEGRAM_ENABLED: 'true' }).telegramEnabled, true)
|
|
86
|
+
})
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
-
import { mkdtemp, rm } from 'node:fs/promises'
|
|
2
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import test from 'node:test'
|
|
6
6
|
import { ControlStore } from '../src/control-state.js'
|
|
7
7
|
import { parseOwnerArgs } from '../src/owner-args.js'
|
|
8
8
|
|
|
9
|
-
const fixture = async (run: (store: ControlStore, advance: (milliseconds: number) => void) => Promise<void>): Promise<void> => {
|
|
9
|
+
const fixture = async (run: (store: ControlStore, advance: (milliseconds: number) => void, directory: string) => Promise<void>): Promise<void> => {
|
|
10
10
|
const directory = await mkdtemp(path.join(tmpdir(), 'ez-control-test-'))
|
|
11
11
|
let now = Date.parse('2026-01-01T00:00:00.000Z')
|
|
12
12
|
try {
|
|
13
|
-
await run(new ControlStore(directory, 60_000, () => now), (milliseconds) => { now += milliseconds })
|
|
13
|
+
await run(new ControlStore(directory, 60_000, () => now), (milliseconds) => { now += milliseconds }, directory)
|
|
14
14
|
} finally {
|
|
15
15
|
await rm(directory, { recursive: true, force: true })
|
|
16
16
|
}
|
|
@@ -35,7 +35,9 @@ test('cannot approve a telegram id that did not request pairing', async () => fi
|
|
|
35
35
|
await store.requestPairing(101, 101)
|
|
36
36
|
await assert.rejects(store.approveOwner(202), /No active pairing request/)
|
|
37
37
|
assert.equal((await store.status()).owner, null)
|
|
38
|
-
|
|
38
|
+
const request = (await store.status()).pending[0]
|
|
39
|
+
if (!request || !('telegramUserId' in request)) throw new Error('Telegram pairing request missing')
|
|
40
|
+
assert.equal(request.telegramUserId, 101)
|
|
39
41
|
}))
|
|
40
42
|
|
|
41
43
|
test('expires pairing requests before an owner can approve them', async () => fixture(async (store, advance) => {
|
|
@@ -51,8 +53,85 @@ test('supports local owner recovery by revocation', async () => fixture(async (s
|
|
|
51
53
|
assert.equal((await store.status()).owner, null)
|
|
52
54
|
}))
|
|
53
55
|
|
|
56
|
+
test('application-owned Telegram link is hashed, one-time, and bound to the current owner', async () => fixture(async (store, advance, directory) => {
|
|
57
|
+
const owner = await store.registerOwner('account:one')
|
|
58
|
+
const bindingId = '11111111-1111-1111-1111-111111111111'
|
|
59
|
+
const pairing = await store.createApplicationTelegramPairing(bindingId, owner)
|
|
60
|
+
assert.ok(pairing)
|
|
61
|
+
assert.doesNotMatch(await readFile(path.join(directory, 'control-state.json'), 'utf8'), new RegExp(pairing.token))
|
|
62
|
+
const flipped = pairing.token.slice(0, -1) + (pairing.token.endsWith('A') ? 'B' : 'A')
|
|
63
|
+
assert.equal(await store.claimApplicationTelegramPairing(flipped, 101, 101, async () => true), null)
|
|
64
|
+
assert.equal(await store.claimApplicationTelegramPairing(pairing.token, 101, 101, async () => false), null)
|
|
65
|
+
assert.equal((await store.status()).owner?.telegramUserId, undefined)
|
|
66
|
+
|
|
67
|
+
const replacement = await store.createApplicationTelegramPairing(bindingId, owner)
|
|
68
|
+
assert.ok(replacement)
|
|
69
|
+
advance(60_001)
|
|
70
|
+
assert.equal(await store.claimApplicationTelegramPairing(replacement.token, 101, 101, async () => true), null)
|
|
71
|
+
|
|
72
|
+
const active = await store.createApplicationTelegramPairing(bindingId, owner)
|
|
73
|
+
assert.ok(active)
|
|
74
|
+
const linked = await store.claimApplicationTelegramPairing(active.token, 101, 101, async (requestedBinding, expectedOwner) =>
|
|
75
|
+
requestedBinding === bindingId && expectedOwner.id === owner.id)
|
|
76
|
+
assert.equal(linked?.id, owner.id)
|
|
77
|
+
assert.equal(linked?.telegramUserId, 101)
|
|
78
|
+
assert.equal(linked?.telegramChatId, 101)
|
|
79
|
+
assert.ok(linked?.telegramLinkedAt)
|
|
80
|
+
assert.equal(await store.claimApplicationTelegramPairing(active.token, 101, 101, async () => true), null)
|
|
81
|
+
assert.equal(await store.createApplicationTelegramPairing(bindingId, linked!), null)
|
|
82
|
+
await store.unlinkTelegram()
|
|
83
|
+
const relink = await store.createApplicationTelegramPairing(bindingId, (await store.status()).owner!)
|
|
84
|
+
assert.ok(relink)
|
|
85
|
+
assert.equal(await store.claimApplicationTelegramPairing(relink.token, 101, 101, async () => false), null)
|
|
86
|
+
}))
|
|
87
|
+
|
|
54
88
|
test('owner CLI treats pnpm -- as a separator, not a command', () => {
|
|
55
89
|
assert.deepEqual(parseOwnerArgs(['--', 'status']), { command: 'status', value: undefined })
|
|
56
90
|
assert.deepEqual(parseOwnerArgs(['--', 'approve', '101']), { command: 'approve', value: '101' })
|
|
57
91
|
assert.deepEqual(parseOwnerArgs(['revoke']), { command: 'revoke', value: undefined })
|
|
58
92
|
})
|
|
93
|
+
|
|
94
|
+
test('provider catalog upgrades a persisted providerless Codex selection', async () => fixture(async (store) => {
|
|
95
|
+
const model = 'deepseek/deepseek-v4.1-flash'
|
|
96
|
+
await store.captureChoice({ id: 'deepseek', name: `${model} · max`, cli: 'codex', model, effort: 'max' })
|
|
97
|
+
const catalog = [{ cli: 'codex', provider: 'openrouter', model, name: `OpenRouter · ${model}`, efforts: ['max'] }]
|
|
98
|
+
|
|
99
|
+
assert.equal(await store.normalizeProviderBindings(catalog), true)
|
|
100
|
+
const state = await store.status()
|
|
101
|
+
const preset = state.ai!.presets.find((item) => item.id === state.ai!.selectedId)!
|
|
102
|
+
assert.equal(preset.provider, 'openrouter')
|
|
103
|
+
assert.equal(preset.name, `${model} · max`)
|
|
104
|
+
assert.equal(state.activeSession?.preset?.provider, 'openrouter')
|
|
105
|
+
assert.equal(await store.normalizeProviderBindings(catalog), false)
|
|
106
|
+
}))
|
|
107
|
+
|
|
108
|
+
test('provider catalog leaves ambiguous and effort-mismatched selections untouched', async () => fixture(async (store) => {
|
|
109
|
+
const model = 'deepseek/deepseek-v4.1-flash'
|
|
110
|
+
await store.captureChoice({ id: 'deepseek', name: 'My deepseek', cli: 'codex', model, effort: 'max' })
|
|
111
|
+
const ambiguous = [
|
|
112
|
+
{ cli: 'codex', provider: 'openrouter', model, name: `OpenRouter · ${model}`, efforts: ['max'] },
|
|
113
|
+
{ cli: 'codex', provider: 'direct', model, name: `Direct · ${model}`, efforts: ['max'] },
|
|
114
|
+
]
|
|
115
|
+
assert.equal(await store.normalizeProviderBindings(ambiguous), false)
|
|
116
|
+
assert.equal((await store.status()).ai!.presets.find((item) => item.id === 'deepseek')!.provider, undefined)
|
|
117
|
+
const mismatch = [{ cli: 'codex', provider: 'openrouter', model, name: `OpenRouter · ${model}`, efforts: ['low'] }]
|
|
118
|
+
assert.equal(await store.normalizeProviderBindings(mismatch), false)
|
|
119
|
+
assert.equal((await store.status()).ai!.presets.find((item) => item.id === 'deepseek')!.provider, undefined)
|
|
120
|
+
}))
|
|
121
|
+
|
|
122
|
+
test('steady-state owner checks and session pins write nothing to control/', async () => fixture(async (store, _advance, directory) => {
|
|
123
|
+
const { stat } = await import('node:fs/promises')
|
|
124
|
+
const { initialPreset } = await import('../src/ai.js')
|
|
125
|
+
await store.requestPairing(101, 101)
|
|
126
|
+
await store.approveOwner(101)
|
|
127
|
+
await store.aiState(initialPreset('codex'))
|
|
128
|
+
// First pin may persist the session binding; everything after must be quiet.
|
|
129
|
+
await store.captureChoice(initialPreset('codex'), 'hello')
|
|
130
|
+
const before = await stat(path.join(directory, 'control-state.json'))
|
|
131
|
+
await store.status()
|
|
132
|
+
await store.captureChoice(initialPreset('codex'), 'hello again')
|
|
133
|
+
await store.status()
|
|
134
|
+
const after = await stat(path.join(directory, 'control-state.json'))
|
|
135
|
+
assert.equal(after.mtimeMs, before.mtimeMs)
|
|
136
|
+
assert.equal(after.size, before.size)
|
|
137
|
+
}))
|