@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
package/src/message.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { readFile,realpath } from 'node:fs/promises'
2
2
  import { loadControlConfig } from './config.js'
3
- import { parseMessageArgs, sendRunDocument, sendRunText, sendRunVoice } from './message-send.js'
4
- import { RunStore } from './runs.js'
3
+ import { parseMessageArgs } from './message-send.js'
5
4
  import { parseArgs } from 'node:util'
6
- import { deliveredMessages } from './message-history.js'
7
5
  import {authorizeDeliveryContext,currentDeliveryOwner} from './delivery-context.mjs'
8
6
  import {workspaceFile} from './files.js'
7
+ import { callDeliverySocket, socketPathFor } from './delivery-socket.js'
9
8
  import path from 'node:path'
9
+ import { randomBytes } from 'node:crypto'
10
10
 
11
11
  const rawArgs = process.argv.slice(2)
12
12
  if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
@@ -19,11 +19,13 @@ if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
19
19
  process.exit(0)
20
20
  }
21
21
 
22
+ const controlDir = loadControlConfig().controlDir
23
+ const socketPath = socketPathFor(controlDir)
22
24
  const runId = process.env.EZ_RUN_ID?.trim()
23
- const deliveryContext = !runId && process.env.EZ_DELIVERY_CONTEXT ? authorizeDeliveryContext(JSON.parse(process.env.EZ_DELIVERY_CONTEXT),await currentDeliveryOwner(loadControlConfig().controlDir)) : undefined
25
+ const deliveryContext = !runId && process.env.EZ_DELIVERY_CONTEXT ? authorizeDeliveryContext(JSON.parse(process.env.EZ_DELIVERY_CONTEXT),await currentDeliveryOwner(controlDir)) : undefined
24
26
  if(rawArgs[0]==='receipt') {
25
27
  if(!deliveryContext||rawArgs.length!==2)throw new Error('Receipt requires an authenticated delivery context and outbox ID')
26
- console.log(JSON.stringify(await new RunStore(loadControlConfig().controlDir).ownerDeliveryReceipt(deliveryContext,rawArgs[1]!)))
28
+ console.log(JSON.stringify(await callDeliverySocket(socketPath, { op: 'receipt', payload: { context: deliveryContext, id: rawArgs[1]! } })))
27
29
  process.exit(0)
28
30
  }
29
31
  if (rawArgs[0] === 'history') {
@@ -32,10 +34,11 @@ if (rawArgs[0] === 'history') {
32
34
  limit: { type: 'string' }, 'message-id': { type: 'string' },
33
35
  } })
34
36
  if (!runId) throw new Error('EZ_RUN_ID is required')
35
- const result = await deliveredMessages(loadControlConfig().controlDir, runId, {
37
+ const result = await callDeliverySocket(socketPath, { op: 'history', payload: {
38
+ runId,
36
39
  limit: values.limit === undefined ? undefined : Number(values.limit),
37
40
  messageId: values['message-id'] === undefined ? undefined : Number(values['message-id']),
38
- })
41
+ } })
39
42
  await new Promise<void>((resolve, reject) => process.stdout.write(JSON.stringify(result) + '\n', error => error ? reject(error) : resolve()))
