@jc_stack/ez-agents 0.1.0-beta.34 → 0.1.0-beta.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +5 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +96 -0
  5. package/Dockerfile +20 -11
  6. package/README.md +10 -13
  7. package/THIRD_PARTY_NOTICES.md +7 -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 +41 -4
  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 +28 -6
  17. package/docs/application-channel.md +32 -10
  18. package/docs/architecture/ai-selection.md +10 -2
  19. package/docs/architecture/authority-boundaries.md +2 -2
  20. package/docs/channel-backend.md +4 -3
  21. package/docs/docker-runtime.md +98 -11
  22. package/docs/local-qa.md +2 -2
  23. package/docs/plugin-guide.md +202 -0
  24. package/docs/plugins.md +48 -2
  25. package/docs/releasing.md +13 -0
  26. package/docs/responsive-channels.md +1 -1
  27. package/docs/scheduling.md +4 -4
  28. package/docs/setup.md +11 -5
  29. package/docs/upgrades.md +46 -23
  30. package/package.json +5 -5
  31. package/scripts/smoke-scheduler.ts +10 -3
  32. package/scripts/smoke.ts +14 -11
  33. package/src/agent-guidance.ts +6 -9
  34. package/src/agent-install.ts +28 -7
  35. package/src/ai-cli.ts +1 -1
  36. package/src/ai.ts +131 -5
  37. package/src/application-channel.ts +78 -7
  38. package/src/application-cli.ts +2 -2
  39. package/src/approval-cli.ts +14 -7
  40. package/src/approval.ts +27 -37
  41. package/src/channel-backend.ts +2 -3
  42. package/src/config.ts +12 -27
  43. package/src/control-state.ts +175 -8
  44. package/src/conversation-menu.ts +6 -17
  45. package/src/delivery-socket-client.d.mts +4 -0
  46. package/src/delivery-socket-client.mjs +104 -0
  47. package/src/delivery-socket.ts +354 -0
  48. package/src/execution-authority.ts +21 -5
  49. package/src/executor.ts +81 -24
  50. package/src/files.ts +38 -11
  51. package/src/host-executor.ts +33 -17
  52. package/src/inbox.ts +15 -49
  53. package/src/index.ts +98 -54
  54. package/src/install-tools.mjs +97 -22
  55. package/src/ledger-endpoint.d.mts +2 -0
  56. package/src/ledger-endpoint.mjs +17 -0
  57. package/src/menu.ts +8 -3
  58. package/src/message-history.ts +13 -18
  59. package/src/message.ts +35 -18
  60. package/src/plugin-broker.mjs +396 -0
  61. package/src/plugins/connection.mjs +2 -2
  62. package/src/plugins/manager.mjs +131 -23
  63. package/src/plugins/native-tasks.mjs +2 -2
  64. package/src/prompt-suffix.ts +12 -0
  65. package/src/react.ts +6 -4
  66. package/src/runs.ts +156 -243
  67. package/src/schedule-cli.ts +29 -10
  68. package/src/scheduled-tasks.ts +53 -10
  69. package/src/scheduler.ts +20 -7
  70. package/src/service.ts +3 -3
  71. package/src/setup.ts +1 -0
  72. package/src/software-status.ts +36 -4
  73. package/src/task-executor.ts +2 -2
  74. package/src/task-workspace.ts +4 -3
  75. package/src/tasks.ts +1 -1
  76. package/src/telegram-provisioning-cli.ts +22 -0
  77. package/src/telegram-provisioning.ts +214 -0
  78. package/src/updates/control.mjs +77 -7
  79. package/src/updates/runtime.mjs +28 -8
  80. package/src/updates/status.mjs +25 -3
  81. package/src/updates/supervisor.mjs +15 -16
  82. package/src/version.ts +20 -0
  83. package/src/workspace.ts +3 -9
  84. package/templates/failure-review.md +1 -1
  85. package/templates/maintainer-purpose.md +1 -1
  86. package/test/agent-guidance.test.ts +14 -80
  87. package/test/agent-install.test.ts +14 -0
  88. package/test/ai.test.ts +220 -1
  89. package/test/application-channel.test.ts +61 -13
  90. package/test/application-only.test.ts +14 -13
  91. package/test/channel-backend.test.ts +3 -7
  92. package/test/channel-delivery.test.ts +4 -1
  93. package/test/channel-owner.test.ts +6 -3
  94. package/test/codex-context.test.ts +2 -2
  95. package/test/config.test.ts +38 -43
  96. package/test/control-state.test.ts +83 -4
  97. package/test/conversation-menu.test.ts +2 -4
  98. package/test/delivery-transport.test.ts +72 -0
  99. package/test/docker-runtime.test.ts +27 -14
  100. package/test/engine-handoff.test.ts +7 -6
  101. package/test/event-sources.test.ts +2 -2
  102. package/test/execution-authority.test.ts +9 -9
  103. package/test/executor.test.ts +120 -12
  104. package/test/failure.test.ts +12 -5
  105. package/test/files.test.ts +2 -2
  106. package/test/helpers/ledger.ts +18 -0
  107. package/test/host-executor.test.ts +87 -3
  108. package/test/inbox.test.ts +8 -10
  109. package/test/install-config.test.ts +1 -0
  110. package/test/install-tools.test.mjs +74 -7
  111. package/test/intake-relay.test.ts +58 -11
  112. package/test/message-history.test.ts +39 -47
  113. package/test/native-tasks.test.ts +1 -1
  114. package/test/plugin-broker.test.mjs +172 -0
  115. package/test/plugin-manager.test.mjs +136 -2
  116. package/test/reaction.test.ts +4 -0
  117. package/test/relay.test.ts +5 -7
  118. package/test/repair-policy.test.ts +1 -4
  119. package/test/schedule-cli.test.ts +14 -9
  120. package/test/scheduled-tasks.test.ts +29 -6
  121. package/test/scheduler-host.test.ts +5 -5
  122. package/test/scheduler-relay.test.ts +15 -9
  123. package/test/security.test.ts +6 -3
  124. package/test/software-status.test.ts +21 -3
  125. package/test/telegram-provisioning.test.ts +160 -0
  126. package/test/updates.test.mjs +93 -13
  127. package/test/workspace.test.ts +4 -1
  128. package/bin/ezenciel-agents-watch.mjs +0 -8
  129. package/compose.workforce-watch.yaml +0 -33
  130. package/docker/recovery.ts +0 -11
  131. package/docs/pagerduty.md +0 -42
  132. package/docs/workforce-watch.md +0 -101
  133. package/src/pagerduty.ts +0 -109
  134. package/src/reply-context.ts +0 -43
  135. package/src/update-attention.ts +0 -18
  136. package/src/workforce-watch-cli.ts +0 -14
  137. package/src/workforce-watch.ts +0 -155
  138. package/templates/agent/AGENTS.md +0 -5
  139. package/templates/agent-guidance.md +0 -17
  140. package/test/pagerduty.test.ts +0 -104
  141. package/test/update-attention.test.ts +0 -20
  142. package/test/workforce-watch.test.ts +0 -180
