@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.
- package/.dockerignore +1 -0
- package/.env.example +9 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +92 -0
- package/Dockerfile +11 -9
- package/README.md +32 -27
- package/THIRD_PARTY_NOTICES.md +4 -4
- package/bin/ez +97 -0
- package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
- package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
- package/bin/ezenciel-agents.mjs +1 -1
- package/compose.yaml +42 -5
- package/docker/entrypoint.sh +1 -1
- package/docker/healthcheck.mjs +12 -4
- package/docker/run.ts +5 -6
- package/docker/smoke.mjs +32 -7
- package/docs/application-channel.md +38 -10
- package/docs/architecture/ai-selection.md +3 -1
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +106 -21
- package/docs/host-service.md +17 -6
- package/docs/local-qa.md +2 -2
- package/docs/plugin-guide.md +202 -0
- package/docs/plugins.md +63 -2
- package/docs/releasing.md +13 -0
- package/docs/repair.md +1 -1
- package/docs/responsive-channels.md +1 -1
- package/docs/scheduling.md +4 -4
- package/docs/setup.md +96 -16
- package/docs/standalone-cli.md +6 -5
- package/docs/upgrades.md +46 -23
- package/package.json +5 -5
- package/scripts/smoke-scheduler.ts +12 -4
- package/scripts/smoke.ts +14 -11
- package/src/agent-guidance.ts +6 -9
- package/src/agent-install.ts +44 -9
- package/src/ai-cli.ts +9 -7
- package/src/ai.ts +7 -7
- package/src/application-channel.ts +88 -17
- package/src/application-cli.ts +2 -2
- package/src/approval-cli.ts +14 -7
- package/src/approval.ts +27 -37
- package/src/channel-backend.ts +2 -3
- package/src/codex-session.ts +5 -3
- package/src/config.ts +18 -28
- package/src/control-state.ts +187 -9
- package/src/conversation-menu.ts +6 -17
- package/src/delivery-socket-client.d.mts +4 -0
- package/src/delivery-socket-client.mjs +104 -0
- package/src/delivery-socket.ts +354 -0
- package/src/execution-authority.ts +21 -5
- package/src/executor.ts +110 -31
- package/src/files.ts +38 -11
- package/src/host-executor.ts +50 -17
- package/src/inbox.ts +15 -49
- package/src/index.ts +96 -52
- package/src/install-config.ts +2 -2
- package/src/install-tools.mjs +100 -23
- package/src/isolation.ts +25 -0
- package/src/ledger-endpoint.d.mts +2 -0
- package/src/ledger-endpoint.mjs +17 -0
- package/src/menu.ts +16 -8
- package/src/message-history.ts +13 -18
- package/src/message.ts +35 -18
- package/src/plugin-broker.mjs +396 -0
- package/src/plugins/connection.mjs +2 -2
- package/src/plugins/manager.mjs +178 -26
- package/src/plugins/native-tasks.mjs +2 -2
- package/src/prompt-suffix.ts +12 -0
- package/src/react.ts +6 -4
- package/src/runs.ts +156 -243
- package/src/schedule-cli.ts +29 -10
- package/src/scheduled-tasks.ts +53 -10
- package/src/scheduler.ts +20 -7
- package/src/service.ts +3 -3
- package/src/setup.ts +11 -7
- package/src/software-status.ts +36 -4
- package/src/task-executor.ts +6 -3
- package/src/task-workspace.ts +4 -3
- package/src/tasks.ts +1 -1
- package/src/telegram-provisioning-cli.ts +22 -0
- package/src/telegram-provisioning.ts +214 -0
- package/src/updates/control.mjs +77 -7
- package/src/updates/runtime.mjs +29 -9
- package/src/updates/status.mjs +25 -3
- package/src/updates/supervisor.mjs +31 -18
- package/src/version.ts +20 -0
- package/src/workspace-confine.ts +38 -0
- package/src/workspace.ts +24 -20
- package/templates/agent-purpose.md +1 -0
- package/templates/failure-review.md +1 -1
- package/templates/maintainer-purpose.md +1 -1
- package/test/agent-guidance.test.ts +18 -88
- package/test/agent-install.test.ts +48 -0
- package/test/ai-cli.test.ts +18 -0
- package/test/ai.test.ts +84 -0
- package/test/application-channel.test.ts +66 -15
- package/test/application-controls.test.ts +56 -3
- package/test/application-only.test.ts +16 -13
- package/test/channel-backend.test.ts +3 -7
- package/test/channel-delivery.test.ts +4 -1
- package/test/channel-owner.test.ts +6 -3
- package/test/codex-context.test.ts +63 -3
- package/test/codex-session.test.ts +6 -1
- package/test/config.test.ts +47 -37
- package/test/control-state.test.ts +83 -4
- package/test/conversation-menu.test.ts +2 -4
- package/test/delivery-transport.test.ts +72 -0
- package/test/docker-runtime.test.ts +27 -14
- package/test/engine-handoff.test.ts +12 -8
- package/test/event-sources.test.ts +2 -2
- package/test/execution-authority.test.ts +9 -9
- package/test/executor.test.ts +47 -11
- package/test/failure.test.ts +18 -5
- package/test/files.test.ts +2 -2
- package/test/helpers/ledger.ts +18 -0
- package/test/host-executor.test.ts +80 -0
- package/test/inbox.test.ts +8 -10
- package/test/install-config.test.ts +18 -6
- package/test/install-tools.test.mjs +80 -7
- package/test/intake-relay.test.ts +58 -11
- package/test/message-history.test.ts +39 -47
- package/test/native-tasks.test.ts +1 -1
- package/test/plugin-broker.test.mjs +172 -0
- package/test/plugin-manager.test.mjs +143 -5
- package/test/reaction.test.ts +4 -0
- package/test/relay.test.ts +5 -7
- package/test/repair-policy.test.ts +1 -4
- package/test/schedule-cli.test.ts +14 -9
- package/test/scheduled-tasks.test.ts +29 -6
- package/test/scheduler-host.test.ts +5 -5
- package/test/scheduler-relay.test.ts +15 -9
- package/test/scheduler.test.ts +2 -2
- package/test/security.test.ts +6 -3
- package/test/software-status.test.ts +21 -3
- package/test/telegram-provisioning.test.ts +160 -0
- package/test/updates.test.mjs +102 -13
- package/test/workspace-confine.test.ts +47 -0
- package/test/workspace.test.ts +27 -11
- package/bin/ezenciel-agents-watch.mjs +0 -8
- package/compose.workforce-watch.yaml +0 -33
- package/docker/recovery.ts +0 -11
- package/docs/pagerduty.md +0 -42
- package/docs/workforce-watch.md +0 -101
- package/src/pagerduty.ts +0 -109
- package/src/reply-context.ts +0 -44
- package/src/update-attention.ts +0 -18
- package/src/workforce-watch-cli.ts +0 -14
- package/src/workforce-watch.ts +0 -155
- package/templates/agent/AGENTS.md +0 -15
- package/templates/agent/SOUL.md +0 -11
- package/templates/agent/USER.md +0 -5
- package/templates/agent-guidance.md +0 -114
- package/test/pagerduty.test.ts +0 -104
- package/test/update-attention.test.ts +0 -20
- package/test/workforce-watch.test.ts +0 -180
package/src/plugins/manager.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
354
|
-
|
|
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
|
|
531
|
-
|
|
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)
|
|
535
|
-
if(
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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 {
|
|
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
|
|
32
|
-
const item = await
|
|
33
|
-
|
|
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 }))
|