@jc_stack/ez-agents 0.1.0-beta.27 → 0.1.0-beta.29
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 +9 -0
- package/AGENTS.md +25 -1
- package/CHANGELOG.md +29 -0
- package/CONTRIBUTING.md +28 -0
- package/Dockerfile +1 -0
- package/README.md +79 -8
- 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/docker-runtime.md +20 -0
- 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 +40 -4
- package/docs/upgrades.md +11 -1
- package/package.json +7 -2
- 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 +5 -3
- package/src/config.ts +20 -2
- package/src/control-state.ts +256 -15
- package/src/conversation-menu.ts +89 -0
- package/src/delivery-context.d.mts +5 -0
- package/src/delivery-context.mjs +25 -0
- package/src/event-sources.ts +2 -2
- package/src/execution-authority.ts +2 -0
- package/src/executor.ts +10 -4
- package/src/host-executor.ts +7 -1
- package/src/identity.ts +11 -3
- package/src/index.ts +149 -54
- package/src/menu.ts +26 -9
- package/src/message-history.ts +52 -0
- package/src/message.ts +48 -7
- 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 +63 -18
- 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/runs.ts +67 -9
- package/src/schedule-cli.ts +11 -6
- package/src/scheduler.ts +17 -7
- package/src/updates/control.mjs +4 -0
- package/src/web-launcher.ts +19 -0
- package/templates/agent-guidance.md +58 -2
- package/templates/deployments.md +24 -0
- 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/channel-delivery.test.ts +63 -0
- package/test/channel-owner.test.ts +161 -0
- package/test/codex-session.test.ts +8 -5
- package/test/config.test.ts +15 -0
- 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/executor.test.ts +56 -0
- package/test/host-executor.test.ts +28 -0
- package/test/intake-relay.test.ts +126 -5
- package/test/message-history.test.ts +127 -0
- package/test/native-tasks.test.ts +36 -0
- package/test/plugin-connection.test.mjs +124 -0
- package/test/plugin-manager.test.mjs +34 -0
- package/test/runtime-identity.test.mjs +18 -0
- package/test/updates.test.mjs +39 -0
package/test/executor.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ownerRun } from './helpers/owner-run.js'
|
|
2
|
+
import { RunStore } from '../src/runs.js'
|
|
2
3
|
import assert from 'node:assert/strict'
|
|
3
4
|
import test from 'node:test'
|
|
4
5
|
import { mkdtemp, mkdir, rm, writeFile, readFile } from 'node:fs/promises'
|
|
@@ -180,3 +181,58 @@ test('adapters do not append instruction files or impose a workflow turn budget'
|
|
|
180
181
|
assert.ok(!EXECUTOR_REGISTRY.claude.buildArgs(opts,'','literal').includes('--append-system-prompt-file'))
|
|
181
182
|
assert.ok(!EXECUTOR_REGISTRY.grok.buildArgs(opts,'/tmp/prompt','literal').includes('--max-turns'))
|
|
182
183
|
})
|
|
184
|
+
|
|
185
|
+
test('Codex external isolation changes only explicit sandbox argv and never enters child environment', () => {
|
|
186
|
+
const args = EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent',codexSandbox:'external',sessionId:'native-id',isResume:true},'', 'hello')
|
|
187
|
+
assert.equal(args[args.indexOf('--sandbox')+1], 'danger-full-access')
|
|
188
|
+
assert.ok(args.includes('native-id'))
|
|
189
|
+
assert.equal(executorEnvironment({EZ_CODEX_SANDBOX:'external'}).EZ_CODEX_SANDBOX, undefined)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('external Codex isolation cannot launch host runs', async t => {
|
|
193
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-external-sandbox-'))
|
|
194
|
+
t.after(() => rm(root,{recursive:true,force:true}))
|
|
195
|
+
await ownerRun(root,'r_sandbox')
|
|
196
|
+
const previous = {transport:process.env.EZ_EXECUTOR_TRANSPORT,telegram:process.env.EZ_TELEGRAM_ENABLED}
|
|
197
|
+
try {
|
|
198
|
+
process.env.EZ_TELEGRAM_ENABLED='false'
|
|
199
|
+
for (const transport of ['host','']) {
|
|
200
|
+
process.env.EZ_EXECUTOR_TRANSPORT=transport
|
|
201
|
+
await assert.rejects(startExecutorJob(['Hello'],{workspace:root,controlDir:root,binDir:root,runId:'r_sandbox',timeoutMs:0,cli:'codex',codexSandbox:'external'}), /owner-authorized native local/)
|
|
202
|
+
}
|
|
203
|
+
} finally {
|
|
204
|
+
for (const [key,value] of [['EZ_EXECUTOR_TRANSPORT',previous.transport],['EZ_TELEGRAM_ENABLED',previous.telegram]]) {
|
|
205
|
+
if (value === undefined) delete process.env[key!]; else process.env[key!]=value
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('external local owner chat preserves authorization and literal input', async t => {
|
|
211
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-external-owner-'))
|
|
212
|
+
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
213
|
+
const prior={path:process.env.PATH,transport:process.env.EZ_EXECUTOR_TRANSPORT}
|
|
214
|
+
const bin=path.join(root,'bin');await mkdir(bin)
|
|
215
|
+
await writeFile(path.join(bin,'codex'),`#!/usr/bin/env node\nconst fs=require('fs');let text='';process.stdin.on('data',b=>text+=b);process.stdin.on('end',()=>fs.writeFileSync(${JSON.stringify(path.join(root,'observed.json'))},JSON.stringify({args:process.argv.slice(2),text,secret:process.env.TELEGRAM_BOT_TOKEN})));`,{mode:0o755})
|
|
216
|
+
await ownerRun(root,'r_owner_external')
|
|
217
|
+
const opts={workspace:root,controlDir:root,binDir:bin,runId:'r_owner_external',timeoutMs:0,cli:'codex',codexSandbox:'external' as const}
|
|
218
|
+
try {
|
|
219
|
+
process.env.PATH=bin+path.delimiter+prior.path;process.env.EZ_EXECUTOR_TRANSPORT='local'
|
|
220
|
+
const job=await startExecutorJob([' literal /goal request\n'],opts)
|
|
221
|
+
const code=await new Promise(resolve=>job.child.once('close',resolve));await job.cleanup()
|
|
222
|
+
assert.equal(code,0)
|
|
223
|
+
const observed=JSON.parse(await readFile(path.join(root,'observed.json'),'utf8'))
|
|
224
|
+
assert.equal(observed.text,' literal /goal request\n')
|
|
225
|
+
assert.equal(observed.args[observed.args.indexOf('--sandbox')+1],'danger-full-access')
|
|
226
|
+
assert.equal(observed.secret,undefined)
|
|
227
|
+
const runs=new RunStore(root)
|
|
228
|
+
await runs.create({id:'r_foreign',chatId:999,telegramUserId:999,texts:['no']})
|
|
229
|
+
await runs.patch('r_foreign',{status:'running'})
|
|
230
|
+
await assert.rejects(startExecutorJob(['no'],{...opts,runId:'r_foreign'}),/blocked|owner/i)
|
|
231
|
+
await runs.create({id:'r_restricted',chatId:101,telegramUserId:101,texts:['no'],taskId:'task_'+'a'.repeat(32)})
|
|
232
|
+
await runs.patch('r_restricted',{status:'running'})
|
|
233
|
+
await assert.rejects(startExecutorJob(['no'],{...opts,runId:'r_restricted'}),/owner-authorized native local/)
|
|
234
|
+
} finally {
|
|
235
|
+
if(prior.path===undefined)delete process.env.PATH;else process.env.PATH=prior.path
|
|
236
|
+
if(prior.transport===undefined)delete process.env.EZ_EXECUTOR_TRANSPORT;else process.env.EZ_EXECUTOR_TRANSPORT=prior.transport
|
|
237
|
+
}
|
|
238
|
+
})
|
|
@@ -12,6 +12,29 @@ import { EXECUTOR_REGISTRY } from '../src/executor.js'
|
|
|
12
12
|
import { RunStore } from '../src/runs.js'
|
|
13
13
|
import { packageVersion } from '../src/version.js'
|
|
14
14
|
import { executionDefaults } from '../src/model-policy.js'
|
|
15
|
+
import { workspaceLease } from '../src/plugins/workspace-lease.mjs'
|
|
16
|
+
|
|
17
|
+
test('host restart clears dead native lease only after proving previous CLI stopped',async()=>{
|
|
18
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-native-recovery-'));
|
|
19
|
+
const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),toolsHome=path.join(root,'tools'),directory=path.join(controlDir,'host-executor');
|
|
20
|
+
const abort=new AbortController();let server:Promise<void>|undefined;
|
|
21
|
+
try {
|
|
22
|
+
await mkdir(workspace);await mkdir(toolsHome);await mkdir(directory,{recursive:true});
|
|
23
|
+
await writeFile(path.join(toolsHome,'config.json'),JSON.stringify({schemaVersion:1,workspace:await realpath(workspace)}));
|
|
24
|
+
const child=spawn(process.execPath,['-e','process.exit(0)']);const deadPid=child.pid;await new Promise(r=>child.once('close',r));
|
|
25
|
+
await writeFile(path.join(toolsHome,'workspace-writer.lock'),JSON.stringify({pid:deadPid,kind:'native',runId:'r_old'}));
|
|
26
|
+
await writeFile(path.join(directory,'r_old.running.json'),'{}');
|
|
27
|
+
await writeFile(path.join(directory,'r_old.process.json'),JSON.stringify({pid:process.pid}));
|
|
28
|
+
const installation={cli:'grok',agents:[{name:'test',workspace,controlDir,toolsHome,binDir:path.join(root,'bin')}]};
|
|
29
|
+
await assert.rejects(serveHostExecutor(installation,abort.signal),/Previous host CLI is still running/);
|
|
30
|
+
await readFile(path.join(toolsHome,'workspace-writer.lock'));
|
|
31
|
+
await writeFile(path.join(directory,'r_old.process.json'),JSON.stringify({pid:deadPid}));
|
|
32
|
+
server=serveHostExecutor(installation,abort.signal);
|
|
33
|
+
for(let n=0;n<100;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
|
|
34
|
+
await readFile(path.join(directory,'heartbeat.json'));
|
|
35
|
+
await assert.rejects(readFile(path.join(toolsHome,'workspace-writer.lock')),{code:'ENOENT'});
|
|
36
|
+
}finally{abort.abort();await server;await rm(root,{recursive:true,force:true});}
|
|
37
|
+
});
|
|
15
38
|
|
|
16
39
|
test('one installed CLI executes two agent bindings with separate minds and sanitized environment', async () => {
|
|
17
40
|
const root=await mkdtemp(path.join(tmpdir(),'ez-host-'))
|
|
@@ -44,8 +67,13 @@ test('one installed CLI executes two agent bindings with separate minds and sani
|
|
|
44
67
|
const dir=path.join(agent.controlDir,'host-executor')
|
|
45
68
|
for(let n=0;n<100;n++){try{await readFile(path.join(dir,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
|
|
46
69
|
assert.equal(JSON.parse(await readFile(path.join(dir,'heartbeat.json'),'utf8')).version,packageVersion)
|
|
70
|
+
const release = await workspaceLease(agent.toolsHome)
|
|
47
71
|
await ownerRun(agent.controlDir, `r_${agent.name}`)
|
|
48
72
|
await writeFile(path.join(dir,`r_${agent.name}.request.json`),JSON.stringify({texts:['test'],options:{workspace:'/wrong',controlDir:'/wrong',toolsHome:'/wrong',cli:'grok',timeoutMs:5000}}))
|
|
73
|
+
await new Promise(r=>setTimeout(r,300))
|
|
74
|
+
await readFile(path.join(dir,`r_${agent.name}.request.json`))
|
|
75
|
+
await assert.rejects(readFile(path.join(dir,`r_${agent.name}.process.json`)),{code:'ENOENT'})
|
|
76
|
+
await release?.()
|
|
49
77
|
}
|
|
50
78
|
for(const agent of agents){
|
|
51
79
|
const file=path.join(agent.controlDir,'host-executor',`r_${agent.name}.events`)
|
|
@@ -59,7 +59,7 @@ const fixture = async (overrides: Partial<Config> = {}) => {
|
|
|
59
59
|
} as typeof relay.bot.botInfo
|
|
60
60
|
relay.bot.api.config.use(async (_previous, method, payload) => {
|
|
61
61
|
if (method === 'getChatMember' && members.get((payload as {user_id: number}).user_id) === 'error') throw new Error('Fixture membership unavailable')
|
|
62
|
-
if (method === 'sendMessage') replies.push((payload as { text: string }).text)
|
|
62
|
+
if (method === 'sendMessage' || method === 'editMessageText') replies.push((payload as { text: string }).text)
|
|
63
63
|
const keyboard = (payload as { reply_markup?: { inline_keyboard?: { text: string; callback_data: string }[][] } }).reply_markup?.inline_keyboard
|
|
64
64
|
if (keyboard) keyboards.push(keyboard)
|
|
65
65
|
return {
|
|
@@ -203,7 +203,7 @@ test('owner group discovery routes only to the private chat and rechecks identit
|
|
|
203
203
|
} finally { await f.close() }
|
|
204
204
|
})
|
|
205
205
|
|
|
206
|
-
test('
|
|
206
|
+
test('conversation menu is owner-only and removes the retired settings control', async () => {
|
|
207
207
|
const f = await fixture()
|
|
208
208
|
const callback = (id: number, data: string, user = 101): Update => ({
|
|
209
209
|
update_id: id,
|
|
@@ -213,7 +213,7 @@ test('three-item menu is owner-only and removes the retired settings control', a
|
|
|
213
213
|
try {
|
|
214
214
|
await f.relay.bot.handleUpdate(message(1, '/menu'))
|
|
215
215
|
assert.deepEqual(f.keyboards.at(-1)!.flat().map((b) => b.text),
|
|
216
|
-
['New conversation', 'Choose AI', 'Work status'])
|
|
216
|
+
['New conversation', 'Conversations', 'Choose AI', 'Work status'])
|
|
217
217
|
await f.relay.bot.handleUpdate(message(2, '/settings'))
|
|
218
218
|
assert.match(f.replies.at(-1)!, /Settings was removed.*Use \/ai/)
|
|
219
219
|
await f.relay.bot.handleUpdate(message(3, '/ai'))
|
|
@@ -247,11 +247,11 @@ test('application-backed channels keep AI and retired settings controls in the a
|
|
|
247
247
|
const store = new ControlStore(f.dir, 1000)
|
|
248
248
|
const before = await store.status()
|
|
249
249
|
let id = 1
|
|
250
|
-
for (const text of ['/ai', '/settings', '/new']) {
|
|
250
|
+
for (const text of ['/ai', '/settings', '/new', '/chats', '/rename Example']) {
|
|
251
251
|
await f.relay.bot.handleUpdate(message(id++, text))
|
|
252
252
|
assert.match(f.replies.at(-1)!, /managed in the connected application/)
|
|
253
253
|
}
|
|
254
|
-
for (const data of ['menu:ai', 'menu:settings', 'menu:new', 'ai:old-button']) {
|
|
254
|
+
for (const data of ['menu:ai', 'menu:settings', 'menu:new', 'menu:chats', 'chat:list:0:0', 'chat:open:invalid', 'ai:old-button']) {
|
|
255
255
|
await f.relay.bot.handleUpdate({ update_id: id, callback_query: {
|
|
256
256
|
id: String(id), chat_instance: 'fixture', data,
|
|
257
257
|
from: { id: 101, first_name: 'Fixture', is_bot: false }, message: message(id++).message!,
|
|
@@ -507,3 +507,124 @@ test('approved family messages enter restricted task runs, never the owner sessi
|
|
|
507
507
|
await tasks.ownerCall('setup','list',{}).then(()=>assert.fail('completed run cannot change grants'),()=>{})
|
|
508
508
|
} finally {await f.close()}
|
|
509
509
|
})
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
test('Telegram named conversation buttons switch and archive without rerouting accepted messages', async () => {
|
|
513
|
+
const f = await fixture()
|
|
514
|
+
try {
|
|
515
|
+
let id = 1
|
|
516
|
+
const send = (text: string) => f.relay.bot.handleUpdate(message(id++, text))
|
|
517
|
+
const click = async (data: string, user = 101) => f.relay.bot.handleUpdate({ update_id: id, callback_query: {
|
|
518
|
+
id: String(id), chat_instance: 'fixture', data,
|
|
519
|
+
from: { id: user, first_name: 'Fixture', is_bot: false }, message: message(id++).message!,
|
|
520
|
+
} })
|
|
521
|
+
const store = new ControlStore(f.dir, 1000)
|
|
522
|
+
await send('Client launch')
|
|
523
|
+
const first = (await store.getActiveSession())!.sessionId
|
|
524
|
+
await send('/new')
|
|
525
|
+
await send('Holiday planning')
|
|
526
|
+
const second = (await store.getActiveSession())!.sessionId
|
|
527
|
+
await send('/chats')
|
|
528
|
+
assert.ok(f.keyboards.at(-1)!.flat().some(b => b.text === 'Client launch'))
|
|
529
|
+
assert.ok(!f.keyboards.at(-1)!.flat().some(b => b.callback_data.startsWith('chat:archive:')))
|
|
530
|
+
assert.ok(f.keyboards.at(-1)!.every(row => row.filter(b => b.callback_data.startsWith('chat:open:')).length === 0 || row.length === 1))
|
|
531
|
+
const before = await store.status()
|
|
532
|
+
await click(`chat:open:${first}`, 202)
|
|
533
|
+
await click(`chat:archive:${second}`, 202)
|
|
534
|
+
assert.deepEqual(await store.status(), before)
|
|
535
|
+
await click(`chat:open:${first}`)
|
|
536
|
+
assert.equal((await store.getActiveSession())!.sessionId, first)
|
|
537
|
+
assert.deepEqual(f.keyboards.at(-1)!.flat().filter(b => b.callback_data.startsWith('chat:archive:')), [{text: 'Archive this conversation', callback_data: `chat:archive:${first}`}])
|
|
538
|
+
await send('/rename@fixture_bot <Client & launch>')
|
|
539
|
+
assert.equal((await store.getActiveSession())!.title, '<Client & launch>')
|
|
540
|
+
await click(`chat:archive:${first}`)
|
|
541
|
+
assert.equal(await store.getActiveSession(), null)
|
|
542
|
+
await click('chat:list:0:0')
|
|
543
|
+
assert.ok(!f.keyboards.at(-1)!.flat().some(b => b.callback_data === `chat:open:${first}`))
|
|
544
|
+
await click('chat:list:1:0')
|
|
545
|
+
assert.ok(f.keyboards.at(-1)!.flat().some(b => b.text === '<Client & launch>'))
|
|
546
|
+
await f.restart()
|
|
547
|
+
await click(`chat:restore:${first}`)
|
|
548
|
+
await click(`chat:open:${first}`)
|
|
549
|
+
await send('Continue launch')
|
|
550
|
+
for (let n = 0; n < 3; n++) await f.relay.drainInbox(true)
|
|
551
|
+
const runs = await new RunStore(f.dir).list()
|
|
552
|
+
assert.equal(runs.find(r => r.texts.includes('Holiday planning'))!.execution!.sessionId, second)
|
|
553
|
+
assert.equal(runs.find(r => r.texts.includes('Continue launch'))!.execution!.sessionId, first)
|
|
554
|
+
assert.ok(!runs.some(r => r.texts.some(t => t.startsWith('/rename'))))
|
|
555
|
+
await click('chat:open:../../escape')
|
|
556
|
+
assert.match(f.replies.at(-1)!, /unavailable/)
|
|
557
|
+
} finally { await f.close() }
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
test('conversation menu paginates and stale pages remain usable after archiving', async () => {
|
|
561
|
+
const f = await fixture()
|
|
562
|
+
try {
|
|
563
|
+
const store = new ControlStore(f.dir, 1000)
|
|
564
|
+
for (let n = 0; n < 10; n++) {
|
|
565
|
+
await store.captureChoice({ id: 'fixture', name: 'Grok', cli: 'grok' }, `Topic ${n}`)
|
|
566
|
+
if (n < 9) await store.resetSession()
|
|
567
|
+
}
|
|
568
|
+
let id = 1
|
|
569
|
+
const click = (data: string) => f.relay.bot.handleUpdate({ update_id: id, callback_query: {
|
|
570
|
+
id: String(id), chat_instance: 'fixture', data,
|
|
571
|
+
from: { id: 101, first_name: 'Fixture', is_bot: false }, message: message(id++).message!,
|
|
572
|
+
} })
|
|
573
|
+
await click('chat:list:0:0')
|
|
574
|
+
assert.equal(f.keyboards.at(-1)!.flat().filter(b => b.callback_data.startsWith('chat:open:')).length, 8)
|
|
575
|
+
assert.ok(f.keyboards.at(-1)!.flat().some(b => b.text === 'Next'))
|
|
576
|
+
await click('chat:list:0:1')
|
|
577
|
+
assert.equal(f.keyboards.at(-1)!.flat().filter(b => b.callback_data.startsWith('chat:open:')).length, 2)
|
|
578
|
+
for (const session of await store.listSessions()) await store.archiveSession(session.sessionId, true)
|
|
579
|
+
await click('chat:list:0:1')
|
|
580
|
+
assert.equal(f.keyboards.at(-1)!.flat().filter(b => b.callback_data.startsWith('chat:open:')).length, 0)
|
|
581
|
+
} finally { await f.close() }
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
test('older conversation names come from owner messages and detail keeps archive available when resume fails', async () => {
|
|
586
|
+
const f = await fixture()
|
|
587
|
+
try {
|
|
588
|
+
const store = new ControlStore(f.dir, 1000)
|
|
589
|
+
const old = await store.ensureActiveSession()
|
|
590
|
+
await store.markSessionStarted(old.sessionId)
|
|
591
|
+
const execution = await store.captureChoice({ id: 'grok', name: 'Grok', cli: 'grok' })
|
|
592
|
+
const runs = new RunStore(f.dir)
|
|
593
|
+
await runs.create({ chatId: 101, telegramUserId: 101, texts: ['Internal update event'], execution })
|
|
594
|
+
await runs.create({ chatId: 101, telegramUserId: 101, messageId: 9, texts: [JSON.stringify({event: 'approval_decision', decision: 'approve'})], execution })
|
|
595
|
+
await runs.create({ chatId: 101, telegramUserId: 101, messageId: 10, texts: ['/start'], execution })
|
|
596
|
+
await runs.create({ chatId: 101, telegramUserId: 101, messageId: 11, texts: ['Client launch checklist'], execution })
|
|
597
|
+
await store.captureChoice({ id: 'grok', name: 'Grok', cli: 'grok' }, 'A later message must not relabel old history')
|
|
598
|
+
assert.equal((await store.getActiveSession())!.title, undefined)
|
|
599
|
+
await store.resetSession()
|
|
600
|
+
await f.relay.bot.handleUpdate(message(1, '/chats'))
|
|
601
|
+
const rows = f.keyboards.at(-1)!
|
|
602
|
+
assert.ok(rows.flat().some(b => b.text.startsWith('Client launch checklist · ')))
|
|
603
|
+
assert.ok(!rows.flat().some(b => b.text === '✓ New conversation'))
|
|
604
|
+
assert.ok(!rows.flat().some(b => b.text.includes(old.sessionId.slice(0, 8))))
|
|
605
|
+
assert.ok(!rows.flat().some(b => b.callback_data.startsWith('chat:archive:')))
|
|
606
|
+
await f.relay.bot.handleUpdate({ update_id: 2, callback_query: {
|
|
607
|
+
id: '2', chat_instance: 'fixture', data: `chat:open:${old.sessionId}`,
|
|
608
|
+
from: { id: 101, first_name: 'Fixture', is_bot: false }, message: message(2).message!,
|
|
609
|
+
} })
|
|
610
|
+
assert.match(f.replies.at(-1)!, /Client launch checklist.*\n.*binding/)
|
|
611
|
+
assert.deepEqual(f.keyboards.at(-1)![0], [{text: 'Archive this conversation', callback_data: `chat:archive:${old.sessionId}`}])
|
|
612
|
+
} finally { await f.close() }
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
test('web launcher is private-owner-only and bypasses native intake without replacing controls', async () => {
|
|
617
|
+
const f=await fixture({webLauncher:{command:'voice',label:'Voice',url:'https://voice.example/'}})
|
|
618
|
+
try {
|
|
619
|
+
const stranger=message(1,'/voice');stranger.message!.from!.id=202
|
|
620
|
+
await f.relay.bot.handleUpdate(stranger)
|
|
621
|
+
assert.equal(f.keyboards.length,0)
|
|
622
|
+
await f.relay.bot.handleUpdate(message(2,'/voice'))
|
|
623
|
+
assert.deepEqual(f.keyboards.at(-1),[[{text:'Voice',web_app:{url:'https://voice.example/'}}]])
|
|
624
|
+
await f.relay.bot.handleUpdate(message(3,'/menu'))
|
|
625
|
+
assert.deepEqual(f.keyboards.at(-1)!.flat().map(b=>b.text),['New conversation','Conversations','Choose AI','Work status','Voice'])
|
|
626
|
+
await f.relay.drainInbox(true);assert.equal(f.launched.length,0)
|
|
627
|
+
await new ControlStore(f.dir,1000).revokeOwner()
|
|
628
|
+
const before=f.keyboards.length;await f.relay.bot.handleUpdate(message(4,'/voice'));assert.equal(f.keyboards.length,before)
|
|
629
|
+
} finally {await f.close()}
|
|
630
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, rm, readFile, writeFile, readdir } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { promisify } from 'node:util'
|
|
7
|
+
import { execFile } from 'node:child_process'
|
|
8
|
+
import { ownerRun } from './helpers/owner-run.js'
|
|
9
|
+
import { deliveredMessages } from '../src/message-history.js'
|
|
10
|
+
import { RunStore } from '../src/runs.js'
|
|
11
|
+
import { ControlStore } from '../src/control-state.js'
|
|
12
|
+
|
|
13
|
+
async function fixture(t: test.TestContext) {
|
|
14
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-history-'))
|
|
15
|
+
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
16
|
+
await ownerRun(dir, 'tg_1')
|
|
17
|
+
const runs = new RunStore(dir)
|
|
18
|
+
const patchFixture = async (id: string, patch: Record<string, unknown>) => {
|
|
19
|
+
const file = join(dir, 'runs', id + '.json')
|
|
20
|
+
await writeFile(file, JSON.stringify({ ...JSON.parse(await readFile(file, 'utf8')), ...patch }))
|
|
21
|
+
}
|
|
22
|
+
const send = async (runId: string, text: string, ids: number[]) => {
|
|
23
|
+
const item = await runs.enqueueMessage(runId, text)
|
|
24
|
+
await runs.claimOutbox(item.id)
|
|
25
|
+
await runs.markOutboxSent(item.id, ids)
|
|
26
|
+
return join(dir, 'outbox', item.id + '.sent.json')
|
|
27
|
+
}
|
|
28
|
+
return { dir, runs, send, patchFixture }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('CLI retrieves Synopsys delivery from another session without changing sessions or sending', async t => {
|
|
32
|
+
const { dir, runs, send, patchFixture } = await fixture(t)
|
|
33
|
+
await ownerRun(dir, 'r_schedule_report')
|
|
34
|
+
const owner = (await new ControlStore(dir, 1000).status()).owner!
|
|
35
|
+
await patchFixture('r_schedule_report', { nativeSessionId: 'report_session', scheduled: {
|
|
36
|
+
id: 's_earnings', revision: 1, dueAt: new Date().toISOString(), pairedAt: owner.pairedAt,
|
|
37
|
+
} })
|
|
38
|
+
await send('r_schedule_report', 'Synopsys (SNPS): quarterly results', [10, 11])
|
|
39
|
+
await runs.patch('r_schedule_report', { status: 'completed' })
|
|
40
|
+
await patchFixture('tg_1', { texts: ['Why now?'] })
|
|
41
|
+
await send('tg_1', 'Other report', [12])
|
|
42
|
+
const before = await readdir(join(dir, 'outbox'))
|
|
43
|
+
const controlBefore = await new ControlStore(dir, 1000).status()
|
|
44
|
+
const { stdout } = await promisify(execFile)(process.execPath, ['bin/ezenciel-agents-message.mjs', 'history', '--message-id', '11'], {
|
|
45
|
+
env: { ...process.env, EZ_CONTROL_DIR: dir, EZ_RUN_ID: 'tg_1' },
|
|
46
|
+
})
|
|
47
|
+
const result = JSON.parse(stdout)
|
|
48
|
+
assert.equal(result.messages.length, 1)
|
|
49
|
+
assert.equal(result.messages[0].text, 'Synopsys (SNPS): quarterly results')
|
|
50
|
+
assert.equal(result.messages[0].nativeSessionId, 'report_session')
|
|
51
|
+
assert.equal(result.messages[0].scheduleId, 's_earnings')
|
|
52
|
+
assert.deepEqual(result.messages[0].messageIds, [10, 11])
|
|
53
|
+
assert.deepEqual(await new ControlStore(dir, 1000).status(), controlBefore)
|
|
54
|
+
assert.deepEqual(await readdir(join(dir, 'outbox')), before)
|
|
55
|
+
const latest = await deliveredMessages(dir, 'tg_1', { limit: 1 })
|
|
56
|
+
assert.equal(latest.messages[0].text, 'Other report')
|
|
57
|
+
assert.equal(latest.hasMore, true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('only confirmed deliveries in current owner binding are exposed', async t => {
|
|
61
|
+
const { dir, runs, send, patchFixture } = await fixture(t)
|
|
62
|
+
await send('tg_1', 'included', [1])
|
|
63
|
+
await runs.enqueueMessage('tg_1', 'pending')
|
|
64
|
+
const failed = await runs.enqueueMessage('tg_1', 'uncertain')
|
|
65
|
+
await runs.claimOutbox(failed.id)
|
|
66
|
+
await runs.failOutbox(failed.id, 'unknown', true)
|
|
67
|
+
await writeFile(join(dir, 'outbox', 'corrupt.sent.json'), '{')
|
|
68
|
+
await writeFile(join(dir, 'outbox', 'null.sent.json'), 'null')
|
|
69
|
+
const invalid = await send('tg_1', 'invalid receipt', [2])
|
|
70
|
+
const record = JSON.parse(await readFile(invalid, 'utf8'))
|
|
71
|
+
record.receipt.messageIds = []
|
|
72
|
+
await writeFile(invalid, JSON.stringify(record))
|
|
73
|
+
for (const [id, patch] of [
|
|
74
|
+
['r_other_chat', { chatId: 202 }],
|
|
75
|
+
['r_other_owner', { telegramUserId: 202 }],
|
|
76
|
+
['r_old', { createdAt: '2000-01-01T00:00:00.000Z' }],
|
|
77
|
+
['r_external', { external: { sourceId: 'test', bindingId: 'binding', eventIds: ['1'] } }],
|
|
78
|
+
] as const) {
|
|
79
|
+
await ownerRun(dir, id)
|
|
80
|
+
await patchFixture(id, patch)
|
|
81
|
+
await send(id, 'excluded', [3])
|
|
82
|
+
}
|
|
83
|
+
assert.deepEqual((await deliveredMessages(dir, 'tg_1')).messages.map(m => m.text), ['included'])
|
|
84
|
+
await writeFile(join(dir, 'runs', 'broken.json'), '{')
|
|
85
|
+
assert.equal((await deliveredMessages(dir, 'tg_1')).messages.length, 1)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('unauthorized callers and invalid arguments fail closed', async t => {
|
|
89
|
+
const { dir, runs, patchFixture } = await fixture(t)
|
|
90
|
+
for (const limit of [0, 51, NaN, 1.5]) await assert.rejects(deliveredMessages(dir, 'tg_1', { limit }), /Limit/)
|
|
91
|
+
await assert.rejects(deliveredMessages(dir, 'tg_1', { messageId: -1 }), /Message ID/)
|
|
92
|
+
await assert.rejects(deliveredMessages(dir, '../tg_1'), /identifier/)
|
|
93
|
+
await assert.rejects(deliveredMessages(dir, 'missing'), /No active/)
|
|
94
|
+
await ownerRun(dir, 'r_external', { sourceId: 'test', bindingId: 'binding', eventIds: ['1'] })
|
|
95
|
+
await assert.rejects(deliveredMessages(dir, 'r_external'), /blocked/)
|
|
96
|
+
await patchFixture('tg_1', { telegramUserId: 202 })
|
|
97
|
+
await assert.rejects(deliveredMessages(dir, 'tg_1'), /owner-mismatch/)
|
|
98
|
+
await patchFixture('tg_1', { telegramUserId: 101, status: 'completed' })
|
|
99
|
+
await assert.rejects(deliveredMessages(dir, 'tg_1'), /No active/)
|
|
100
|
+
await runs.patch('tg_1', { status: 'running' })
|
|
101
|
+
await new ControlStore(dir, 1000).revokeOwner()
|
|
102
|
+
await assert.rejects(deliveredMessages(dir, 'tg_1'), /owner-mismatch/)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('approval messages retain the content delivered to Telegram', async t => {
|
|
106
|
+
const { dir, send } = await fixture(t)
|
|
107
|
+
const file = await send('tg_1', 'placeholder', [20])
|
|
108
|
+
const item = JSON.parse(await readFile(file, 'utf8'))
|
|
109
|
+
delete item.text
|
|
110
|
+
item.type = 'approval'
|
|
111
|
+
item.approvalPrompt = 'Approve this messaging task?'
|
|
112
|
+
await writeFile(file, JSON.stringify(item))
|
|
113
|
+
assert.equal((await deliveredMessages(dir, 'tg_1')).messages[0].text, item.approvalPrompt)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('CLI flushes complete long reports and rejects send options in history mode', async t => {
|
|
117
|
+
const { dir, send } = await fixture(t)
|
|
118
|
+
const text = 'Report evidence. '.repeat(8000)
|
|
119
|
+
await send('tg_1', text, [30])
|
|
120
|
+
const env = { ...process.env, EZ_CONTROL_DIR: dir, EZ_RUN_ID: 'tg_1' }
|
|
121
|
+
const cli = [ 'bin/ezenciel-agents-message.mjs', 'history' ]
|
|
122
|
+
const { stdout } = await promisify(execFile)(process.execPath, cli, { env })
|
|
123
|
+
assert.equal(JSON.parse(stdout).messages[0].text, text)
|
|
124
|
+
const before = await readdir(join(dir, 'outbox'))
|
|
125
|
+
await assert.rejects(promisify(execFile)(process.execPath, [...cli, '--text', 'do not send'], { env }), /Unknown option/)
|
|
126
|
+
assert.deepEqual(await readdir(join(dir, 'outbox')), before)
|
|
127
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
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 { nativeTaskBinding,nativeTasks } from '../src/plugins/native-tasks.mjs'
|
|
7
|
+
import { ControlStore } from '../src/control-state.js'
|
|
8
|
+
|
|
9
|
+
test('native tasks use verified control binding, sanitized environment and unchanged scheduler',async()=>{
|
|
10
|
+
const root=await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(),'ez-native-tasks-'))),home=path.join(root,'tools'),workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),hostConfig=path.join(root,'host-executor.json')
|
|
11
|
+
try {
|
|
12
|
+
for(const dir of [home,workspace,controlDir])await fs.mkdir(dir)
|
|
13
|
+
await fs.writeFile(path.join(home,'config.json'),JSON.stringify({schemaVersion:1,workspace,hostConfig}))
|
|
14
|
+
const host={cli:'grok',agents:[{toolsHome:home,workspace,controlDir}]}
|
|
15
|
+
await fs.writeFile(hostConfig,JSON.stringify(host))
|
|
16
|
+
const binding=await nativeTaskBinding(home,{HOME:root,PATH:process.env.PATH,TELEGRAM_BOT_TOKEN:'secret',EZ_RUN_ID:'forged',NODE_OPTIONS:'injection',EZ_CONTROL_DIR:'/wrong'})
|
|
17
|
+
assert.equal(binding.cwd,workspace);assert.equal(binding.env.EZ_CONTROL_DIR,controlDir);assert.equal(binding.env.EZ_EXECUTOR_CLI,'grok')
|
|
18
|
+
for(const key of ['TELEGRAM_BOT_TOKEN','EZ_RUN_ID','NODE_OPTIONS'])assert.equal(binding.env[key],undefined)
|
|
19
|
+
assert.match((await nativeTasks(home,['--help'])).stdout,/durable, asynchronous CLI task/)
|
|
20
|
+
const denied=await nativeTasks(home,['list']);assert.notEqual(denied.code,0);assert.match(denied.stderr,/Pair an owner/)
|
|
21
|
+
const control=new ControlStore(controlDir,1000);await control.requestPairing(101,101);await control.approveOwner(101)
|
|
22
|
+
const text='Read scan; $(must-not-run) /goal literal'
|
|
23
|
+
const saved=await nativeTasks(home,['create','native-fixture','--now','--text',text]);assert.equal(saved.code,0,saved.stderr)
|
|
24
|
+
const value=JSON.parse(saved.stdout);assert.equal(value.text,text);assert.equal(value.execution.preset.cli,'grok')
|
|
25
|
+
assert.equal(JSON.parse((await nativeTasks(home,['show','native-fixture'])).stdout).text,text)
|
|
26
|
+
assert.equal((await nativeTasks(home,['remove','native-fixture'])).code,0)
|
|
27
|
+
await assert.rejects(nativeTasks(home,['--help'],{signal:AbortSignal.abort()}),/cancelled/)
|
|
28
|
+
await assert.rejects(nativeTasks(home,['bad\0argument']),/literal/)
|
|
29
|
+
for(const input of [['--text-file','/private/secret'],['--text-file=/private/secret']])
|
|
30
|
+
await assert.rejects(nativeTasks(home,['create','--now',...input]),/inline --text/)
|
|
31
|
+
await fs.writeFile(hostConfig,JSON.stringify({...host,agents:[{...host.agents[0],workspace:root}]}))
|
|
32
|
+
await assert.rejects(nativeTaskBinding(home),/does not match/)
|
|
33
|
+
await fs.writeFile(path.join(home,'config.json'),JSON.stringify({schemaVersion:1,workspace}))
|
|
34
|
+
await assert.rejects(nativeTasks(home,['--help']),/standalone/)
|
|
35
|
+
}finally{await fs.rm(root,{recursive:true,force:true})}
|
|
36
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import * as fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { connectionProtocol,jsonLines } from '../src/plugins/connection.mjs';
|
|
7
|
+
import { workspaceLease,invokeLease,recoverNativeLease } from '../src/plugins/workspace-lease.mjs';
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
const tick=()=>new Promise(resolve=>setImmediate(resolve));
|
|
11
|
+
function fixture(options={}) {
|
|
12
|
+
const r={commands:{voice:'voice',notes:'notes'},plugins:{voice:{},notes:{revision:'one',manifest:{description:'Notes',skills:[]}}}};
|
|
13
|
+
const client=[],plugin=[],calls=[];
|
|
14
|
+
const protocol=connectionProtocol({readRegistry:async()=>r,excludedPlugin:'voice',sendClient:x=>client.push(x),sendPlugin:x=>plugin.push(x),execute:async(...args)=>{calls.push(args);return {code:0,stdout:'ok',stderr:''};},...options});
|
|
15
|
+
const request=(method,params={},id='r1')=>protocol.plugin({coreRequest:{id,method,params}});
|
|
16
|
+
return {r,client,plugin,calls,protocol,request};
|
|
17
|
+
}
|
|
18
|
+
test('discovery follows installed registry; self and missing aliases are unavailable',async()=>{
|
|
19
|
+
const f=fixture();f.request('tools.list');await tick();assert.equal(f.plugin[0].coreResponse.result[0].alias,'notes');
|
|
20
|
+
delete f.r.commands.notes;f.request('tools.list',{},'r2');await tick();assert.deepEqual(f.plugin[1].coreResponse.result,[]);
|
|
21
|
+
for(const alias of ['voice','missing','notes']){f.request('tools.help',{alias},alias);await tick();assert.match(f.plugin.at(-1).coreResponse.error,/unavailable/);}
|
|
22
|
+
assert.equal(f.calls.length,0);
|
|
23
|
+
});
|
|
24
|
+
test('trusted connection invokes literal command once without a permission exchange',async()=>{
|
|
25
|
+
const f=fixture();await f.request('tools.invoke',{alias:'notes',args:['read','a; $(x)'],stdin:'literal input'});
|
|
26
|
+
assert.equal(f.calls.length,1);assert.deepEqual(f.calls[0].slice(0,2),['notes',['read','a; $(x)']]);assert.equal(f.calls[0][2].stdin,'literal input');assert.deepEqual(f.client,[]);assert.equal(f.plugin[0].coreResponse.result.code,0);
|
|
27
|
+
assert.throws(()=>f.request('tools.invoke',{alias:'notes',args:[]}),/Duplicate/);assert.equal(f.calls.length,1);
|
|
28
|
+
await f.request('tools.invoke',{alias:'notes',args:[{}]},'invalid');assert.match(f.plugin.at(-1).coreResponse.error,/literal/);assert.equal(f.calls.length,1);
|
|
29
|
+
});
|
|
30
|
+
test('native task access uses the separate fixed scheduler adapter',async()=>{
|
|
31
|
+
const native=[];const f=fixture({executeNative:async(args)=>{native.push(args);return {code:0,stdout:'help',stderr:''};}});
|
|
32
|
+
await f.request('tools.native',{args:['--help']});assert.deepEqual(native,[['--help']]);assert.equal(f.calls.length,0);assert.equal(f.plugin[0].coreResponse.result.stdout,'help');
|
|
33
|
+
const unavailable=fixture();await unavailable.request('tools.native',{args:['--help']});assert.match(unavailable.plugin[0].coreResponse.error,/unavailable/);
|
|
34
|
+
await f.request('tools.native',{args:['--help'],deliveryContext:{owner:'forged'}},'forged');assert.match(f.plugin.at(-1).coreResponse.error,/parameter/);assert.equal(native.length,1);
|
|
35
|
+
});
|
|
36
|
+
test('registry revision change during admission prevents invocation',async()=>{
|
|
37
|
+
let reads=0;const f=fixture({readRegistry:async()=>({commands:{notes:'notes'},plugins:{notes:{revision:++reads===1?'one':'two'}}})});
|
|
38
|
+
await f.request('tools.invoke',{alias:'notes',args:[]});assert.match(f.plugin[0].coreResponse.error,/changed/);assert.equal(f.calls.length,0);
|
|
39
|
+
});
|
|
40
|
+
test('reserved frames cannot cross authority directions; cancellation aborts running call',async()=>{
|
|
41
|
+
let signal;const f=fixture({execute:async(a,args,opts)=>{signal=opts.signal;return new Promise(resolve=>signal.addEventListener('abort',()=>resolve({code:130})));}});
|
|
42
|
+
for(const key of ['coreRequest','coreResponse','coreApprove','coreApproval','coreApprovalResolved'])assert.throws(()=>f.protocol.client({[key]:{}}),/forged/);
|
|
43
|
+
for(const key of ['coreResponse','coreApprove','coreApproval','coreApprovalResolved'])assert.throws(()=>f.protocol.plugin({[key]:{}}),/forged/);
|
|
44
|
+
const first=f.request('tools.invoke',{alias:'notes',args:[]});await tick();assert.equal(signal.aborted,false);f.protocol.plugin({coreCancel:{id:'r1'}});assert.equal(signal.aborted,true);await first;assert.equal(f.plugin[0].coreResponse.result.code,130);assert.deepEqual(f.client,[]);
|
|
45
|
+
const second=f.request('tools.invoke',{alias:'notes',args:[]},'r2');await tick();f.protocol.close();assert.equal(signal.aborted,true);await second;
|
|
46
|
+
});
|
|
47
|
+
test('declared skill reads are bounded and reject traversal and escaping symlinks',async()=>{
|
|
48
|
+
const dir=await fs.mkdtemp(path.join(os.tmpdir(),'ez-skill-'));
|
|
49
|
+
try {await fs.mkdir(path.join(dir,'plugin'));await fs.writeFile(path.join(dir,'private'),'secret');await fs.writeFile(path.join(dir,'plugin','SKILL.md'),'hello');await fs.symlink(path.join(dir,'private'),path.join(dir,'plugin','link'));
|
|
50
|
+
const f=fixture();f.r.plugins.notes.source=path.join(dir,'plugin');f.r.plugins.notes.manifest.skills=['SKILL.md','../private','link'];
|
|
51
|
+
await f.request('tools.skill',{alias:'notes',index:0});assert.equal(f.plugin[0].coreResponse.result.text,'hello');
|
|
52
|
+
for(const index of [1,2]){await f.request('tools.skill',{alias:'notes',index},'escape'+index);assert.match(f.plugin.at(-1).coreResponse.error,/escapes/);}
|
|
53
|
+
}finally{await fs.rm(dir,{recursive:true,force:true});}
|
|
54
|
+
});
|
|
55
|
+
test('JSONL rejects malformed and oversized input',()=>{
|
|
56
|
+
const frames=[],errors=[];const parse=jsonLines(f=>frames.push(f),e=>errors.push(e));parse(Buffer.from('{"ok":true}\n'));assert.equal(frames.length,1);parse(Buffer.from('bad\n'));assert.equal(errors.length,1);parse(Buffer.alloc(1048577,97));assert.equal(errors.length,2);
|
|
57
|
+
});
|
|
58
|
+
test('completed request IDs cannot replay and early cancellation prevents execution',async()=>{
|
|
59
|
+
const f=fixture();f.request('tools.list');await tick();assert.throws(()=>f.request('tools.list'),/Duplicate/);
|
|
60
|
+
f.request('tools.invoke',{alias:'notes',args:[]},'early');f.protocol.plugin({coreCancel:{id:'early'}});await tick();assert.match(f.plugin.at(-1).coreResponse.error,/cancelled/);assert.equal(f.client.length,0);assert.equal(f.calls.length,0);
|
|
61
|
+
});
|
|
62
|
+
test('shared workspace lease excludes concurrent writers and refuses queued native work',async()=>{
|
|
63
|
+
const dir=await fs.mkdtemp(path.join(os.tmpdir(),'ez-lease-'));
|
|
64
|
+
try {
|
|
65
|
+
await fs.writeFile(path.join(dir,'config.json'),'{}');const release=await workspaceLease(dir);assert.equal(await workspaceLease(dir),undefined);await assert.rejects(invokeLease(dir),/busy/);await release();
|
|
66
|
+
await fs.mkdir(path.join(dir,'host-executor'));await fs.writeFile(path.join(dir,'host.json'),JSON.stringify({agents:[{toolsHome:dir,workspace:dir,controlDir:dir}]}));await fs.writeFile(path.join(dir,'config.json'),JSON.stringify({hostConfig:path.join(dir,'host.json'),workspace:dir}));await fs.writeFile(path.join(dir,'host-executor','r.request.json'),'{}');await assert.rejects(invokeLease(dir),/pending/);await fs.rm(path.join(dir,'host-executor','r.request.json'));const unlock=await invokeLease(dir);await unlock();
|
|
67
|
+
}finally{await fs.rm(dir,{recursive:true,force:true});}
|
|
68
|
+
});
|
|
69
|
+
test('command output bound and timeout remove exact containers without leaking daemon secrets',async()=>{
|
|
70
|
+
const dir=await fs.mkdtemp(path.join(os.tmpdir(),'ez-bound-'));
|
|
71
|
+
try {
|
|
72
|
+
await fs.writeFile(path.join(dir,'docker'),`#!${process.execPath}\nif(process.env.TELEGRAM_BOT_TOKEN)process.exit(99);if(process.argv[2]==='container')process.exit(0);if(process.argv[2]==='loud')process.stdout.write('x'.repeat(10000));setInterval(()=>{},1000);`,{mode:0o700});
|
|
73
|
+
const url=new URL('../src/plugins/manager.mjs',import.meta.url).href;
|
|
74
|
+
for(const [arg,options,expected] of [['loud',{maxBytes:100},'output limit'],['wait',{timeoutMs:25},'timed out']]){
|
|
75
|
+
const script=`import {run} from ${JSON.stringify(url)};try{await run([${JSON.stringify(arg)}],{capture:true,container:'bound-test',...${JSON.stringify(options)}});process.exitCode=9}catch(e){console.log(e.message)}`;
|
|
76
|
+
const result=await promisify(execFile)(process.execPath,['--input-type=module','-e',script],{env:{...process.env,PATH:dir+path.delimiter+process.env.PATH,TELEGRAM_BOT_TOKEN:'private'},timeout:10000});assert.match(result.stdout,new RegExp(expected));
|
|
77
|
+
}
|
|
78
|
+
}finally{await fs.rm(dir,{recursive:true,force:true});}
|
|
79
|
+
});
|
|
80
|
+
test('startup recovers dead native leases but preserves live owners and surfaces dead plugin leases',async()=>{
|
|
81
|
+
const dir=await fs.mkdtemp(path.join(os.tmpdir(),'ez-recover-')),file=path.join(dir,'workspace-writer.lock');
|
|
82
|
+
try {
|
|
83
|
+
const result=await promisify(execFile)(process.execPath,['-e','console.log(process.pid)']);const deadPid=Number(result.stdout.trim());
|
|
84
|
+
await fs.writeFile(file,JSON.stringify({pid:process.pid,kind:'native'}));await recoverNativeLease(dir);await fs.access(file);
|
|
85
|
+
await fs.writeFile(file,JSON.stringify({pid:deadPid,kind:'native',runId:'r_test'}));await recoverNativeLease(dir);await assert.rejects(fs.access(file),{code:'ENOENT'});
|
|
86
|
+
await fs.writeFile(file,JSON.stringify({pid:deadPid,kind:'plugin'}));await assert.rejects(recoverNativeLease(dir),/verify command containers stopped/);await fs.access(file);await assert.rejects(workspaceLease(dir),/Stale/);
|
|
87
|
+
await fs.writeFile(file,'{}');await assert.rejects(recoverNativeLease(dir),/Invalid/);await fs.access(file);
|
|
88
|
+
}finally{await fs.rm(dir,{recursive:true,force:true});}
|
|
89
|
+
});
|
|
90
|
+
test('owner discovery is read-only, live and rejects caller-selected identity',async()=>{
|
|
91
|
+
let owner={telegramUserId:42,pairedAt:'epoch'};
|
|
92
|
+
const f=fixture({readOwner:async()=>owner});
|
|
93
|
+
await f.request('tools.owner');assert.deepEqual(f.plugin.at(-1).coreResponse.result,owner);
|
|
94
|
+
owner=null;await f.request('tools.owner',{},'second');assert.equal(f.plugin.at(-1).coreResponse.result,null);
|
|
95
|
+
await f.request('tools.owner',{telegramUserId:43},'forged');assert.match(f.plugin.at(-1).coreResponse.error,/unavailable/);
|
|
96
|
+
assert.equal(f.calls.length,0);
|
|
97
|
+
});
|
|
98
|
+
test('web publication accepts only explicit bounded loopback port mapping',async()=>{
|
|
99
|
+
const {loopbackPublish}=await import('../src/plugins/connection.mjs');
|
|
100
|
+
assert.equal(loopbackPublish('8791:8080'),'127.0.0.1:8791:8080');
|
|
101
|
+
for(const value of ['80:8080','8791:0','8791:65536','0.0.0.0:8791:8080','8791:8080/udp','$(x)',''])assert.throws(()=>loopbackPublish(value));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('channel-neutral owner does not require a Telegram delivery context',async()=>{
|
|
105
|
+
const {captureDeliveryContext}=await import('../src/delivery-context.mjs');
|
|
106
|
+
const root=await fs.mkdtemp(path.join(os.tmpdir(),'ez-owner-web-'));
|
|
107
|
+
try {
|
|
108
|
+
await fs.writeFile(path.join(root,'control-state.json'),JSON.stringify({version:1,owner:{id:'owner',generation:'epoch',pairedAt:new Date().toISOString()}}));
|
|
109
|
+
assert.equal(await captureDeliveryContext(root,'voice','revision'),undefined);
|
|
110
|
+
await fs.writeFile(path.join(root,'control-state.json'),JSON.stringify({version:1,owner:{telegramUserId:42,pairedAt:new Date().toISOString()}}));
|
|
111
|
+
await assert.rejects(captureDeliveryContext(root,'voice','revision'),/invalid/);
|
|
112
|
+
}finally{await fs.rm(root,{recursive:true,force:true});}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('cancellation verifies a container already being removed by Compose',async()=>{
|
|
116
|
+
const {removeCommandContainer}=await import('../src/plugins/manager.mjs');
|
|
117
|
+
const calls=[];await removeCommandContainer('fixture',async args=>{
|
|
118
|
+
calls.push(args);
|
|
119
|
+
return args[1]==='rm'?{code:1,stderr:'removal of container fixture is already in progress'}:{code:1,stderr:'No such object: fixture'};
|
|
120
|
+
});
|
|
121
|
+
assert.deepEqual(calls.map(args=>args[1]),['rm','inspect']);
|
|
122
|
+
await assert.rejects(removeCommandContainer('fixture',async()=>({code:1,stderr:'permission denied'})),/cleanup failed/);
|
|
123
|
+
await assert.rejects(removeCommandContainer('fixture',async args=>({code:1,stderr:args[1]==='rm'?'removal of container fixture is already in progress':'daemon unavailable'})),/cleanup failed/);
|
|
124
|
+
});
|
|
@@ -41,6 +41,16 @@ async function fixture(t) {
|
|
|
41
41
|
const call=(...args)=>exec(process.execPath,[bin,'--home',home,...args],{env});
|
|
42
42
|
return {root,source,home,deploymentDir,workspace,control,hostConfig,fake,log,manifest,deployment,env,call};
|
|
43
43
|
}
|
|
44
|
+
test('persistent connection reuses one container and releases registry lock between frames',async t=>{
|
|
45
|
+
const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
|
|
46
|
+
await fs.writeFile(path.join(f.fake,'docker'),`#!${process.execPath}\nif(process.argv[2]==='container')process.exit(0);require('readline').createInterface({input:process.stdin}).on('line',line=>{const f=JSON.parse(line);console.log(JSON.stringify(f.coreResponse?{reply:f.coreResponse}:{coreRequest:{id:f.id,method:'tools.list',params:{}}}));});`,{mode:0o700});
|
|
47
|
+
const child=spawn(process.execPath,[bin,'--home',f.home,'tools','connect','sample','connect'],{env:f.env,stdio:['pipe','pipe','pipe']});t.after(()=>child.kill('SIGKILL'));
|
|
48
|
+
const frame=async id=>{const output=once(child.stdout,'data');child.stdin.write(JSON.stringify({id})+'\n');return JSON.parse((await output)[0].toString());};
|
|
49
|
+
assert.deepEqual((await frame('first')).reply.result,[]);
|
|
50
|
+
await locked(f.home,async()=>{});
|
|
51
|
+
assert.deepEqual((await frame('second')).reply.result,[]);
|
|
52
|
+
child.stdin.end();await once(child,'close');
|
|
53
|
+
});
|
|
44
54
|
test('catalog paths resolve relative to the catalog and pin each new agent independently',async t=>{
|
|
45
55
|
const f=await fixture(t),catalog=path.join(f.root,'defaults.json');
|
|
46
56
|
await fs.writeFile(catalog,JSON.stringify({sample:'./source'}));
|
|
@@ -172,6 +182,8 @@ test('host install exposes runnable public commands without source aliases',asyn
|
|
|
172
182
|
for(const [name,entry] of Object.entries(manifest.bin)) assert.equal(await fs.realpath(path.join(f.home,'bin',name)),await fs.realpath(new URL('../'+entry,import.meta.url)));
|
|
173
183
|
const result=await exec(path.join(f.home,'bin','ezenciel-agents-message'),['--help'],{env:{...process.env,PATH:path.dirname(process.execPath)+path.delimiter+process.env.PATH}});
|
|
174
184
|
assert.match(result.stdout,/Usage: ezenciel-agents-message/);
|
|
185
|
+
const application=await exec(path.join(f.home,'bin','ezenciel-agents-application'),['--help'],{env:{...process.env,PATH:path.dirname(process.execPath)+path.delimiter+process.env.PATH}});
|
|
186
|
+
assert.match(application.stdout,/--token-file/);
|
|
175
187
|
});
|
|
176
188
|
test('copying another agent registry is rejected before any Docker operation',async t=>{
|
|
177
189
|
const f=await fixture(t),other=path.join(f.root,'other');await init(f.home,f.workspace);await init(other,f.workspace);
|
|
@@ -332,3 +344,25 @@ test('registered calls honor registry lock and regenerate stale Compose from cur
|
|
|
332
344
|
await fs.writeFile(path.join(f.home,'registry.lock'),'test');
|
|
333
345
|
await assert.rejects(f.call('sample','read'),/busy|EEXIST|locked/i);
|
|
334
346
|
});
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
test('writable folders require an explicit per-folder grant and can be revoked', async t => {
|
|
350
|
+
const f=await fixture(t);await init(f.home,f.workspace);
|
|
351
|
+
const p=await snapshot(f.source);
|
|
352
|
+
await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
|
|
353
|
+
const source=await fs.realpath(f.workspace),binding=['sample','--service','sample','--source',source,'--target','/data/files'];
|
|
354
|
+
const result=JSON.parse((await f.call('plugins','folder-bind',...binding,'--writable')).stdout);
|
|
355
|
+
assert.equal(result.readOnly,false);
|
|
356
|
+
const readConfig=async()=>JSON.parse(await fs.readFile(path.join(f.home,'config.json'),'utf8'));
|
|
357
|
+
let config=await readConfig();
|
|
358
|
+
assert.deepEqual(config.folders.sample,[{service:'sample',source,target:'/data/files',writable:true}]);
|
|
359
|
+
const record={...p,project:'ezp-test-sample'};
|
|
360
|
+
assert.equal((await compose(config,record)).services.sample.volumes.find(v=>v.target==='/data/files').read_only,false);
|
|
361
|
+
config.folders.sample[0].writable='true';
|
|
362
|
+
await assert.rejects(compose(config,record),/writable must be boolean/);
|
|
363
|
+
await assert.rejects(f.call('plugins','folder-bind',...binding,'--writable','--writable'),/Supply/);
|
|
364
|
+
await f.call('plugins','folder-bind',...binding);
|
|
365
|
+
config=await readConfig();
|
|
366
|
+
assert.equal((await compose(config,record)).services.sample.volumes.find(v=>v.target==='/data/files').read_only,true);
|
|
367
|
+
await assert.rejects(f.call('plugins','folder-unbind','sample','--service','sample','--target','/data/files','--writable'),/Supply/);
|
|
368
|
+
});
|