@@ -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,46 @@ 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
+ }
278
+ });
279
+ test('isolated broker allows unbound plugins when another plugin has host binding',async t=>{
280
+ const f=await fixture(t),p=await snapshot(f.source),isolated=path.join(f.root,'isolated-other'),workspace=path.join(isolated,'workspace'),control=path.join(isolated,'control'),hostConfig=path.join(isolated,'host-executor.json'),previous=process.env.EZ_DOCKER_COMPOSE;
281
+ await fs.mkdir(f.home,{recursive:true});await fs.mkdir(workspace,{recursive:true});await fs.mkdir(control,{recursive:true});
282
+ 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}]}));
283
+ const record={source:p.source,project:'ezp-synthetic',revision:p.revision,manifest:p.manifest,deployment:p.deployment};
284
+ const config={workspace:await fs.realpath(workspace),hostConfig:await fs.realpath(hostConfig)};
285
+ 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,pluginNetworkBindings:{voice:{revisions:[p.revision],bindings:[{service:'sample',network:'voice_default'}]}}}]}));
286
+ process.env.EZ_DOCKER_COMPOSE='standalone';
287
+ try {
288
+ const c=await compose(config,record,{},f.home);
289
+ assert.equal(c.version,'3.8');assert.equal(c.networks,undefined);
290
+ 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,pluginNetworkBindings:{sample:{revisions:[p.revision],bindings:[{service:'sample',network:'sample_default'}]}}}]}));
291
+ await assert.rejects(compose(config,record,{},f.home),/Isolated broker cannot use host network bindings/);
292
+ } finally {
293
+ if(previous===undefined)delete process.env.EZ_DOCKER_COMPOSE;else process.env.EZ_DOCKER_COMPOSE=previous;
294
+ }
161
295
  });
