@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.
Files changed (157) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +9 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +92 -0
  5. package/Dockerfile +11 -9
  6. package/README.md +32 -27
  7. package/THIRD_PARTY_NOTICES.md +4 -4
  8. package/bin/ez +97 -0
  9. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  10. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  11. package/bin/ezenciel-agents.mjs +1 -1
  12. package/compose.yaml +42 -5
  13. package/docker/entrypoint.sh +1 -1
  14. package/docker/healthcheck.mjs +12 -4
  15. package/docker/run.ts +5 -6
  16. package/docker/smoke.mjs +32 -7
  17. package/docs/application-channel.md +38 -10
  18. package/docs/architecture/ai-selection.md +3 -1
  19. package/docs/architecture/authority-boundaries.md +2 -2
  20. package/docs/channel-backend.md +4 -3
  21. package/docs/docker-runtime.md +106 -21
  22. package/docs/host-service.md +17 -6
  23. package/docs/local-qa.md +2 -2
  24. package/docs/plugin-guide.md +202 -0
  25. package/docs/plugins.md +63 -2
  26. package/docs/releasing.md +13 -0
  27. package/docs/repair.md +1 -1
  28. package/docs/responsive-channels.md +1 -1
  29. package/docs/scheduling.md +4 -4
  30. package/docs/setup.md +96 -16
  31. package/docs/standalone-cli.md +6 -5
  32. package/docs/upgrades.md +46 -23
  33. package/package.json +5 -5
  34. package/scripts/smoke-scheduler.ts +12 -4
  35. package/scripts/smoke.ts +14 -11
  36. package/src/agent-guidance.ts +6 -9
  37. package/src/agent-install.ts +44 -9
  38. package/src/ai-cli.ts +9 -7
  39. package/src/ai.ts +7 -7
  40. package/src/application-channel.ts +88 -17
  41. package/src/application-cli.ts +2 -2
  42. package/src/approval-cli.ts +14 -7
  43. package/src/approval.ts +27 -37
  44. package/src/channel-backend.ts +2 -3
  45. package/src/codex-session.ts +5 -3
  46. package/src/config.ts +18 -28
  47. package/src/control-state.ts +187 -9
  48. package/src/conversation-menu.ts +6 -17
  49. package/src/delivery-socket-client.d.mts +4 -0
  50. package/src/delivery-socket-client.mjs +104 -0
  51. package/src/delivery-socket.ts +354 -0
  52. package/src/execution-authority.ts +21 -5
  53. package/src/executor.ts +110 -31
  54. package/src/files.ts +38 -11
  55. package/src/host-executor.ts +50 -17
  56. package/src/inbox.ts +15 -49
  57. package/src/index.ts +96 -52
  58. package/src/install-config.ts +2 -2
  59. package/src/install-tools.mjs +100 -23
  60. package/src/isolation.ts +25 -0
  61. package/src/ledger-endpoint.d.mts +2 -0
  62. package/src/ledger-endpoint.mjs +17 -0
  63. package/src/menu.ts +16 -8
  64. package/src/message-history.ts +13 -18
  65. package/src/message.ts +35 -18
  66. package/src/plugin-broker.mjs +396 -0
  67. package/src/plugins/connection.mjs +2 -2
  68. package/src/plugins/manager.mjs +178 -26
  69. package/src/plugins/native-tasks.mjs +2 -2
  70. package/src/prompt-suffix.ts +12 -0
  71. package/src/react.ts +6 -4
  72. package/src/runs.ts +156 -243
  73. package/src/schedule-cli.ts +29 -10
  74. package/src/scheduled-tasks.ts +53 -10
  75. package/src/scheduler.ts +20 -7
  76. package/src/service.ts +3 -3
  77. package/src/setup.ts +11 -7
  78. package/src/software-status.ts +36 -4
  79. package/src/task-executor.ts +6 -3
  80. package/src/task-workspace.ts +4 -3
  81. package/src/tasks.ts +1 -1
  82. package/src/telegram-provisioning-cli.ts +22 -0
  83. package/src/telegram-provisioning.ts +214 -0
  84. package/src/updates/control.mjs +77 -7
  85. package/src/updates/runtime.mjs +29 -9
  86. package/src/updates/status.mjs +25 -3
  87. package/src/updates/supervisor.mjs +31 -18
  88. package/src/version.ts +20 -0
  89. package/src/workspace-confine.ts +38 -0
  90. package/src/workspace.ts +24 -20
  91. package/templates/agent-purpose.md +1 -0
  92. package/templates/failure-review.md +1 -1
  93. package/templates/maintainer-purpose.md +1 -1
  94. package/test/agent-guidance.test.ts +18 -88
  95. package/test/agent-install.test.ts +48 -0
  96. package/test/ai-cli.test.ts +18 -0
  97. package/test/ai.test.ts +84 -0
  98. package/test/application-channel.test.ts +66 -15
  99. package/test/application-controls.test.ts +56 -3
  100. package/test/application-only.test.ts +16 -13
  101. package/test/channel-backend.test.ts +3 -7
  102. package/test/channel-delivery.test.ts +4 -1
  103. package/test/channel-owner.test.ts +6 -3
  104. package/test/codex-context.test.ts +63 -3
  105. package/test/codex-session.test.ts +6 -1
  106. package/test/config.test.ts +47 -37
  107. package/test/control-state.test.ts +83 -4
  108. package/test/conversation-menu.test.ts +2 -4
  109. package/test/delivery-transport.test.ts +72 -0
  110. package/test/docker-runtime.test.ts +27 -14
  111. package/test/engine-handoff.test.ts +12 -8
  112. package/test/event-sources.test.ts +2 -2
  113. package/test/execution-authority.test.ts +9 -9
  114. package/test/executor.test.ts +47 -11
  115. package/test/failure.test.ts +18 -5
  116. package/test/files.test.ts +2 -2
  117. package/test/helpers/ledger.ts +18 -0
  118. package/test/host-executor.test.ts +80 -0
  119. package/test/inbox.test.ts +8 -10
  120. package/test/install-config.test.ts +18 -6
  121. package/test/install-tools.test.mjs +80 -7
  122. package/test/intake-relay.test.ts +58 -11
  123. package/test/message-history.test.ts +39 -47
  124. package/test/native-tasks.test.ts +1 -1
  125. package/test/plugin-broker.test.mjs +172 -0
  126. package/test/plugin-manager.test.mjs +143 -5
  127. package/test/reaction.test.ts +4 -0
  128. package/test/relay.test.ts +5 -7
  129. package/test/repair-policy.test.ts +1 -4
  130. package/test/schedule-cli.test.ts +14 -9
  131. package/test/scheduled-tasks.test.ts +29 -6
  132. package/test/scheduler-host.test.ts +5 -5
  133. package/test/scheduler-relay.test.ts +15 -9
  134. package/test/scheduler.test.ts +2 -2
  135. package/test/security.test.ts +6 -3
  136. package/test/software-status.test.ts +21 -3
  137. package/test/telegram-provisioning.test.ts +160 -0
  138. package/test/updates.test.mjs +102 -13
  139. package/test/workspace-confine.test.ts +47 -0
  140. package/test/workspace.test.ts +27 -11
  141. package/bin/ezenciel-agents-watch.mjs +0 -8
  142. package/compose.workforce-watch.yaml +0 -33
  143. package/docker/recovery.ts +0 -11
  144. package/docs/pagerduty.md +0 -42
  145. package/docs/workforce-watch.md +0 -101
  146. package/src/pagerduty.ts +0 -109
  147. package/src/reply-context.ts +0 -44
  148. package/src/update-attention.ts +0 -18
  149. package/src/workforce-watch-cli.ts +0 -14
  150. package/src/workforce-watch.ts +0 -155
  151. package/templates/agent/AGENTS.md +0 -15
  152. package/templates/agent/SOUL.md +0 -11
  153. package/templates/agent/USER.md +0 -5
  154. package/templates/agent-guidance.md +0 -114
  155. package/test/pagerduty.test.ts +0 -104
  156. package/test/update-attention.test.ts +0 -20
  157. 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, readFile, writeFile, readdir } from 'node:fs/promises'
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
- t.after(() => rm(dir, { recursive: true, force: true }))
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 join(dir, 'outbox', item.id + '.sent.json')
25
+ return item
27
26
  }
