@jc_stack/ez-agents 0.1.0-beta.26 → 0.1.0-beta.28
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/.env.example +10 -1
- package/AGENTS.md +40 -9
- package/CHANGELOG.md +35 -0
- package/CONTRIBUTING.md +31 -1
- package/Dockerfile +1 -0
- package/README.md +84 -12
- package/bin/ezenciel-agents-application +2 -0
- package/bin/ezenciel-agents-application.mjs +16 -0
- package/compose.yaml +8 -0
- package/docker/entrypoint.sh +20 -2
- package/docker/healthcheck.mjs +1 -1
- package/docker/run.ts +3 -3
- package/docker/smoke.mjs +41 -2
- package/docs/application-channel.md +366 -0
- package/docs/architecture/ai-selection.md +12 -15
- package/docs/docker-runtime.md +29 -0
- package/docs/host-service.md +5 -8
- package/docs/local-qa.md +1 -1
- package/docs/managed-applications.md +68 -0
- package/docs/plugin-catalog.md +1 -0
- package/docs/plugin-connection.md +76 -0
- package/docs/plugins.md +54 -5
- package/docs/repair.md +26 -25
- package/docs/responsive-channels.md +13 -55
- package/docs/scheduling.md +40 -36
- package/docs/setup.md +11 -21
- package/docs/standalone-cli.md +2 -2
- package/docs/upgrades.md +43 -18
- package/package.json +8 -4
- package/src/agent-guidance.ts +32 -3
- package/src/ai-cli.ts +5 -1
- package/src/ai.ts +6 -28
- package/src/application-channel.ts +308 -0
- package/src/application-cli.ts +41 -0
- package/src/application-client.mjs +87 -0
- package/src/application-origin.ts +15 -0
- package/src/codex-session.ts +7 -10
- package/src/config.ts +23 -5
- package/src/control-state.ts +274 -21
- package/src/conversation-menu.ts +89 -0
- package/src/delivery-context.d.mts +5 -0
- package/src/delivery-context.mjs +25 -0
- package/src/desktop-bridge.ts +11 -43
- package/src/event-sources.ts +2 -2
- package/src/execution-authority.ts +2 -0
- package/src/executor.ts +29 -58
- package/src/host-executor.ts +11 -9
- package/src/identity.ts +11 -3
- package/src/index.ts +191 -93
- package/src/menu.ts +76 -55
- package/src/message-history.ts +52 -0
- package/src/message-send.ts +1 -1
- package/src/message.ts +49 -7
- package/src/model-policy.ts +5 -15
- package/src/owner.ts +7 -1
- package/src/plugins/connection-artifacts.mjs +31 -0
- package/src/plugins/connection.mjs +124 -0
- package/src/plugins/manager.mjs +93 -23
- package/src/plugins/native-tasks.d.mts +4 -0
- package/src/plugins/native-tasks.mjs +66 -0
- package/src/plugins/workspace-lease.d.mts +3 -0
- package/src/plugins/workspace-lease.mjs +44 -0
- package/src/repair-policy.ts +0 -8
- package/src/reply-context.ts +3 -29
- package/src/runs.ts +67 -9
- package/src/schedule-cli.ts +33 -15
- package/src/scheduled-tasks.ts +20 -21
- package/src/scheduler.ts +55 -22
- package/src/task-executor.ts +4 -5
- package/src/task-workspace.ts +2 -11
- package/src/update-attention.ts +1 -1
- package/src/updates/binding.mjs +2 -6
- package/src/updates/control.mjs +4 -0
- package/src/updates/supervisor.mjs +10 -4
- package/src/web-launcher.ts +19 -0
- package/src/workspace.ts +3 -1
- package/templates/agent/AGENTS.md +13 -55
- package/templates/agent-guidance.md +90 -37
- package/templates/deployments.md +24 -0
- package/templates/failure-review.md +6 -0
- package/templates/maintainer-purpose.md +12 -6
- package/test/agent-guidance.test.ts +29 -39
- package/test/ai-cli.test.ts +9 -0
- package/test/ai.test.ts +66 -22
- package/test/application-channel.test.ts +283 -0
- package/test/application-client.test.mjs +84 -0
- package/test/application-controls.test.ts +224 -0
- package/test/application-only.test.ts +100 -0
- package/test/busy-reply-relay.test.ts +11 -7
- package/test/channel-delivery.test.ts +63 -0
- package/test/channel-owner.test.ts +161 -0
- package/test/client-defaults.test.ts +1 -1
- package/test/codex-session.test.ts +18 -10
- package/test/config.test.ts +16 -1
- package/test/connection-artifacts.test.mjs +32 -0
- package/test/conversation-menu.test.ts +67 -0
- package/test/conversations.test.ts +84 -0
- package/test/desktop-bridge.test.ts +17 -11
- package/test/engine-handoff.test.ts +73 -0
- package/test/event-sources.test.ts +5 -8
- package/test/executor.test.ts +68 -16
- package/test/failure.test.ts +64 -0
- package/test/host-executor.test.ts +58 -17
- package/test/install-config.test.ts +1 -1
- package/test/intake-relay.test.ts +169 -25
- package/test/message-history.test.ts +127 -0
- package/test/model-policy.test.ts +23 -48
- package/test/native-tasks.test.ts +36 -0
- package/test/plugin-connection.test.mjs +124 -0
- package/test/plugin-manager.test.mjs +70 -10
- package/test/repair-policy.test.ts +8 -12
- package/test/runs.test.ts +13 -0
- package/test/runtime-identity.test.mjs +18 -0
- package/test/schedule-cli.test.ts +34 -5
- package/test/scheduled-tasks.test.ts +79 -8
- package/test/scheduler.test.ts +30 -1
- package/test/task-native.test.ts +5 -2
- package/test/update-attention.test.ts +1 -2
- package/test/updates.test.mjs +44 -5
- package/test/workspace.test.ts +2 -3
- package/scripts/smoke-busy-reply.ts +0 -58
- package/src/reply-executor.ts +0 -55
- package/src/reply-mcp.ts +0 -23
- package/templates/agent/TOOLS.md +0 -105
- package/templates/chat-guidance.md +0 -23
- package/templates/standalone-tools.md +0 -20
- package/templates/updates.md +0 -45
- package/test/reply.test.ts +0 -159
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
7
|
+
import { createServer } from 'node:net'
|
|
8
|
+
import { createRequire } from 'node:module'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { loadConfig } from '../src/config.js'
|
|
11
|
+
import { createRelay } from '../src/index.js'
|
|
12
|
+
import { ControlStore } from '../src/control-state.js'
|
|
13
|
+
import { RunStore } from '../src/runs.js'
|
|
14
|
+
import { EXECUTOR_REGISTRY } from '../src/executor.js'
|
|
15
|
+
import { ApplicationBindings } from '../src/application-channel.js'
|
|
16
|
+
|
|
17
|
+
const waitFor = async (condition: () => Promise<boolean>) => {
|
|
18
|
+
for (let i=0;i<300;i++) { if (await condition()) return; await new Promise(r=>setTimeout(r,10)) }
|
|
19
|
+
throw new Error('Timed out')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('application-only mode explicitly requires native listener and ignores bot credentials', () => {
|
|
23
|
+
assert.throws(()=>loadConfig({}), /TELEGRAM_BOT_TOKEN/)
|
|
24
|
+
assert.throws(()=>loadConfig({EZ_TELEGRAM_ENABLED:'false'}), /EZ_APPLICATION_PORT/)
|
|
25
|
+
assert.throws(()=>loadConfig({EZ_TELEGRAM_ENABLED:'no'}), /true or false/)
|
|
26
|
+
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/)
|
|
27
|
+
const config=loadConfig({EZ_TELEGRAM_ENABLED:'false',EZ_APPLICATION_PORT:'8110',TELEGRAM_BOT_TOKEN:'unused-secret'})
|
|
28
|
+
assert.equal(config.telegramBotToken,'')
|
|
29
|
+
assert.equal(config.telegramEnabled,false)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('administrator bootstrap is explicit, local-only and cannot replace identity', async t => {
|
|
33
|
+
const root=await mkdtemp(join(tmpdir(),'ez-app-only-admin-'))
|
|
34
|
+
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
35
|
+
const token=join(root,'token'); await writeFile(token,'x'.repeat(48),{mode:0o600})
|
|
36
|
+
const cli=fileURLToPath(new URL('../bin/ezenciel-agents-application.mjs',import.meta.url))
|
|
37
|
+
const args=[cli,'--id','app','--token-file',token,'--owner','42']
|
|
38
|
+
const env={...process.env,EZ_CONTROL_DIR:root,EZ_RUN_ID:'',EZ_TELEGRAM_ENABLED:'false'}
|
|
39
|
+
assert.notEqual(spawnSync(process.execPath,args,{env:{...env,EZ_TELEGRAM_ENABLED:'true'}}).status,0)
|
|
40
|
+
assert.notEqual(spawnSync(process.execPath,args,{env:{...env,EZ_RUN_ID:'r_agent'}}).status,0)
|
|
41
|
+
assert.equal((await new ControlStore(root,1000).status()).owner,null)
|
|
42
|
+
const invalidToken=join(root,'invalid-token');await writeFile(invalidToken,'short')
|
|
43
|
+
for (const invalidArgs of [
|
|
44
|
+
[cli,'--id','../bad','--token-file',token,'--owner','42'],
|
|
45
|
+
[cli,'--id','app','--token-file',invalidToken,'--owner','42'],
|
|
46
|
+
[cli,'--id','app','--token-file',join(root,'missing'),'--owner','42'],
|
|
47
|
+
]) {
|
|
48
|
+
assert.notEqual(spawnSync(process.execPath,invalidArgs,{env}).status,0)
|
|
49
|
+
assert.equal((await new ControlStore(root,1000).status()).owner,null)
|
|
50
|
+
}
|
|
51
|
+
const good=spawnSync(process.execPath,args,{env,encoding:'utf8'});assert.equal(good.status,0,good.stderr)
|
|
52
|
+
assert.equal((await new ControlStore(root,1000).status()).owner?.telegramUserId,42)
|
|
53
|
+
assert.notEqual(spawnSync(process.execPath,args,{env}).status,0)
|
|
54
|
+
assert.notEqual(spawnSync(process.execPath,[cli,'--id','app','--token-file',token,'--share-telegram'],{env}).status,0)
|
|
55
|
+
const sharedToken=join(root,'shared-token');await writeFile(sharedToken,'s'.repeat(43),{mode:0o600})
|
|
56
|
+
const shared=spawnSync(process.execPath,[cli,'--id','shared','--token-file',sharedToken,'--share-active'],{env,encoding:'utf8'})
|
|
57
|
+
assert.equal(shared.status,0,shared.stderr)
|
|
58
|
+
assert.equal((await new ApplicationBindings(root).list()).find(binding=>binding.id==='shared')?.shareTelegram,true)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('botless daemon executes application turn and rejects Telegram-origin work/outbound', async t => {
|
|
62
|
+
const root=await mkdtemp(join(tmpdir(),'ez-app-only-runtime-'))
|
|
63
|
+
const portServer=createServer();await new Promise<void>(r=>portServer.listen(0,'127.0.0.1',r))
|
|
64
|
+
const port=(portServer.address() as {port:number}).port;await new Promise<void>(r=>portServer.close(()=>r()))
|
|
65
|
+
const config=loadConfig({EZ_TELEGRAM_ENABLED:'false',EZ_APPLICATION_PORT:String(port),EZ_CONTROL_DIR:root,EZ_AGENT_WORKSPACE:root,EZ_EXECUTOR_CLI:'codex'})
|
|
66
|
+
const native='11111111-1111-1111-1111-111111111111', fixture=join(root,'engine.mjs')
|
|
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);`)
|
|
68
|
+
const original=EXECUTOR_REGISTRY.codex,require=createRequire(import.meta.url)
|
|
69
|
+
let launches=0
|
|
70
|
+
EXECUTOR_REGISTRY.codex={...original,command:process.execPath,buildArgs:()=>{launches++;return ['--import',require.resolve('tsx'),fixture]}}
|
|
71
|
+
const relay=createRelay(config),control=new ControlStore(root,1000),runs=new RunStore(root)
|
|
72
|
+
assert.equal(relay.bot,null)
|
|
73
|
+
const owner=await control.bootstrapApplicationOwner(42)
|
|
74
|
+
const token='x'.repeat(48),binding=(await relay.applicationChannel.bindings.register('app',token,owner))!
|
|
75
|
+
const running=relay.start()
|
|
76
|
+
t.after(async()=>{await relay.stop();await running;EXECUTOR_REGISTRY.codex=original;await rm(root,{recursive:true,force:true})})
|
|
77
|
+
await waitFor(async()=>relay.isRunning())
|
|
78
|
+
const unauthorized=await fetch(`http://127.0.0.1:${port}/v1/runs`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({requestId:'bad',scope:'main',text:'bad'})})
|
|
79
|
+
assert.equal(unauthorized.status,401)
|
|
80
|
+
await assert.rejects(relay.applicationChannel.submit(binding.bindingId,{requestId:'sandbox-override',scope:'main',text:'Hello',codexSandbox:'external'}), /Invalid application request/)
|
|
81
|
+
const run=await relay.applicationChannel.submit(binding.bindingId,{requestId:'good',scope:'main',text:'Hello'})
|
|
82
|
+
await waitFor(async()=> (await runs.get(run.id))?.status==='completed')
|
|
83
|
+
await waitFor(async()=> (await relay.applicationChannel.snapshot(binding.bindingId,run.id)).messages.length>0)
|
|
84
|
+
assert.equal((await relay.applicationChannel.snapshot(binding.bindingId,run.id)).messages[0].text,'Application reply')
|
|
85
|
+
const legacy=await runs.create({chatId:42,telegramUserId:42,texts:['Old Telegram work']})
|
|
86
|
+
await runs.enqueueMessage(legacy.id,'Never send')
|
|
87
|
+
await relay.drainSources();await relay.drainOutbox();await relay.drainInbox(true)
|
|
88
|
+
assert.equal(launches,1)
|
|
89
|
+
assert.equal((await runs.get(legacy.id))?.status,'queued')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('Docker health accepts application readiness only with explicit botless configuration', async t => {
|
|
93
|
+
const root=await mkdtemp(join(tmpdir(),'ez-app-only-health-'))
|
|
94
|
+
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
95
|
+
await writeFile(join(root,'heartbeat.json'),JSON.stringify({at:Date.now(),polling:false,applicationOnly:true}))
|
|
96
|
+
const command=fileURLToPath(new URL('../docker/healthcheck.mjs',import.meta.url))
|
|
97
|
+
const env={...process.env,EZ_HEALTH_RELAY_CONTROL_DIR:root,EZ_EXECUTOR_TRANSPORT:'local'}
|
|
98
|
+
assert.equal(spawnSync(process.execPath,[command],{env:{...env,EZ_TELEGRAM_ENABLED:'false'}}).status,0)
|
|
99
|
+
assert.notEqual(spawnSync(process.execPath,[command],{env:{...env,EZ_TELEGRAM_ENABLED:'true'}}).status,0)
|
|
100
|
+
})
|
|
@@ -10,7 +10,7 @@ import { ControlStore } from '../src/control-state.js'
|
|
|
10
10
|
import { RunStore } from '../src/runs.js'
|
|
11
11
|
import type { Update } from 'grammy/types'
|
|
12
12
|
const until=async(check:()=>Promise<boolean>)=>{for(let n=0;n<150;n++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Timed out')}
|
|
13
|
-
test('
|
|
13
|
+
test('owner input queues literally without creating a second agent and rejects other senders',async()=>{
|
|
14
14
|
const root=await mkdtemp(join(tmpdir(),'ez-busy-relay-')),runs=new RunStore(root),control=new ControlStore(root,1000),children:ReturnType<typeof spawn>[]=[]
|
|
15
15
|
const relay=createRelay({workspace:root,controlDir:root,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'codex',telegramBotToken:'fixture'},async(_texts,opts)=>{
|
|
16
16
|
const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true});children.push(child);await once(child,'spawn')
|
|
@@ -24,17 +24,21 @@ test('busy owner replies serialize independently of the writer and reject other
|
|
|
24
24
|
await relay.bot.handleUpdate(message(1));await relay.drainInbox(true)
|
|
25
25
|
await relay.bot.handleUpdate(message(2));await relay.drainInbox(true)
|
|
26
26
|
await relay.bot.handleUpdate(message(3));await relay.drainInbox(true)
|
|
27
|
-
assert.equal((await runs.get('tg_2'))?.replyOnly,
|
|
27
|
+
assert.equal((await runs.get('tg_2'))?.replyOnly,undefined)
|
|
28
28
|
assert.equal((await runs.get('tg_3'))?.status,'queued')
|
|
29
|
-
assert.equal(children.length,
|
|
29
|
+
assert.equal(children.length,1)
|
|
30
30
|
await relay.bot.handleUpdate(message(4,202));await relay.drainInbox(true)
|
|
31
31
|
await relay.bot.handleUpdate(message(5,-42,'group'));await relay.drainInbox(true)
|
|
32
|
-
assert.equal(children.length,
|
|
32
|
+
assert.equal(children.length,1)
|
|
33
|
+
children[0].kill()
|
|
34
|
+
await until(async()=>children.length===2)
|
|
35
|
+
assert.equal(children[1].exitCode,null)
|
|
36
|
+
assert.equal(children[1].signalCode,null)
|
|
37
|
+
assert.equal((await runs.get('tg_2'))?.replyOnly,undefined)
|
|
38
|
+
assert.deepEqual((await runs.get('tg_2'))?.texts,['status'])
|
|
33
39
|
children[1].kill()
|
|
34
40
|
await until(async()=>children.length===3)
|
|
35
|
-
|
|
36
|
-
assert.equal(children[0].signalCode,null)
|
|
37
|
-
assert.equal((await runs.get('tg_3'))?.replyOnly,true)
|
|
41
|
+
await until(async()=>(await runs.get('tg_3'))?.status==='running')
|
|
38
42
|
await relay.bot.handleUpdate({...message(6),message:{...message(6).message!,text:'/stop'}} as Update)
|
|
39
43
|
await until(async()=>children.every(c=>c.exitCode!==null || c.signalCode!==null))
|
|
40
44
|
}finally{await relay.stop();for(const c of children)c.kill();await until(async()=>!(await runs.list()).some(r=>r.status==='running'));await rm(root,{recursive:true,force:true})}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import * as fs from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
import {createRelay} from '../src/index.js'
|
|
7
|
+
import {ControlStore} from '../src/control-state.js'
|
|
8
|
+
import {RunStore} from '../src/runs.js'
|
|
9
|
+
import {authorizeDeliveryContext,captureDeliveryContext} from '../src/delivery-context.mjs'
|
|
10
|
+
import {nativeTasks,nativeTaskBinding} from '../src/plugins/native-tasks.mjs'
|
|
11
|
+
|
|
12
|
+
test('authenticated connection sends text and files through ordinary outbox receipts without a native run',async()=>{
|
|
13
|
+
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
|
+
for(const dir of [workspace,controlDir,home])await fs.mkdir(dir)
|
|
15
|
+
const relay=createRelay({controlDir,workspace,pairingTtlMs:1000,executorTimeoutMs:1000,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw Error('Must not launch native execution')})
|
|
16
|
+
const calls:string[]=[];relay.bot.api.config.use(async(_prev,method)=>{calls.push(method);return {ok:true,result:{message_id:42}} as never})
|
|
17
|
+
try {
|
|
18
|
+
await fs.writeFile(path.join(home,'config.json'),JSON.stringify({schemaVersion:1,workspace,hostConfig}))
|
|
19
|
+
await fs.writeFile(hostConfig,JSON.stringify({cli:'grok',agents:[{toolsHome:home,workspace,controlDir}]}))
|
|
20
|
+
const control=new ControlStore(controlDir,1000);await control.requestPairing(101,101);await control.approveOwner(101)
|
|
21
|
+
const context=(await captureDeliveryContext(controlDir,'voice','revision'))!,store=new RunStore(controlDir)
|
|
22
|
+
const env=await nativeTaskBinding(home,{EZ_DELIVERY_CONTEXT:'forged',EZ_RUN_ID:'forged'});assert.equal(env.env.EZ_DELIVERY_CONTEXT,undefined);assert.equal(env.env.EZ_RUN_ID,undefined)
|
|
23
|
+
const file=path.join(workspace,'scan.pdf');await fs.writeFile(file,'%PDF-fixture')
|
|
24
|
+
for(const args of [['--text','literal $(not-a-shell)'],['--document',file]]) {
|
|
25
|
+
const pending=nativeTasks(home,args,{command:'message',deliveryContext:context})
|
|
26
|
+
const deadline=Date.now()+5000;while(!(await store.pendingOutbox()).length){if(Date.now()>deadline)throw Error('Message was not queued');await new Promise(r=>setTimeout(r,10))}
|
|
27
|
+
const item=(await store.pendingOutbox())[0]!;assert.equal(item.runId,undefined);if(item.documentPath)assert.equal(item.documentPath,'scan.pdf')
|
|
28
|
+
await relay.drainOutbox();const result=await pending;assert.equal(result.code,0,result.stderr)
|
|
29
|
+
const frames=result.stdout.trim().split('\n').map(line=>JSON.parse(line));assert.equal(frames[0].status,'queued');assert.equal(frames.at(-1).status,'delivered');assert.deepEqual(frames.at(-1).receipt.messageIds,[42])
|
|
30
|
+
const receipt=await nativeTasks(home,['receipt',item.id],{command:'message',deliveryContext:context});assert.equal(JSON.parse(receipt.stdout).status,'delivered')
|
|
31
|
+
}
|
|
32
|
+
assert.deepEqual(calls,['sendMessage','sendDocument']);assert.deepEqual(await store.list(),[])
|
|
33
|
+
await assert.rejects(nativeTasks(home,['--document',hostConfig],{command:'message',deliveryContext:context}),/outside/)
|
|
34
|
+
await fs.symlink(hostConfig,path.join(workspace,'escape'));await assert.rejects(nativeTasks(home,['--document','escape'],{command:'message',deliveryContext:context}),/outside/)
|
|
35
|
+
await assert.rejects(nativeTasks(home,['--text-file',hostConfig],{command:'message',deliveryContext:context}),/inline/)
|
|
36
|
+
const queued=await store.enqueueOwnerDelivery(context,{type:'message',text:'revoked'})
|
|
37
|
+
await control.revokeOwner();await control.requestPairing(101,101);await control.approveOwner(101)
|
|
38
|
+
await relay.drainOutbox();assert.equal(calls.length,2);await assert.rejects(store.waitForDelivery(queued.id),/revoked/)
|
|
39
|
+
await assert.rejects(nativeTasks(home,['--text','denied'],{command:'message',deliveryContext:context}),/revoked/)
|
|
40
|
+
await assert.rejects(store.ownerDeliveryReceipt(context,queued.id),/revoked/)
|
|
41
|
+
} finally {await relay.stop();await fs.rm(root,{recursive:true,force:true})}
|
|
42
|
+
})
|
|
43
|
+
test('delivery authority cannot revive after Telegram relink or same-time owner replacement',async t=>{
|
|
44
|
+
const root=await fs.mkdtemp(path.join(os.tmpdir(),'ez-channel-delivery-epoch-'))
|
|
45
|
+
t.after(()=>fs.rm(root,{recursive:true,force:true}))
|
|
46
|
+
const control=new ControlStore(root,60000,()=>Date.parse('2026-09-14T00:00:00Z'))
|
|
47
|
+
await control.requestPairing(101,101)
|
|
48
|
+
await control.approveOwner(101)
|
|
49
|
+
const original=(await captureDeliveryContext(root,'voice','revision'))!
|
|
50
|
+
|
|
51
|
+
await control.unlinkTelegram()
|
|
52
|
+
await control.requestPairing(101,101)
|
|
53
|
+
await control.approveOwner(101)
|
|
54
|
+
const relinked=(await control.status()).owner!
|
|
55
|
+
assert.throws(()=>authorizeDeliveryContext(original,relinked),/revoked/)
|
|
56
|
+
|
|
57
|
+
const relinkedContext=(await captureDeliveryContext(root,'voice','revision'))!
|
|
58
|
+
await control.revokeOwner()
|
|
59
|
+
await control.requestPairing(101,101)
|
|
60
|
+
await control.approveOwner(101)
|
|
61
|
+
const replacement=(await control.status()).owner
|
|
62
|
+
assert.throws(()=>authorizeDeliveryContext(relinkedContext,replacement),/revoked/)
|
|
63
|
+
})
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { randomBytes } from 'node:crypto'
|
|
7
|
+
import { spawn } from 'node:child_process'
|
|
8
|
+
import { createRequire } from 'node:module'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { ControlStore, ownerId, ownerEpoch, sameOwner } from '../src/control-state.js'
|
|
11
|
+
import { ApplicationChannel } from '../src/application-channel.js'
|
|
12
|
+
import { RunStore } from '../src/runs.js'
|
|
13
|
+
import { Scheduler } from '../src/scheduler.js'
|
|
14
|
+
import { initialPreset } from '../src/ai.js'
|
|
15
|
+
import { ownsRun, isOwner } from '../src/identity.js'
|
|
16
|
+
import { createRelay } from '../src/index.js'
|
|
17
|
+
import { EXECUTOR_REGISTRY } from '../src/executor.js'
|
|
18
|
+
import { loadConfig } from '../src/config.js'
|
|
19
|
+
|
|
20
|
+
const secret = () => randomBytes(32).toString('base64url')
|
|
21
|
+
const waitFor = async (check: () => Promise<boolean>) => {
|
|
22
|
+
for (let i=0;i<500;i++) { if (await check()) return; await new Promise(resolve=>setTimeout(resolve,10)) }
|
|
23
|
+
throw new Error('Timed out waiting for native delivery')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('one owner links channels, rotates credentials and unlinks Telegram without resetting native continuity', async t => {
|
|
27
|
+
const root=await mkdtemp(join(tmpdir(),'ez-channel-owner-'))
|
|
28
|
+
const control=new ControlStore(root,60000), runs=new RunStore(root)
|
|
29
|
+
const channel=new ApplicationChannel({controlDir:root,initial:initialPreset('codex'),wake:()=>{},cancel:async()=>{}})
|
|
30
|
+
t.after(async()=>{await channel.stop();await rm(root,{recursive:true,force:true})})
|
|
31
|
+
const owner=await control.registerOwner('verified-account')
|
|
32
|
+
assert.equal(owner.telegramUserId,undefined)
|
|
33
|
+
assert.deepEqual(await control.registerOwner('verified-account'),owner)
|
|
34
|
+
await assert.rejects(control.registerOwner('other-account'),/different owner/)
|
|
35
|
+
const webToken=secret(), phoneToken=secret()
|
|
36
|
+
const web=(await channel.bindings.register('web',webToken,owner,true))!
|
|
37
|
+
const phone=(await channel.bindings.register('phone',phoneToken,owner,true))!
|
|
38
|
+
await assert.rejects(channel.bindings.register('duplicate',webToken,owner),/credential already/)
|
|
39
|
+
const first=await channel.submit(web.bindingId,{requestId:'one',scope:'main',text:'Remember cobalt',followOwner:true})
|
|
40
|
+
await control.saveNativeSession(first.execution!.sessionId,'native-owner-session')
|
|
41
|
+
const second=await channel.submit(phone.bindingId,{requestId:'two',scope:'main',text:'Which word?',followOwner:true})
|
|
42
|
+
assert.equal(first.execution!.sessionId,second.execution!.sessionId)
|
|
43
|
+
assert.equal(first.chatId,undefined)
|
|
44
|
+
assert.equal(first.ownerId,owner.id)
|
|
45
|
+
assert.equal(ownsRun(owner,first),true)
|
|
46
|
+
assert.equal(ownsRun({...owner,id:'someone-else'},first),false)
|
|
47
|
+
const rotated=secret()
|
|
48
|
+
const after=(await channel.bindings.register('web',rotated,owner,false,true))!
|
|
49
|
+
assert.equal(after.bindingId,web.bindingId)
|
|
50
|
+
await assert.rejects(channel.bindings.authenticate(webToken),/Unauthorized/)
|
|
51
|
+
assert.equal((await channel.bindings.authenticate(rotated)).bindingId,web.bindingId)
|
|
52
|
+
assert.equal((await channel.submit(web.bindingId,{requestId:'one',scope:'main',text:'Remember cobalt',followOwner:true})).id,first.id)
|
|
53
|
+
await assert.rejects(control.approveOwner(42),/No active pairing/)
|
|
54
|
+
await control.requestPairing(42,42)
|
|
55
|
+
const linked=await control.approveOwner(42)
|
|
56
|
+
assert.equal(ownerId(linked),owner.id)
|
|
57
|
+
assert.equal(linked.pairedAt,owner.pairedAt)
|
|
58
|
+
assert.equal(isOwner({from:{id:42,is_bot:false} as never,chat:{id:42,type:'private'} as never},linked),true)
|
|
59
|
+
const telegramRun={telegramUserId:42,chatId:42,telegramEpoch:linked.telegramLinkedAt}
|
|
60
|
+
assert.equal(ownsRun(linked,telegramRun),true)
|
|
61
|
+
await control.unlinkTelegram()
|
|
62
|
+
assert.equal(ownsRun((await control.status()).owner,telegramRun),false)
|
|
63
|
+
assert.equal((await control.executionSession(first.execution!)).nativeSessionId,'native-owner-session')
|
|
64
|
+
assert.equal((await channel.bindings.authenticate(phoneToken)).id,'phone')
|
|
65
|
+
await control.requestPairing(42,42);await control.approveOwner(42)
|
|
66
|
+
assert.equal(ownsRun((await control.status()).owner,telegramRun),false)
|
|
67
|
+
const address=await channel.listen(0) as {port:number}
|
|
68
|
+
const registration=await fetch(`http://127.0.0.1:${address.port}/v1/registration`,{headers:{Authorization:`Bearer ${rotated}`}})
|
|
69
|
+
assert.deepEqual(await registration.json(),{ownerId:'verified-account',bindingId:web.bindingId,channel:'web'})
|
|
70
|
+
await channel.bindings.register('phone',null,(await control.status()).owner!)
|
|
71
|
+
await assert.rejects(channel.bindings.authenticate(phoneToken),/Unauthorized/)
|
|
72
|
+
assert.equal((await channel.bindings.authenticate(rotated)).id,'web')
|
|
73
|
+
await control.revokeOwner()
|
|
74
|
+
await assert.rejects(channel.bindings.authenticate(rotated),/Unauthorized/)
|
|
75
|
+
assert.equal((await runs.list()).length,2)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('web owner uses the standard schedule CLI, executor and outbox with Telegram disabled',async t=>{
|
|
79
|
+
const root=await mkdtemp(join(tmpdir(),'ez-channel-schedule-'))
|
|
80
|
+
const original=EXECUTOR_REGISTRY.grok, require=createRequire(import.meta.url)
|
|
81
|
+
const fixture=join(root,'engine.mjs')
|
|
82
|
+
const scheduleCli=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
|
|
83
|
+
const messageCli=fileURLToPath(new URL('../bin/ezenciel-agents-message.mjs',import.meta.url))
|
|
84
|
+
await writeFile(fixture,`import {spawnSync} from 'node:child_process';
|
|
85
|
+
if(process.env.TELEGRAM_BOT_TOKEN)throw Error('secret leak');
|
|
86
|
+
const scheduled=process.env.EZ_RUN_ID.startsWith('r_schedule_');
|
|
87
|
+
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
|
+
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
|
+
EXECUTOR_REGISTRY.grok={...original,command:process.execPath,buildArgs:()=>['--import',require.resolve('tsx'),fixture]}
|
|
90
|
+
const config=loadConfig({EZ_TELEGRAM_ENABLED:'false',EZ_APPLICATION_PORT:'8787',EZ_CONTROL_DIR:root,EZ_AGENT_WORKSPACE:root,EZ_EXECUTOR_CLI:'grok'})
|
|
91
|
+
const relay=createRelay(config), control=new ControlStore(root,60000), runs=new RunStore(root)
|
|
92
|
+
const owner=await control.registerOwner('web-owner'),token=secret()
|
|
93
|
+
const binding=(await relay.applicationChannel.bindings.register('web',token,owner))!
|
|
94
|
+
const address=await relay.applicationChannel.listen(0) as {port:number}
|
|
95
|
+
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})})
|
|
97
|
+
const first=await relay.applicationChannel.submit(binding.bindingId,{requestId:'chat',scope:'main',text:'Schedule a followup'})
|
|
98
|
+
await waitFor(async()=> (await runs.get(first.id))?.status==='completed')
|
|
99
|
+
await waitFor(async()=> (await runs.list()).some(r=>r.scheduled && r.status==='completed'))
|
|
100
|
+
const scheduled=(await runs.list()).find(r=>r.scheduled)!
|
|
101
|
+
assert.equal(scheduled.chatId,undefined)
|
|
102
|
+
assert.equal(scheduled.ownerId,'web-owner')
|
|
103
|
+
assert.equal(scheduled.delivery?.bindingId,binding.bindingId)
|
|
104
|
+
await waitFor(async()=> (await relay.applicationChannel.snapshot(binding.bindingId,scheduled.id)).messages.length===1)
|
|
105
|
+
assert.equal((await relay.applicationChannel.snapshot(binding.bindingId,scheduled.id)).messages[0].text,'Scheduled reply')
|
|
106
|
+
const inbox=await fetch(`http://127.0.0.1:${address.port}/v1/runs`,{headers:{Authorization:`Bearer ${token}`}})
|
|
107
|
+
assert.equal((await inbox.json() as {runs:unknown[]}).runs.length,2)
|
|
108
|
+
const stranger=secret();await relay.applicationChannel.bindings.register('other',stranger,owner)
|
|
109
|
+
assert.equal((await fetch(`http://127.0.0.1:${address.port}/v1/runs/${scheduled.id}`,{headers:{Authorization:`Bearer ${stranger}`}})).status,404)
|
|
110
|
+
const scheduler=new Scheduler(root), saved=await scheduler.get('followup')
|
|
111
|
+
await scheduler.save({...saved,id:'revoked',trigger:{at:new Date(Date.now()+1000).toISOString()}})
|
|
112
|
+
await relay.applicationChannel.bindings.register('web',null,owner)
|
|
113
|
+
await scheduler.tick(owner,runs,Date.now()+2000)
|
|
114
|
+
assert.equal((await runs.list()).some(r=>r.scheduled?.id==='revoked'),false)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('legacy web runs survive Telegram unlink, but owner revocation invalidates their binding',async t=>{
|
|
118
|
+
const root=await mkdtemp(join(tmpdir(),'ez-owner-legacy-'))
|
|
119
|
+
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
120
|
+
const control=new ControlStore(root,60000,()=>Date.parse('2026-01-01T00:00:00Z'))
|
|
121
|
+
await control.requestPairing(42,42)
|
|
122
|
+
const owner=await control.approveOwner(42)
|
|
123
|
+
const channel=new ApplicationChannel({controlDir:root,initial:initialPreset('codex'),wake:()=>{},cancel:async()=>{}})
|
|
124
|
+
const token=secret(),binding=(await channel.bindings.register('web',token,owner))!
|
|
125
|
+
const runs=new RunStore(root)
|
|
126
|
+
const legacy=await runs.create({id:'r_legacy',chatId:42,telegramUserId:42,texts:['legacy'],application:{bindingId:binding.bindingId,scope:'main',requestId:'legacy'}})
|
|
127
|
+
await control.unlinkTelegram()
|
|
128
|
+
await channel.bindings.authorize(legacy)
|
|
129
|
+
assert.equal(ownsRun((await control.status()).owner,legacy),true)
|
|
130
|
+
await control.revokeOwner()
|
|
131
|
+
await control.requestPairing(42,42)
|
|
132
|
+
const replacement=await control.approveOwner(42)
|
|
133
|
+
assert.equal(owner.pairedAt,replacement.pairedAt)
|
|
134
|
+
assert.equal(sameOwner(owner,replacement),false,'same timestamp must not revive old authority')
|
|
135
|
+
await assert.rejects(channel.bindings.authenticate(token),/Unauthorized/)
|
|
136
|
+
await assert.rejects(channel.bindings.authorize(legacy),/revoked/)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
for (const mode of ['owner','web','telegram']) test(`${mode} revocation stops its already running worker`,async t=>{
|
|
140
|
+
const root=await mkdtemp(join(tmpdir(),'ez-owner-stop-'))
|
|
141
|
+
const control=new ControlStore(root,60000),runs=new RunStore(root),scheduler=new Scheduler(root)
|
|
142
|
+
await control.requestPairing(42,42)
|
|
143
|
+
const owner=await control.approveOwner(42)
|
|
144
|
+
const relay=createRelay(loadConfig({TELEGRAM_BOT_TOKEN:'synthetic',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
|
+
relay.bot.api.config.use(async()=>({ok:true,result:true}) as never)
|
|
146
|
+
t.after(async()=>{await relay.stop();await rm(root,{recursive:true,force:true})})
|
|
147
|
+
if (mode==='telegram') {
|
|
148
|
+
await runs.create({id:'r_telegram',chatId:42,telegramUserId:42,ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),texts:['Wait'],execution:await control.captureChoice(initialPreset('grok'))})
|
|
149
|
+
} else {
|
|
150
|
+
const binding=(await relay.applicationChannel.bindings.register('web',secret(),owner))!
|
|
151
|
+
await scheduler.save({id:'wait',name:'Wait',text:'Wait',enabled:true,trigger:{at:new Date(Date.now()+1000).toISOString()},owner,delivery:{bindingId:binding.bindingId,scope:'main'},execution:await control.captureChoice(initialPreset('grok'))})
|
|
152
|
+
await scheduler.tick(owner,runs,Date.now()+2000)
|
|
153
|
+
}
|
|
154
|
+
await relay.drainSources()
|
|
155
|
+
await waitFor(async()=>(await runs.list()).some(r=>r.status==='running'))
|
|
156
|
+
if(mode==='owner')await control.revokeOwner()
|
|
157
|
+
else if(mode==='web')await relay.applicationChannel.bindings.register('web',null,owner)
|
|
158
|
+
else await control.unlinkTelegram()
|
|
159
|
+
await relay.drainSources()
|
|
160
|
+
await waitFor(async()=>(await runs.list()).every(r=>r.status==='cancelled'))
|
|
161
|
+
})
|
|
@@ -70,7 +70,7 @@ test('status projects the native client default without pinning the seed', () =>
|
|
|
70
70
|
assert.deepEqual(statusPreset({ ...initial, id: 'detected_empty' }, [discovered]), discovered)
|
|
71
71
|
const explicit = { ...discovered, id: 'saved', model: 'chosen-codex', effort: 'high' }
|
|
72
72
|
assert.equal(statusPreset(explicit, [discovered]), explicit)
|
|
73
|
-
assert.equal(statusPreset(initialPreset('codex-gui'), [{ ...discovered, cli: 'codex-gui' }]).model,
|
|
73
|
+
assert.equal(statusPreset(initialPreset('codex-gui'), [{ ...discovered, cli: 'codex-gui' }]).model, discovered.model)
|
|
74
74
|
})
|
|
75
75
|
|
|
76
76
|
test('seed uses the configured executor; repeated refresh preserves current/default and queued snapshots', async () => {
|
|
@@ -3,7 +3,8 @@ import assert from 'node:assert/strict'
|
|
|
3
3
|
import { spawn } from 'node:child_process'
|
|
4
4
|
import { runCodexSession } from '../src/codex-session.js'
|
|
5
5
|
|
|
6
|
-
for(const mode of ['goal','plain','tool-goal','blocked','disconnect','approval','late-limit','early-limit','early-clear','missing-goal'])test(`native Codex session: ${mode}`,async()=>{
|
|
6
|
+
for(const mode of ['external','goal','long-goal','plain','tool-goal','blocked','disconnect','approval','late-limit','early-limit','early-clear','missing-goal'])test(`native Codex session: ${mode}`,async()=>{
|
|
7
|
+
const prompt=mode==='long-goal'?'/goal Complete the research.\n'+'Full workflow context.\n'.repeat(400)+'FINAL_COMPLETION_CRITERION':'test'
|
|
7
8
|
const requests:string[]=[],output:string[]=[]
|
|
8
9
|
const program=`
|
|
9
10
|
const rl=require('readline').createInterface({input:process.stdin});
|
|
@@ -15,13 +16,15 @@ let reads=0;
|
|
|
15
16
|
rl.on('line',line=>{const q=JSON.parse(line);if(!q.id)return;
|
|
16
17
|
if(q.method==='initialize')return send({id:q.id,result:{}});
|
|
17
18
|
if(q.method==='thread/start')return send({id:q.id,result:{thread:{id:'native-test'}}});
|
|
18
|
-
if(q.method==='thread/goal/set'
|
|
19
|
+
if(q.method==='thread/goal/set')return send({id:q.id,error:{message:'Transport must not create goals'}});
|
|
20
|
+
if(q.method==='turn/start'){
|
|
19
21
|
send({id:q.id,result:{turn:{id:'one'}}});
|
|
20
22
|
if(${JSON.stringify(mode)}==='early-limit')return event('thread/goal/updated',{goal:{status:'usageLimited'}});
|
|
21
|
-
if(${JSON.stringify(mode)}==='early-clear')return event('thread/goal/cleared',{});
|
|
23
|
+
if(${JSON.stringify(mode)}==='early-clear'){event('thread/goal/updated',{goal:{status:'active'}});return event('thread/goal/cleared',{});}
|
|
22
24
|
send({method:'turn/completed',params:{threadId:'unrelated',turn:{id:'unrelated',status:'completed'}}});start('one');
|
|
23
25
|
if(${JSON.stringify(mode)}==='disconnect')return process.exit(0);
|
|
24
26
|
if(${JSON.stringify(mode)}==='approval')return send({id:999,method:'item/commandExecution/requestApproval',params:{threadId:'native-test'}});
|
|
27
|
+
if(${JSON.stringify(mode)}==='missing-goal')event('thread/goal/updated',{goal:{status:'active'}});
|
|
25
28
|
end('one');return;
|
|
26
29
|
}
|
|
27
30
|
if(q.method==='thread/goal/get'){
|
|
@@ -33,19 +36,24 @@ if(q.method==='thread/goal/get'){
|
|
|
33
36
|
}
|
|
34
37
|
}
|
|
35
38
|
});setInterval(()=>{},1000);`
|
|
36
|
-
let threadConfig:any
|
|
39
|
+
let threadConfig:any,threadSandbox:string,turnPolicy:any,turnPrompt:string|undefined
|
|
37
40
|
const launch=()=>{
|
|
38
41
|
const child=spawn(process.execPath,['-e',program],{stdio:['pipe','pipe','pipe'],detached:process.platform!=='win32'})
|
|
39
42
|
const write=child.stdin.write.bind(child.stdin)
|
|
40
|
-
child.stdin.write=((chunk:any,...args:any[])=>{try{
|
|
43
|
+
child.stdin.write=((chunk:any,...args:any[])=>{try{const q=JSON.parse(String(chunk));if(q.method==='turn/start'){turnPrompt=q.params.input[0].text;turnPolicy=q.params.sandboxPolicy}requests.push(q.method);if(q.method==='thread/start'){threadConfig=q.params.config;threadSandbox=q.params.sandbox}}catch{};return (write as any)(chunk,...args)}) as typeof child.stdin.write
|
|
41
44
|
return child
|
|
42
45
|
}
|
|
43
|
-
const
|
|
44
|
-
|
|
46
|
+
const result=await runCodexSession({workspace:'/tmp',controlDir:'/tmp/control',sharedWorkspace:'/canonical',prompt,
|
|
47
|
+
...(mode==='external'?{codexSandbox:'external' as const}:{})},{launch,emit:line=>output.push(line)})
|
|
48
|
+
assert.equal(threadSandbox!,mode==='external'?'danger-full-access':'workspace-write')
|
|
49
|
+
assert.deepEqual(turnPolicy,mode==='external'?{type:'externalSandbox',networkAccess:'enabled'}:undefined)
|
|
50
|
+
assert.deepEqual(threadConfig.project_root_markers,['AGENTS.md','.git'])
|
|
51
|
+
assert.equal(turnPrompt,prompt,'full input reaches the engine without goal admission or truncation')
|
|
52
|
+
if(mode==='long-goal')assert.ok(prompt.length>4000)
|
|
45
53
|
assert.ok(threadConfig['sandbox_workspace_write.writable_roots'].includes('/canonical'))
|
|
46
|
-
assert.equal(result,['plain','goal','tool-goal'].includes(mode)?0:1)
|
|
47
|
-
assert.equal(requests.filter(x=>x==='turn/start').length,
|
|
48
|
-
assert.equal(requests.filter(x=>x==='thread/goal/set').length,
|
|
54
|
+
assert.equal(result,['external','plain','goal','long-goal','tool-goal'].includes(mode)?0:1)
|
|
55
|
+
assert.equal(requests.filter(x=>x==='turn/start').length,1,'transport must not send goal continuation prompts')
|
|
56
|
+
assert.equal(requests.filter(x=>x==='thread/goal/set').length,0)
|
|
49
57
|
if(mode==='goal')assert.equal(requests.filter(x=>x==='thread/goal/get').length,2,'must wait for the second turn to complete')
|
|
50
58
|
assert.equal(JSON.parse(output[0]).thread_id,'native-test')
|
|
51
59
|
})
|
package/test/config.test.ts
CHANGED
|
@@ -32,7 +32,7 @@ test('loads executor CLI configuration with agy fallback', () => {
|
|
|
32
32
|
})
|
|
33
33
|
|
|
34
34
|
test('Codex context limit is configurable and rejects invalid values', () => {
|
|
35
|
-
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test'}).codexAutoCompactTokens,
|
|
35
|
+
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test'}).codexAutoCompactTokens,undefined)
|
|
36
36
|
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_CODEX_AUTO_COMPACT_TOKENS:'32000'}).codexAutoCompactTokens,32000)
|
|
37
37
|
for(const value of ['0','-1','bad','1.5','9007199254740992'])
|
|
38
38
|
assert.throws(()=>loadConfig({TELEGRAM_BOT_TOKEN:'test',EZ_CODEX_AUTO_COMPACT_TOKENS:value}),/positive integer/)
|
|
@@ -59,3 +59,18 @@ test('PagerDuty Stocks monitoring requires a routing key and validates its targe
|
|
|
59
59
|
assert.equal(config.pagerDutyPollMs, 45_000)
|
|
60
60
|
assert.equal(config.pagerDutyFailureThreshold, 4)
|
|
61
61
|
})
|
|
62
|
+
|
|
63
|
+
test('external Codex isolation requires explicit native local deployment', () => {
|
|
64
|
+
const env = {EZ_TELEGRAM_ENABLED:'false', EZ_APPLICATION_PORT:'8110', EZ_EXECUTOR_TRANSPORT:'local', EZ_CODEX_SANDBOX:'external'}
|
|
65
|
+
assert.equal(loadConfig(env).codexSandbox, 'external')
|
|
66
|
+
assert.equal(loadConfig({...env,EZ_TELEGRAM_ENABLED:'true',TELEGRAM_BOT_TOKEN:'test'}).codexSandbox, 'external')
|
|
67
|
+
assert.equal(loadConfig({TELEGRAM_BOT_TOKEN:'test'}).codexSandbox, undefined)
|
|
68
|
+
for (const override of [{EZ_EXECUTOR_TRANSPORT:'host'}, {EZ_EXECUTOR_TRANSPORT:''}, {EZ_CODEX_SANDBOX:'danger-full-access'}])
|
|
69
|
+
assert.throws(() => loadConfig({...env,...override}), /sandbox|SANDBOX/)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('optional web launcher preserves reserved commands and accepts only HTTPS without secrets', () => {
|
|
73
|
+
const config = (value: unknown) => loadConfig({ TELEGRAM_BOT_TOKEN: 'test', EZ_TELEGRAM_WEB_APP: JSON.stringify(value) })
|
|
74
|
+
assert.deepEqual(config({command:'voice',label:'Voice',url:'https://voice.example/'}).webLauncher,{command:'voice',label:'Voice',url:'https://voice.example/'})
|
|
75
|
+
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))
|
|
76
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import * as fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import {createHash} from 'node:crypto';
|
|
7
|
+
import {commandArtifact} from '../src/plugins/connection-artifacts.mjs';
|
|
8
|
+
|
|
9
|
+
test('CLI attachment output preserves exact binary bytes and excludes them from model output',async()=>{
|
|
10
|
+
const root=await fs.mkdtemp(path.join(os.tmpdir(),'voice-artifact-'));
|
|
11
|
+
try {
|
|
12
|
+
const bytes=Buffer.from([0,255,128,10,13,42]);
|
|
13
|
+
const result=await commandArtifact(root,'source.pdf',async({onStdout})=>{onStdout(bytes.subarray(0,3));onStdout(bytes.subarray(3));return {code:0,stdout:'',stderr:''};});
|
|
14
|
+
assert.equal(result.stdout,'');assert.equal(result.artifact.bytes,bytes.length);
|
|
15
|
+
assert.equal(result.artifact.sha256,createHash('sha256').update(bytes).digest('hex'));
|
|
16
|
+
assert.deepEqual(await fs.readFile(path.join(root,result.artifact.path)),bytes);
|
|
17
|
+
assert.equal((await fs.stat(path.join(root,result.artifact.path))).mode&0o777,0o600);
|
|
18
|
+
}finally{await fs.rm(root,{recursive:true,force:true});}
|
|
19
|
+
});
|
|
20
|
+
test('artifact output rejects escaping paths, symlinks, overflow and cancelled or failed writes',async()=>{
|
|
21
|
+
const root=await fs.mkdtemp(path.join(os.tmpdir(),'voice-artifact-')),outside=await fs.mkdtemp(path.join(os.tmpdir(),'voice-outside-'));
|
|
22
|
+
const ok=async()=>({code:0,stdout:'',stderr:''});
|
|
23
|
+
try {
|
|
24
|
+
for(const name of ['../secret','/secret','.env','x/y'])await assert.rejects(commandArtifact(root,name,ok),/filename/);
|
|
25
|
+
await fs.symlink(outside,path.join(root,'artifacts'));await assert.rejects(commandArtifact(root,'x',ok),/symlink/);await fs.unlink(path.join(root,'artifacts'));
|
|
26
|
+
await assert.rejects(commandArtifact(root,'x',async({onStdout})=>{onStdout(Buffer.alloc(20*1024*1024+1));return ok();}),/20 MiB/);
|
|
27
|
+
const abort=new AbortController();abort.abort();await assert.rejects(commandArtifact(root,'x',ok,{signal:abort.signal}));
|
|
28
|
+
assert.equal((await commandArtifact(root,'x',async()=>({code:1,stdout:'',stderr:'failed'}))).code,1);
|
|
29
|
+
assert.deepEqual(await fs.readdir(path.join(root,'artifacts')),[]);
|
|
30
|
+
assert.deepEqual(await fs.readdir(outside),[]);
|
|
31
|
+
}finally{await fs.rm(root,{recursive:true,force:true});await fs.rm(outside,{recursive:true,force:true});}
|
|
32
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
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 type { Context, InlineKeyboard } from 'grammy'
|
|
7
|
+
import { ControlStore } from '../src/control-state.js'
|
|
8
|
+
import { RunStore } from '../src/runs.js'
|
|
9
|
+
import { createConversationMenu } from '../src/conversation-menu.js'
|
|
10
|
+
import { initialPreset } from '../src/ai.js'
|
|
11
|
+
|
|
12
|
+
test('empty placeholders stay out of history and Back edits one panel without creating sessions', async () => {
|
|
13
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-empty-chats-'))
|
|
14
|
+
try {
|
|
15
|
+
const control = new ControlStore(dir, 1000)
|
|
16
|
+
const runs = new RunStore(dir)
|
|
17
|
+
const choice = await control.captureChoice(initialPreset('grok'), 'Client launch')
|
|
18
|
+
await control.markSessionStarted(choice.sessionId)
|
|
19
|
+
for (let n = 0; n < 3; n++) await control.resetSession()
|
|
20
|
+
const menu = createConversationMenu(control, runs)
|
|
21
|
+
let sent = 0, edited = 0, text = '', keyboard: InlineKeyboard
|
|
22
|
+
const ctx = {
|
|
23
|
+
callbackQuery: undefined,
|
|
24
|
+
reply: async (value: string, options: {reply_markup: InlineKeyboard}) => { sent++; text = value; keyboard = options.reply_markup },
|
|
25
|
+
editMessageText: async (value: string, options: {reply_markup: InlineKeyboard}) => { edited++; text = value; keyboard = options.reply_markup },
|
|
26
|
+
answerCallbackQuery: async () => {},
|
|
27
|
+
}
|
|
28
|
+
await menu.list(ctx as unknown as Context)
|
|
29
|
+
assert.equal(sent, 1)
|
|
30
|
+
assert.deepEqual(keyboard!.inline_keyboard.flat().filter(b => 'callback_data' in b && b.callback_data.startsWith('chat:open:')).map(b => b.text), ['Client launch'])
|
|
31
|
+
assert.equal(keyboard!.inline_keyboard.flat().filter(b => b.text.includes('New conversation')).length, 1)
|
|
32
|
+
assert.ok(keyboard!.inline_keyboard.every(row => row.length))
|
|
33
|
+
const click = async (data: string) => {
|
|
34
|
+
await menu.handle({...ctx, callbackQuery:{data, message:{message_id:1}}} as unknown as Context)
|
|
35
|
+
}
|
|
36
|
+
await click(`chat:open:${choice.sessionId}`)
|
|
37
|
+
const before = await control.status()
|
|
38
|
+
for (let n = 0; n < 5; n++) { await click('chat:list:0:0'); await click(`chat:open:${choice.sessionId}`) }
|
|
39
|
+
assert.deepEqual(await control.status(), before)
|
|
40
|
+
assert.equal(sent, 1)
|
|
41
|
+
assert.equal(edited, 11)
|
|
42
|
+
await control.archiveSession(choice.sessionId, true)
|
|
43
|
+
await click('chat:list:0:0')
|
|
44
|
+
assert.match(text, /No active conversations/)
|
|
45
|
+
assert.equal(keyboard!.inline_keyboard.flat().filter(b => 'callback_data' in b && b.callback_data.startsWith('chat:open:')).length, 0)
|
|
46
|
+
// No underlying bindings were deleted: previously accepted work can still resolve them.
|
|
47
|
+
assert.equal((await control.listSessions()).length, 4)
|
|
48
|
+
assert.equal((await control.executionSession(choice)).sessionId, choice.sessionId)
|
|
49
|
+
await click('chat:list:1:0')
|
|
50
|
+
assert.ok(keyboard!.inline_keyboard.flat().some(b => b.text === 'Client launch'))
|
|
51
|
+
} finally { await rm(dir, {recursive:true, force:true}) }
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('an identical Back edit does not send another message', async () => {
|
|
55
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-same-menu-'))
|
|
56
|
+
try {
|
|
57
|
+
let sent = 0
|
|
58
|
+
const menu = createConversationMenu(new ControlStore(dir,1000),new RunStore(dir))
|
|
59
|
+
await menu.handle({
|
|
60
|
+
callbackQuery:{data:'chat:list:0:0',message:{message_id:1}},
|
|
61
|
+
answerCallbackQuery:async()=>{},
|
|
62
|
+
editMessageText:async()=>{throw {description:'Bad Request: message is not modified'}},
|
|
63
|
+
reply:async()=>{sent++},
|
|
64
|
+
} as unknown as Context)
|
|
65
|
+
assert.equal(sent,0)
|
|
66
|
+
} finally { await rm(dir,{recursive:true,force:true}) }
|
|
67
|
+
})
|