162
296
  test('host-owned private networks survive registered-call Compose regeneration',async t=>{
163
297
  const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);
@@ -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 })
@@ -2,10 +2,9 @@ import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import { loadConfig } from '../src/config.js'
4
4
  import { executorJobEnv } from '../src/executor.js'
5
- import { agentGuidance } from '../src/agent-guidance.js'
6
5
 
7
6
  test('repair preference is bound in the environment, never rewritten into the request', () => {
8
- const config = (value?:string) => loadConfig({TELEGRAM_BOT_TOKEN:'fixture', ...(value===undefined ? {} : {EZ_REPAIR_ENABLED:value})})
7
+ const config = (value?:string) => loadConfig({TELEGRAM_BOT_TOKEN:'110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY', ...(value===undefined ? {} : {EZ_REPAIR_ENABLED:value})})
9
8
  assert.equal(config().repairEnabled,true)
10
9
  assert.equal(config('false').repairEnabled,false)
11
10
  assert.throws(()=>config('disabled'),/EZ_REPAIR_ENABLED/)
@@ -14,6 +13,4 @@ test('repair preference is bound in the environment, never rewritten into the re
14
13
  assert.equal(env.EZ_REPAIR_ENABLED,String(enabled))
15
14
  assert.equal(env.TELEGRAM_BOT_TOKEN,undefined)
16
15
  }
17
- assert.match(agentGuidance(),/EZ_REPAIR_ENABLED/)
18
- assert.match(agentGuidance(),/explicit owner request or saved maintenance mandate/)
19
16
  })
@@ -9,9 +9,12 @@ import { fileURLToPath } from 'node:url'
9
9
  import { ControlStore } from '../src/control-state.js'
10
10
  import { RunStore } from '../src/runs.js'
11
11
  import { initialPreset } from '../src/ai.js'
12
+ import { serveTestLedger } from './helpers/ledger.js'
12
13
  const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
