@jc_stack/ez-agents 0.1.0-beta.33 → 0.1.0-beta.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +9 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +92 -0
  5. package/Dockerfile +11 -9
  6. package/README.md +32 -27
  7. package/THIRD_PARTY_NOTICES.md +4 -4
  8. package/bin/ez +97 -0
  9. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  10. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  11. package/bin/ezenciel-agents.mjs +1 -1
  12. package/compose.yaml +42 -5
  13. package/docker/entrypoint.sh +1 -1
  14. package/docker/healthcheck.mjs +12 -4
  15. package/docker/run.ts +5 -6
  16. package/docker/smoke.mjs +32 -7
  17. package/docs/application-channel.md +38 -10
  18. package/docs/architecture/ai-selection.md +3 -1
  19. package/docs/architecture/authority-boundaries.md +2 -2
  20. package/docs/channel-backend.md +4 -3
  21. package/docs/docker-runtime.md +106 -21
  22. package/docs/host-service.md +17 -6
  23. package/docs/local-qa.md +2 -2
  24. package/docs/plugin-guide.md +202 -0
  25. package/docs/plugins.md +63 -2
  26. package/docs/releasing.md +13 -0
  27. package/docs/repair.md +1 -1
  28. package/docs/responsive-channels.md +1 -1
  29. package/docs/scheduling.md +4 -4
  30. package/docs/setup.md +96 -16
  31. package/docs/standalone-cli.md +6 -5
  32. package/docs/upgrades.md +46 -23
  33. package/package.json +5 -5
  34. package/scripts/smoke-scheduler.ts +12 -4
  35. package/scripts/smoke.ts +14 -11
  36. package/src/agent-guidance.ts +6 -9
  37. package/src/agent-install.ts +44 -9
  38. package/src/ai-cli.ts +9 -7
  39. package/src/ai.ts +7 -7
  40. package/src/application-channel.ts +88 -17
  41. package/src/application-cli.ts +2 -2
  42. package/src/approval-cli.ts +14 -7
  43. package/src/approval.ts +27 -37
  44. package/src/channel-backend.ts +2 -3
  45. package/src/codex-session.ts +5 -3
  46. package/src/config.ts +18 -28
  47. package/src/control-state.ts +187 -9
  48. package/src/conversation-menu.ts +6 -17
  49. package/src/delivery-socket-client.d.mts +4 -0
  50. package/src/delivery-socket-client.mjs +104 -0
  51. package/src/delivery-socket.ts +354 -0
  52. package/src/execution-authority.ts +21 -5
  53. package/src/executor.ts +110 -31
  54. package/src/files.ts +38 -11
  55. package/src/host-executor.ts +50 -17
  56. package/src/inbox.ts +15 -49
  57. package/src/index.ts +96 -52
  58. package/src/install-config.ts +2 -2
  59. package/src/install-tools.mjs +100 -23
  60. package/src/isolation.ts +25 -0
  61. package/src/ledger-endpoint.d.mts +2 -0
  62. package/src/ledger-endpoint.mjs +17 -0
  63. package/src/menu.ts +16 -8
  64. package/src/message-history.ts +13 -18
  65. package/src/message.ts +35 -18
  66. package/src/plugin-broker.mjs +396 -0
  67. package/src/plugins/connection.mjs +2 -2
  68. package/src/plugins/manager.mjs +178 -26
  69. package/src/plugins/native-tasks.mjs +2 -2
  70. package/src/prompt-suffix.ts +12 -0
  71. package/src/react.ts +6 -4
  72. package/src/runs.ts +156 -243
  73. package/src/schedule-cli.ts +29 -10
  74. package/src/scheduled-tasks.ts +53 -10
  75. package/src/scheduler.ts +20 -7
  76. package/src/service.ts +3 -3
  77. package/src/setup.ts +11 -7
  78. package/src/software-status.ts +36 -4
  79. package/src/task-executor.ts +6 -3
  80. package/src/task-workspace.ts +4 -3
  81. package/src/tasks.ts +1 -1
  82. package/src/telegram-provisioning-cli.ts +22 -0
  83. package/src/telegram-provisioning.ts +214 -0
  84. package/src/updates/control.mjs +77 -7
  85. package/src/updates/runtime.mjs +29 -9
  86. package/src/updates/status.mjs +25 -3
  87. package/src/updates/supervisor.mjs +31 -18
  88. package/src/version.ts +20 -0
  89. package/src/workspace-confine.ts +38 -0
  90. package/src/workspace.ts +24 -20
  91. package/templates/agent-purpose.md +1 -0
  92. package/templates/failure-review.md +1 -1
  93. package/templates/maintainer-purpose.md +1 -1
  94. package/test/agent-guidance.test.ts +18 -88
  95. package/test/agent-install.test.ts +48 -0
  96. package/test/ai-cli.test.ts +18 -0
  97. package/test/ai.test.ts +84 -0
  98. package/test/application-channel.test.ts +66 -15
  99. package/test/application-controls.test.ts +56 -3
  100. package/test/application-only.test.ts +16 -13
  101. package/test/channel-backend.test.ts +3 -7
  102. package/test/channel-delivery.test.ts +4 -1
  103. package/test/channel-owner.test.ts +6 -3
  104. package/test/codex-context.test.ts +63 -3
  105. package/test/codex-session.test.ts +6 -1
  106. package/test/config.test.ts +47 -37
  107. package/test/control-state.test.ts +83 -4
  108. package/test/conversation-menu.test.ts +2 -4
  109. package/test/delivery-transport.test.ts +72 -0
  110. package/test/docker-runtime.test.ts +27 -14
  111. package/test/engine-handoff.test.ts +12 -8
  112. package/test/event-sources.test.ts +2 -2
  113. package/test/execution-authority.test.ts +9 -9
  114. package/test/executor.test.ts +47 -11
  115. package/test/failure.test.ts +18 -5
  116. package/test/files.test.ts +2 -2
  117. package/test/helpers/ledger.ts +18 -0
  118. package/test/host-executor.test.ts +80 -0
  119. package/test/inbox.test.ts +8 -10
  120. package/test/install-config.test.ts +18 -6
  121. package/test/install-tools.test.mjs +80 -7
  122. package/test/intake-relay.test.ts +58 -11
  123. package/test/message-history.test.ts +39 -47
  124. package/test/native-tasks.test.ts +1 -1
  125. package/test/plugin-broker.test.mjs +172 -0
  126. package/test/plugin-manager.test.mjs +143 -5
  127. package/test/reaction.test.ts +4 -0
  128. package/test/relay.test.ts +5 -7
  129. package/test/repair-policy.test.ts +1 -4
  130. package/test/schedule-cli.test.ts +14 -9
  131. package/test/scheduled-tasks.test.ts +29 -6
  132. package/test/scheduler-host.test.ts +5 -5
  133. package/test/scheduler-relay.test.ts +15 -9
  134. package/test/scheduler.test.ts +2 -2
  135. package/test/security.test.ts +6 -3
  136. package/test/software-status.test.ts +21 -3
  137. package/test/telegram-provisioning.test.ts +160 -0
  138. package/test/updates.test.mjs +102 -13
  139. package/test/workspace-confine.test.ts +47 -0
  140. package/test/workspace.test.ts +27 -11
  141. package/bin/ezenciel-agents-watch.mjs +0 -8
  142. package/compose.workforce-watch.yaml +0 -33
  143. package/docker/recovery.ts +0 -11
  144. package/docs/pagerduty.md +0 -42
  145. package/docs/workforce-watch.md +0 -101
  146. package/src/pagerduty.ts +0 -109
  147. package/src/reply-context.ts +0 -44
  148. package/src/update-attention.ts +0 -18
  149. package/src/workforce-watch-cli.ts +0 -14
  150. package/src/workforce-watch.ts +0 -155
  151. package/templates/agent/AGENTS.md +0 -15
  152. package/templates/agent/SOUL.md +0 -11
  153. package/templates/agent/USER.md +0 -5
  154. package/templates/agent-guidance.md +0 -114
  155. package/test/pagerduty.test.ts +0 -104
  156. package/test/update-attention.test.ts +0 -20
  157. package/test/workforce-watch.test.ts +0 -180
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { createHash, randomUUID, randomBytes } from 'node:crypto';
7
7
  import { spawn } from 'node:child_process';
