@jc_stack/ez-agents 0.1.0-beta.34 → 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 (137) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +5 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +67 -0
  5. package/Dockerfile +7 -9
  6. package/README.md +10 -13
  7. package/bin/ez +97 -0
  8. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  9. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  10. package/bin/ezenciel-agents.mjs +1 -1
  11. package/compose.yaml +40 -4
  12. package/docker/entrypoint.sh +1 -1
  13. package/docker/healthcheck.mjs +12 -4
  14. package/docker/run.ts +5 -6
  15. package/docker/smoke.mjs +28 -6
  16. package/docs/application-channel.md +32 -10
  17. package/docs/architecture/authority-boundaries.md +2 -2
  18. package/docs/channel-backend.md +4 -3
  19. package/docs/docker-runtime.md +79 -11
  20. package/docs/local-qa.md +2 -2
  21. package/docs/plugin-guide.md +202 -0
  22. package/docs/plugins.md +48 -2
  23. package/docs/releasing.md +13 -0
  24. package/docs/responsive-channels.md +1 -1
  25. package/docs/scheduling.md +4 -4
  26. package/docs/setup.md +11 -5
  27. package/docs/upgrades.md +46 -23
  28. package/package.json +5 -5
  29. package/scripts/smoke-scheduler.ts +10 -3
  30. package/scripts/smoke.ts +14 -11
  31. package/src/agent-guidance.ts +6 -9
  32. package/src/agent-install.ts +28 -7
  33. package/src/application-channel.ts +78 -7
  34. package/src/application-cli.ts +2 -2
  35. package/src/approval-cli.ts +14 -7
  36. package/src/approval.ts +27 -37
  37. package/src/channel-backend.ts +2 -3
  38. package/src/config.ts +12 -27
  39. package/src/control-state.ts +175 -8
  40. package/src/conversation-menu.ts +6 -17
  41. package/src/delivery-socket-client.d.mts +4 -0
  42. package/src/delivery-socket-client.mjs +104 -0
  43. package/src/delivery-socket.ts +354 -0
  44. package/src/execution-authority.ts +21 -5
  45. package/src/executor.ts +20 -23
  46. package/src/files.ts +38 -11
  47. package/src/host-executor.ts +28 -14
  48. package/src/inbox.ts +15 -49
  49. package/src/index.ts +95 -52
  50. package/src/install-tools.mjs +97 -22
  51. package/src/ledger-endpoint.d.mts +2 -0
  52. package/src/ledger-endpoint.mjs +17 -0
  53. package/src/menu.ts +7 -2
  54. package/src/message-history.ts +13 -18
  55. package/src/message.ts +35 -18
  56. package/src/plugin-broker.mjs +396 -0
  57. package/src/plugins/connection.mjs +2 -2
  58. package/src/plugins/manager.mjs +132 -23
  59. package/src/plugins/native-tasks.mjs +2 -2
  60. package/src/prompt-suffix.ts +12 -0
  61. package/src/react.ts +6 -4
  62. package/src/runs.ts +156 -243
  63. package/src/schedule-cli.ts +29 -10
  64. package/src/scheduled-tasks.ts +53 -10
  65. package/src/scheduler.ts +20 -7
  66. package/src/service.ts +3 -3
  67. package/src/software-status.ts +36 -4
  68. package/src/task-executor.ts +2 -2
  69. package/src/task-workspace.ts +4 -3
  70. package/src/tasks.ts +1 -1
  71. package/src/telegram-provisioning-cli.ts +22 -0
  72. package/src/telegram-provisioning.ts +214 -0
  73. package/src/updates/control.mjs +77 -7
  74. package/src/updates/runtime.mjs +28 -8
  75. package/src/updates/status.mjs +25 -3
  76. package/src/updates/supervisor.mjs +15 -16
  77. package/src/version.ts +20 -0
  78. package/src/workspace.ts +3 -9
  79. package/templates/failure-review.md +1 -1
  80. package/templates/maintainer-purpose.md +1 -1
  81. package/test/agent-guidance.test.ts +14 -80
  82. package/test/agent-install.test.ts +14 -0
  83. package/test/ai.test.ts +36 -0
  84. package/test/application-channel.test.ts +61 -13
  85. package/test/application-only.test.ts +14 -13
  86. package/test/channel-backend.test.ts +3 -7
  87. package/test/channel-delivery.test.ts +4 -1
  88. package/test/channel-owner.test.ts +6 -3
  89. package/test/codex-context.test.ts +2 -2
  90. package/test/config.test.ts +38 -43
  91. package/test/control-state.test.ts +83 -4
  92. package/test/conversation-menu.test.ts +2 -4
  93. package/test/delivery-transport.test.ts +72 -0
  94. package/test/docker-runtime.test.ts +27 -14
  95. package/test/engine-handoff.test.ts +7 -6
  96. package/test/event-sources.test.ts +2 -2
  97. package/test/execution-authority.test.ts +9 -9
  98. package/test/executor.test.ts +10 -11
  99. package/test/failure.test.ts +12 -5
  100. package/test/files.test.ts +2 -2
  101. package/test/helpers/ledger.ts +18 -0
  102. package/test/host-executor.test.ts +74 -0
  103. package/test/inbox.test.ts +8 -10
  104. package/test/install-config.test.ts +1 -0
  105. package/test/install-tools.test.mjs +74 -7
  106. package/test/intake-relay.test.ts +58 -11
  107. package/test/message-history.test.ts +39 -47
  108. package/test/native-tasks.test.ts +1 -1
  109. package/test/plugin-broker.test.mjs +172 -0
  110. package/test/plugin-manager.test.mjs +119 -2
  111. package/test/reaction.test.ts +4 -0
  112. package/test/relay.test.ts +5 -7
  113. package/test/repair-policy.test.ts +1 -4
  114. package/test/schedule-cli.test.ts +14 -9
  115. package/test/scheduled-tasks.test.ts +29 -6
  116. package/test/scheduler-host.test.ts +5 -5
  117. package/test/scheduler-relay.test.ts +15 -9
  118. package/test/security.test.ts +6 -3
  119. package/test/software-status.test.ts +21 -3
  120. package/test/telegram-provisioning.test.ts +160 -0
  121. package/test/updates.test.mjs +93 -13
  122. package/test/workspace.test.ts +4 -1
  123. package/bin/ezenciel-agents-watch.mjs +0 -8
  124. package/compose.workforce-watch.yaml +0 -33
  125. package/docker/recovery.ts +0 -11
  126. package/docs/pagerduty.md +0 -42
  127. package/docs/workforce-watch.md +0 -101
  128. package/src/pagerduty.ts +0 -109
  129. package/src/reply-context.ts +0 -43
  130. package/src/update-attention.ts +0 -18
  131. package/src/workforce-watch-cli.ts +0 -14
  132. package/src/workforce-watch.ts +0 -155
  133. package/templates/agent/AGENTS.md +0 -5
  134. package/templates/agent-guidance.md +0 -17
  135. package/test/pagerduty.test.ts +0 -104
  136. package/test/update-attention.test.ts +0 -20
  137. package/test/workforce-watch.test.ts +0 -180