28
- return { dir, runs, send, patchFixture }
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, patchFixture } = await fixture(t)
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 patchFixture('r_schedule_report', { nativeSessionId: 'report_session', scheduled: {
36
- id: 's_earnings', revision: 1, dueAt: new Date().toISOString(), pairedAt: owner.pairedAt,
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
- assert.deepEqual(await readdir(join(dir, 'outbox')), before)
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, patchFixture } = await fixture(t)
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
- await writeFile(join(dir, 'outbox', 'corrupt.sent.json'), '{')
68
- await writeFile(join(dir, 'outbox', 'null.sent.json'), 'null')
69
- const invalid = await send('tg_1', 'invalid receipt', [2])
70
- const record = JSON.parse(await readFile(invalid, 'utf8'))
71
- record.receipt.messageIds = []
72
- await writeFile(invalid, JSON.stringify(record))
73
- for (const [id, patch] of [
74
- ['r_other_chat', { chatId: 202 }],
75
- ['r_other_owner', { telegramUserId: 202 }],
76
- ['r_old', { createdAt: '2000-01-01T00:00:00.000Z' }],
77
- ['r_external', { external: { sourceId: 'test', bindingId: 'binding', eventIds: ['1'] } }],
78
- ] as const) {
79
- await ownerRun(dir, id)
80
- await patchFixture(id, patch)
81
- await send(id, 'excluded', [3])
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, patchFixture } = await fixture(t)
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 patchFixture('tg_1', { telegramUserId: 202 })
97
- await assert.rejects(deliveredMessages(dir, 'tg_1'), /owner-mismatch/)
98
- await patchFixture('tg_1', { telegramUserId: 101, status: 'completed' })
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, send } = await fixture(t)
107
- const file = await send('tg_1', 'placeholder', [20])
108
- const item = JSON.parse(await readFile(file, 'utf8'))
109
- delete item.text
110
- item.type = 'approval'
111
- item.approvalPrompt = 'Approve this messaging task?'
112
- await writeFile(file, JSON.stringify(item))
113
- assert.equal((await deliveredMessages(dir, 'tg_1')).messages[0].text, item.approvalPrompt)
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
- assert.deepEqual(await readdir(join(dir, 'outbox')), before)
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,/durable, asynchronous CLI task/)
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.name,'ezp-synthetic');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']);
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.call('sample','read');
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'};
@@ -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
  }
@@ -1,6 +1,6 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { mkdtemp, rm, readdir, readFile } from 'node:fs/promises'
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
- assert.equal(
211
- (await readdir(join(dir, 'outbox'))).filter((name) => name.endsWith('.failed.json')).length,
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
- const sent = (await readdir(join(dir, 'outbox'))).find((name) => name.endsWith('.sent.json'))!
249
- assert.deepEqual(JSON.parse(await readFile(join(dir, 'outbox', sent), 'utf8')).receipt.messageIds, [42])
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 })