8
+ import { callDeliverySocket } from '../delivery-socket-client.mjs';
8
9
  import { readFileSync, realpathSync } from 'node:fs';
9
10
 
10
11
  const reserved = new Set(['status','updates','plugins','tools','message','owner','approval','react','setup','help','version']);
@@ -21,10 +22,24 @@ export async function atomic(file, value) {
21
22
  const tmp = `${file}.${randomUUID()}.tmp`;
22
23
  await fs.writeFile(tmp,JSON.stringify(value,null,2)+'\n',{mode:0o600,flag:'wx'});
23
24
  await fs.rename(tmp,file);
25
+ await stewardOwned(file);
26
+ }
27
+ // Files the broker creates as root must stay usable by the agent owner:
28
+ // adopt the owning uid/gid of the containing directory. Off root this is a no-op.
29
+ export async function stewardOwned(file) {
30
+ if (process.geteuid?.() !== 0) return;
31
+ const parent = await fs.stat(path.dirname(file));
32
+ await fs.chown(file, parent.uid, parent.gid);
24
33
  }
25
34
  // Only a registry locator lives in native instructions. Inventory is generated on read.
26
35
  export async function bindToolDiscovery(home,workspace) {
27
36
  home=await fs.realpath(home);workspace=await fs.realpath(workspace);
37
+ let launcher=path.join(home,'bin','ez');
38
+ const config=await json(path.join(home,'config.json')).catch(error=>{if(error.code==='ENOENT')return undefined;throw error;});
39
+ if(config?.hostConfig) {
40
+ const host=await json(config.hostConfig);
41
+ if(host.isolation==='isolated') launcher='ez';
42
+ }
28
43
  const start='<!-- ez tools: begin -->',end='<!-- ez tools: end -->';
29
44
  for(const name of ['AGENTS.md','AGENTS.override.md']) {
30
45
  const file=path.join(workspace,name);
@@ -34,7 +49,7 @@ export async function bindToolDiscovery(home,workspace) {
34
49
  const prior=stat?await fs.readFile(file,'utf8'):'';
35
50
  const from=prior.indexOf(start),to=prior.indexOf(end);
36
51
  if((from<0)!==(to<0)||(from>=0&&(to<from||prior.indexOf(start,from+start.length)>=0||prior.indexOf(end,to+end.length)>=0)))throw Error('Malformed tool discovery block');
37
- const block=start+'\nInstalled plugin snippets and skills: `'+path.join(home,'bin','ez')+' tools list --details`. Use this bound launcher for plugin commands; read the relevant skill when needed.\n'+end;
52
+ const block=start+'\nInstalled capabilities: `'+launcher+' --help` and `'+launcher+' tools list --details`. Use this bound launcher; read the matching skill when needed.\n'+end;
38
53
  const next=from<0?prior+'\n'+block+'\n':prior.slice(0,from)+block+prior.slice(to+end.length);
39
54
  if(next===prior)continue;
40
55
  const tmp=file+'.'+randomUUID()+'.tmp';
@@ -48,7 +63,7 @@ async function activeInvocations(home) {
48
63
  if(!/^[0-9a-f-]{36}\.json$/.test(file))throw Error('Invalid command invocation lease; inspect before recovery');
49
64
  const lease=await json(path.join(directory,file));
50
65
  if(!Number.isSafeInteger(lease.pid)||lease.pid<1||typeof lease.container!=='string'||!lease.container)throw Error('Invalid command invocation lease; inspect before recovery');
51
- try {process.kill(lease.pid,0);return true;} catch(error) {if(error.code!=='ESRCH')throw error;}
66
+ try {process.kill(lease.pid,0);return true;} catch(error) {if(error.code!=='ESRCH'&&error.code!=='EPERM')throw error;}
52
67
  const state=await run(['container','inspect',lease.container],{capture:true});
53
68
  if(state.code===0)throw Error(`Stale command invocation lease; inspect container ${lease.container} before recovery`);
54
69
  if(!/No such (?:object|container)/i.test(state.stderr+state.stdout))throw Error('Unable to verify a stale command invocation lease; inspect Docker before recovery');
@@ -59,6 +74,7 @@ async function activeInvocations(home) {
59
74
  async function invocationLease(home,container) {
60
75
  const directory=invocationDirectory(home);await privateDir(directory);
61
76
  const file=path.join(directory,randomUUID()+'.json');await fs.writeFile(file,JSON.stringify({pid:process.pid,container}),{mode:0o600,flag:'wx'});
77
+ await stewardOwned(file);
62
78
  return async()=>{await fs.rm(file,{force:true});};
63
79
  }
64
80
  export async function locked(home, fn, {allowInvocations=false}={}) {
@@ -188,10 +204,55 @@ export function folderMounts(config, record) {
188
204
  }
189
205
  return mounts;
190
206
  }
191
- export async function checkFolders(config, record) {
207
+ async function hostFolderRoots(config, record, home) {
208
+ const configuredHost = config.hostConfig ?? (typeof config.deploymentDir === 'string' && path.isAbsolute(config.deploymentDir) ? path.join(config.deploymentDir, 'host-executor.json') : undefined);
209
+ if (configuredHost === undefined) return null;
210
+ if (!home || typeof configuredHost !== 'string' || !path.isAbsolute(configuredHost)) throw Error('Invalid host folder binding');
211
+ home = realpathSync(home);
212
+ const hostConfig = realpathSync(configuredHost);
213
+ if (hostConfig !== configuredHost || path.basename(hostConfig) !== 'host-executor.json' || childOf(hostConfig, home))
214
+ throw Error('Host folder bindings require an external host config');
215
+ const host = JSON.parse(readFileSync(hostConfig, 'utf8'));
216
+ if (!host || typeof host !== 'object' || !Array.isArray(host.agents)) throw Error('Invalid host folder bindings');
217
+ const candidates = host.agents.filter(agent => agent && typeof agent === 'object' && typeof agent.toolsHome === 'string' && realpathSync(agent.toolsHome) === home);
218
+ if (candidates.length !== 1) throw Error('Host folder binding does not belong to this registry');
219
+ const agent = candidates[0], deployment = path.dirname(hostConfig);
220
+ if (typeof agent.workspace !== 'string' || typeof agent.controlDir !== 'string' ||
221
+ realpathSync(agent.workspace) !== config.workspace ||
222
+ childOf(hostConfig, config.workspace) || childOf(hostConfig, realpathSync(agent.controlDir)))
223
+ throw Error('Invalid host folder binding deployment');
224
+ // The isolated broker receives only the exact workspace, control directory,
225
+ // tools home, and host config mounts. It must not require the host
226
+ // deployment's convenience mind/control symlinks (or a deployment mount).
227
+ if (process.env.EZ_DOCKER_COMPOSE !== 'standalone' &&
228
+ (realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
229
+ realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir)))
230
+ throw Error('Invalid host folder binding deployment');
231
+ const configured = agent.pluginFolderRoots;
232
+ if (configured !== undefined && (!configured || typeof configured !== 'object' || Array.isArray(configured)))
233
+ throw Error('Invalid host folder roots');
234
+ for (const [plugin, roots] of Object.entries(configured || {})) {
235
+ id(plugin); strings(roots);
236
+ for (const root of roots) {
237
+ if (!path.isAbsolute(root) || root === '/' || realpathSync(root) !== root) throw Error('Invalid host folder root');
238
+ if (!((await fs.stat(root)).isDirectory())) throw Error('Invalid host folder root');
239
+ }
240
+ }
241
+ const roots = [config.workspace];
242
+ if (agent.sharedWorkspace !== undefined) {
243
+ if (typeof agent.sharedWorkspace !== 'string' || !path.isAbsolute(agent.sharedWorkspace)) throw Error('Invalid shared workspace folder root');
244
+ roots.push(realpathSync(agent.sharedWorkspace));
245
+ }
246
+ roots.push(...(configured?.[record.manifest.id] || []).map(root => realpathSync(root)));
247
+ return roots;
248
+ }
249
+ export async function checkFolders(config, record, home) {
250
+ const allowed = await hostFolderRoots(config, record, home);
192
251
  for (const { source } of folderMounts(config, record)) {
193
252
  if (await fs.realpath(source) !== source || !(await fs.stat(source)).isDirectory())
194
253
  throw Error('Folder source must remain an existing real directory');
254
+ if (allowed && !allowed.some(root => childOf(source, root)))
255
+ throw Error('Folder source is not granted to this agent');
195
256
  }
196
257
  }
197
258
  const childOf = (candidate, parent) => candidate === parent || candidate.startsWith(parent + path.sep);
@@ -211,13 +272,19 @@ export async function hostNetworkBindings(config, record, home) {
211
272
  if (candidates.length !== 1) throw Error('Host network binding does not belong to this registry');
212
273
  const agent = candidates[0], deployment = path.dirname(hostConfig);
213
274
  if (typeof agent.workspace !== 'string' || typeof agent.controlDir !== 'string' ||
214
- realpathSync(agent.workspace) !== config.workspace || realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
215
- realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir) ||
275
+ realpathSync(agent.workspace) !== config.workspace ||
216
276
  childOf(hostConfig, config.workspace) || childOf(hostConfig, realpathSync(agent.controlDir)))
217
277
  throw Error('Invalid host network binding deployment');
218
278
  const configured = agent.pluginNetworkBindings;
219
279
  if (configured !== undefined && (!configured || typeof configured !== 'object' || Array.isArray(configured)))
220
280
  throw Error('Invalid host network bindings');
281
+ if (process.env.EZ_DOCKER_COMPOSE === 'standalone') {
282
+ if (configured && Object.keys(configured).length) throw Error('Isolated broker cannot use host network bindings');
283
+ return [];
284
+ }
285
+ if (realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
286
+ realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir))
287
+ throw Error('Invalid host network binding deployment');
221
288
  const route = configured?.[record.manifest?.id];
222
289
  if (route === undefined) return [];
223
290
  keys(route, ['revisions', 'bindings']);
@@ -273,14 +340,24 @@ export async function compose(config, record, secrets={}, home) {
273
340
  return [key,(value.prefix||'')+secrets[value.secret]+(value.suffix||'')];
274
341
  }))}:{}),...(s.command?{command:s.command}:{})};