@@ -3,7 +3,10 @@ import assert from 'node:assert/strict';
3
3
  import * as fs from 'node:fs/promises';
4
4
  import {tmpdir} from 'node:os';
5
5
  import path from 'node:path';
6
- import {preflight,installationStatus,build,hostEnvironment} from '../src/install-tools.mjs';
6
+ import {preflight,installationStatus,build,hostEnvironment,migrateLedger} from '../src/install-tools.mjs';
7
+ import {parseEnv} from 'node:util';
8
+ import {RunStore} from '../src/runs.js';
9
+ import {serveTestLedger} from './helpers/ledger.js';
7
10
 
8
11
  async function fixture(t){const root=await fs.mkdtemp(path.join(tmpdir(),'ez-install-tools-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));return root;}
9
12
  test('preflight checks the selected executable and reports missing prerequisites without credentials',async t=>{
@@ -19,10 +22,12 @@ test('preflight checks the selected executable and reports missing prerequisites
19
22
  test('installation status separates files, pairing and current-owner Telegram delivery evidence',async t=>{
20
23
  const root=await fixture(t),control=path.join(root,'control');
21
24
  assert.equal((await installationStatus(root)).stage,'not-configured');
22
- for(const dir of ['host-executor','outbox','runs'])await fs.mkdir(path.join(control,dir),{recursive:true});
25
+ await fs.mkdir(path.join(control,'host-executor'),{recursive:true});
23
26
  for(const name of ['agent.json','host-executor.json','docker.env','relay.env'])await fs.writeFile(path.join(root,name),'private');
24
27
  const write=(name,obj)=>fs.writeFile(path.join(control,name),JSON.stringify(obj));
25
28
  await write('heartbeat.json',{at:Date.now(),polling:true});await write('host-executor/heartbeat.json',{at:Date.now()});
29
+ // Delivery evidence comes from the running relay's memory ledger.
30
+ const ledger=await serveTestLedger(control,()=>({polling:true,applicationOnly:false,telegramConfigured:true,version:'test'}));t.after(()=>ledger.stop());
26
31
  assert.equal((await installationStatus(root)).stage,'awaiting-owner');
27
32
  await fs.writeFile(path.join(root,'agent.json'),JSON.stringify({isolation:'isolated'}));
28
33
  await fs.rm(path.join(control,'host-executor/heartbeat.json'));
@@ -32,15 +37,46 @@ test('installation status separates files, pairing and current-owner Telegram de
32
37
  assert.equal((await installationStatus(root)).stage,'awaiting-owner');
33
38
  const owner={telegramUserId:123,telegramChatId:123,pairedAt:new Date(Date.now()-10000).toISOString()};await write('control-state.json',{owner});
34
39
  assert.equal((await installationStatus(root)).stage,'awaiting-telegram-reply');
35
- const item={runId:'tg_12',chatId:123,receipt:{messageIds:[42],deliveredAt:new Date().toISOString()}};
36
- await write('outbox/test.sent.json',item);await write('runs/tg_12.json',{status:'completed',chatId:123,telegramUserId:999});
40
+ const runs=new RunStore(control),deliver=async(id,text,chatId,telegramUserId,ids,status='completed')=>{
41
+ const run=await runs.create({id,chatId,telegramUserId,texts:[text]});
42
+ await runs.patch(run.id,{status:'running'});
43
+ const item=await runs.enqueueMessage(run.id,text);
44
+ await runs.claimOutbox(item.id);await runs.markOutboxSent(item.id,ids);
45
+ await runs.patch(run.id,{status});
46
+ };
47
+ // Another chat member's completed delivery is not the owner's reply evidence.
48
+ await deliver('tg_13','other member',123,999,[42]);
37
49
  assert.equal((await installationStatus(root)).telegramReplyVerified,false);
38
- await write('runs/tg_12.json',{status:'completed',chatId:123,telegramUserId:123});
50
+ // A failed run never counts, even with a receipt.
51
+ await deliver('tg_14','failed',123,123,[43],'failed');
52
+ assert.equal((await installationStatus(root)).telegramReplyVerified,false);
53
+ await deliver('tg_12','hello',123,123,[42]);
39
54
  assert.equal((await installationStatus(root)).stage,'ready-for-telegram-plugin-request');
40
- await write('heartbeat.json',{at:0,polling:true});assert.equal((await installationStatus(root)).stage,'runtime-offline');
41
- await write('outbox/test.sent.json',{...item,runId:'../../escape'});assert.equal((await installationStatus(root)).telegramReplyVerified,false);
55
+ // With the relay down, status falls back to the legacy heartbeat file.
56
+ await ledger.stop();await write('heartbeat.json',{at:0,polling:true});assert.equal((await installationStatus(root)).stage,'runtime-offline');
42
57
  await fs.writeFile(path.join(control,'control-state.json'),'{');await assert.rejects(installationStatus(root));
43
58
  });
59
+ test('host-capable deployments migrate once to a loopback ledger endpoint',async t=>{
60
+ const root=await fixture(t),deployment=path.join(root,'agent');await fs.mkdir(deployment);
61
+ const envFile=path.join(deployment,'docker.env');
62
+ await fs.writeFile(envFile,"COMPOSE_PROJECT_NAME='ez-agent-canary'\nCOMPOSE_FILE='/opt/ez/compose.yaml:/opt/ez/extra.compose.yaml'\n",{mode:0o600});
63
+ const first=await migrateLedger({deployment});
64
+ assert.equal(first.changed,true);assert.ok(Number.isSafeInteger(first.port)&&first.port>=1024);
65
+ const values=parseEnv(await fs.readFile(envFile,'utf8'));
66
+ assert.equal(values.EZ_DELIVERY_TCP_PORT,String(first.port));
67
+ assert.equal(values.COMPOSE_FILE,`/opt/ez/compose.yaml:/opt/ez/extra.compose.yaml:${path.join(deployment,'ledger.compose.yaml')}`);
68
+ assert.match(await fs.readFile(path.join(deployment,'ledger.compose.yaml'),'utf8'),/127\.0\.0\.1:\$\{EZ_DELIVERY_TCP_PORT:\?\}/);
69
+ assert.equal((await fs.stat(envFile)).mode&0o777,0o600);
70
+ const second=await migrateLedger({deployment});
71
+ assert.equal(second.changed,false);assert.equal(second.port,first.port);
72
+ await assert.rejects(migrateLedger({deployment:path.join(root,'missing')}),/docker.env not found/);
73
+ });
74
+ test('isolated deployments need no host ledger endpoint',async t=>{
75
+ const root=await fixture(t),deployment=path.join(root,'isolated');await fs.mkdir(deployment);
76
+ await fs.writeFile(path.join(deployment,'docker.env'),"COMPOSE_PROJECT_NAME='ez-agent-isolated'\nCOMPOSE_FILE='/opt/ez/compose.yaml'\nEZ_ISOLATION='isolated'\nEZ_EXECUTOR_TRANSPORT='local'\n",{mode:0o600});
77
+ const result=await migrateLedger({deployment});
78
+ assert.equal(result.changed,false);assert.match(result.note,/Isolated/);
79
+ });
44
80
  test('same-artifact build retries do not spawn a second Docker build and reuse verified completed image',async t=>{
45
81
  const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
46
82
  let started,finish,count=0;const running=new Promise(r=>started=r),held=new Promise(r=>finish=r);
@@ -51,6 +87,37 @@ test('same-artifact build retries do not spawn a second Docker build and reuse v
51
87
  assert.equal((await build({home,source},invoke)).reused,true);assert.equal(count,1);
52
88
  await fs.writeFile(path.join(source,'package.json'),'{"changed":true}');await build({home,source},invoke);assert.equal(count,2);
53
89
  });
90
+ test('source checkouts build RCs only: clean reviewed commit plus increasing label',async t=>{
91
+ const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
92
+ let inside=true,status='',built=[],toplevel=source;const sha='a'.repeat(40);
93
+ const invoke=async(cmd,args)=>{
94
+ if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);
95
+ if(cmd==='git'){
96
+ if(!inside)throw Error('not a git repository');
97
+ if(args[2]==='rev-parse'&&args[3]==='--show-toplevel')return toplevel;
98
+ if(args[2]==='status')return status;
99
+ if(args[2]==='rev-parse'&&args[3]==='HEAD')return sha;
100
+ return '';
101
+ }
102
+ if(args[0]==='build'){built.push(args);return 'image';}
103
+ return 'sha256:fixture';
104
+ };
105
+ await assert.rejects(build({home,source},invoke),/requires --label/);
106
+ await assert.rejects(build({home,source,label:'0.1.0-beta.36'},invoke),/RC label/);
107
+ status=' M src/executor.ts';
108
+ await assert.rejects(build({home,source,label:'0.1.0-beta.36.rc.2'},invoke),/Commit the reviewed source/);
109
+ status='';
110
+ const result=await build({home,source,label:'0.1.0-beta.36.rc.2'},invoke);
111
+ assert.equal(result.label,'0.1.0-beta.36.rc.2');assert.equal(result.sha,sha);
112
+ const args=built.at(-1);
113
+ assert.ok(args.includes('BUILD_TAG=0.1.0-beta.36.rc.2'));assert.ok(args.includes(`BUILD_SHA=${sha}`));
114
+ assert.ok(args.includes('ezenciel-agents:0.1.0-beta.36.rc.2'));
115
+ inside=false;
116
+ await assert.rejects(build({home,source,label:'0.1.0-beta.36.rc.2'},invoke),/--label requires a git checkout/);
117
+ // A tarball unpacked inside an unrelated repository is not a source checkout.
118
+ inside=true;toplevel=home;
119
+ assert.equal((await build({home,source},invoke)).label,undefined);
120
+ });
54
121
  test('a failed build releases its own lock and leaves a failure receipt for diagnosis',async t=>{
55
122
  const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
56
123
  const invoke=async(cmd)=>{if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);throw Error('Synthetic build failed');};
@@ -1,5 +1,6 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
+ import { randomBytes } from 'node:crypto'
3
4
  import { mkdtemp, readdir, rm } from 'node:fs/promises'
4
5
  import { tmpdir } from 'node:os'
5
6
  import { join } from 'node:path'
@@ -8,6 +9,7 @@ import { once } from 'node:events'
8
9
  import type { Update } from 'grammy/types'
9
10
  import { createRelay } from '../src/index.js'
10
11
  import { ControlStore } from '../src/control-state.js'
12
+ import { ApplicationBindings } from '../src/application-channel.js'
11
13
  import { RunStore } from '../src/runs.js'
12
14
  import { InboxStore } from '../src/inbox.js'
13
15
  import { ApprovalStore } from '../src/approval.js'
@@ -113,7 +115,9 @@ test('approved owner group accepts different members, controls and group deliver
113
115
  await control.revokeOwner()
114
116
  await f.relay.bot.handleUpdate(group(1))
115
117
  assert.equal(f.launched.length, 0)
116
- assert.equal((await control.status()).pending[0].title, 'Team')
118
+ const request = (await control.status()).pending[0]
119
+ if (!request || !('title' in request)) throw new Error('Group pairing request missing')
120
+ assert.equal(request.title, 'Team')
117
121
  await control.approveOwner(-101, true)
118
122
  await f.relay.bot.handleUpdate(group(2, 303))
119
123
  await f.relay.bot.handleUpdate(group(3, 404))
@@ -263,7 +267,7 @@ test('conversation menu is owner-only and removes the retired settings control',
263
267
  try {
264
268
  await f.relay.bot.handleUpdate(message(1, '/menu'))
265
269
  assert.deepEqual(f.keyboards.at(-1)!.flat().map((b) => b.text),
266
- ['New conversation', 'Conversations', 'Choose AI', 'Work status'])
270
+ ['New conversation', 'Conversations', 'Choose AI', 'Work status', 'Scheduled tasks'])
267
271
  await f.relay.bot.handleUpdate(message(2, '/settings'))
268
272
  assert.match(f.replies.at(-1)!, /Settings was removed.*Use \/ai/)
269
273
  await f.relay.bot.handleUpdate(message(3, '/ai'))
@@ -284,9 +288,11 @@ test('conversation menu is owner-only and removes the retired settings control',
284
288
  assert.match(f.replies.at(-1)!, /Menu expired/)
285
289
  assert.deepEqual(await store.status(), refreshed)
286
290
  await f.relay.bot.handleUpdate(message(8, '/status'))
287
- assert.ok(f.keyboards.at(-1)!.flat().some((button) => button.text === 'Scheduled tasks'))
288
- await f.relay.bot.handleUpdate(callback(9, 'menu:scheduled-tasks'))
289
- assert.match(f.replies.at(-1)!, /No active scheduled tasks for this owner/)
291
+ assert.ok(!f.keyboards.at(-1)!.flat().some((button) => button.text === 'Scheduled tasks'))
292
+ await f.relay.bot.handleUpdate(message(9, '/tasks'))
293
+ assert.match(f.replies.at(-1)!, /📅 Scheduled tasks[\s\S]*No active scheduled tasks/)
294
+ await f.relay.bot.handleUpdate(message(10, '/tasks 2'))
295
+ assert.match(f.replies.at(-1)!, /Scheduled task 2 was not found/)
290
296
  assert.equal(f.launched.length, 0)
291
297
  } finally { await f.close() }
292
298
  })
@@ -386,7 +392,7 @@ test('slow voice normalization preserves instruction order and leaves controls r
386
392
  release()
387
393
  await processing
388
394
  assert.equal(f.launched[0][0], 'Use the following voice note')
389
- assert.match(f.launched[0][1], /Quoted context/)
395
+ assert.doesNotMatch(f.launched[0][1], /Quoted context/)
390
396
  assert.match(f.launched[0][1], /Fixture voice transcript/)
391
397
  assert.equal(f.launched[0].length, 2)
392
398
  assert.equal((await new InboxStore(f.dir).status()).pending, 1)
@@ -632,7 +638,7 @@ test('conversation menu paginates and stale pages remain usable after archiving'
632
638
  })
633
639
 
634
640
 
635
- test('older conversation names come from owner messages and detail keeps archive available when resume fails', async () => {
641
+ test('conversation names never read run history and detail keeps archive available when resume fails', async () => {
636
642
  const f = await fixture()
637
643
  try {
638
644
  const store = new ControlStore(f.dir, 1000)
@@ -649,7 +655,8 @@ test('older conversation names come from owner messages and detail keeps archive
649
655
  await store.resetSession()
650
656
  await f.relay.bot.handleUpdate(message(1, '/chats'))
651
657
  const rows = f.keyboards.at(-1)!
652
- assert.ok(rows.flat().some(b => b.text.startsWith('Client launch checklist · ')))
658
+ assert.ok(rows.flat().some(b => b.text.startsWith('Untitled conversation')))
659
+ assert.ok(!rows.flat().some(b => b.text.includes('Client launch checklist')))
653
660
  assert.ok(!rows.flat().some(b => b.text === '✓ New conversation'))
654
661
  assert.ok(!rows.flat().some(b => b.text.includes(old.sessionId.slice(0, 8))))
655
662
  assert.ok(!rows.flat().some(b => b.callback_data.startsWith('chat:archive:')))
@@ -657,7 +664,7 @@ test('older conversation names come from owner messages and detail keeps archive
657
664
  id: '2', chat_instance: 'fixture', data: `chat:open:${old.sessionId}`,
658
665
  from: { id: 101, first_name: 'Fixture', is_bot: false }, message: message(2).message!,
659
666
  } })
660
- assert.match(f.replies.at(-1)!, /Client launch checklist.*\n.*binding/)
667
+ assert.match(f.replies.at(-1)!, /Untitled conversation \d+[\s\S]*binding/)
661
668
  assert.deepEqual(f.keyboards.at(-1)![0], [{text: 'Archive this conversation', callback_data: `chat:archive:${old.sessionId}`}])
662
669
  } finally { await f.close() }
663
670
  })
@@ -680,7 +687,7 @@ test('Telegram and application image/PDF/text use identical native attachment me
680
687
  const telegram=(await new RunStore(f.dir).list()).find(run=>run.messageId===update.message!.message_id)!
681
688
  assert.ok(telegram)
682
689
  const application=await f.relay.applicationChannel.submit(binding.bindingId,{requestId:name,scope:'chat',followOwner:true,text:update.message!.caption,attachment:{name,data:bytes.toString('base64')}})
683
- const normalize=(text:string)=>text.replace(/inbox\/[a-f0-9-]+_/,'inbox/ID_')
690
+ const normalize=(text:string)=>text.replace(/attachments\/[a-f0-9-]+_/,'attachments/ID_')
684
691
  assert.equal(normalize(application.texts[0]),normalize(telegram.texts[0]))
685
692
  assert.equal(application.ownerId,telegram.ownerId)
686
693
  assert.equal(application.execution?.sessionId,telegram.execution?.sessionId)
@@ -699,9 +706,49 @@ test('web launcher is private-owner-only and bypasses native intake without repl
699
706
  await f.relay.bot.handleUpdate(message(2,'/voice'))
700
707
  assert.deepEqual(f.keyboards.at(-1),[[{text:'Voice',web_app:{url:'https://voice.example/'}}]])
701
708
  await f.relay.bot.handleUpdate(message(3,'/menu'))
702
- assert.deepEqual(f.keyboards.at(-1)!.flat().map(b=>b.text),['New conversation','Conversations','Choose AI','Work status','Voice'])
709
+ assert.deepEqual(f.keyboards.at(-1)!.flat().map(b=>b.text),['New conversation','Conversations','Choose AI','Work status','Scheduled tasks','Voice'])
703
710
  await f.relay.drainInbox(true);assert.equal(f.launched.length,0)
704
711
  await new ControlStore(f.dir,1000).revokeOwner()
705
712
  const before=f.keyboards.length;await f.relay.bot.handleUpdate(message(4,'/voice'));assert.equal(f.keyboards.length,before)
706
713
  } finally {await f.close()}
707
714
  })
715
+
716
+ test('application Telegram link connects without launching work; bad link replies expired', async () => {
717
+ const f=await fixture()
718
+ try {
719
+ const control=new ControlStore(f.dir,1000)
720
+ await control.revokeOwner()
721
+ const owner=await control.registerOwner('account:web')
722
+ const binding=await new ApplicationBindings(f.dir).register('webapp',randomBytes(32).toString('base64url'),owner,true)
723
+ assert.ok(binding)
724
+ const pairing=await control.createApplicationTelegramPairing(binding.bindingId,owner)
725
+ assert.ok(pairing)
726
+ await f.relay.bot.handleUpdate(message(1,`/start ${'A'.repeat(43)}`))
727
+ assert.match(f.replies.at(-1)!,/expired|no longer valid/)
728
+ assert.equal(f.launched.length,0)
729
+ await f.relay.bot.handleUpdate(message(2,`/start ${pairing.token}`))
730
+ assert.match(f.replies.at(-1)! ,/connected/)
731
+ assert.equal(f.launched.length,0)
732
+ assert.equal((await control.status()).owner?.telegramUserId,101)
733
+ } finally {await f.close()}
734
+ })
735
+
736
+ test('tasks command never reaches the executor on invalid input or foreign senders', async () => {
737
+ const f = await fixture()
738
+ try {
739
+ const stranger = message(1, '/tasks');stranger.message!.from!.id = 202
740
+ await f.relay.bot.handleUpdate(stranger)
741
+ assert.equal(f.launched.length, 0)
742
+ assert.ok(!f.replies.join('\n').includes('Scheduled tasks'))
743
+ const strangerDetail = message(2, '/tasks 1');strangerDetail.message!.from!.id = 202
744
+ await f.relay.bot.handleUpdate(strangerDetail)
745
+ assert.equal(f.launched.length, 0)
746
+ assert.ok(!f.replies.join('\n').includes('Scheduled tasks'))
747
+ for (const [id, text] of [[3, '/tasks 0'], [4, '/tasks abc'], [5, '/tasks 1.5'], [6, '/tasks 2 extra']] as const) {
748
+ await f.relay.bot.handleUpdate(message(id, text))
749
+ assert.match(f.replies.at(-1)!, /Usage: \/tasks \[number\]/)
750
+ }
751
+ await f.relay.drainInbox(true)
752
+ assert.equal(f.launched.length, 0)
753
+ } finally { await f.close() }
754
+ })
@@ -1,45 +1,42 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { mkdtemp, rm, 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
+ });