40
43
  } catch (error) {
41
44
  console.error(error instanceof Error ? error.message : String(error))
@@ -49,8 +52,6 @@ if (!runId && !deliveryContext) {
49
52
  process.exit(1)
50
53
  }
51
54
 
52
- const store = new RunStore(loadControlConfig().controlDir)
53
-
54
55
  let textContent = args.text?.trim()
55
56
  if (args.textFile) {
56
57
  if(deliveryContext)throw new Error('Channel delivery requires inline text, not host file input')
@@ -62,21 +63,28 @@ if (args.textFile) {
62
63
  }
63
64
  }
64
65
 
65
- let item
66
+ // Delivery rides the relay socket (same envelope the relay pump delivers);
67
+ // the relay re-verifies ownership before sending. Client-generated IDs keep
68
+ // retried sends idempotent without a second delivery.
69
+ const envelope: Record<string, unknown> = {
70
+ ...(runId ? { runId } : {}),
71
+ ...(deliveryContext ? { kind: 'owner', deliveryContext } : {}),
72
+ ...(args.replyTo !== undefined ? { replyToMessageId: args.replyTo } : {}),
73
+ }
66
74
  if(deliveryContext) {
67
75
  if(args.document) {
68
76
  if(!process.env.EZ_AGENT_WORKSPACE)throw new Error('Channel delivery requires owning workspace')
69
77
  const documentPath=await workspaceFile(process.env.EZ_AGENT_WORKSPACE,args.document)
70
- item=await store.enqueueOwnerDelivery(deliveryContext,{type:'document',documentPath:path.relative(await realpath(process.env.EZ_AGENT_WORKSPACE),documentPath),text:textContent,replyToMessageId:args.replyTo})
71
- } else if(args.voice) item=await store.enqueueOwnerDelivery(deliveryContext,{type:'voice',voiceText:args.voice,replyToMessageId:args.replyTo})
72
- else if(textContent) item=await store.enqueueOwnerDelivery(deliveryContext,{type:'message',text:textContent,replyToMessageId:args.replyTo})
78
+ Object.assign(envelope,{type:'document',documentPath:path.relative(await realpath(process.env.EZ_AGENT_WORKSPACE),documentPath),text:textContent})
79
+ } else if(args.voice) Object.assign(envelope,{type:'voice',voiceText:args.voice})
80
+ else if(textContent) Object.assign(envelope,{type:'message',text:textContent})
73
81
  else throw new Error('Message content is required')
74
82
  } else if (args.document) {
75
- item = await sendRunDocument(store, runId!, args.document, textContent, { replyTo: args.replyTo })
83
+ Object.assign(envelope,{kind:'document',documentPath:args.document,text:textContent})
76
84
  } else if (args.voice) {
77
- item = await sendRunVoice(store, runId!, args.voice, { replyTo: args.replyTo })
85
+ Object.assign(envelope,{kind:'voice',voiceText:args.voice})
78
86
  } else if (textContent) {
79
- item = await sendRunText(store, runId!, textContent, { replyTo: args.replyTo })
87
+ Object.assign(envelope,{kind:'message',text:textContent,id:`${runId}_${Date.now().toString(36)}_${randomBytes(2).toString('hex')}`})
80
88
  } else {
81
89
  console.error(
82
90
  'Usage: ezenciel-agents-message [--text-file <path> | --text <text>] [--document <path>] [--voice <text>] [--reply-to <id>]',
@@ -84,9 +92,18 @@ if(deliveryContext) {
84
92
  process.exit(1)
85
93
  }
86
94
 
95
+ let item: { outbox_id: string; id: string; type?: string }
96
+ try {
97
+ item = await callDeliverySocket(socketPath, { op: 'enqueue', payload: envelope }) as typeof item
98
+ } catch (error) {
99
+ console.error(error instanceof Error ? error.message : String(error))
100
+ process.exit(1)
101
+ }
102
+
87
103
  try {
88
104
  if(deliveryContext)console.log(JSON.stringify({ok:true,status:'queued',outbox_id:item.id}))
89
- const receipt = await store.waitForDelivery(item.id,deliveryContext?20000:undefined)
105
+ const receipt = await callDeliverySocket(socketPath,
106
+ { op: 'wait', payload: { id: item.id } }, deliveryContext?30000:130000)
90
107
  console.log(
91
108
  JSON.stringify({
92
109
  ok: true,
@@ -95,7 +112,7 @@ try {
95
112
  ...(deliveryContext?{connection:deliveryContext.connectionId}:{}),
96
113
  outbox_id: item.id,
97
114
  type: item.type,
98
- receipt,
115
+ receipt: (receipt as { receipt?: unknown }).receipt ?? receipt,
99
116
  }),
100
117
  )
101
118
  } catch (error) {
@@ -0,0 +1,396 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createServer } from 'node:net';
4
+ import { randomBytes, randomUUID, createHash } from 'node:crypto';
5
+ import { spawn } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { prepareCommand, registry, run as dockerRun, stewardOwned } from './plugins/manager.mjs';
8
+
9
+ const MAX_FRAME = 4 * 1024 * 1024;
10
+ const MAX_ARGS = 100;
11
+ const MAX_ARG_BYTES = 8192;
12
+ const MAX_STDIN_BYTES = 1024 * 1024;
13
+ const MAX_OUTPUT_BYTES = 1024 * 1024;
14
+ const MAX_MANAGER_OUTPUT_BYTES = 256 * 1024;
15
+ const ADMISSION_TTL_MS = 30_000;
16
+ const RUN_ID = /^[A-Za-z0-9_-]{1,160}$/;
17
+ const IDENTIFIER = /^[a-z][a-z0-9-]{0,39}$/;
18
+ const REVISION = /^sha256:[a-f0-9]{64}$/;
19
+ const REQUEST_ID = /^[a-f0-9-]{36}$/;
20
+
21
+ const { authorizeRun, deliverySocketPath } = await import('./delivery-socket.js');
22
+
23
+ const childOf = (candidate, parent) => candidate === parent || candidate.startsWith(parent + path.sep);
24
+ const json = async file => JSON.parse(await fs.readFile(file, 'utf8'));
25
+ const boundedString = (value, limit, name) => {
26
+ if (typeof value !== 'string' || value.includes('\0') || Buffer.byteLength(value) > limit) throw Error(`Invalid ${name}`);
27
+ return value;
28
+ };
29
+ const validId = (value, pattern, name) => {
30
+ if (typeof value !== 'string' || !pattern.test(value)) throw Error(`Invalid ${name}`);
31
+ return value;
32
+ };
33
+ const validateArgs = value => {
34
+ if (!Array.isArray(value) || value.length > MAX_ARGS || value.some(arg => typeof arg !== 'string' || arg.includes('\0') || Buffer.byteLength(arg) > MAX_ARG_BYTES))
35
+ throw Error('Invalid literal plugin arguments');
36
+ return value;
37
+ };
38
+ const validateRevision = value => validId(value, REVISION, 'plugin revision');
39
+
40
+ const safeEnvironment = (binding, runId) => {
41
+ const names = ['HOME', 'PATH', 'LANG', 'LC_ALL', 'TMPDIR', 'DOCKER_HOST', 'DOCKER_CONTEXT', 'DOCKER_CONFIG', 'BUILDX_CONFIG', 'EZ_DOCKER_COMPOSE', 'EZ_DELIVERY_SOCKET'];
42
+ const environment = Object.fromEntries(names.flatMap(name => process.env[name] === undefined ? [] : [[name, process.env[name]]]));
43
+ return { ...environment, EZ_CONTROL_DIR: binding.controlDir, EZ_RUN_ID: runId, EZ_EXECUTOR_TRANSPORT: 'local', EZ_DELIVERY_SOCKET: environment.EZ_DELIVERY_SOCKET ?? deliverySocketPath(binding.controlDir) };
44
+ };
45
+
46
+ const recordForAlias = (r, alias) => {
47
+ validId(alias, IDENTIFIER, 'plugin alias');
48
+ const plugin = r.commands?.[alias];
49
+ const record = typeof plugin === 'string' ? r.plugins?.[plugin] : undefined;
50
+ const command = record?.manifest?.commands?.[alias];
51
+ if (!record || !command || record.manifest.id !== plugin || record.deployment?.commands?.[alias] === undefined)
52
+ throw Error('Unknown or unavailable registered CLI');
53
+ validateRevision(record.revision);
54
+ return { plugin, record, command };
55
+ };
56
+
57
+ const validateManagerArgs = async (value, workspace) => {
58
+ const args = validateArgs(value);
59
+ if (!args.length) throw Error('Missing broker management command');
60
+ const [group, action, name, extra, ...tail] = args;
61
+ if (group === 'tools' && action === 'list' && (args.length === 2 || args.length === 3 && name === '--details')) return args;
62
+ if (group === 'tools' && action === 'exposure' && args.length === 2) return args;
63
+ if (group === 'plugins' && action === 'available' && args.length === 2) return args;
64
+ if (group === 'plugins' && action === 'list' && args.length === 2) return args;
65
+ if (group === 'status' && args.length === 1) return args;
66
+ if (group === 'plugins' && ['inspect', 'install', 'start', 'stop', 'status', 'logs', 'uninstall'].includes(action) && IDENTIFIER.test(name) && args.length === 3) return args;
67
+ if (group === 'plugins' && ['inspect', 'install'].includes(action) && IDENTIFIER.test(name) &&
68
+ args.length === 7 && extra === '--source' && tail[1] === '--revision' && REVISION.test(tail[2])) {
69
+ const source = tail[0];
70
+ if (!workspace || !path.isAbsolute(source)) throw Error('Plugin source must be an absolute path inside the agent workspace');
71
+ let resolved;
72
+ try { resolved = await fs.realpath(source); } catch { throw Error('Plugin source must be an existing path inside the agent workspace'); }
73
+ if (!childOf(resolved, workspace)) throw Error('Plugin source must stay inside the agent workspace');
74
+ return args;
75
+ }
76
+ if (group === 'plugins' && action === 'catalog-add' && IDENTIFIER.test(name) &&
77
+ args.length === 7 && extra === '--source' && tail[1] === '--revision' && REVISION.test(tail[2])) {
78
+ const source = tail[0];
79
+ if (!workspace || !path.isAbsolute(source)) throw Error('Plugin source must be an absolute path inside the agent workspace');
80
+ let resolved;
81
+ try { resolved = await fs.realpath(source); } catch { throw Error('Plugin source must be an existing path inside the agent workspace'); }
82
+ if (!childOf(resolved, workspace)) throw Error('Plugin source must stay inside the agent workspace');
83
+ return args;
84
+ }
85
+ if (group === 'plugins' && ['shared-enable', 'shared-disable', 'shared-status'].includes(action) && IDENTIFIER.test(name) && IDENTIFIER.test(extra) && args.length === 4) return args;
86
+ if (group === 'updates') {
87
+ if (!action || (action === '--help' && args.length === 2)) return args;
88
+ if (action === 'status' && args.length === 2) return args;
89
+ if (action === 'policy' && (args.length === 3 || args.length === 4) && IDENTIFIER.test(name) &&
90
+ (args.length === 3 || ['stable', 'beta', 'manual'].includes(extra))) return args;
91
+ if (action === 'prepare' && args.length === 5 && IDENTIFIER.test(name) && extra === '--file' &&
92
+ typeof tail[0] === 'string' && path.isAbsolute(tail[0])) {
93
+ let resolved;
94
+ try { resolved = await fs.realpath(tail[0]); } catch { throw Error('Upgrade candidate must be an existing file inside the agent workspace'); }
95
+ if (!childOf(resolved, workspace)) throw Error('Upgrade candidate must stay inside the agent workspace');
96
+ return args;
97
+ }
98
+ if (action === 'apply' && (args.length === 3 || args.length === 4) &&
99
+ /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(name) &&
100
+ (args.length === 3 || extra === '--automatic')) return args;
101
+ if (action === 'recover' && args.length === 3 &&
102
+ /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(name)) return args;
103
+ }
104
+ throw Error('Management command is not allowed through the isolated plugin broker');
105
+ };
106
+
107
+ const hashPath = value => createHash('sha256').update(value).digest('hex');
108
+
109
+ export async function loadPluginBrokerBinding(input) {
110
+ const required = ['home', 'workspace', 'controlDir', 'socket', 'hostConfig'];
111
+ if (!input || required.some(key => typeof input[key] !== 'string' || !path.isAbsolute(input[key]))) throw Error('Plugin broker requires absolute agent bindings');
112
+ const home = await fs.realpath(input.home);
113
+ const workspace = await fs.realpath(input.workspace);
114
+ const controlDir = await fs.realpath(input.controlDir);
115
+ const hostConfig = await fs.realpath(input.hostConfig);
116
+ const socket = path.resolve(input.socket);
117
+ if (home === workspace || home === controlDir || workspace === controlDir) throw Error('Plugin broker bindings must be separate');
118
+ if (!childOf(socket, controlDir) || path.extname(socket) !== '.sock' || socket.length > 100) throw Error('Plugin broker socket must stay inside the agent control directory and fit the Unix socket limit');
119
+ if (childOf(hostConfig, home) || childOf(hostConfig, workspace) || childOf(hostConfig, controlDir)) throw Error('Plugin broker host binding must stay outside agent state');
120
+ const config = await json(path.join(home, 'config.json'));
121
+ if (config.schemaVersion !== 1 || config.workspace !== workspace || config.hostConfig !== hostConfig) throw Error('Plugin registry belongs to another agent');
122
+ const host = await json(hostConfig);
123
+ if (host.isolation !== 'isolated' || !Array.isArray(host.agents)) throw Error('Plugin broker requires an isolated agent binding');
124
+ const agents = host.agents.filter(agent => agent && typeof agent === 'object' && typeof agent.toolsHome === 'string' && typeof agent.workspace === 'string' && typeof agent.controlDir === 'string');
125
+ const matches = [];
126
+ for (const agent of agents) {
127
+ try {
128
+ if (await fs.realpath(agent.toolsHome) === home && await fs.realpath(agent.workspace) === workspace && await fs.realpath(agent.controlDir) === controlDir) matches.push(agent);
129
+ } catch { /* a broken sibling binding is not this broker's authority */ }
130
+ }
131
+ if (matches.length !== 1) throw Error('Plugin broker registry is not owned by exactly one isolated agent');
132
+ const r = await registry(home);
133
+ for (const [plugin, record] of Object.entries(r.plugins)) {
134
+ if (!IDENTIFIER.test(plugin) || !REVISION.test(record.revision) || typeof record.source !== 'string' || !path.isAbsolute(record.source) || !childOf(record.source, home))
135
+ throw Error('Plugin registry contains an unsafe agent binding');
136
+ }
137
+ const timeoutMs = input.timeoutMs === undefined ? 300_000 : Number(input.timeoutMs);
138
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 900_000) throw Error('Invalid plugin broker timeout');
139
+ return { home, workspace, controlDir, hostConfig, socket, agent: String(matches[0].name || hashPath(home).slice(0, 16)), timeoutMs };
140
+ }
141
+
142
+ const authorize = async (binding, runId) => {
143
+ validId(runId, RUN_ID, 'plugin run ID');
144
+ return authorizeRun(binding.controlDir, runId);
145
+ };
146
+
147
+ const writeReceipt = async (binding, receipt) => {
148
+ const receipts = path.join(binding.controlDir, 'plugin-receipts');
149
+ const directory = path.join(receipts, receipt.runId);
150
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
151
+ await stewardOwned(receipts);
152
+ await stewardOwned(directory);
153
+ const file = path.join(directory, `${receipt.receiptId}.json`);
154
+ const temporary = `${file}.${randomUUID()}.tmp`;
155
+ await fs.writeFile(temporary, JSON.stringify(receipt, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
156
+ await fs.rename(temporary, file);
157
+ await stewardOwned(file);
158
+ return file;
159
+ };
160
+
161
+ // Installed plugin versions for relay status. Rewritten on broker start and
162
+ // after every management call; the relay reads it without Docker access.
163
+ // Failures stay local so a status snapshot never breaks plugin operations.
164
+ const refreshBrokerPlugins = async binding => {
165
+ const r = await registry(binding.home);
166
+ const plugins = Object.values(r.plugins).map(record => ({ id: record.manifest.id, version: record.manifest.version }));
167
+ const file = path.join(binding.controlDir, 'plugin-broker-plugins.json');
168
+ const temporary = `${file}.${randomUUID()}.tmp`;
169
+ await fs.writeFile(temporary, JSON.stringify({ version: 1, at: new Date().toISOString(), plugins }) + '\n', { mode: 0o600, flag: 'wx' });
170
+ await fs.rename(temporary, file);
171
+ await stewardOwned(file);
172
+ };
173
+
174
+ const response = (socket, value) => {
175
+ if (socket.destroyed || !socket.writable) return;
176
+ const text = JSON.stringify(value) + '\n';
177
+ if (Buffer.byteLength(text) > MAX_FRAME) return socket.destroy(new Error('Broker response exceeds frame limit'));
178
+ socket.end(text);
179
+ };
180
+
181
+ const terminate = child => {
182
+ if (!child.pid || child.exitCode !== null || child.signalCode !== null) return;
183
+ try { if (process.platform !== 'win32') process.kill(-child.pid, 'SIGTERM'); } catch {}
184
+ try { child.kill('SIGTERM'); } catch {}
185
+ setTimeout(() => {
186
+ try { if (process.platform !== 'win32') process.kill(-child.pid, 'SIGKILL'); } catch {}
187
+ try { child.kill('SIGKILL'); } catch {}
188
+ }, 2_000).unref();
189
+ };
190
+
191
+ const managerCommand = async (binding, args, signal, runId) => new Promise((resolve, reject) => {
192
+ const entry = fileURLToPath(new URL('../bin/ezenciel-agents-tools.mjs', import.meta.url));
193
+ const child = spawn(process.execPath, [entry, '--home', binding.home, ...args], {
194
+ cwd: binding.workspace,
195
+ env: safeEnvironment(binding, runId),
196
+ stdio: ['pipe', 'pipe', 'pipe'],
197
+ detached: process.platform !== 'win32',
198
+ });
199
+ let stdout = '', stderr = '', bytes = 0, failure;
200
+ const collect = (chunk, error) => {
201
+ bytes += chunk.length;
202
+ if (bytes > MAX_MANAGER_OUTPUT_BYTES) {
203
+ failure = Error('Plugin broker management output limit exceeded');
204
+ terminate(child);
205
+ return;
206
+ }
207
+ if (error) stderr += chunk.toString(); else stdout += chunk.toString();
208
+ };
209
+ child.stdout.on('data', chunk => collect(chunk, false));
210
+ child.stderr.on('data', chunk => collect(chunk, true));
211
+ const abort = () => { failure = Error('Plugin broker request cancelled'); terminate(child); };
212
+ signal.addEventListener('abort', abort, { once: true });
213
+ const timer = setTimeout(() => { failure = Error('Plugin broker management timed out'); terminate(child); }, binding.timeoutMs);
214
+ child.stdin.end();
215
+ child.once('error', error => { clearTimeout(timer); signal.removeEventListener('abort', abort); reject(error); });
216
+ child.once('close', code => {
217
+ clearTimeout(timer);
218
+ signal.removeEventListener('abort', abort);
219
+ if (failure) return reject(failure);
220
+ resolve({ code: code ?? 1, stdout, stderr });
221
+ });
222
+ });
223
+
224
+ const invokePlugin = async (binding, request, signal) => {
225
+ const startedAt = new Date().toISOString();
226
+ let plugin = request.alias;
227
+ let revision = request.revision;
228
+ let result;
229
+ let failure;
230
+ try {
231
+ await authorize(binding, request.runId);
232
+ const current = await registry(binding.home);
233
+ const selected = recordForAlias(current, request.alias);
234
+ plugin = selected.plugin;
235
+ revision = selected.record.revision;
236
+ if (selected.record.revision !== request.revision) throw Error('Plugin changed; discover again');
237
+ const command = await prepareCommand(binding.home, request.alias, request.args, {
238
+ revision: request.revision,
239
+ invocation: true,
240
+ environment: { EZ_CONTROL_DIR: binding.controlDir, EZ_RUN_ID: request.runId, EZ_DELIVERY_SOCKET: process.env.EZ_DELIVERY_SOCKET ?? deliverySocketPath(binding.controlDir) },
241
+ });
242
+ try {
243
+ result = await dockerRun(command.argv, {
244
+ container: command.container,
245
+ capture: true,
246
+ stdin: request.stdin,
247
+ signal,
248
+ timeoutMs: binding.timeoutMs,
249
+ maxBytes: MAX_OUTPUT_BYTES,
250
+ });
251
+ } finally {
252
+ await command.release?.();
253
+ }
254
+ } catch (error) {
255
+ failure = error instanceof Error ? error.message : 'Plugin invocation failed';
256
+ }
257
+ const endedAt = new Date().toISOString();
258
+ const receipt = {
259
+ version: 1,
260
+ kind: 'plugin-invocation',
261
+ receiptId: request.id,
262
+ agent: binding.agent,
263
+ runId: request.runId,
264
+ plugin,
265
+ alias: request.alias,
266
+ revision,
267
+ startedAt,
268
+ endedAt,
269
+ status: failure ? 'failed' : result.code === 0 ? 'completed' : 'plugin-failed',
270
+ exitCode: failure ? 1 : result.code,
271
+ stdinBytes: Buffer.byteLength(request.stdin || ''),
272
+ stdoutBytes: Buffer.byteLength(result?.stdout || ''),
273
+ stderrBytes: Buffer.byteLength(result?.stderr || ''),
274
+ ...(failure ? { error: failure } : {}),
275
+ };
276
+ const receiptPath = await writeReceipt(binding, receipt);
277
+ if (failure) return { ok: false, error: failure, receipt, receiptPath };
278
+ return { ok: true, code: result.code, stdout: result.stdout, stderr: result.stderr, receipt, receiptPath };
279
+ };
280
+
281
+ export async function validateBrokerRequest(request, workspace) {
282
+ if (!request || typeof request !== 'object' || request.version !== 1 || !REQUEST_ID.test(request.id)) throw Error('Invalid plugin broker request');
283
+ if (!['resolve', 'invoke', 'manager'].includes(request.operation)) throw Error('Unknown plugin broker operation');
284
+ validId(request.runId, RUN_ID, 'plugin run ID');
285
+ if (request.operation === 'manager') {
286
+ await validateManagerArgs(request.args, workspace);
287
+ if (Object.keys(request).some(key => !['version', 'id', 'operation', 'runId', 'args'].includes(key))) throw Error('Unexpected broker management field');
288
+ } else {
289
+ validId(request.alias, IDENTIFIER, 'plugin alias');
290
+ if (request.operation === 'resolve') {
291
+ if (Object.keys(request).some(key => !['version', 'id', 'operation', 'runId', 'alias'].includes(key))) throw Error('Unexpected broker resolve field');
292
+ } else {
293
+ validateRevision(request.revision);
294
+ validateArgs(request.args);
295
+ if (request.stdin !== undefined) boundedString(request.stdin, MAX_STDIN_BYTES, 'plugin stdin');
296
+ if (typeof request.capability !== 'string' || request.capability.length < 32 || request.capability.length > 128 || !/^[A-Za-z0-9_-]+$/.test(request.capability)) throw Error('Invalid plugin broker capability');
297
+ if (Object.keys(request).some(key => !['version', 'id', 'operation', 'runId', 'alias', 'revision', 'args', 'stdin', 'capability'].includes(key))) throw Error('Unexpected broker invoke field');
298
+ }
299
+ }
300
+ return request;
301
+ }
302
+
303
+ export async function servePluginBroker(binding, signal = new AbortController().signal) {
304
+ await fs.mkdir(path.dirname(binding.socket), { recursive: true, mode: 0o700 });
305
+ try {
306
+ const existing = await fs.lstat(binding.socket);
307
+ if (!existing.isSocket()) throw Error('Plugin broker socket path is not a socket');
308
+ await fs.rm(binding.socket);
309
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
310
+ await refreshBrokerPlugins(binding).catch(() => {});
311
+ const admissions = new Map();
312
+ const server = createServer({ allowHalfOpen: true }, socket => {
313
+ let buffer = '', handled = false;
314
+ const abort = new AbortController();
315
+ socket.on('error', () => abort.abort());
316
+ socket.on('close', () => abort.abort());
317
+ socket.on('data', chunk => {
318
+ if (handled) return;
319
+ buffer += chunk.toString();
320
+ if (Buffer.byteLength(buffer) > MAX_FRAME) { socket.destroy(new Error('Broker request exceeds frame limit')); return; }
321
+ const end = buffer.indexOf('\n');
322
+ if (end < 0) return;
323
+ handled = true;
324
+ let request;
325
+ try { request = JSON.parse(buffer.slice(0, end)); } catch (error) { response(socket, { version: 1, ok: false, error: 'Invalid JSON request' }); return; }
326
+ (async () => {
327
+ try {
328
+ await validateBrokerRequest(request, binding.workspace);
329
+ if (request.operation === 'resolve') {
330
+ await authorize(binding, request.runId);
331
+ const selected = recordForAlias(await registry(binding.home), request.alias);
332
+ const capability = randomBytes(48).toString('base64url');
333
+ if (admissions.size >= 512) admissions.delete(admissions.keys().next().value);
334
+ admissions.set(capability, { runId: request.runId, alias: request.alias, revision: selected.record.revision, expiresAt: Date.now() + ADMISSION_TTL_MS });
335
+ setTimeout(() => admissions.delete(capability), ADMISSION_TTL_MS).unref();
336
+ response(socket, { version: 1, id: request.id, ok: true, plugin: selected.plugin, alias: request.alias, revision: selected.record.revision, capability });
337
+ return;
338
+ }
339
+ if (request.operation === 'manager') {
340
+ await authorize(binding, request.runId);
341
+ const result = await managerCommand(binding, request.args, abort.signal, request.runId);
342
+ await refreshBrokerPlugins(binding).catch(() => {});
343
+ response(socket, { version: 1, id: request.id, ok: true, ...result });
344
+ return;
345
+ }
346
+ const admission = admissions.get(request.capability);
347
+ admissions.delete(request.capability);
348
+ if (!admission || admission.expiresAt < Date.now() || admission.runId !== request.runId || admission.alias !== request.alias || admission.revision !== request.revision)
349
+ throw Error('Invalid or expired plugin broker capability');
350
+ const result = await invokePlugin(binding, request, abort.signal);
351
+ response(socket, { version: 1, id: request.id, ...result });
352
+ } catch (error) {
353
+ response(socket, { version: 1, id: request.id, ok: false, error: error instanceof Error ? error.message : 'Plugin broker request failed' });
354
+ }
355
+ })();
356
+ });
357
+ });
358
+ await new Promise((resolve, reject) => {
359
+ server.once('error', reject);
360
+ server.listen(binding.socket, () => { server.off('error', reject); resolve(); });
361
+ });
362
+ // World-writable is deliberate: the root broker and the dropped-privilege
363
+ // relay share only this agent-private control directory (0700, never
364
+ // mounted elsewhere). Per-request capability auth still gates every call.
365
+ await fs.chmod(binding.socket, 0o666);
366
+ const stop = () => {
367
+ for (const admission of admissions.keys()) admissions.delete(admission);
368
+ server.close();
369
+ };
370
+ signal.addEventListener('abort', stop, { once: true });
371
+ await new Promise(resolve => server.once('close', resolve));
372
+ signal.removeEventListener('abort', stop);
373
+ await fs.rm(binding.socket, { force: true });
374
+ }
375
+
376
+ const option = (args, name) => {
377
+ const index = args.indexOf(name);
378
+ return index < 0 ? undefined : args[index + 1];
379
+ };
380
+
381
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
382
+ const args = process.argv.slice(2);
383
+ const input = {
384
+ home: option(args, '--home') || process.env.EZ_PLUGIN_BROKER_HOME,
385
+ workspace: option(args, '--workspace') || process.env.EZ_PLUGIN_BROKER_WORKSPACE,
386
+ controlDir: option(args, '--control-dir') || process.env.EZ_PLUGIN_BROKER_CONTROL_DIR,
387
+ socket: option(args, '--socket') || process.env.EZ_PLUGIN_BROKER_SOCKET,
388
+ hostConfig: option(args, '--host-config') || process.env.EZ_PLUGIN_BROKER_HOST_CONFIG,
389
+ timeoutMs: option(args, '--timeout-ms') || process.env.EZ_PLUGIN_BROKER_TIMEOUT_MS,
390
+ };
391
+ if (args.some((value, index) => value.startsWith('--') && !['--home', '--workspace', '--control-dir', '--socket', '--host-config', '--timeout-ms'].includes(value) || value.startsWith('--') && index + 1 >= args.length)) throw Error('Unknown plugin broker option');
392
+ const binding = await loadPluginBrokerBinding(input);
393
+ const abort = new AbortController();
394
+ for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => abort.abort());
395
+ await servePluginBroker(binding, abort.signal);
396
+ }
@@ -112,7 +112,7 @@ export async function connect(home,alias,args,{input=process.stdin,output=proces
112
112
  },
113
113
  listNative:()=>nativeCommands().map(item=>({...item,available:!!nativeBinding&&(item.command!=='message'||!!deliveryContext)})),
114
114
  executeNative:(args,options)=>nativeTasks(home,args,{...options,deliveryContext}),
115
- execute:async(a,argv,options)=>{const release=options.invocation?await invokeLease(home):undefined;try {const c=await prepareCommand(home,a,argv,{revision:options.revision,exclude:command.plugin});if(options.signal.aborted)throw Error('Request cancelled');const execute=overrides=>run(c.argv,{...options,...overrides,container:c.container,capture:true,timeoutMs:30000,maxBytes:262144});return options.output===undefined?await execute({}):await commandArtifact(config.workspace,options.output,execute,{signal:options.signal});}finally{await release?.();}},
115
+ execute:async(a,argv,options)=>{const release=options.invocation?await invokeLease(home):undefined;try {const c=await prepareCommand(home,a,argv,{revision:options.revision,exclude:command.plugin});if(options.signal.aborted)throw Error('Request cancelled');const execute=overrides=>run(c.argv,{...options,...overrides,container:c.container,capture:true,timeoutMs:30000,maxBytes:262144});try {return options.output===undefined?await execute({}):await commandArtifact(config.workspace,options.output,execute,{signal:options.signal});}finally{if(c.contextFile)await fs.rm(c.contextFile,{force:true}).catch(()=>{});}}finally{await release?.();}},
116
116
  sendClient:frame=>send(output,frame),sendPlugin:frame=>send(child?.stdin,frame)});
117
117
  const onInput=jsonLines(frame=>protocol.client(frame),fail),onEnd=()=>{protocol.close();abort.abort();};
118
118
  const onSignal=()=>{protocol.close();abort.abort();};
@@ -120,5 +120,5 @@ export async function connect(home,alias,args,{input=process.stdin,output=proces
120
120
  try {
121
121
  const result=await run(command.argv,{container:command.container,capture:true,maxBytes:262144,signal:abort.signal,onStdout:jsonLines(frame=>protocol.plugin(frame),fail),onStart:c=>{child=c;if(!serve){input.on('data',onInput);input.once('end',onEnd);input.resume();}}});
122
122
  if(failure)throw failure;process.exitCode=result.code;
123
- } finally {if(serve)for(const signal of ['SIGINT','SIGTERM'])process.off(signal,onSignal);input.off('data',onInput);input.off('end',onEnd);input.pause();protocol.close();}
123
+ } finally {if(serve)for(const signal of ['SIGINT','SIGTERM'])process.off(signal,onSignal);input.off('data',onInput);input.off('end',onEnd);input.pause();protocol.close();if(command.contextFile)await fs.rm(command.contextFile,{force:true}).catch(()=>{});}
124
124
  }