275
342
  }
276
- return attachShared({name:record.project,services,volumes,...(Object.keys(networks).length?{networks}:{})}, record);
343
+ // Keep the persisted descriptor portable between the host supervisor's
344
+ // Compose v2 and the isolated broker's bundled Compose v1. Project identity
345
+ // is always supplied with --project-name, so a top-level name is unnecessary.
346
+ return attachShared({version:'3.8',services,volumes,...(Object.keys(networks).length?{networks}:{})}, record);
347
+ }
348
+ async function refreshRecordCompose(home, config, record) {
349
+ await checkFolders(config, record, home);
350
+ const secrets=await json(path.join(home,'packages',record.manifest.id,'secrets.json')).catch(error=>{if(error.code==='ENOENT')return {};throw error;});
351
+ await atomic(record.compose, await compose(config, record, secrets, home));
277
352
  }
278
353
  function dockerEnv() {
279
- return Object.fromEntries(['HOME','PATH','LANG','LC_ALL','TMPDIR','DOCKER_HOST','DOCKER_CONTEXT','DOCKER_CONFIG','BUILDX_CONFIG'].filter(k=>process.env[k]!==undefined).map(k=>[k,process.env[k]]));
354
+ return Object.fromEntries(['HOME','PATH','LANG','LC_ALL','TMPDIR','DOCKER_HOST','DOCKER_CONTEXT','DOCKER_CONFIG','BUILDX_CONFIG','EZ_DOCKER_COMPOSE'].filter(k=>process.env[k]!==undefined).map(k=>[k,process.env[k]]));
280
355
  }
