@jc_stack/ez-agents 0.1.0-beta.33 → 0.1.0-beta.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +1 -0
- package/.env.example +9 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +92 -0
- package/Dockerfile +11 -9
- package/README.md +32 -27
- package/THIRD_PARTY_NOTICES.md +4 -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 +42 -5
- package/docker/entrypoint.sh +1 -1
- package/docker/healthcheck.mjs +12 -4
- package/docker/run.ts +5 -6
- package/docker/smoke.mjs +32 -7
- package/docs/application-channel.md +38 -10
- package/docs/architecture/ai-selection.md +3 -1
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +106 -21
- package/docs/host-service.md +17 -6
- package/docs/local-qa.md +2 -2
- package/docs/plugin-guide.md +202 -0
- package/docs/plugins.md +63 -2
- package/docs/releasing.md +13 -0
- package/docs/repair.md +1 -1
- package/docs/responsive-channels.md +1 -1
- package/docs/scheduling.md +4 -4
- package/docs/setup.md +96 -16
- package/docs/standalone-cli.md +6 -5
- package/docs/upgrades.md +46 -23
- package/package.json +5 -5
- package/scripts/smoke-scheduler.ts +12 -4
- package/scripts/smoke.ts +14 -11
- package/src/agent-guidance.ts +6 -9
- package/src/agent-install.ts +44 -9
- package/src/ai-cli.ts +9 -7
- package/src/ai.ts +7 -7
- package/src/application-channel.ts +88 -17
- 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/codex-session.ts +5 -3
- package/src/config.ts +18 -28
- package/src/control-state.ts +187 -9
- 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 +110 -31
- package/src/files.ts +38 -11
- package/src/host-executor.ts +50 -17
- package/src/inbox.ts +15 -49
- package/src/index.ts +96 -52
- package/src/install-config.ts +2 -2
- package/src/install-tools.mjs +100 -23
- package/src/isolation.ts +25 -0
- package/src/ledger-endpoint.d.mts +2 -0
- package/src/ledger-endpoint.mjs +17 -0
- package/src/menu.ts +16 -8
- 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 +178 -26
- 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 +11 -7
- package/src/software-status.ts +36 -4
- package/src/task-executor.ts +6 -3
- 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 +29 -9
- package/src/updates/status.mjs +25 -3
- package/src/updates/supervisor.mjs +31 -18
- package/src/version.ts +20 -0
- package/src/workspace-confine.ts +38 -0
- package/src/workspace.ts +24 -20
- package/templates/agent-purpose.md +1 -0
- package/templates/failure-review.md +1 -1
- package/templates/maintainer-purpose.md +1 -1
- package/test/agent-guidance.test.ts +18 -88
- package/test/agent-install.test.ts +48 -0
- package/test/ai-cli.test.ts +18 -0
- package/test/ai.test.ts +84 -0
- package/test/application-channel.test.ts +66 -15
- package/test/application-controls.test.ts +56 -3
- package/test/application-only.test.ts +16 -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 +63 -3
- package/test/codex-session.test.ts +6 -1
- package/test/config.test.ts +47 -37
- 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 +12 -8
- package/test/event-sources.test.ts +2 -2
- package/test/execution-authority.test.ts +9 -9
- package/test/executor.test.ts +47 -11
- package/test/failure.test.ts +18 -5
- package/test/files.test.ts +2 -2
- package/test/helpers/ledger.ts +18 -0
- package/test/host-executor.test.ts +80 -0
- package/test/inbox.test.ts +8 -10
- package/test/install-config.test.ts +18 -6
- package/test/install-tools.test.mjs +80 -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 +143 -5
- 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/scheduler.test.ts +2 -2
- 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 +102 -13
- package/test/workspace-confine.test.ts +47 -0
- package/test/workspace.test.ts +27 -11
- 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 -44
- 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 -15
- package/templates/agent/SOUL.md +0 -11
- package/templates/agent/USER.md +0 -5
- package/templates/agent-guidance.md +0 -114
- package/test/pagerduty.test.ts +0 -104
- package/test/update-attention.test.ts +0 -20
- package/test/workforce-watch.test.ts +0 -180
|
@@ -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
|
+
});
|
|
@@ -6,7 +6,12 @@ import path from 'node:path';
|
|
|
6
6
|
import {execFile,spawn} from 'node:child_process';
|
|
7
7
|
import {once} from 'node:events';
|
|
8
8
|
import {promisify} from 'node:util';
|
|
9
|
-
import {snapshot,init as initManager,validate,compose,locked,bindToolDiscovery} from '../src/plugins/manager.mjs';
|
|
9
|
+
import {snapshot,init as initManager,validate,compose,locked,bindToolDiscovery,prepareCommand} from '../src/plugins/manager.mjs';
|
|
10
|
+
import { ControlStore, ownerId, ownerEpoch } from '../src/control-state.js';
|
|
11
|
+
import { ApplicationBindings } from '../src/application-channel.js';
|
|
12
|
+
import { RunStore } from '../src/runs.js';
|
|
13
|
+
import { serveTestLedger } from './helpers/ledger.js';
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
10
15
|
// Synthetic manager tests explicitly opt out of the product's default packages.
|
|
11
16
|
async function init(home,workspace,catalog,hostConfig) {
|
|
12
17
|
const file=path.join(path.dirname(home),'test-catalog.json');
|
|
@@ -69,6 +74,7 @@ test('native discovery binding preserves notes and never seeds a tool inventory'
|
|
|
69
74
|
await init(f.home,f.workspace);
|
|
70
75
|
await assert.rejects(fs.access(notes),{code:'ENOENT'});
|
|
71
76
|
const first=await fs.readFile(instructions,'utf8');assert(first.startsWith('Owner mandate\n'));assert(first.includes(f.home+'/bin/ez'));
|
|
77
|
+
assert.match(first,/tools list --details/);assert.match(first,/ --help/);assert.doesNotMatch(first,/ezenciel-agents-message|KISS/);
|
|
72
78
|
await bindToolDiscovery(f.home,f.workspace);assert.equal(await fs.readFile(instructions,'utf8'),first);
|
|
73
79
|
await fs.writeFile(notes,'Legacy policy');
|
|
74
80
|
await bindToolDiscovery(f.home,f.workspace);assert.equal(await fs.readFile(notes,'utf8'),'Legacy policy');
|
|
@@ -81,6 +87,12 @@ test('native discovery binding preserves notes and never seeds a tool inventory'
|
|
|
81
87
|
await assert.rejects(bindToolDiscovery(f.home,f.workspace),/regular file/);
|
|
82
88
|
assert.equal(await fs.readFile(notes,'utf8'),'Legacy policy');
|
|
83
89
|
});
|
|
90
|
+
test('isolated discovery uses the relay-bound ez client without exposing toolsHome',async t=>{
|
|
91
|
+
const f=await fixture(t);await fs.mkdir(f.home);const workspace=await fs.realpath(f.workspace),controlDir=await fs.realpath(f.control),toolsHome=await fs.realpath(f.home),host={cli:'codex',isolation:'isolated',agents:[{name:'sample',workspace,controlDir,binDir:path.join(f.root,'bin'),toolsHome}]};
|
|
92
|
+
await fs.mkdir(path.join(f.root,'bin'));await fs.writeFile(f.hostConfig,JSON.stringify(host));
|
|
93
|
+
await init(f.home,f.workspace,undefined,f.hostConfig);
|
|
94
|
+
const instructions=await fs.readFile(path.join(f.workspace,'AGENTS.md'),'utf8');assert.match(instructions,/`ez tools list --details`/);assert.doesNotMatch(instructions,new RegExp(f.home.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')));
|
|
95
|
+
});
|
|
84
96
|
test('installed snippets follow install, upgrade and uninstall without files or Docker reads',async t=>{
|
|
85
97
|
const f=await fixture(t);await init(f.home,f.workspace);
|
|
86
98
|
const details=async()=>JSON.parse((await f.call('tools','list','--details')).stdout);
|
|
@@ -98,6 +110,72 @@ test('installed snippets follow install, upgrade and uninstall without files or
|
|
|
98
110
|
}
|
|
99
111
|
await assert.rejects(fs.access(path.join(f.workspace,'TOOLS.md')),{code:'ENOENT'});
|
|
100
112
|
});
|
|
113
|
+
test('application plugin context is namespaced, authorized, and absent from ordinary commands',async t=>{
|
|
114
|
+
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);
|
|
115
|
+
const home=await fs.realpath(f.home);
|
|
116
|
+
const ledger=await serveTestLedger(f.control);t.after(()=>ledger.stop());
|
|
117
|
+
const control=new ControlStore(f.control,1000);await control.requestPairing(42,42);const owner=await control.approveOwner(42);
|
|
118
|
+
const binding=await new ApplicationBindings(f.control).register('app',randomBytes(32).toString('base64url'),owner);
|
|
119
|
+
assert.ok(binding);
|
|
120
|
+
const runs=new RunStore(f.control),run=await runs.create({id:'r_app_plugin',ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),texts:['Use the plugin'],application:{bindingId:binding.bindingId,requestId:'plugin',scope:'owner-chat',context:{plugins:{sample:{capability:'scoped-value',template:'cost $5 for ${plan} and $HOME'},other:{capability:'must-not-leak'}}}}});
|
|
121
|
+
await runs.patch(run.id,{status:'running'});
|
|
122
|
+
const command=await prepareCommand(home,'sample',['context'],{environment:{EZ_CONTROL_DIR:f.control,EZ_RUN_ID:run.id}});
|
|
123
|
+
const index=command.argv.lastIndexOf('--file');assert.ok(index>0);
|
|
124
|
+
assert.equal(command.argv[index+2],'run');
|
|
125
|
+
const contextFile=command.argv[index+1];
|
|
126
|
+
assert.match(contextFile,/\/plugin-context\/[0-9a-f-]+\.json$/);
|
|
127
|
+
assert.equal(command.argv.join(' ').includes('must-not-leak'),false);
|
|
128
|
+
// The run container receives the slice through the service environment, not
|
|
129
|
+
// through interpolation-only --env-file. Compose collapses the escaped
|
|
130
|
+
// `$$` back to `$`, so every context value must survive byte-for-byte.
|
|
131
|
+
const override=JSON.parse(await fs.readFile(contextFile,'utf8'));
|
|
132
|
+
const delivered=override.services.sample.environment.EZ_PLUGIN_CONTEXT.replaceAll('$$','$');
|
|
133
|
+
assert.deepEqual(JSON.parse(delivered),{capability:'scoped-value',template:'cost $5 for ${plan} and $HOME'});
|
|
134
|
+
assert.ok(override.services.sample.environment.EZ_PLUGIN_CONTEXT.includes('$$5'));
|
|
135
|
+
assert.ok(override.services.sample.environment.EZ_PLUGIN_CONTEXT.includes('$${plan}'));
|
|
136
|
+
assert.equal((await fs.stat(contextFile)).mode&0o777,0o600);
|
|
137
|
+
await command.release();await assert.rejects(fs.access(contextFile),{code:'ENOENT'});
|
|
138
|
+
const ordinary=await prepareCommand(home,'sample',['context'],{environment:{EZ_CONTROL_DIR:f.control}});
|
|
139
|
+
assert.equal(ordinary.argv.includes('--env-file'),false);assert.equal(ordinary.contextFile,undefined);
|
|
140
|
+
});
|
|
141
|
+
test('Telegram-admitted application runs receive the same scoped plugin context',async t=>{
|
|
142
|
+
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);
|
|
143
|
+
const home=await fs.realpath(f.home);
|
|
144
|
+
const ledger=await serveTestLedger(f.control);t.after(()=>ledger.stop());
|
|
145
|
+
const control=new ControlStore(f.control,1000);await control.requestPairing(42,42);const owner=await control.approveOwner(42);
|
|
146
|
+
const binding=await new ApplicationBindings(f.control).register('telegram-app',randomBytes(32).toString('base64url'),owner,true);
|
|
147
|
+
assert.ok(binding);
|
|
148
|
+
const runs=new RunStore(f.control),run=await runs.create({id:'tg_plugin_context',chatId:42,telegramUserId:42,ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),texts:['Use the plugin']});
|
|
149
|
+
await runs.attachTelegramApplication(run.id,{bindingId:binding.bindingId,requestId:run.id,scope:'telegram',context:{plugins:{sample:{capability:'telegram-scoped-value'}}}});
|
|
150
|
+
await runs.patch(run.id,{status:'running'});
|
|
151
|
+
const command=await prepareCommand(home,'sample',['context'],{environment:{EZ_CONTROL_DIR:f.control,EZ_RUN_ID:run.id}});
|
|
152
|
+
const index=command.argv.lastIndexOf('--file');assert.ok(index>0);
|
|
153
|
+
assert.equal(command.argv[index+2],'run');
|
|
154
|
+
const contextFile=command.argv[index+1];
|
|
155
|
+
assert.deepEqual(JSON.parse(await fs.readFile(contextFile,'utf8')),{services:{sample:{environment:{EZ_PLUGIN_CONTEXT:'{"capability":"telegram-scoped-value"}'}}}});
|
|
156
|
+
await command.release();await assert.rejects(fs.access(contextFile),{code:'ENOENT'});
|
|
157
|
+
});
|
|
158
|
+
test('application plugin context fails closed on revoked, cancelled, and oversized runs',async t=>{
|
|
159
|
+
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);
|
|
160
|
+
const home=await fs.realpath(f.home);
|
|
161
|
+
const ledger=await serveTestLedger(f.control);t.after(()=>ledger.stop());
|
|
162
|
+
const control=new ControlStore(f.control,1000);await control.requestPairing(42,42);const owner=await control.approveOwner(42);
|
|
163
|
+
const binding=await new ApplicationBindings(f.control).register('app',randomBytes(32).toString('base64url'),owner);
|
|
164
|
+
const runs=new RunStore(f.control);
|
|
165
|
+
const run=await runs.create({id:'r_app_ctx',ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),texts:['Use the plugin'],application:{bindingId:binding.bindingId,requestId:'plugin',scope:'owner-chat',context:{plugins:{sample:{capability:'scoped-value'}}}}});
|
|
166
|
+
await runs.patch(run.id,{status:'running'});
|
|
167
|
+
await control.revokeOwner();
|
|
168
|
+
await control.requestPairing(43,43);await control.approveOwner(43);
|
|
169
|
+
await assert.rejects(prepareCommand(home,'sample',['context'],{environment:{EZ_CONTROL_DIR:f.control,EZ_RUN_ID:run.id}}),/revoked/);
|
|
170
|
+
const stale=await runs.create({id:'r_app_stale',ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),texts:['Use the plugin'],application:{bindingId:binding.bindingId,requestId:'stale',scope:'owner-chat',context:{plugins:{sample:{capability:'scoped-value'}}}}});
|
|
171
|
+
const idle=await prepareCommand(home,'sample',['context'],{environment:{EZ_CONTROL_DIR:f.control,EZ_RUN_ID:stale.id}});
|
|
172
|
+
assert.equal(idle.contextFile,undefined);assert.equal(idle.argv.includes('--env-file'),false);
|
|
173
|
+
const freshOwner=(await control.status()).owner;
|
|
174
|
+
const binding2=await new ApplicationBindings(f.control).register('app2',randomBytes(32).toString('base64url'),freshOwner);
|
|
175
|
+
const big=await runs.create({id:'r_app_big',ownerId:ownerId(freshOwner),ownerEpoch:ownerEpoch(freshOwner),texts:['Use the plugin'],application:{bindingId:binding2.bindingId,requestId:'big',scope:'owner-chat',context:{plugins:{sample:{blob:'x'.repeat(17*1024)}}}}});
|
|
176
|
+
await runs.patch(big.id,{status:'running'});
|
|
177
|
+
await assert.rejects(prepareCommand(home,'sample',['context'],{environment:{EZ_CONTROL_DIR:f.control,EZ_RUN_ID:big.id}}),/too large/);
|
|
178
|
+
});
|
|
101
179
|
test('bound launcher installs without startup; literal args and exit codes; scopes and secrets',async t=>{
|
|
102
180
|
const f=await fixture(t);const p=await snapshot(f.source);await init(f.home,f.workspace);
|
|
103
181
|
const install=['plugins','install','sample','--source',f.source,'--revision',p.revision];
|
|
@@ -139,6 +217,23 @@ test('unconfirmed direct-command cancellation retains its lifecycle lease',async
|
|
|
139
217
|
assert.equal((await fs.readdir(path.join(f.home,'command-invocations'))).length,1);
|
|
140
218
|
await assert.rejects(f.call('plugins','uninstall','sample'),/Stale command invocation lease/);
|
|
141
219
|
});
|
|
220
|
+
test('an unreadable invocation lease pid falls through to container verification',async t=>{
|
|
221
|
+
const f=await fixture(t);
|
|
222
|
+
const directory=path.join(f.home,'command-invocations');await fs.mkdir(directory,{recursive:true});
|
|
223
|
+
const lease=path.join(directory,'00000000-0000-4000-8000-000000000000.json');
|
|
224
|
+
await fs.writeFile(lease,JSON.stringify({pid:process.pid,container:'gone-call'}),{mode:0o600});
|
|
225
|
+
const path0=process.env.PATH,kill0=process.kill;
|
|
226
|
+
process.env.PATH=f.fake+path.delimiter+path0;
|
|
227
|
+
process.kill=()=>{const error=new Error('kill EPERM');error.code='EPERM';throw error;};
|
|
228
|
+
try {
|
|
229
|
+
await fs.writeFile(path.join(f.fake,'docker'),`#!${process.execPath}\nconst a=process.argv.slice(2);if(a[0]==='container'&&a[1]==='inspect'){console.error('No such container: gone-call');process.exit(1)}\n`,{mode:0o700});
|
|
230
|
+
assert.equal(await locked(f.home,async()=>'ok'),'ok');
|
|
231
|
+
await assert.rejects(fs.access(lease),{code:'ENOENT'});
|
|
232
|
+
await fs.writeFile(lease,JSON.stringify({pid:process.pid,container:'live-call'}),{mode:0o600});
|
|
233
|
+
await fs.writeFile(path.join(f.fake,'docker'),`#!${process.execPath}\nconst a=process.argv.slice(2);if(a[0]==='container'&&a[1]==='inspect'){console.log('live-call');process.exit(0)}\n`,{mode:0o700});
|
|
234
|
+
await assert.rejects(locked(f.home,async()=>{}),/Stale command invocation lease/);
|
|
235
|
+
} finally {process.kill=kill0;process.env.PATH=path0;}
|
|
236
|
+
});
|
|
142
237
|
test('changed source, symlinks, reserved aliases, arbitrary Docker fields rejected',async t=>{
|
|
143
238
|
const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);
|
|
144
239
|
await fs.writeFile(path.join(f.source,'client.mjs'),'changed');await assert.rejects(f.call('plugins','install','sample','--source',f.source,'--revision',p.revision));
|
|
@@ -157,7 +252,29 @@ test('registry corruption, active writer lock and alias collision fail closed',a
|
|
|
157
252
|
test('Compose resources are namespaced, private, and restricted to owning workspace',async t=>{
|
|
158
253
|
const f=await fixture(t),p=await snapshot(f.source);
|
|
159
254
|
const c=await compose({workspace:f.workspace},{source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:p.deployment});
|
|
160
|
-
assert.equal(c.
|
|
255
|
+
assert.equal(c.version,'3.8');assert.equal(c.name,undefined);assert.equal(c.services.sample.ports,undefined);assert.equal(c.services.sample.privileged,undefined);assert.equal(c.services.sample.volumes[1].read_only,true);assert.deepEqual(c.services.sample.cap_drop,['ALL']);
|
|
256
|
+
});
|
|
257
|
+
test('standalone Compose fallback emits a v1-compatible document and keeps project identity in argv',async t=>{
|
|
258
|
+
const f=await fixture(t),p=await snapshot(f.source),previous=process.env.EZ_DOCKER_COMPOSE;
|
|
259
|
+
process.env.EZ_DOCKER_COMPOSE='standalone';
|
|
260
|
+
try {
|
|
261
|
+
const c=await compose({workspace:f.workspace},{source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:p.deployment});
|
|
262
|
+
assert.equal(c.version,'3.8');assert.equal(c.name,undefined);assert.ok(c.services.sample.build.target);
|
|
263
|
+
} finally {
|
|
264
|
+
if(previous===undefined)delete process.env.EZ_DOCKER_COMPOSE;else process.env.EZ_DOCKER_COMPOSE=previous;
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
test('isolated broker host binding validates exact mounts without deployment symlinks',async t=>{
|
|
268
|
+
const f=await fixture(t),p=await snapshot(f.source),isolated=path.join(f.root,'isolated'),workspace=path.join(isolated,'workspace'),control=path.join(isolated,'control'),hostConfig=path.join(isolated,'host-executor.json'),previous=process.env.EZ_DOCKER_COMPOSE;
|
|
269
|
+
await fs.mkdir(f.home,{recursive:true});await fs.mkdir(workspace,{recursive:true});await fs.mkdir(control,{recursive:true});
|
|
270
|
+
await fs.writeFile(hostConfig,JSON.stringify({cli:'codex',isolation:'isolated',agents:[{name:'sample',workspace,controlDir:control,binDir:path.join(f.root,'bin'),toolsHome:f.home}]}));
|
|
271
|
+
process.env.EZ_DOCKER_COMPOSE='standalone';
|
|
272
|
+
try {
|
|
273
|
+
const c=await compose({workspace:await fs.realpath(workspace),hostConfig:await fs.realpath(hostConfig)},{source:p.source,project:'ezp-synthetic',revision:p.revision,manifest:p.manifest,deployment:p.deployment},{},f.home);
|
|
274
|
+
assert.equal(c.version,'3.8');assert.equal(c.services.sample.volumes[1].source,await fs.realpath(workspace));
|
|
275
|
+
} finally {
|
|
276
|
+
if(previous===undefined)delete process.env.EZ_DOCKER_COMPOSE;else process.env.EZ_DOCKER_COMPOSE=previous;
|
|
277
|
+
}
|
|
161
278
|
});
|
|
162
279
|
test('host-owned private networks survive registered-call Compose regeneration',async t=>{
|
|
163
280
|
const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);
|
|
@@ -169,7 +286,7 @@ test('host-owned private networks survive registered-call Compose regeneration',
|
|
|
169
286
|
config.hostConfig=await fs.realpath(f.hostConfig);
|
|
170
287
|
await fs.writeFile(path.join(f.home,'config.json'),JSON.stringify(config));
|
|
171
288
|
const file=path.join(f.home,'packages/sample/compose.json');await fs.writeFile(file,'{}');
|
|
172
|
-
await f.
|
|
289
|
+
await exec(path.join(f.home,'bin','ez'),['sample','read'],{cwd:f.workspace,env:f.env});
|
|
173
290
|
const c=JSON.parse(await fs.readFile(file,'utf8'));
|
|
174
291
|
assert.deepEqual(c.networks.default,{});
|
|
175
292
|
const [key,network]=Object.entries(c.networks).find(([name])=>name!=='default');
|
|
@@ -323,8 +440,8 @@ test('standalone rejects relay binding and preserves literal plugin arguments ac
|
|
|
323
440
|
}
|
|
324
441
|
const log=(await fs.readFile(f.log,'utf8')).trim().split('\n').map(JSON.parse);
|
|
325
442
|
assert(log.every(call=>call.secret===undefined));
|
|
326
|
-
await fs.writeFile(path.join(f.home,'config.json'),JSON.stringify({schemaVersion:1,workspace:f.workspace,catalog:{},deploymentDir:'/missing'}));
|
|
327
|
-
await assert.rejects(exec(launcher,['status']),/deployment-bound/); // never hide a broken relay binding
|
|
443
|
+
await fs.writeFile(path.join(f.home,'config.json'),JSON.stringify({schemaVersion:1,workspace:await fs.realpath(f.workspace),catalog:{},deploymentDir:'/missing'}));
|
|
444
|
+
await assert.rejects(exec(launcher,['status'],{cwd:f.workspace,env:f.env}),/deployment-bound/); // never hide a broken relay binding
|
|
328
445
|
});
|
|
329
446
|
|
|
330
447
|
test('existing folders are read-only, persistent and fail closed when missing', async t => {
|
|
@@ -349,6 +466,27 @@ test('existing folders are read-only, persistent and fail closed when missing',
|
|
|
349
466
|
assert.deepEqual(JSON.parse((await f.call('plugins','folders','sample')).stdout),[]);
|
|
350
467
|
});
|
|
351
468
|
|
|
469
|
+
test('relay registry rejects foreign callers and ungranted host folders', async t => {
|
|
470
|
+
const f=await fixture(t);await init(f.home,f.workspace);const p=await snapshot(f.source);
|
|
471
|
+
await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
|
|
472
|
+
await fs.mkdir(path.join(f.root,'allowed'));await fs.mkdir(path.join(f.root,'denied'));
|
|
473
|
+
const allowed=await fs.realpath(path.join(f.root,'allowed')),denied=await fs.realpath(path.join(f.root,'denied'));
|
|
474
|
+
const host={cli:'synthetic',agents:[{name:'sample',workspace:f.workspace,controlDir:f.control,binDir:path.join(f.home,'bin'),toolsHome:f.home,pluginFolderRoots:{sample:[allowed]}}]};
|
|
475
|
+
await fs.writeFile(f.hostConfig,JSON.stringify(host));
|
|
476
|
+
const config=JSON.parse(await fs.readFile(path.join(f.home,'config.json'),'utf8'));
|
|
477
|
+
config.hostConfig=await fs.realpath(f.hostConfig);await fs.writeFile(path.join(f.home,'config.json'),JSON.stringify(config));
|
|
478
|
+
const launcher=path.join(f.home,'bin','ez'),foreign={cwd:f.source,env:f.env};
|
|
479
|
+
const before=await fs.readFile(f.log,'utf8');
|
|
480
|
+
await assert.rejects(exec(launcher,['sample','read'],foreign),/Registry belongs/);
|
|
481
|
+
await assert.rejects(exec(launcher,['--for-workspace',f.workspace,'sample','read'],foreign),/Registry belongs/);
|
|
482
|
+
assert.equal(await fs.readFile(f.log,'utf8'),before);
|
|
483
|
+
assert.deepEqual(JSON.parse((await exec(launcher,['sample','read'],{cwd:f.workspace,env:f.env})).stdout),['read']);
|
|
484
|
+
await exec(launcher,['plugins','folder-bind','sample','--service','sample','--source',allowed,'--target','/data/files'],{cwd:f.workspace,env:f.env});
|
|
485
|
+
const saved=await fs.readFile(path.join(f.home,'config.json'),'utf8');
|
|
486
|
+
await assert.rejects(exec(launcher,['plugins','folder-bind','sample','--service','sample','--source',denied,'--target','/data/files'],{cwd:f.workspace,env:f.env}),/not granted/);
|
|
487
|
+
assert.equal(await fs.readFile(path.join(f.home,'config.json'),'utf8'),saved);
|
|
488
|
+
});
|
|
489
|
+
|
|
352
490
|
test('folder bindings survive compatible descriptors and reject incompatible updates',async t=>{
|
|
353
491
|
const f=await fixture(t);const p=await snapshot(f.source);
|
|
354
492
|
const record={...p,project:'ezp-test-sample'};
|
package/test/reaction.test.ts
CHANGED
|
@@ -7,15 +7,19 @@ import test from 'node:test'
|
|
|
7
7
|
import { promisify } from 'node:util'
|
|
8
8
|
import { isSupportedReactionEmoji, normalizeReactionEmoji, TELEGRAM_REACTIONS } from '../src/reaction.js'
|
|
9
9
|
import { RunStore } from '../src/runs.js'
|
|
10
|
+
import { serveTestLedger } from './helpers/ledger.js'
|
|
10
11
|
|
|
11
12
|
const execFileAsync = promisify(execFile)
|
|
12
13
|
const reactScriptPath = path.resolve('src/react.ts')
|
|
13
14
|
|
|
14
15
|
const fixture = async (run: (store: RunStore, dir: string) => Promise<void>): Promise<void> => {
|
|
15
16
|
const directory = await mkdtemp(path.join(tmpdir(), 'ez-reactions-'))
|
|
17
|
+
// The react CLI child reaches the memory ledger through the socket.
|
|
18
|
+
const ledger = await serveTestLedger(directory)
|
|
16
19
|
try {
|
|
17
20
|
await run(new RunStore(directory), directory)
|
|
18
21
|
} finally {
|
|
22
|
+
await ledger.stop()
|
|
19
23
|
await rm(directory, { recursive: true, force: true })
|
|
20
24
|
}
|
|
21
25
|
}
|
package/test/relay.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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 { spawn } from 'node:child_process'
|
|
@@ -207,10 +207,8 @@ test('failed delivery is quarantined and never blindly retried', async () => {
|
|
|
207
207
|
await assert.rejects(runs.waitForDelivery(item.id), /Ambiguous network failure/)
|
|
208
208
|
assert.deepEqual(await runs.deliveryStatus(), { failed: 0, unknown: 1 })
|
|
209
209
|
await assert.rejects(runs.waitForDelivery('missing', 1), /outcome unknown/)
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
1,
|
|
213
|
-
)
|
|
210
|
+
// Quarantine lives in relay memory; no outbox state touches control/.
|
|
211
|
+
await assert.rejects(stat(join(dir, 'outbox')))
|
|
214
212
|
} finally {
|
|
215
213
|
await relay.stop()
|
|
216
214
|
await rm(dir, { recursive: true, force: true })
|
|
@@ -245,8 +243,8 @@ test('outbox stores provider receipts and does not redeliver sent messages', asy
|
|
|
245
243
|
assert.equal(sends, 1)
|
|
246
244
|
assert.deepEqual(((await runs.waitForDelivery(item.id)) as { messageIds: number[] }).messageIds, [42])
|
|
247
245
|
assert.deepEqual(await runs.pendingOutbox(), [])
|
|
248
|
-
|
|
249
|
-
assert.
|
|
246
|
+
// Receipts live in relay memory; no outbox state touches control/.
|
|
247
|
+
await assert.rejects(stat(join(dir, 'outbox')))
|
|
250
248
|
} finally {
|
|
251
249
|
await relay.stop()
|
|
252
250
|
await rm(dir, { recursive: true, force: true })
|