@jc_stack/ez-agents 0.1.0-beta.34 → 0.1.0-beta.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +1 -0
- package/.env.example +5 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +96 -0
- package/Dockerfile +20 -11
- package/README.md +10 -13
- package/THIRD_PARTY_NOTICES.md +7 -4
- package/bin/ez +97 -0
- package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
- package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
- package/bin/ezenciel-agents.mjs +1 -1
- package/compose.yaml +41 -4
- package/docker/entrypoint.sh +1 -1
- package/docker/healthcheck.mjs +12 -4
- package/docker/run.ts +5 -6
- package/docker/smoke.mjs +28 -6
- package/docs/application-channel.md +32 -10
- package/docs/architecture/ai-selection.md +10 -2
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +98 -11
- package/docs/local-qa.md +2 -2
- package/docs/plugin-guide.md +202 -0
- package/docs/plugins.md +48 -2
- package/docs/releasing.md +13 -0
- package/docs/responsive-channels.md +1 -1
- package/docs/scheduling.md +4 -4
- package/docs/setup.md +11 -5
- package/docs/upgrades.md +46 -23
- package/package.json +5 -5
- package/scripts/smoke-scheduler.ts +10 -3
- package/scripts/smoke.ts +14 -11
- package/src/agent-guidance.ts +6 -9
- package/src/agent-install.ts +28 -7
- package/src/ai-cli.ts +1 -1
- package/src/ai.ts +131 -5
- package/src/application-channel.ts +78 -7
- package/src/application-cli.ts +2 -2
- package/src/approval-cli.ts +14 -7
- package/src/approval.ts +27 -37
- package/src/channel-backend.ts +2 -3
- package/src/config.ts +12 -27
- package/src/control-state.ts +175 -8
- package/src/conversation-menu.ts +6 -17
- package/src/delivery-socket-client.d.mts +4 -0
- package/src/delivery-socket-client.mjs +104 -0
- package/src/delivery-socket.ts +354 -0
- package/src/execution-authority.ts +21 -5
- package/src/executor.ts +81 -24
- package/src/files.ts +38 -11
- package/src/host-executor.ts +33 -17
- package/src/inbox.ts +15 -49
- package/src/index.ts +98 -54
- package/src/install-tools.mjs +97 -22
- package/src/ledger-endpoint.d.mts +2 -0
- package/src/ledger-endpoint.mjs +17 -0
- package/src/menu.ts +8 -3
- package/src/message-history.ts +13 -18
- package/src/message.ts +35 -18
- package/src/plugin-broker.mjs +396 -0
- package/src/plugins/connection.mjs +2 -2
- package/src/plugins/manager.mjs +131 -23
- package/src/plugins/native-tasks.mjs +2 -2
- package/src/prompt-suffix.ts +12 -0
- package/src/react.ts +6 -4
- package/src/runs.ts +156 -243
- package/src/schedule-cli.ts +29 -10
- package/src/scheduled-tasks.ts +53 -10
- package/src/scheduler.ts +20 -7
- package/src/service.ts +3 -3
- package/src/setup.ts +1 -0
- package/src/software-status.ts +36 -4
- package/src/task-executor.ts +2 -2
- package/src/task-workspace.ts +4 -3
- package/src/tasks.ts +1 -1
- package/src/telegram-provisioning-cli.ts +22 -0
- package/src/telegram-provisioning.ts +214 -0
- package/src/updates/control.mjs +77 -7
- package/src/updates/runtime.mjs +28 -8
- package/src/updates/status.mjs +25 -3
- package/src/updates/supervisor.mjs +15 -16
- package/src/version.ts +20 -0
- package/src/workspace.ts +3 -9
- package/templates/failure-review.md +1 -1
- package/templates/maintainer-purpose.md +1 -1
- package/test/agent-guidance.test.ts +14 -80
- package/test/agent-install.test.ts +14 -0
- package/test/ai.test.ts +220 -1
- package/test/application-channel.test.ts +61 -13
- package/test/application-only.test.ts +14 -13
- package/test/channel-backend.test.ts +3 -7
- package/test/channel-delivery.test.ts +4 -1
- package/test/channel-owner.test.ts +6 -3
- package/test/codex-context.test.ts +2 -2
- package/test/config.test.ts +38 -43
- package/test/control-state.test.ts +83 -4
- package/test/conversation-menu.test.ts +2 -4
- package/test/delivery-transport.test.ts +72 -0
- package/test/docker-runtime.test.ts +27 -14
- package/test/engine-handoff.test.ts +7 -6
- package/test/event-sources.test.ts +2 -2
- package/test/execution-authority.test.ts +9 -9
- package/test/executor.test.ts +120 -12
- package/test/failure.test.ts +12 -5
- package/test/files.test.ts +2 -2
- package/test/helpers/ledger.ts +18 -0
- package/test/host-executor.test.ts +87 -3
- package/test/inbox.test.ts +8 -10
- package/test/install-config.test.ts +1 -0
- package/test/install-tools.test.mjs +74 -7
- package/test/intake-relay.test.ts +58 -11
- package/test/message-history.test.ts +39 -47
- package/test/native-tasks.test.ts +1 -1
- package/test/plugin-broker.test.mjs +172 -0
- package/test/plugin-manager.test.mjs +136 -2
- package/test/reaction.test.ts +4 -0
- package/test/relay.test.ts +5 -7
- package/test/repair-policy.test.ts +1 -4
- package/test/schedule-cli.test.ts +14 -9
- package/test/scheduled-tasks.test.ts +29 -6
- package/test/scheduler-host.test.ts +5 -5
- package/test/scheduler-relay.test.ts +15 -9
- package/test/security.test.ts +6 -3
- package/test/software-status.test.ts +21 -3
- package/test/telegram-provisioning.test.ts +160 -0
- package/test/updates.test.mjs +93 -13
- package/test/workspace.test.ts +4 -1
- package/bin/ezenciel-agents-watch.mjs +0 -8
- package/compose.workforce-watch.yaml +0 -33
- package/docker/recovery.ts +0 -11
- package/docs/pagerduty.md +0 -42
- package/docs/workforce-watch.md +0 -101
- package/src/pagerduty.ts +0 -109
- package/src/reply-context.ts +0 -43
- package/src/update-attention.ts +0 -18
- package/src/workforce-watch-cli.ts +0 -14
- package/src/workforce-watch.ts +0 -155
- package/templates/agent/AGENTS.md +0 -5
- package/templates/agent-guidance.md +0 -17
- package/test/pagerduty.test.ts +0 -104
- package/test/update-attention.test.ts +0 -20
- package/test/workforce-watch.test.ts +0 -180
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import test from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
+
import { randomBytes } from 'node:crypto'
|
|
3
4
|
import { mkdtemp, readdir, rm } from 'node:fs/promises'
|
|
4
5
|
import { tmpdir } from 'node:os'
|
|
5
6
|
import { join } from 'node:path'
|
|
@@ -8,6 +9,7 @@ import { once } from 'node:events'
|
|
|
8
9
|
import type { Update } from 'grammy/types'
|
|
9
10
|
import { createRelay } from '../src/index.js'
|
|
10
11
|
import { ControlStore } from '../src/control-state.js'
|
|
12
|
+
import { ApplicationBindings } from '../src/application-channel.js'
|
|
11
13
|
import { RunStore } from '../src/runs.js'
|
|
12
14
|
import { InboxStore } from '../src/inbox.js'
|
|
13
15
|
import { ApprovalStore } from '../src/approval.js'
|
|
@@ -113,7 +115,9 @@ test('approved owner group accepts different members, controls and group deliver
|
|
|
113
115
|
await control.revokeOwner()
|
|
114
116
|
await f.relay.bot.handleUpdate(group(1))
|
|
115
117
|
assert.equal(f.launched.length, 0)
|
|
116
|
-
|
|
118
|
+
const request = (await control.status()).pending[0]
|
|
119
|
+
if (!request || !('title' in request)) throw new Error('Group pairing request missing')
|
|
120
|
+
assert.equal(request.title, 'Team')
|
|
117
121
|
await control.approveOwner(-101, true)
|
|
118
122
|
await f.relay.bot.handleUpdate(group(2, 303))
|
|
119
123
|
await f.relay.bot.handleUpdate(group(3, 404))
|
|
@@ -263,7 +267,7 @@ test('conversation menu is owner-only and removes the retired settings control',
|
|
|
263
267
|
try {
|
|
264
268
|
await f.relay.bot.handleUpdate(message(1, '/menu'))
|
|
265
269
|
assert.deepEqual(f.keyboards.at(-1)!.flat().map((b) => b.text),
|
|
266
|
-
['New conversation', 'Conversations', 'Choose AI', 'Work status'])
|
|
270
|
+
['New conversation', 'Conversations', 'Choose AI', 'Work status', 'Scheduled tasks'])
|
|
267
271
|
await f.relay.bot.handleUpdate(message(2, '/settings'))
|
|
268
272
|
assert.match(f.replies.at(-1)!, /Settings was removed.*Use \/ai/)
|
|
269
273
|
await f.relay.bot.handleUpdate(message(3, '/ai'))
|
|
@@ -284,9 +288,11 @@ test('conversation menu is owner-only and removes the retired settings control',
|
|
|
284
288
|
assert.match(f.replies.at(-1)!, /Menu expired/)
|
|
285
289
|
assert.deepEqual(await store.status(), refreshed)
|
|
286
290
|
await f.relay.bot.handleUpdate(message(8, '/status'))
|
|
287
|
-
assert.ok(f.keyboards.at(-1)!.flat().some((button) => button.text === 'Scheduled tasks'))
|
|
288
|
-
await f.relay.bot.handleUpdate(
|
|
289
|
-
assert.match(f.replies.at(-1)!,
|
|
291
|
+
assert.ok(!f.keyboards.at(-1)!.flat().some((button) => button.text === 'Scheduled tasks'))
|
|
292
|
+
await f.relay.bot.handleUpdate(message(9, '/tasks'))
|
|
293
|
+
assert.match(f.replies.at(-1)!, /📅 Scheduled tasks[\s\S]*No active scheduled tasks/)
|
|
294
|
+
await f.relay.bot.handleUpdate(message(10, '/tasks 2'))
|
|
295
|
+
assert.match(f.replies.at(-1)!, /Scheduled task 2 was not found/)
|
|
290
296
|
assert.equal(f.launched.length, 0)
|
|
291
297
|
} finally { await f.close() }
|
|
292
298
|
})
|
|
@@ -386,7 +392,7 @@ test('slow voice normalization preserves instruction order and leaves controls r
|
|
|
386
392
|
release()
|
|
387
393
|
await processing
|
|
388
394
|
assert.equal(f.launched[0][0], 'Use the following voice note')
|
|
389
|
-
assert.
|
|
395
|
+
assert.doesNotMatch(f.launched[0][1], /Quoted context/)
|
|
390
396
|
assert.match(f.launched[0][1], /Fixture voice transcript/)
|
|
391
397
|
assert.equal(f.launched[0].length, 2)
|
|
392
398
|
assert.equal((await new InboxStore(f.dir).status()).pending, 1)
|
|
@@ -632,7 +638,7 @@ test('conversation menu paginates and stale pages remain usable after archiving'
|
|
|
632
638
|
})
|
|
633
639
|
|
|
634
640
|
|
|
635
|
-
test('
|
|
641
|
+
test('conversation names never read run history and detail keeps archive available when resume fails', async () => {
|
|
636
642
|
const f = await fixture()
|
|
637
643
|
try {
|
|
638
644
|
const store = new ControlStore(f.dir, 1000)
|
|
@@ -649,7 +655,8 @@ test('older conversation names come from owner messages and detail keeps archive
|
|
|
649
655
|
await store.resetSession()
|
|
650
656
|
await f.relay.bot.handleUpdate(message(1, '/chats'))
|
|
651
657
|
const rows = f.keyboards.at(-1)!
|
|
652
|
-
assert.ok(rows.flat().some(b => b.text.startsWith('
|
|
658
|
+
assert.ok(rows.flat().some(b => b.text.startsWith('Untitled conversation')))
|
|
659
|
+
assert.ok(!rows.flat().some(b => b.text.includes('Client launch checklist')))
|
|
653
660
|
assert.ok(!rows.flat().some(b => b.text === '✓ New conversation'))
|
|
654
661
|
assert.ok(!rows.flat().some(b => b.text.includes(old.sessionId.slice(0, 8))))
|
|
655
662
|
assert.ok(!rows.flat().some(b => b.callback_data.startsWith('chat:archive:')))
|
|
@@ -657,7 +664,7 @@ test('older conversation names come from owner messages and detail keeps archive
|
|
|
657
664
|
id: '2', chat_instance: 'fixture', data: `chat:open:${old.sessionId}`,
|
|
658
665
|
from: { id: 101, first_name: 'Fixture', is_bot: false }, message: message(2).message!,
|
|
659
666
|
} })
|
|
660
|
-
assert.match(f.replies.at(-1)!, /
|
|
667
|
+
assert.match(f.replies.at(-1)!, /Untitled conversation \d+[\s\S]*binding/)
|
|
661
668
|
assert.deepEqual(f.keyboards.at(-1)![0], [{text: 'Archive this conversation', callback_data: `chat:archive:${old.sessionId}`}])
|
|
662
669
|
} finally { await f.close() }
|
|
663
670
|
})
|
|
@@ -680,7 +687,7 @@ test('Telegram and application image/PDF/text use identical native attachment me
|
|
|
680
687
|
const telegram=(await new RunStore(f.dir).list()).find(run=>run.messageId===update.message!.message_id)!
|
|
681
688
|
assert.ok(telegram)
|
|
682
689
|
const application=await f.relay.applicationChannel.submit(binding.bindingId,{requestId:name,scope:'chat',followOwner:true,text:update.message!.caption,attachment:{name,data:bytes.toString('base64')}})
|
|
683
|
-
const normalize=(text:string)=>text.replace(/
|
|
690
|
+
const normalize=(text:string)=>text.replace(/attachments\/[a-f0-9-]+_/,'attachments/ID_')
|
|
684
691
|
assert.equal(normalize(application.texts[0]),normalize(telegram.texts[0]))
|
|
685
692
|
assert.equal(application.ownerId,telegram.ownerId)
|
|
686
693
|
assert.equal(application.execution?.sessionId,telegram.execution?.sessionId)
|
|
@@ -699,9 +706,49 @@ test('web launcher is private-owner-only and bypasses native intake without repl
|
|
|
699
706
|
await f.relay.bot.handleUpdate(message(2,'/voice'))
|
|
700
707
|
assert.deepEqual(f.keyboards.at(-1),[[{text:'Voice',web_app:{url:'https://voice.example/'}}]])
|
|
701
708
|
await f.relay.bot.handleUpdate(message(3,'/menu'))
|
|
702
|
-
assert.deepEqual(f.keyboards.at(-1)!.flat().map(b=>b.text),['New conversation','Conversations','Choose AI','Work status','Voice'])
|
|
709
|
+
assert.deepEqual(f.keyboards.at(-1)!.flat().map(b=>b.text),['New conversation','Conversations','Choose AI','Work status','Scheduled tasks','Voice'])
|
|
703
710
|
await f.relay.drainInbox(true);assert.equal(f.launched.length,0)
|
|
704
711
|
await new ControlStore(f.dir,1000).revokeOwner()
|
|
705
712
|
const before=f.keyboards.length;await f.relay.bot.handleUpdate(message(4,'/voice'));assert.equal(f.keyboards.length,before)
|
|
706
713
|
} finally {await f.close()}
|
|
707
714
|
})
|
|
715
|
+
|
|
716
|
+
test('application Telegram link connects without launching work; bad link replies expired', async () => {
|
|
717
|
+
const f=await fixture()
|
|
718
|
+
try {
|
|
719
|
+
const control=new ControlStore(f.dir,1000)
|
|
720
|
+
await control.revokeOwner()
|
|
721
|
+
const owner=await control.registerOwner('account:web')
|
|
722
|
+
const binding=await new ApplicationBindings(f.dir).register('webapp',randomBytes(32).toString('base64url'),owner,true)
|
|
723
|
+
assert.ok(binding)
|
|
724
|
+
const pairing=await control.createApplicationTelegramPairing(binding.bindingId,owner)
|
|
725
|
+
assert.ok(pairing)
|
|
726
|
+
await f.relay.bot.handleUpdate(message(1,`/start ${'A'.repeat(43)}`))
|
|
727
|
+
assert.match(f.replies.at(-1)!,/expired|no longer valid/)
|
|
728
|
+
assert.equal(f.launched.length,0)
|
|
729
|
+
await f.relay.bot.handleUpdate(message(2,`/start ${pairing.token}`))
|
|
730
|
+
assert.match(f.replies.at(-1)! ,/connected/)
|
|
731
|
+
assert.equal(f.launched.length,0)
|
|
732
|
+
assert.equal((await control.status()).owner?.telegramUserId,101)
|
|
733
|
+
} finally {await f.close()}
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
test('tasks command never reaches the executor on invalid input or foreign senders', async () => {
|
|
737
|
+
const f = await fixture()
|
|
738
|
+
try {
|
|
739
|
+
const stranger = message(1, '/tasks');stranger.message!.from!.id = 202
|
|
740
|
+
await f.relay.bot.handleUpdate(stranger)
|
|
741
|
+
assert.equal(f.launched.length, 0)
|
|
742
|
+
assert.ok(!f.replies.join('\n').includes('Scheduled tasks'))
|
|
743
|
+
const strangerDetail = message(2, '/tasks 1');strangerDetail.message!.from!.id = 202
|
|
744
|
+
await f.relay.bot.handleUpdate(strangerDetail)
|
|
745
|
+
assert.equal(f.launched.length, 0)
|
|
746
|
+
assert.ok(!f.replies.join('\n').includes('Scheduled tasks'))
|
|
747
|
+
for (const [id, text] of [[3, '/tasks 0'], [4, '/tasks abc'], [5, '/tasks 1.5'], [6, '/tasks 2 extra']] as const) {
|
|
748
|
+
await f.relay.bot.handleUpdate(message(id, text))
|
|
749
|
+
assert.match(f.replies.at(-1)!, /Usage: \/tasks \[number\]/)
|
|
750
|
+
}
|
|
751
|
+
await f.relay.drainInbox(true)
|
|
752
|
+
assert.equal(f.launched.length, 0)
|
|
753
|
+
} finally { await f.close() }
|
|
754
|
+
})
|
|
@@ -1,45 +1,42 @@
|
|
|
1
1
|
import test from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
-
import { mkdtemp, rm,
|
|
3
|
+
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { promisify } from 'node:util'
|
|
7
7
|
import { execFile } from 'node:child_process'
|
|
8
8
|
import { ownerRun } from './helpers/owner-run.js'
|
|
9
|
+
import { serveTestLedger } from './helpers/ledger.js'
|
|
9
10
|
import { deliveredMessages } from '../src/message-history.js'
|
|
10
11
|
import { RunStore } from '../src/runs.js'
|
|
11
12
|
import { ControlStore } from '../src/control-state.js'
|
|
12
13
|
|
|
13
14
|
async function fixture(t: test.TestContext) {
|
|
14
15
|
const dir = await mkdtemp(join(tmpdir(), 'ez-history-'))
|
|
15
|
-
|
|
16
|
+
// Child CLI assertions below reach the memory ledger through the socket.
|
|
17
|
+
const ledger = await serveTestLedger(dir)
|
|
18
|
+
t.after(async () => { await ledger.stop(); await rm(dir, { recursive: true, force: true }) })
|
|
16
19
|
await ownerRun(dir, 'tg_1')
|
|
17
20
|
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
21
|
const send = async (runId: string, text: string, ids: number[]) => {
|
|
23
22
|
const item = await runs.enqueueMessage(runId, text)
|
|
24
23
|
await runs.claimOutbox(item.id)
|
|
25
24
|
await runs.markOutboxSent(item.id, ids)
|
|
26
|
-
return
|
|
25
|
+
return item
|
|
27
26
|
}
|
|
28
|
-
return { dir, runs, send
|
|
27
|
+
return { dir, runs, send }
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
test('CLI retrieves Synopsys delivery from another session without changing sessions or sending', async t => {
|
|
32
|
-
const { dir, runs, send
|
|
33
|
-
await ownerRun(dir, 'r_schedule_report')
|
|
31
|
+
const { dir, runs, send } = await fixture(t)
|
|
34
32
|
const owner = (await new ControlStore(dir, 1000).status()).owner!
|
|
35
|
-
await
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
const report = await runs.create({ id: 'r_schedule_report', chatId: 101, telegramUserId: 101, texts: ['quarterly results'],
|
|
34
|
+
execution: { sessionId: 'report_session', preset: { id: 'fixture', name: 'Fixture', cli: 'codex' } },
|
|
35
|
+
scheduled: { id: 's_earnings', revision: 'rev', dueAt: new Date().toISOString(), pairedAt: owner.pairedAt } })
|
|
36
|
+
await runs.patch(report.id, { status: 'running', nativeSessionId: 'report_session' })
|
|
38
37
|
await send('r_schedule_report', 'Synopsys (SNPS): quarterly results', [10, 11])
|
|
39
38
|
await runs.patch('r_schedule_report', { status: 'completed' })
|
|
40
|
-
await patchFixture('tg_1', { texts: ['Why now?'] })
|
|
41
39
|
await send('tg_1', 'Other report', [12])
|
|
42
|
-
const before = await readdir(join(dir, 'outbox'))
|
|
43
40
|
const controlBefore = await new ControlStore(dir, 1000).status()
|
|
44
41
|
const { stdout } = await promisify(execFile)(process.execPath, ['bin/ezenciel-agents-message.mjs', 'history', '--message-id', '11'], {
|
|
45
42
|
env: { ...process.env, EZ_CONTROL_DIR: dir, EZ_RUN_ID: 'tg_1' },
|
|
@@ -51,51 +48,49 @@ test('CLI retrieves Synopsys delivery from another session without changing sess
|
|
|
51
48
|
assert.equal(result.messages[0].scheduleId, 's_earnings')
|
|
52
49
|
assert.deepEqual(result.messages[0].messageIds, [10, 11])
|
|
53
50
|
assert.deepEqual(await new ControlStore(dir, 1000).status(), controlBefore)
|
|
54
|
-
|
|
51
|
+
// History reads never create outbox state on disk.
|
|
52
|
+
await assert.rejects(stat(join(dir, 'outbox')))
|
|
55
53
|
const latest = await deliveredMessages(dir, 'tg_1', { limit: 1 })
|
|
56
54
|
assert.equal(latest.messages[0].text, 'Other report')
|
|
57
55
|
assert.equal(latest.hasMore, true)
|
|
58
56
|
})
|
|
59
57
|
|
|
60
58
|
test('only confirmed deliveries in current owner binding are exposed', async t => {
|
|
61
|
-
const { dir, runs, send
|
|
59
|
+
const { dir, runs, send } = await fixture(t)
|
|
62
60
|
await send('tg_1', 'included', [1])
|
|
63
61
|
await runs.enqueueMessage('tg_1', 'pending')
|
|
64
62
|
const failed = await runs.enqueueMessage('tg_1', 'uncertain')
|
|
65
63
|
await runs.claimOutbox(failed.id)
|
|
66
64
|
await runs.failOutbox(failed.id, 'unknown', true)
|
|
67
|
-
|
|
68
|
-
await
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
await ownerRun(dir, id)
|
|
80
|
-
await patchFixture(id, patch)
|
|
81
|
-
await send(id, 'excluded', [3])
|
|
65
|
+
// Receipts without usable message IDs are not delivery evidence.
|
|
66
|
+
const invalid = await runs.enqueueMessage('tg_1', 'invalid receipt')
|
|
67
|
+
await runs.claimOutbox(invalid.id)
|
|
68
|
+
await runs.markOutboxSent(invalid.id, [])
|
|
69
|
+
for (const input of [
|
|
70
|
+
{ id: 'r_other_chat', chatId: 202, telegramUserId: 101 },
|
|
71
|
+
{ id: 'r_other_owner', chatId: 101, telegramUserId: 202 },
|
|
72
|
+
{ id: 'r_external', chatId: 101, telegramUserId: 101, external: { sourceId: 'test', bindingId: 'binding', eventIds: ['1'] } },
|
|
73
|
+
]) {
|
|
74
|
+
await runs.create({ ...input, texts: ['excluded'] })
|
|
75
|
+
await runs.patch(input.id, { status: 'running' })
|
|
76
|
+
await send(input.id, 'excluded', [3])
|
|
82
77
|
}
|
|
83
78
|
assert.deepEqual((await deliveredMessages(dir, 'tg_1')).messages.map(m => m.text), ['included'])
|
|
84
|
-
await writeFile(join(dir, 'runs', 'broken.json'), '{')
|
|
85
79
|
assert.equal((await deliveredMessages(dir, 'tg_1')).messages.length, 1)
|
|
86
80
|
})
|
|
87
81
|
|
|
88
82
|
test('unauthorized callers and invalid arguments fail closed', async t => {
|
|
89
|
-
const { dir, runs
|
|
83
|
+
const { dir, runs } = await fixture(t)
|
|
90
84
|
for (const limit of [0, 51, NaN, 1.5]) await assert.rejects(deliveredMessages(dir, 'tg_1', { limit }), /Limit/)
|
|
91
85
|
await assert.rejects(deliveredMessages(dir, 'tg_1', { messageId: -1 }), /Message ID/)
|
|
92
86
|
await assert.rejects(deliveredMessages(dir, '../tg_1'), /identifier/)
|
|
93
87
|
await assert.rejects(deliveredMessages(dir, 'missing'), /No active/)
|
|
94
88
|
await ownerRun(dir, 'r_external', { sourceId: 'test', bindingId: 'binding', eventIds: ['1'] })
|
|
95
89
|
await assert.rejects(deliveredMessages(dir, 'r_external'), /blocked/)
|
|
96
|
-
await
|
|
97
|
-
await
|
|
98
|
-
await
|
|
90
|
+
await runs.create({ id: 'r_stranger', chatId: 101, telegramUserId: 202, texts: ['other'] })
|
|
91
|
+
await runs.patch('r_stranger', { status: 'running' })
|
|
92
|
+
await assert.rejects(deliveredMessages(dir, 'r_stranger'), /owner-mismatch/)
|
|
93
|
+
await runs.patch('tg_1', { status: 'completed' })
|
|
99
94
|
await assert.rejects(deliveredMessages(dir, 'tg_1'), /No active/)
|
|
100
95
|
await runs.patch('tg_1', { status: 'running' })
|
|
101
96
|
await new ControlStore(dir, 1000).revokeOwner()
|
|
@@ -103,14 +98,11 @@ test('unauthorized callers and invalid arguments fail closed', async t => {
|
|
|
103
98
|
})
|
|
104
99
|
|
|
105
100
|
test('approval messages retain the content delivered to Telegram', async t => {
|
|
106
|
-
const { dir,
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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)
|
|
101
|
+
const { dir, runs } = await fixture(t)
|
|
102
|
+
const item = await runs.enqueueApproval('tg_1', 'Approve this messaging task?', 'act_history')
|
|
103
|
+
await runs.claimOutbox(item.id)
|
|
104
|
+
await runs.markOutboxSent(item.id, [20])
|
|
105
|
+
assert.equal((await deliveredMessages(dir, 'tg_1')).messages[0].text, 'Approve this messaging task?')
|
|
114
106
|
})
|
|
115
107
|
|
|
116
108
|
test('CLI flushes complete long reports and rejects send options in history mode', async t => {
|
|
@@ -121,7 +113,7 @@ test('CLI flushes complete long reports and rejects send options in history mode
|
|
|
121
113
|
const cli = [ 'bin/ezenciel-agents-message.mjs', 'history' ]
|
|
122
114
|
const { stdout } = await promisify(execFile)(process.execPath, cli, { env })
|
|
123
115
|
assert.equal(JSON.parse(stdout).messages[0].text, text)
|
|
124
|
-
const before = await readdir(join(dir, 'outbox'))
|
|
125
116
|
await assert.rejects(promisify(execFile)(process.execPath, [...cli, '--text', 'do not send'], { env }), /Unknown option/)
|
|
126
|
-
|
|
117
|
+
// History reads never create outbox state on disk.
|
|
118
|
+
await assert.rejects(stat(join(dir, 'outbox')))
|
|
127
119
|
})
|
|
@@ -16,7 +16,7 @@ test('native tasks use verified control binding, sanitized environment and uncha
|
|
|
16
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
17
|
assert.equal(binding.cwd,workspace);assert.equal(binding.env.EZ_CONTROL_DIR,controlDir);assert.equal(binding.env.EZ_EXECUTOR_CLI,'grok')
|
|
18
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,/
|
|
19
|
+
assert.match((await nativeTasks(home,['--help'])).stdout,/Creates a scheduled task/)
|
|
20
20
|
const denied=await nativeTasks(home,['list']);assert.notEqual(denied.code,0);assert.match(denied.stderr,/Pair an owner/)
|
|
21
21
|
const control=new ControlStore(controlDir,1000);await control.requestPairing(101,101);await control.approveOwner(101)
|
|
22
22
|
const text='Read scan; $(must-not-run) /goal literal'
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import * as fs from 'node:fs/promises';
|
|
4
|
+
import { createConnection } from 'node:net';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
8
|
+
import { ControlStore, ownerId, ownerEpoch } from '../src/control-state.js';
|
|
9
|
+
import { RunStore } from '../src/runs.js';
|
|
10
|
+
import { loadPluginBrokerBinding, servePluginBroker, validateBrokerRequest } from '../src/plugin-broker.mjs';
|
|
11
|
+
|
|
12
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
13
|
+
const revision = 'sha256:' + 'a'.repeat(64);
|
|
14
|
+
|
|
15
|
+
test('relay compose surface has no broker Docker socket or tools-home mount', async () => {
|
|
16
|
+
const compose = await fs.readFile(new URL('../compose.yaml', import.meta.url), 'utf8');
|
|
17
|
+
const relay = compose.slice(0, compose.indexOf(' plugin-broker:'));
|
|
18
|
+
assert.equal(relay.includes('/var/run/docker.sock'), false);
|
|
19
|
+
assert.equal(relay.includes('EZ_TOOLS_HOME'), false);
|
|
20
|
+
assert.equal(compose.slice(compose.indexOf(' plugin-broker:')).includes('/var/run/docker.sock'), true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
async function fixture(t) {
|
|
24
|
+
const root = await fs.realpath(await fs.mkdtemp(path.join(tmpdir(), 'e-')));
|
|
25
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
26
|
+
const home = path.join(root, 'tools');
|
|
27
|
+
const workspace = path.join(root, 'mind');
|
|
28
|
+
const controlDir = path.join(root, 'control');
|
|
29
|
+
const hostConfig = path.join(root, 'host-executor.json');
|
|
30
|
+
const socket = path.join(controlDir, 'plugin-broker.sock');
|
|
31
|
+
const source = path.join(home, 'packages', 'sample', revision.slice(7));
|
|
32
|
+
await Promise.all([home, workspace, controlDir, source].map(directory => fs.mkdir(directory, { recursive: true, mode: 0o700 })));
|
|
33
|
+
await fs.mkdir(path.join(root, 'bin'), { mode: 0o700 });
|
|
34
|
+
const host = { cli: 'codex', isolation: 'isolated', agents: [{ name: 'tenant-a', toolsHome: home, workspace, controlDir, binDir: path.join(root, 'bin') }] };
|
|
35
|
+
await fs.writeFile(hostConfig, JSON.stringify(host) + '\n', { mode: 0o600 });
|
|
36
|
+
const realHost = await fs.realpath(hostConfig);
|
|
37
|
+
const realHome = await fs.realpath(home);
|
|
38
|
+
const realWorkspace = await fs.realpath(workspace);
|
|
39
|
+
const realControl = await fs.realpath(controlDir);
|
|
40
|
+
await fs.writeFile(path.join(home, 'config.json'), JSON.stringify({ schemaVersion: 1, workspace: realWorkspace, hostConfig: realHost }) + '\n', { mode: 0o600 });
|
|
41
|
+
const record = {
|
|
42
|
+
revision,
|
|
43
|
+
source,
|
|
44
|
+
project: `ezp-${hash(realHome).slice(0, 16)}-sample`,
|
|
45
|
+
manifest: { schemaVersion: 1, id: 'sample', version: '1.0.0', commands: { sample: { executable: 'client.mjs', args: [] } }, skills: [] },
|
|
46
|
+
deployment: { schemaVersion: 1, services: { sample: { buildTarget: 'runtime', volumes: {}, workspace: false, healthcheck: ['node', '--version'] } }, commands: { sample: { service: 'sample', argv: ['node', '/app/client.mjs'], suffix: [] } }, exports: {} },
|
|
47
|
+
compose: path.join(home, 'packages', 'sample', 'compose.json'),
|
|
48
|
+
};
|
|
49
|
+
await fs.writeFile(path.join(home, 'registry.json'), JSON.stringify({ schemaVersion: 1, owner: realHome, plugins: { sample: record }, commands: { sample: 'sample' } }) + '\n', { mode: 0o600 });
|
|
50
|
+
const fake = path.join(root, 'fake');
|
|
51
|
+
await fs.mkdir(fake, { mode: 0o700 });
|
|
52
|
+
await fs.writeFile(path.join(fake, 'docker'), `#!${process.execPath}
|
|
53
|
+
const a=process.argv.slice(2);
|
|
54
|
+
if(a.includes('loud'))process.stdout.write('x'.repeat(1024*1024+1));
|
|
55
|
+
else if(a.includes('run'))process.stdout.write('broker-ok');
|
|
56
|
+
`, { mode: 0o700 });
|
|
57
|
+
const control = new ControlStore(realControl, 1000);
|
|
58
|
+
await control.requestPairing(42, 42);
|
|
59
|
+
const owner = await control.approveOwner(42);
|
|
60
|
+
const run = await new RunStore(realControl).create({ id: 'r_broker', chatId: 42, telegramUserId: 42, texts: ['broker test'], ownerId: ownerId(owner), ownerEpoch: ownerEpoch(owner) });
|
|
61
|
+
await new RunStore(realControl).patch(run.id, { status: 'running' });
|
|
62
|
+
return { root, home: realHome, workspace: realWorkspace, controlDir: realControl, hostConfig: realHost, socket, runId: run.id, fake };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function request(socketPath, payload) {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const socket = createConnection(socketPath);
|
|
68
|
+
let buffer = '';
|
|
69
|
+
socket.once('error', reject);
|
|
70
|
+
socket.on('data', chunk => {
|
|
71
|
+
buffer += chunk.toString();
|
|
72
|
+
const end = buffer.indexOf('\n');
|
|
73
|
+
if (end < 0) return;
|
|
74
|
+
try { resolve(JSON.parse(buffer.slice(0, end))); } catch (error) { reject(error); }
|
|
75
|
+
socket.destroy();
|
|
76
|
+
});
|
|
77
|
+
socket.on('connect', () => socket.end(JSON.stringify(payload) + '\n'));
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function waitForSocket(socketPath) {
|
|
82
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
83
|
+
try { if ((await fs.stat(socketPath)).isSocket()) return; } catch {}
|
|
84
|
+
await new Promise(resolve => setTimeout(resolve, 5));
|
|
85
|
+
}
|
|
86
|
+
throw new Error('Broker socket did not start');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
test('isolated broker authenticates an owned run, pins plugin revision, and writes receipts', async t => {
|
|
90
|
+
const f = await fixture(t);
|
|
91
|
+
const binding = await loadPluginBrokerBinding({ home: f.home, workspace: f.workspace, controlDir: f.controlDir, socket: f.socket, hostConfig: f.hostConfig, timeoutMs: 10_000 });
|
|
92
|
+
const previousPath = process.env.PATH;
|
|
93
|
+
process.env.PATH = f.fake + path.delimiter + previousPath;
|
|
94
|
+
const abort = new AbortController();
|
|
95
|
+
const serving = servePluginBroker(binding, abort.signal);
|
|
96
|
+
try {
|
|
97
|
+
await waitForSocket(f.socket);
|
|
98
|
+
const published = JSON.parse(await fs.readFile(path.join(f.controlDir, 'plugin-broker-plugins.json'), 'utf8'));
|
|
99
|
+
assert.deepEqual(published.plugins, [{ id: 'sample', version: '1.0.0' }]);
|
|
100
|
+
const listed = await request(f.socket, { version: 1, id: randomUUID(), operation: 'manager', runId: f.runId, args: ['tools', 'list'] });
|
|
101
|
+
assert.equal(listed.ok, true);
|
|
102
|
+
assert.deepEqual(JSON.parse(listed.stdout), { sample: 'sample' });
|
|
103
|
+
const detailed = await request(f.socket, { version: 1, id: randomUUID(), operation: 'manager', runId: f.runId, args: ['tools', 'list', '--details'] });
|
|
104
|
+
assert.equal(detailed.ok, true);
|
|
105
|
+
assert.deepEqual(JSON.parse(detailed.stdout).sample.commands, ['ez sample --help']);
|
|
106
|
+
const resolved = await request(f.socket, { version: 1, id: randomUUID(), operation: 'resolve', runId: f.runId, alias: 'sample' });
|
|
107
|
+
assert.equal(resolved.ok, true);
|
|
108
|
+
assert.equal(resolved.revision, revision);
|
|
109
|
+
const invoked = await request(f.socket, { version: 1, id: randomUUID(), operation: 'invoke', runId: f.runId, alias: 'sample', revision, capability: resolved.capability, args: ['literal', '$(not-a-shell)'], stdin: 'input' });
|
|
110
|
+
assert.equal(invoked.ok, true);
|
|
111
|
+
assert.equal(invoked.stdout, 'broker-ok');
|
|
112
|
+
assert.equal(invoked.receipt.status, 'completed');
|
|
113
|
+
assert.deepEqual(JSON.parse(await fs.readFile(invoked.receiptPath, 'utf8')), invoked.receipt);
|
|
114
|
+
const replay = await request(f.socket, { version: 1, id: randomUUID(), operation: 'invoke', runId: f.runId, alias: 'sample', revision, capability: resolved.capability, args: [] });
|
|
115
|
+
assert.equal(replay.ok, false);
|
|
116
|
+
assert.match(replay.error, /capability/);
|
|
117
|
+
const loudResolved = await request(f.socket, { version: 1, id: randomUUID(), operation: 'resolve', runId: f.runId, alias: 'sample' });
|
|
118
|
+
const loud = await request(f.socket, { version: 1, id: randomUUID(), operation: 'invoke', runId: f.runId, alias: 'sample', revision, capability: loudResolved.capability, args: ['loud'] });
|
|
119
|
+
assert.equal(loud.ok, false);
|
|
120
|
+
assert.match(loud.error, /output limit/);
|
|
121
|
+
assert.equal(loud.receipt.status, 'failed');
|
|
122
|
+
const generated = JSON.parse(await fs.readFile(f.home + '/packages/sample/compose.json', 'utf8'));
|
|
123
|
+
assert.equal(generated.version, '3.8');
|
|
124
|
+
} finally {
|
|
125
|
+
abort.abort();
|
|
126
|
+
await serving;
|
|
127
|
+
process.env.PATH = previousPath;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('broker rejects foreign agent bindings and unsafe management or request fields', async t => {
|
|
132
|
+
const f = await fixture(t);
|
|
133
|
+
const foreign = path.join(f.root, 'foreign');
|
|
134
|
+
const foreignHome = path.join(foreign, 'tools');
|
|
135
|
+
const foreignWorkspace = path.join(foreign, 'mind');
|
|
136
|
+
const foreignControl = path.join(foreign, 'control');
|
|
137
|
+
await Promise.all([foreignHome, foreignWorkspace, foreignControl].map(directory => fs.mkdir(directory, { recursive: true, mode: 0o700 })));
|
|
138
|
+
await fs.writeFile(path.join(foreignHome, 'config.json'), JSON.stringify({ schemaVersion: 1, workspace: await fs.realpath(foreignWorkspace), hostConfig: f.hostConfig }));
|
|
139
|
+
await fs.writeFile(path.join(foreignHome, 'registry.json'), JSON.stringify({ schemaVersion: 1, owner: await fs.realpath(foreignHome), plugins: {}, commands: {} }));
|
|
140
|
+
await assert.rejects(loadPluginBrokerBinding({ home: foreignHome, workspace: foreignWorkspace, controlDir: foreignControl, socket: path.join(foreignControl, 'plugin-broker.sock'), hostConfig: f.hostConfig }), /owned by exactly one isolated agent/);
|
|
141
|
+
await assert.rejects(validateBrokerRequest({ version: 1, id: randomUUID(), operation: 'manager', runId: 'r_broker', args: ['plugins', 'install', 'sample', '--source', '/tmp/plugin'] }), /not allowed/);
|
|
142
|
+
await assert.doesNotReject(validateBrokerRequest({ version: 1, id: randomUUID(), operation: 'manager', runId: 'r_broker', args: ['plugins', 'uninstall', 'sample'] }, f.workspace));
|
|
143
|
+
const source = path.join(f.workspace, 'plugin-source');
|
|
144
|
+
await fs.mkdir(source);
|
|
145
|
+
await assert.doesNotReject(validateBrokerRequest({ version: 1, id: randomUUID(), operation: 'manager', runId: 'r_broker', args: ['plugins', 'catalog-add', 'sample', '--source', source, '--revision', revision] }, f.workspace));
|
|
146
|
+
await assert.rejects(validateBrokerRequest({ version: 1, id: randomUUID(), operation: 'manager', runId: 'r_broker', args: ['plugins', 'catalog-add', 'sample', '--source', '/tmp/plugin', '--revision', revision] }, f.workspace), /agent workspace/);
|
|
147
|
+
const candidate = path.join(source, 'candidate.tgz');
|
|
148
|
+
await fs.writeFile(candidate, 'candidate');
|
|
149
|
+
await assert.doesNotReject(validateBrokerRequest({ version: 1, id: randomUUID(), operation: 'manager', runId: 'r_broker', args: ['updates', 'prepare', 'sample', '--file', candidate] }, f.workspace));
|
|
150
|
+
await assert.rejects(validateBrokerRequest({ version: 1, id: randomUUID(), operation: 'manager', runId: 'r_broker', args: ['updates', 'prepare', 'sample', '--file', '/tmp/candidate.tgz'] }, f.workspace), /agent workspace/);
|
|
151
|
+
await assert.rejects(validateBrokerRequest({ version: 1, id: randomUUID(), operation: 'manager', runId: 'r_broker', args: ['updates', 'prepare', 'sample', '--version', '1.0.1'] }, f.workspace), /not allowed/);
|
|
152
|
+
await assert.rejects(validateBrokerRequest({ version: 1, id: randomUUID(), operation: 'invoke', runId: 'r_broker', alias: 'sample', revision, capability: 'short', args: [] }), /capability/);
|
|
153
|
+
await assert.rejects(validateBrokerRequest({ version: 1, id: randomUUID(), operation: 'invoke', runId: 'r_broker', alias: 'sample', revision, capability: 'a'.repeat(48), args: [], stdin: 'x'.repeat(1024 * 1024 + 1) }), /stdin/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('broker rejects resolve and invoke for a revoked run', async t => {
|
|
157
|
+
const f = await fixture(t);
|
|
158
|
+
const binding = await loadPluginBrokerBinding({ home: f.home, workspace: f.workspace, controlDir: f.controlDir, socket: f.socket, hostConfig: f.hostConfig, timeoutMs: 10_000 });
|
|
159
|
+
const abort = new AbortController();
|
|
160
|
+
const serving = servePluginBroker(binding, abort.signal);
|
|
161
|
+
try {
|
|
162
|
+
await waitForSocket(f.socket);
|
|
163
|
+
await new RunStore(f.controlDir).patch(f.runId, { status: 'cancelled' });
|
|
164
|
+
const resolved = await request(f.socket, { version: 1, id: randomUUID(), operation: 'resolve', runId: f.runId, alias: 'sample' });
|
|
165
|
+
assert.equal(resolved.ok, false);
|
|
166
|
+
const invoked = await request(f.socket, { version: 1, id: randomUUID(), operation: 'invoke', runId: f.runId, alias: 'sample', revision, capability: 'a'.repeat(64), args: [] });
|
|
167
|
+
assert.equal(invoked.ok, false);
|
|
168
|
+
} finally {
|
|
169
|
+
abort.abort();
|
|
170
|
+
await serving;
|
|
171
|
+
}
|
|
172
|
+
});
|