356
+ const dockerInvocation = argv => process.env.EZ_DOCKER_COMPOSE === 'standalone' && argv[0] === 'compose'
357
+ ? {command:'docker-compose',args:argv.slice(1)} : {command:'docker',args:argv};
281
358
  export function run(argv,{capture=false,container,signal,stdin,onStdout,onStart,timeoutMs=0,maxBytes=Infinity}={}) {
282
359
  return new Promise((resolve,reject)=>{
283
- const child=spawn('docker',argv,{env:dockerEnv(),stdio:capture?['pipe','pipe','pipe']:['inherit','inherit','inherit']});
360
+ const invocation=dockerInvocation(argv),child=spawn(invocation.command,invocation.args,{env:dockerEnv(),stdio:capture?['pipe','pipe','pipe']:['inherit','inherit','inherit']});
284
361
  let stdout='',stderr='',cancelled=false,killTimer,bytes=0,failure;
285
362
  if(capture) {
286
363
  const collect=(b,err)=>{bytes+=b.length;if(bytes>maxBytes){failure=Error('Command output limit exceeded');cancel('SIGTERM');return;}if(err)stderr+=b;else stdout+=b;};
@@ -329,6 +406,53 @@ async function checked(args) {
329
406
  const r=await run(args,{capture:true});if(r.code) throw Error(r.stderr||r.stdout||`Docker failed (${r.code})`);return r.stdout;
330
407
  }
331
408
  const composeArgs = record => ['compose','--project-name',record.project,'--file',record.compose];
409
+ // Application context is opaque domain data. A command container receives only
410
+ // the object addressed to its installed plugin ID, never core state or another
411
+ // plugin's context. The current run and binding are re-authorized before it is
412
+ // forwarded to Docker.
413
+ const ownerKey = owner => owner?.id || (Number.isSafeInteger(owner?.telegramUserId) && Number.isSafeInteger(owner?.telegramChatId)
414
+ ? `telegram:${owner.telegramUserId}:${owner.telegramChatId}` : undefined);
415
+ const ownerEpoch = owner => owner?.generation || owner?.pairedAt;
416
+ const readJson = async file => JSON.parse(await fs.readFile(file,'utf8'));
417
+ // Run records live in relay memory; this manager stays plain Node, so it asks
418
+ // the relay through the shared delivery socket client. A relay that is down
419
+ // reads like a missing run: no context is forwarded.
420
+ const readRun = async (socketPath,runId) => callDeliverySocket(socketPath,{op:'get',payload:{runId}}).catch(error=>{
421
+ if(/Unknown run/.test(error.message)||/Delivery relay unavailable/.test(error.message))return undefined;
422
+ throw error;
423
+ });
424
+ // This manager is intentionally runnable as plain Node. Read only the current
425
+ // run and existing application binding rather than importing the TypeScript
426
+ // relay, and fail closed on missing/revoked ownership.
427
+ const authorizedApplicationRun = async (controlDir,runId,socketPath) => {
428
+ if(!path.isAbsolute(controlDir)||!/^[A-Za-z0-9_-]+$/.test(runId))throw Error('Plugin context requires a bound active run');
429
+ const run=await readRun(socketPath||path.join(controlDir,'delivery.sock'),runId);
430
+ const application=run?.application ?? run?.telegramApplication;
431
+ if(!application?.context||run?.id!==runId||run.status!=='running'||typeof application.bindingId!=='string')return undefined;
432
+ const state=await readJson(path.join(controlDir,'control-state.json'));
433
+ const bindings=await readJson(path.join(controlDir,'application-bindings.json'));
434
+ const binding=Array.isArray(bindings)?bindings.find(candidate=>candidate?.bindingId===application.bindingId):undefined;
435
+ if(!binding?.owner||!state?.owner||ownerKey(binding.owner)!==ownerKey(state.owner)||ownerEpoch(binding.owner)!==ownerEpoch(state.owner)||
436
+ run.ownerId!==ownerKey(state.owner)||run.ownerEpoch!==ownerEpoch(state.owner))throw Error('Application authority revoked');
437
+ return application;
438
+ };
439
+ export async function applicationPluginContext(pluginId, environment=process.env) {
440
+ const rawDir=environment.EZ_CONTROL_DIR?.trim(),runId=environment.EZ_RUN_ID?.trim();
441
+ if(!rawDir||!runId)return undefined;
442
+ if(!path.isAbsolute(rawDir))throw Error('Plugin context requires a bound active run');
443
+ const controlDir=await fs.realpath(rawDir).catch(()=>undefined);
444
+ if(!controlDir)return undefined;
445
+ const application=await authorizedApplicationRun(controlDir,runId,environment.EZ_DELIVERY_SOCKET?.trim()||path.join(rawDir,'delivery.sock'));
446
+ const contexts=application?.context?.plugins;
447
+ if(!contexts||typeof contexts!=='object'||Array.isArray(contexts)||!Object.hasOwn(contexts,pluginId))return undefined;
448
+ const context=contexts[pluginId];
449
+ if(!context||typeof context!=='object'||Array.isArray(context))throw Error('Plugin application context must be an object');
450
+ const checkDepth=(value,level=0)=>{if(level>10)throw Error('Plugin application context is too deeply nested');if(value&&typeof value==='object')for(const entry of Object.values(value))checkDepth(entry,level+1);};
451
+ checkDepth(context);
452
+ const encoded=JSON.stringify(context);
453
+ if(Buffer.byteLength(encoded)>16*1024)throw Error('Plugin application context is too large');
454
+ return encoded;
455
+ }
332
456
  export async function registry(home) {
333
457
  const r=await json(path.join(home,'registry.json'));
334
458
  if(r.schemaVersion!==1 || r.owner!==home || !r.plugins || !r.commands) throw Error('Corrupt registry');
@@ -339,19 +463,36 @@ export async function registry(home) {
339
463
  return r;
340
464
  }
341
465
  // The lock protects admission and compose refresh, never a persistent connection.
342
- export async function prepareCommand(home,alias,args,{revision,exclude,publish,invocation=false}={}) {
466
+ export async function prepareCommand(home,alias,args,{revision,exclude,publish,invocation=false,environment=process.env}={}) {
343
467
  strings(args);
344
468
  return locked(home,async()=>{
345
469
  const config=await json(path.join(home,'config.json')),r=await registry(home);
346
470
  const record=r.plugins[r.commands[alias]],binding=record?.deployment.commands[alias];
347
471
  if(!binding||r.commands[alias]===exclude)throw Error('Unknown or unavailable registered CLI');
348
472
  if(revision!==undefined&&revision!==record.revision)throw Error('Plugin changed; discover again');
349
- await checkFolders(config,record);
473
+ await checkFolders(config,record,home);
350
474
  const secrets=await json(path.join(home,'packages',record.manifest.id,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
351
475
  await atomic(record.compose,await compose(config,record,secrets,home));
352
476
  const container=`${record.project}-call-${randomUUID()}`;
353
- const release=invocation?await invocationLease(home,container):undefined;
354
- return {container,plugin:record.manifest.id,revision:record.revision,argv:[...composeArgs(record),'run','--rm','--no-deps','-T','--name',container,...(publish?['--publish',publish]:[]),'--entrypoint',binding.argv[0],binding.service,...binding.argv.slice(1),...record.manifest.commands[alias].args,...args,...(binding.suffix||[])],release};
477
+ // The application context must reach the command container as an
478
+ // environment variable. A top-level `compose --env-file` only feeds
479
+ // variable interpolation in both Compose v1 and v2, so the container never
480
+ // receives it; a one-shot override file sets the service environment
481
+ // directly and keeps the value out of the process argument list.
482
+ const context=await applicationPluginContext(record.manifest.id,environment);
483
+ let contextFile;
484
+ if(context!==undefined){
485
+ await privateDir(path.join(home,'plugin-context'));
486
+ contextFile=path.join(home,'plugin-context',`${randomUUID()}.json`);
487
+ // Compose interpolates every file it loads, including this override, so
488
+ // an opaque `$` in the context must be escaped as `$$` to reach the
489
+ // container byte-for-byte.
490
+ const override={services:{[binding.service]:{environment:{EZ_PLUGIN_CONTEXT:context.replaceAll('$',()=> '$$')}}}};
491
+ await fs.writeFile(contextFile,JSON.stringify(override,null,2)+'\n',{mode:0o600,flag:'wx'});
492
+ }
493
+ const invocationRelease=invocation?await invocationLease(home,container):undefined;
494
+ const release=(invocationRelease||contextFile)?(async()=>{try{if(contextFile)await fs.rm(contextFile,{force:true});}finally{await invocationRelease?.();}}):undefined;
495
+ return {container,plugin:record.manifest.id,revision:record.revision,contextFile,argv:[...composeArgs(record),...(contextFile?['--file',contextFile]:[]),'run','--rm','--no-deps','-T','--name',container,...(publish?['--publish',publish]:[]),'--entrypoint',binding.argv[0],binding.service,...binding.argv.slice(1),...record.manifest.commands[alias].args,...args,...(binding.suffix||[])],release};
355
496
  },{allowInvocations:true});
356
497
  }
357
498
  export async function init(home,workspace,catalogFile,hostConfig,standalone=false) {
@@ -431,6 +572,10 @@ export async function main(args) {
431
572
  home=await fs.realpath(home);
432
573
  const config=await json(path.join(home,'config.json'));
433
574
  if(config.schemaVersion!==1 || !path.isAbsolute(config.workspace)) throw Error('Invalid binding');
575
+ if(config.hostConfig || config.deploymentDir) {
576
+ const cwd=await fs.realpath(process.cwd());
577
+ if(!childOf(cwd,config.workspace)) throw Error(`Registry belongs to ${config.workspace}; invoke its launcher only from that owning workspace`);
578
+ }
434
579
  const [group,action,...rest]=args;
435
580
  if(group==='status'){if(args.length!==1)throw Error('Use status without arguments');await registry(home);return emit(await (await import('../updates/status.mjs')).status(home));}
436
581
  if(group==='updates')return emit(await (await import('../updates/control.mjs')).command(home,args.slice(1)));
@@ -439,7 +584,7 @@ export async function main(args) {
439
584
  return (await import('./connection.mjs')).connect(home,rest[0],rest.slice(1),{publish:port,serve:true});
440
585
  }
441
586
  if(group==='tools'&&action==='connect')return (await import('./connection.mjs')).connect(home,rest[0],rest.slice(1));
442
- if(group==='--help'||!group) return emit({commands:['status','updates check|policy|prepare|apply|status','plugins available|catalog-add|list|inspect|install|start|stop|status|logs|uninstall|export','tools list [--details]|exposure|connect <alias> <args...>|serve <host-port:container-port> <alias> <args...>','<registered CLI> ...'],scope:home});
587
+ if(group==='--help'||!group) return emit({commands:['status','updates check|policy|prepare|apply|status','plugins available|catalog-add|list|inspect|install|start|stop|status|logs|uninstall|export','tools list [--details]|exposure|connect <alias> <args...>|serve <host-port:container-port> <alias> <args...>','<registered CLI> ...'],foreignWorkspace:'Relay-bound registries run only from their owning agent workspace',scope:home});
443
588
  if(group==='plugins'&&(!action||args.includes('--help'))) return emit({commands:['available','list','inspect <id>','install <id>','start <id>','stop <id>','status <id>','logs <id>','uninstall <id>','catalog-add <id> --source PATH --revision HASH','export <id> <artifact> --output PATH','folder-bind <id> --service NAME --source PATH --target PATH [--writable]','folder-unbind <id> --service NAME --target PATH','folders <id>','shared-enable <id> <service>','shared-disable <id> <service>','shared-status <id> <service>'],uninstall:'Stops and removes containers/network and unregisters aliases; retains all volumes and secrets. No data deletion flag.',scope:home});
444
589
  if(group==='plugins'||group==='tools') {
445
590
  args=rest;args=args.filter(a=>a!=='--json');
@@ -490,7 +635,7 @@ export async function main(args) {
490
635
  const folders=(settings.folders?.[name] || []).filter(f => f.service !== service || f.target !== target);
491
636
  if(action === 'folder-bind') folders.push({service,source,target,...(writable ? {writable:true} : {})});
492
637
  settings.folders={...settings.folders,[name]:folders};
493
- await checkFolders(settings,latest);
638
+ await checkFolders(settings,latest,home);
494
639
  const secrets=await json(path.join(home,'packages',name,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
495
640
  const generated=await compose(settings,latest,secrets,home);
496
641
  await atomic(path.join(home,'config.json'),settings);
@@ -506,14 +651,14 @@ export async function main(args) {
506
651
  const current = await registry(home), latest = current.plugins[name];
507
652
  if (latest?.revision !== record.revision) throw Error('Plugin changed during shared service request');
508
653
  const currentConfig=await json(path.join(home,'config.json'));
509
- await checkFolders(currentConfig, latest);
654
+ await checkFolders(currentConfig, latest,home);
510
655
  const result = action === 'shared-enable' ? await sharedService(latest, key, 'enable', run) : { state: 'detached' };
511
656
  latest.sharedEnabled = [...new Set([...(latest.sharedEnabled || []).filter(k => k !== key), ...(action === 'shared-enable' ? [key] : [])])];
512
657
  const secrets = await json(path.join(home, 'packages', name, 'secrets.json')).catch(e => { if (e.code === 'ENOENT') return {}; throw e; });
513
658
  await atomic(latest.compose, await compose(currentConfig, latest, secrets, home));
514
659
  // Persist the binding before recreating clients; start can recover an interrupted recreation.
515
660
  await atomic(path.join(home, 'registry.json'), current);
516
- await checked([...composeArgs(latest), 'up', '-d', '--wait']);
661
+ await checked([...composeArgs(latest), 'up', '-d', ...(process.env.EZ_DOCKER_COMPOSE === 'standalone' ? [] : ['--wait'])]);
517
662
  return emit({ ok: true, plugin: name, shared: key, ...result });
518
663
  });
519
664
  }
@@ -527,18 +672,25 @@ export async function main(args) {
527
672
  return emit({ok:true,path:dest});
528
673
  }
529
674
  if(args.length)throw Error('Unknown lifecycle arguments');
530
- if(action==='logs') return emit({plugin:name,logs:await checked([...composeArgs(record),'logs','--tail','100','--no-color'])});
531
- if(action==='status') {const output=await checked([...composeArgs(record),'ps','--all','--format','json']);return emit({plugin:name,installedVersion:record.manifest.version,containers:output});}
675
+ if(action==='logs'||action==='status') return locked(home,async()=>{
676
+ const current=await registry(home),latest=current.plugins[name];
677
+ if(latest?.revision!==record.revision)throw Error('Plugin changed during lifecycle request');
678
+ const currentConfig=await json(path.join(home,'config.json'));
679
+ // A host-side Compose v2 operation may have rewritten this file with
680
+ // fields that the broker's bundled Compose v1 does not understand.
681
+ await refreshRecordCompose(home,currentConfig,latest);
682
+ if(action==='logs')return emit({plugin:name,logs:await checked([...composeArgs(latest),'logs','--tail','100','--no-color'])});
683
+ const ps = process.env.EZ_DOCKER_COMPOSE === 'standalone' ? ['ps', '--all'] : ['ps', '--all', '--format', 'json'];
684
+ const output=await checked([...composeArgs(latest),...ps]);
685
+ return emit({plugin:name,installedVersion:latest.manifest.version,containers:output});
686
+ });
532
687
  if(!['start','stop','uninstall'].includes(action))throw Error('Unknown lifecycle command');
533
688
  return locked(home,async()=>{
534
- const current=await registry(home);if(current.plugins[name]?.revision!==record.revision)throw Error('Plugin changed during lifecycle request');
535
- if(action==='start') {
536
- const currentConfig=await json(path.join(home,'config.json'));
537
- await checkFolders(currentConfig,current.plugins[name]);
538
- const secrets=await json(path.join(home,'packages',name,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
539
- await atomic(record.compose,await compose(currentConfig,current.plugins[name],secrets,home));
540
- }
541
- await checked([...composeArgs(record),...(action==='start'?['up','-d','--wait']:action==='stop'?['stop']:['down'])]);
689
+ const current=await registry(home),latest=current.plugins[name];
690
+ if(latest?.revision!==record.revision)throw Error('Plugin changed during lifecycle request');
691
+ const currentConfig=await json(path.join(home,'config.json'));
692
+ await refreshRecordCompose(home,currentConfig,latest);
693
+ await checked([...composeArgs(latest),...(action==='start'?['up','-d',...(process.env.EZ_DOCKER_COMPOSE === 'standalone'?[]:['--wait'])]:action==='stop'?['stop']:['down'])]);
542
694
  if(action==='uninstall') {delete current.plugins[name];for(const [alias,owner] of Object.entries(current.commands))if(owner===name)delete current.commands[alias];await atomic(path.join(home,'registry.json'),current);}
543
695
  emit({ok:true,plugin:name,action,dataPreserved:true});
544
696
  });
@@ -20,8 +20,8 @@ export async function nativeTaskBinding(home,environment=process.env) {
20
20
  const matches=host.agents.filter(a=>a.toolsHome===home&&a.workspace===workspace);
21
21
  if(matches.length!==1||typeof matches[0].controlDir!=='string'||!path.isAbsolute(matches[0].controlDir))throw Error('Native task binding does not match owning agent');
22
22
  const controlDir=await fs.realpath(matches[0].controlDir);
23
- const env=Object.fromEntries(['HOME','PATH','LANG','LC_ALL','TMPDIR'].filter(k=>environment[k]!==undefined).map(k=>[k,environment[k]]));
24
- return {cwd:workspace,env:{...env,EZ_CONTROL_DIR:controlDir,EZ_AGENT_WORKSPACE:workspace,EZ_EXECUTOR_CLI:host.cli}};
23
+ const env=Object.fromEntries(['HOME','PATH','LANG','LC_ALL','TMPDIR','EZ_DELIVERY_SOCKET'].filter(k=>environment[k]!==undefined).map(k=>[k,environment[k]]));
24
+ return {cwd:workspace,env:{...env,EZ_CONTROL_DIR:controlDir,EZ_AGENT_WORKSPACE:workspace,EZ_EXECUTOR_CLI:host.cli,EZ_DELIVERY_SOCKET:env.EZ_DELIVERY_SOCKET??path.join(matches[0].controlDir,'delivery.sock')}};
25
25
  }
26
26
 
27
27
  export async function nativeTasks(home,args,{signal,command='schedule',deliveryContext}={}) {
@@ -0,0 +1,12 @@
1
+ import type { RunRecord } from './runs.js'
2
+
3
+ // Relay/host overhead converts a run's trigger fields into the one slim suffix
4
+ // the core appends to literal input. The core itself never reads the ledger.
5
+ export const runPromptSuffix = (run: RunRecord | null | undefined): string => {
6
+ if (!run || run.taskId) return ''
7
+ if (run.messageId === undefined && !run.application && !run.delivery) return ''
8
+ const scope = run.application?.scope ?? run.delivery?.scope
9
+ const channel = run.scheduled ? 'schedule' : run.application || run.delivery ? 'application' : run.messageId !== undefined ? 'chat' : 'trigger'
10
+ const contact = run.messageId !== undefined ? ` message ${run.messageId}` : scope !== undefined ? ` scope ${JSON.stringify(scope)}` : ''
11
+ return `\n\n[${channel}${contact}] Reply via ezenciel-agents-message --text "..."; stdout is not delivered.`
12
+ }
package/src/react.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { loadControlConfig } from './config.js'
2
2
  import { normalizeReactionEmoji, TELEGRAM_REACTIONS } from './reaction.js'
3
- import { RunStore } from './runs.js'
3
+ import { callDeliverySocket, socketPathFor } from './delivery-socket.js'
4
4
 
5
5
  const runId = process.env.EZ_RUN_ID?.trim()
6
6
  const args = process.argv.slice(2).filter((arg) => arg !== '--')
@@ -28,6 +28,8 @@ if (!emoji) {
28
28
  process.exit(1)
29
29
  }
30
30
 
31
- const store = new RunStore(loadControlConfig().controlDir)
32
- const item = await store.enqueueReaction(runId, emoji)
33
- console.log(JSON.stringify({ ok: true, run: runId, outbox_id: item.id, emoji: item.emoji }))
31
+ const controlDir = loadControlConfig().controlDir
32
+ const item = await callDeliverySocket(socketPathFor(controlDir), {
33
+ op: 'enqueue', payload: { kind: 'reaction', runId, emoji },
34
+ }) as { outbox_id: string; id: string; emoji?: string }
35
+ console.log(JSON.stringify({ ok: true, run: runId, outbox_id: item.id, emoji: item.emoji ?? emoji }))