13
14
  test('public scheduler CLI saves literal text, reads back, edits, pauses, and rejects external or finished callers',async t=>{
14
15
  const dir=await mkdtemp(join(tmpdir(),'ez-schedule-cli-'));t.after(()=>rm(dir,{recursive:true,force:true}))
16
+ // The schedule CLI child reaches the relay memory ledger through the socket.
17
+ const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
15
18
  const control=new ControlStore(dir,1000),runs=new RunStore(dir)
16
19
  const env={...process.env,EZ_CONTROL_DIR:dir,EZ_RUN_ID:'',EZ_EXECUTOR_CLI:'grok'}
17
20
  await assert.rejects(exec(process.execPath,[bin,'list'],{env}),/Pair an owner/)
@@ -19,8 +22,8 @@ test('public scheduler CLI saves literal text, reads back, edits, pauses, and re
19
22
  const execution=await control.captureChoice(initialPreset('grok'))
20
23
  const run=await runs.create({chatId:101,telegramUserId:101,texts:['owner request'],execution})
21
24
  await runs.patch(run.id,{status:'running'});env.EZ_RUN_ID=run.id
22
- const context=JSON.parse((await exec(process.execPath,[bin,'context'],{env})).stdout)
23
- assert.deepEqual(context.run.texts,['owner request']);assert.deepEqual(context.busyReplies,[])
25
+ const context=JSON.parse((await exec(process.execPath,[bin,'context'],{env})).stdout)
26
+ assert.deepEqual(context.run.texts,['owner request']);assert.equal(context.busyReplies,undefined)
24
27
  await assert.rejects(exec(process.execPath,[bin,'context'],{env:{...env,EZ_RUN_ID:''}}),/active owner run/)
25
28
  const args=['create','test','--at','2027-09-09T09:00:00+04:00','--text','Literal $(do-not-execute) /goal objective']
26
29
  const saved=JSON.parse((await exec(process.execPath,[bin,...args],{env})).stdout)
@@ -59,13 +62,14 @@ test('public scheduler CLI saves literal text, reads back, edits, pauses, and re
59
62
 
60
63
  test('executor PATH exposes the extensionless scheduler command',async()=>{
61
64
  const command=fileURLToPath(new URL('../bin/ezenciel-agents-schedule',import.meta.url))
62
- assert.match((await exec(command,['--help'])).stdout,/durable, asynchronous CLI task/)
65
+ assert.match((await exec(command,['--help'])).stdout,/Creates a scheduled task/)
63
66
  })
64
67
 
65
68
 
66
69
  test('deferred literal input retains owner-scoped conversation through source metadata',async t=>{
67
70
  const {Scheduler}=await import('../src/scheduler.js')
68
71
  const dir=await mkdtemp(join(tmpdir(),'ez-origin-context-'));t.after(()=>rm(dir,{recursive:true,force:true}))
72
+ const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
69
73
  const control=new ControlStore(dir,1000),runs=new RunStore(dir)
70
74
  await control.requestPairing(101,101);await control.approveOwner(101)
71
75
  const execution=await control.captureChoice(initialPreset('codex'))
@@ -76,13 +80,14 @@ test('deferred literal input retains owner-scoped conversation through source me
76
80
  await new Scheduler(dir).save({id:'legacy-deferred',name:'Owner request',text:'Do the same for March',originRunId:'tg_2',owner:(await control.status()).owner!,execution,enabled:true,trigger:{at:new Date(Date.now()+1000).toISOString()}},true)
77
81
  await new Scheduler(dir).tick((await control.status()).owner!,runs,Date.now()+2000)
78
82
  const worker=(await runs.list()).find(r=>r.scheduled)!
79
- assert.deepEqual(worker.texts,['Do the same for March']);assert.equal(worker.scheduled?.originRunId,'tg_2')
83
+ assert.match(worker.texts[0],/^\[schedule legacy-deferred due .+\]$/);assert.equal(worker.texts[1],'Do the same for March');assert.equal(worker.scheduled?.originRunId,'tg_2')
80
84
  await runs.patch(worker.id,{status:'running'})
81
85
  const env={...process.env,EZ_CONTROL_DIR:dir,EZ_RUN_ID:worker.id}
82
- const result=JSON.parse((await exec(process.execPath,[bin,'context'],{env})).stdout)
83
- assert.ok(result.origin.recent.some((r:any)=>r.texts==='Use the blue ledger'))
86
+ const result=JSON.parse((await exec(process.execPath,[bin,'context'],{env})).stdout)
87
+ assert.equal(result.run.texts[1],'Do the same for March')
88
+ assert.equal(result.origin.id,'tg_2')
84
89
  await runs.create({id:'other',chatId:999,telegramUserId:999,texts:['PRIVATE OTHER OWNER']})
85
- const {writeFile}=await import('node:fs/promises')
86
- await writeFile(join(dir,'runs',worker.id+'.json'),JSON.stringify({...worker,status:'running',scheduled:{...worker.scheduled,originRunId:'other'}}))
87
- await assert.rejects(exec(process.execPath,[bin,'context'],{env}),/outside this owner binding/)
90
+ const tampered=await runs.create({id:'tg_tampered',chatId:101,telegramUserId:101,texts:worker.texts,execution,scheduled:{...worker.scheduled!,originRunId:'other'}})
91
+ await runs.patch(tampered.id,{status:'running'})
92
+ await assert.rejects(exec(process.execPath,[bin,'context'],{env:{...env,EZ_RUN_ID:tampered.id}}),/outside this owner binding/)
88
93
  })
@@ -5,7 +5,8 @@ import { randomUUID } from 'node:crypto'
5
5
  import { join } from 'node:path'
6
6
  import { tmpdir } from 'node:os'
7
7
  import { Scheduler } from '../src/scheduler.js'
8
- import { scheduledTasksText } from '../src/scheduled-tasks.js'
8
+ import { scheduledTaskDetailText, scheduledTasksText } from '../src/scheduled-tasks.js'
9
+ import { RunStore } from '../src/runs.js'
9
10
 
10
11
  const owner = { telegramUserId: 101, telegramChatId: 101, pairedAt: '2026-09-11T00:00:00.000Z' }
11
12
  const execution = { sessionId: randomUUID(), preset: { id: 'fixture', name: 'Fixture', cli: 'codex' } }
@@ -18,7 +19,7 @@ test('scheduled task view is read-only, owner-bound, and shows active task promp
18
19
  assert.deepEqual(await scheduler.listReadOnly(), [])
19
20
  await assert.rejects(access(join(dir, 'schedules')), /ENOENT/)
20
21
 
21
- await scheduler.save({
22
+ const saved = await scheduler.save({
22
23
  id: 'owner-task', name: 'Daily report', text: 'Read the ledger and send the owner a concise report.', owner, execution, enabled: true,
23
24
  trigger: { cron: '0 9 * * 1-5', timezone: 'Asia/Dubai', start: '2026-01-01T00:00:00.000Z' },
24
25
  })
@@ -30,9 +31,17 @@ test('scheduled task view is read-only, owner-bound, and shows active task promp
30
31
  const scheduleDir = join(dir, 'schedules')
31
32
  const before = await readFile(join(scheduleDir, 'owner-task.json'), 'utf8')
32
33
  const entries = await readdir(scheduleDir)
33
- const text = scheduledTasksText(await scheduler.listActiveReadOnly([]), owner)
34
+ const runs = new RunStore(dir)
35
+ const run = await runs.create({
36
+ id: 'owner-task-run', ownerId: 'telegram:101:101', ownerEpoch: owner.pairedAt, chatId: 101, telegramUserId: 101, texts: [],
37
+ scheduled: { id: saved.id, revision: saved.revision, dueAt: '2026-01-01T05:00:00.000Z', pairedAt: owner.pairedAt },
38
+ })
39
+ await runs.patch(run.id, { status: 'completed', endedAt: '2026-01-01T05:04:00.000Z' })
40
+ const active = await scheduler.listActiveReadOnly(await runs.list())
41
+ const text = scheduledTasksText(active, owner)
34
42
 
35
- assert.equal(text, 'Active scheduled tasks\n\n• Daily report\n codex · client default · default effort\n Next: 2026-01-01 05:00:00 UTC\n Read the ledger and send the owner a concise report.')
43
+ assert.equal(text, '📅 Scheduled tasks · 1 active\n\nAll times UTC. Use /tasks 1 for task details.\n\n1 · Daily report\n ● Active\n Next · Thu, Jan 1 · 05:00 UTC\n Last · ✓ Completed · Thu, Jan 1 · 05:04 UTC\n codex · client default · default effort\n Read the ledger and send the owner a concise report.')
44
+ assert.match(scheduledTaskDetailText(active.find(schedule => schedule.id === saved.id)!, 1), /Last run\n✓ Completed · Thu, Jan 1 · 05:04 UTC/)
36
45
  assert.doesNotMatch(text, /Other owner task|This must never be visible/)
37
46
  assert.equal(await readFile(join(scheduleDir, 'owner-task.json'), 'utf8'), before)
38
47
  assert.deepEqual(await readdir(scheduleDir), entries)
@@ -42,7 +51,6 @@ test('active view follows existing cursor and run state without changing schedul
42
51
  const dir = await mkdtemp(join(tmpdir(),'ez-active-schedules-'))
43
52
  t.after(()=>rm(dir,{recursive:true,force:true}))
44
53
  const scheduler = new Scheduler(dir)
45
- const { RunStore } = await import('../src/runs.js')
46
54
  const runs = new RunStore(dir)
47
55
  const due = Date.now()+60_000
48
56
  const common = {text:'/goal List files.',owner,execution,enabled:true}
@@ -88,7 +96,7 @@ test('prompt preview is one bounded Unicode sentence and preserves stored input'
88
96
  enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}})
89
97
  const output=scheduledTasksText(await scheduler.listActiveReadOnly([]),owner)
90
98
  assert.match(output,/gpt-5.6-terra · high/)
91
- assert.match(output,/Next: 2027-01-01 00:00:00 UTC/)
99
+ assert.match(output,/Next · Fri, Jan 1 · 00:00 UTC/)
92
100
  assert.equal(Array.from(output.split('\n').at(-1)!.trim()).length,140)
93
101
  assert.ok(output.endsWith('…'))
94
102
  assert.doesNotMatch(output,/Do not display/)
@@ -112,3 +120,18 @@ test('a legacy invalid selection does not prevent the active menu from rendering
112
120
  const active = await scheduler.listActiveReadOnly([])
113
121
  assert.match(scheduledTasksText(active,owner),/codex · gpt-5.6-terra · max/)
114
122
  })
123
+
124
+ test('detail view truncates long instructions and tolerates invalid timestamps', async t => {
125
+ const dir = await mkdtemp(join(tmpdir(),'ez-schedule-detail-bounds-'))
126
+ t.after(()=>rm(dir,{recursive:true,force:true}))
127
+ const scheduler = new Scheduler(dir)
128
+ const saved = await scheduler.save({id:'long',name:'Long task',text:'/goal '+'x'.repeat(5000),owner,
129
+ execution,enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}})
130
+ const active = await scheduler.listActiveReadOnly([])
131
+ const detail = scheduledTaskDetailText(active.find(schedule => schedule.id === saved.id)!,1,1)
132
+ assert.match(detail,/📅 1 of 1 · Long task/)
133
+ assert.match(detail,/truncated; full text lives in the workspace/)
134
+ assert.ok(detail.length < 3000)
135
+ const broken = {...active.find(schedule => schedule.id === saved.id)!,nextAt:'not-a-date',lastRun:{status:'completed',at:'also-bad',currentRevision:true}} as never
136
+ assert.doesNotMatch(scheduledTaskDetailText(broken,1,1),/NaN/)
137
+ })
@@ -10,6 +10,7 @@ import { EXECUTOR_REGISTRY } from '../src/executor.js'
10
10
  import { RunStore } from '../src/runs.js'
11
11
 
12
12
  const until=async(check:()=>Promise<boolean>)=>{for(let n=0;n<250;n++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Host probe timed out')}
13
+ const stdout=(events:string) => events.trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)).filter(event=>event.stream==='stdout').map(event=>event.text).join('')
13
14
  test('host transport reserves separate task and main lanes, pins directories, and cancels only its target',async()=>{
14
15
  const root=await mkdtemp(join(tmpdir(),'ez-scheduler-host-')),workspace=join(root,'agent'),controlDir=join(root,'control')
15
16
  await mkdir(workspace);await mkdir(controlDir)
@@ -32,12 +33,11 @@ test('host transport reserves separate task and main lanes, pins directories, an
32
33
  await submit('tg_1')
33
34
  await until(async()=>(await exists('tg_1.events')).includes('"stream":"exit","code":0'))
34
35
  assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
35
- const scheduled=JSON.parse(JSON.parse((await exists(id+'.events')).trim().split('\n')[0]).text)
36
- const main=JSON.parse(JSON.parse((await exists('tg_1.events')).trim().split('\n')[0]).text)
37
- assert.equal(scheduled.cwd,await realpath(join(workspace,'work/tasks',id)));assert.equal(main.cwd,await realpath(workspace))
36
+ const scheduled=JSON.parse(stdout(await exists(id+'.events')))
37
+ const main=JSON.parse(stdout(await exists('tg_1.events')))
38
+ assert.equal(scheduled.cwd,await realpath(join(controlDir,'work/tasks',id)));assert.equal(main.cwd,await realpath(workspace))
38
39
  assert.equal(scheduled.token,undefined);assert.equal(main.token,undefined)
39
- // A malformed scheduled request must not unwind the shared host service.
40
- await writeFile(join(controlDir,'runs/r_schedule_corrupt.json'),'{')
40
+ // An unknown scheduled run must not unwind the shared host service.
41
41
  await submit('r_schedule_corrupt')
42
42
  await until(async()=>(await exists('r_schedule_corrupt.events')).includes('"stream":"exit","code":1'))
43
43
  assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
@@ -11,16 +11,18 @@ import { ControlStore } from '../src/control-state.js'
11
11
  import { RunStore } from '../src/runs.js'
12
12
  import { Scheduler } from '../src/scheduler.js'
13
13
  import { initialPreset } from '../src/ai.js'
14
- import { randomUUID } from 'node:crypto'
15
14
 
16
15
  const until=async(check:()=>Promise<boolean>)=>{for(let i=0;i<200;i++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Timed out')}
17
16
 
18
- test('maintenance wakeups use an independent ephemeral session after a relay restart',async()=>{
19
- const dir=await mkdtemp(join(tmpdir(),'ez-maintenance-session-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
20
- let launchSession='',onSessionCalls=0
17
+ // Adversarial: the relay must not reserve wrapper semantics for an
18
+ // update-shaped run id or a stale attention notice.
19
+ test('a wrapper-shaped run id has no maintenance treatment and a stale notice is inert',async()=>{
20
+ const dir=await mkdtemp(join(tmpdir(),'ez-no-maintenance-session-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
21
+ let launchSession='',onSessionCalls=0,launches=0
21
22
  const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'codex',telegramBotToken:'fixture'},async(_texts,options)=>{
23
+ launches++
22
24
  launchSession=options.sessionId ?? ''
23
- if(options.onSession){onSessionCalls++;await options.onSession('native-maintenance')}
25
+ if(options.onSession){onSessionCalls++;await options.onSession('native-review')}
24
26
  const child=spawn(process.execPath,['-e','setTimeout(()=>{},10)'],{detached:process.platform!=='win32'})
25
27
  await once(child,'spawn')
26
28
  return {child,cleanup:async()=>{},stdout:''}
@@ -29,13 +31,17 @@ test('maintenance wakeups use an independent ephemeral session after a relay res
29
31
  relay.bot.api.config.use(async()=>({ok:true,result:{message_id:42}}) as never)
30
32
  try{
31
33
  await control.requestPairing(101,101);await control.approveOwner(101)
32
- const execution={sessionId:randomUUID(),preset:initialPreset('codex')}
34
+ const execution=await control.captureChoice(initialPreset('codex'))
33
35
  await runs.create({id:'r_update_fixture',chatId:101,telegramUserId:101,texts:['maintenance'],execution})
34
36
  await relay.drainSources()
35
37
  await until(async()=> (await runs.get('r_update_fixture'))?.status==='completed')
36
- assert.notEqual(launchSession,execution.sessionId)
37
- assert.equal(onSessionCalls,0)
38
- }finally{await relay.stop();await rm(dir,{recursive:true,force:true})}
38
+ assert.equal(launchSession,execution.sessionId)
39
+ assert.equal(onSessionCalls,1)
40
+ await writeFile(join(dir,'update-attention.json'),JSON.stringify({id:'a'.repeat(64)}))
41
+ await relay.drainSources()
42
+ assert.equal(launches,1)
43
+ assert.equal((await runs.list()).length,1)
44
+ }finally{await relay.stop();await rm(dir,{recursive:true,force:true})}
39
45
  })
40
46
 
41
47
  test('chat replies through the real ingress/outbox while scheduled CLI remains alive; targeted cancellation',async()=>{
@@ -1,6 +1,6 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { mkdtemp, rm, writeFile, symlink, mkdir } from 'node:fs/promises'
3
+ import { mkdtemp, rm, writeFile, symlink, mkdir, stat } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import { isOwner } from '../src/identity.js'
@@ -26,7 +26,7 @@ test('owner gate denies groups, bots, missing identity, and a different sender o
26
26
  assert.equal(isOwner({ from, chat }, null), false)
27
27
  })
28
28
 
29
- test('store identifiers reject traversal; corrupt records cannot authorize or block healthy runs', async () => {
29
+ test('store identifiers reject traversal; corrupt control state fails closed and the ledger writes nothing', async () => {
30
30
  const dir = await mkdtemp(join(tmpdir(), 'ez-negative-'))
31
31
  try {
32
32
  const runs = new RunStore(dir)
@@ -34,8 +34,11 @@ test('store identifiers reject traversal; corrupt records cannot authorize or bl
34
34
  await assert.rejects(runs.claimOutbox('../control-state'), /Invalid/)
35
35
  await assert.rejects(new ApprovalStore(dir).getDecision('../escape'), /Invalid/)
36
36
  const run = await runs.create({ chatId: 101, telegramUserId: 101, texts: ['test'] })
37
- await writeFile(join(dir, 'runs', 'broken.json'), '{')
38
37
  assert.equal((await runs.nextQueued())?.id, run.id)
38
+ // Run/outbox/inbox bookkeeping is memory-only: no control/ files exist to corrupt.
39
+ await assert.rejects(stat(join(dir, 'runs')), { code: 'ENOENT' })
40
+ await assert.rejects(stat(join(dir, 'outbox')), { code: 'ENOENT' })
41
+ await assert.rejects(stat(join(dir, 'inbox.json')), { code: 'ENOENT' })
39
42
  await writeFile(join(dir, 'control-state.json'), '{')
40
43
  await assert.rejects(new ControlStore(dir, 1000).status())
41
44
  } finally {
@@ -4,7 +4,13 @@ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import path from 'node:path'
6
6
  import { installedPluginVersions, softwareStatus } from '../src/software-status.js'
7
- import { packageVersion } from '../src/version.js'
7
+ import { formatRelayLabel, packageVersion } from '../src/version.js'
8
+
9
+ test('relay label shows the running RC tag first', () => {
10
+ assert.equal(formatRelayLabel('0.1.0-beta.35', 'rc2', 'abc123def4567890'), 'rc2 (v0.1.0-beta.35 · abc123def456)')
11
+ assert.equal(formatRelayLabel('0.1.0-beta.35', 'rc2'), 'rc2 (v0.1.0-beta.35)')
12
+ assert.equal(formatRelayLabel('1.0.0'), 'v1.0.0')
13
+ })
8
14
 
9
15
  test('Telegram software status uses loaded version and only fresh host plugin metadata', async t => {
10
16
  const root=await mkdtemp(path.join(tmpdir(),'ez-software-status-'))
@@ -14,9 +20,10 @@ test('Telegram software status uses loaded version and only fresh host plugin me
14
20
  const plugins=await installedPluginVersions(root)
15
21
  assert.deepEqual(plugins,[{id:'whatsapp',version:'0.1.0-beta.3'}])
16
22
  const heartbeat=path.join(root,'host-executor/heartbeat.json')
17
- await writeFile(heartbeat,JSON.stringify({at:Date.now(),version:'0.1.0-beta.4',plugins}))
18
- const lines=await softwareStatus(root)
23
+ await writeFile(heartbeat,JSON.stringify({at:Date.now(),version:'0.1.0-beta.4',platform:'darwin',arch:'arm64',plugins}))
24
+ const lines=await softwareStatus(root,'host-capable')
19
25
  assert.equal(lines[0],`Relay: running · v${packageVersion}`)
26
+ assert(lines.includes('Env: mac (darwin/arm64) · host-capable · host'))
20
27
  assert(lines.includes('Host transport: running · v0.1.0-beta.4'))
21
28
  assert(lines.includes('Plugins: whatsapp 0.1.0-beta.3'))
22
29
  assert(!JSON.stringify(lines).includes('/private'))
@@ -28,4 +35,15 @@ test('Telegram software status uses loaded version and only fresh host plugin me
28
35
  assert((await softwareStatus(root)).includes('Host transport: running · version unknown'))
29
36
  await writeFile(path.join(root,'registry.json'),'broken')
30
37
  assert.equal(await installedPluginVersions(root),null)
38
+ await rm(heartbeat)
39
+ assert((await softwareStatus(root)).includes('Host transport: unavailable'))
40
+ assert((await softwareStatus(root)).includes('Plugins: unknown'))
41
+ assert((await softwareStatus(root)).some(l => l.startsWith('Env:') && l.includes('unknown · unknown')))
42
+ assert((await softwareStatus(root,'isolated')).includes('Host transport: n/a · isolated execution'))
43
+ assert((await softwareStatus(root,'isolated')).some(l => l.startsWith('Env:') && l.includes('isolated · local')))
44
+ assert((await softwareStatus(root,'isolated')).includes('Plugins: isolated broker · versions via `ez tools list`'))
45
+ await writeFile(path.join(root,'plugin-broker-plugins.json'),JSON.stringify({version:1,at:new Date().toISOString(),plugins:[{id:'voice',version:'0.2.0'}]}))
46
+ assert((await softwareStatus(root,'isolated')).includes('Plugins: voice 0.2.0'))
47
+ await writeFile(path.join(root,'plugin-broker-plugins.json'),'broken')
48
+ assert((await softwareStatus(root,'isolated')).includes('Plugins: isolated broker · versions via `ez tools list`'))
31
49
  })