@jc_stack/ez-agents 0.1.0-beta.12
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 +24 -0
- package/.env.example +26 -0
- package/AGENTS.md +84 -0
- package/CHANGELOG.md +39 -0
- package/CONTRIBUTING.md +73 -0
- package/Dockerfile +16 -0
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/SECURITY.md +26 -0
- package/THIRD_PARTY_NOTICES.md +14 -0
- package/bin/ezenciel-agents +2 -0
- package/bin/ezenciel-agents-ai +2 -0
- package/bin/ezenciel-agents-ai.mjs +5 -0
- package/bin/ezenciel-agents-approval +2 -0
- package/bin/ezenciel-agents-approval.mjs +16 -0
- package/bin/ezenciel-agents-create +12 -0
- package/bin/ezenciel-agents-docker +6 -0
- package/bin/ezenciel-agents-host +5 -0
- package/bin/ezenciel-agents-install +2 -0
- package/bin/ezenciel-agents-message +2 -0
- package/bin/ezenciel-agents-message.mjs +16 -0
- package/bin/ezenciel-agents-owner +2 -0
- package/bin/ezenciel-agents-owner.mjs +18 -0
- package/bin/ezenciel-agents-react +2 -0
- package/bin/ezenciel-agents-react.mjs +16 -0
- package/bin/ezenciel-agents-setup.mjs +18 -0
- package/bin/ezenciel-agents-source +2 -0
- package/bin/ezenciel-agents-source.mjs +16 -0
- package/bin/ezenciel-agents-tools.mjs +3 -0
- package/bin/ezenciel-agents.mjs +37 -0
- package/compose.whatsapp.yaml +12 -0
- package/compose.yaml +40 -0
- package/default-plugins.json +1 -0
- package/docker/entrypoint.sh +15 -0
- package/docker/healthcheck.mjs +8 -0
- package/docker/plugin-smoke.mjs +48 -0
- package/docker/pnpm-lock.yaml +415 -0
- package/docker/recovery.ts +11 -0
- package/docker/run.ts +52 -0
- package/docker/smoke.mjs +47 -0
- package/docker/status-smoke.mjs +30 -0
- package/docker/upgrade-smoke.mjs +58 -0
- package/docs/architecture/ai-selection.md +37 -0
- package/docs/architecture/authority-boundaries.md +14 -0
- package/docs/architecture/event-sources.md +34 -0
- package/docs/architecture/telegram-intake.md +29 -0
- package/docs/development-and-testing.md +18 -0
- package/docs/docker-runtime.md +118 -0
- package/docs/host-service.md +80 -0
- package/docs/plugin-contributions.md +34 -0
- package/docs/plugins.md +181 -0
- package/docs/releasing.md +71 -0
- package/docs/setup.md +234 -0
- package/docs/upgrades.md +193 -0
- package/package.json +106 -0
- package/scripts/assert-local-registry.mjs +22 -0
- package/scripts/release-check.mjs +14 -0
- package/scripts/smoke.ts +102 -0
- package/src/agent-install.ts +96 -0
- package/src/ai-cli.ts +22 -0
- package/src/ai.ts +88 -0
- package/src/approval-cli.ts +59 -0
- package/src/approval.ts +119 -0
- package/src/audio.ts +184 -0
- package/src/client-defaults.ts +101 -0
- package/src/config.ts +48 -0
- package/src/control-state.ts +350 -0
- package/src/desktop-bridge.ts +284 -0
- package/src/event-sources.ts +112 -0
- package/src/executor.ts +335 -0
- package/src/files.ts +75 -0
- package/src/format.ts +57 -0
- package/src/host-executor-client.ts +46 -0
- package/src/host-executor-protocol.ts +2 -0
- package/src/host-executor.ts +129 -0
- package/src/identity.ts +17 -0
- package/src/inbox.ts +171 -0
- package/src/index.ts +812 -0
- package/src/install-config.ts +56 -0
- package/src/install-tools.mjs +98 -0
- package/src/menu.ts +123 -0
- package/src/message-send.ts +57 -0
- package/src/message.ts +68 -0
- package/src/owner-args.ts +4 -0
- package/src/owner.ts +30 -0
- package/src/plugins/manager.mjs +272 -0
- package/src/react.ts +33 -0
- package/src/reaction.ts +32 -0
- package/src/read-request.ts +72 -0
- package/src/reply.ts +13 -0
- package/src/runs.ts +445 -0
- package/src/service.ts +28 -0
- package/src/setup.ts +180 -0
- package/src/software-status.ts +23 -0
- package/src/source-cli.ts +18 -0
- package/src/update-attention.ts +18 -0
- package/src/updates/artifact.mjs +83 -0
- package/src/updates/binding.mjs +27 -0
- package/src/updates/control.mjs +131 -0
- package/src/updates/launch.mjs +13 -0
- package/src/updates/runtime.mjs +140 -0
- package/src/updates/status.mjs +49 -0
- package/src/updates/supervisor.mjs +102 -0
- package/src/version.ts +4 -0
- package/src/workspace.ts +32 -0
- package/templates/agent/AGENTS.md +49 -0
- package/templates/agent/SOUL.md +11 -0
- package/templates/agent/TOOLS.md +46 -0
- package/templates/agent/USER.md +5 -0
- package/templates/updates.md +45 -0
- package/test/agent-install.test.ts +48 -0
- package/test/ai-cli.test.ts +28 -0
- package/test/ai.test.ts +105 -0
- package/test/approval.test.ts +40 -0
- package/test/audio.test.ts +77 -0
- package/test/client-defaults.test.ts +64 -0
- package/test/codex-context.test.ts +33 -0
- package/test/config.test.ts +32 -0
- package/test/control-state.test.ts +58 -0
- package/test/desktop-bridge.test.ts +159 -0
- package/test/docker-runtime.test.ts +23 -0
- package/test/event-sources.test.ts +113 -0
- package/test/executor.test.ts +135 -0
- package/test/files.test.ts +50 -0
- package/test/format.test.ts +41 -0
- package/test/host-executor.test.ts +149 -0
- package/test/inbox-burst.test.ts +66 -0
- package/test/inbox.test.ts +102 -0
- package/test/install-config.test.ts +75 -0
- package/test/install-tools.test.mjs +59 -0
- package/test/intake-relay.test.ts +337 -0
- package/test/owner-help.test.mjs +10 -0
- package/test/plugin-manager.test.mjs +157 -0
- package/test/publish-guard.test.ts +21 -0
- package/test/reaction.test.ts +122 -0
- package/test/read-request.test.ts +134 -0
- package/test/relay.test.ts +254 -0
- package/test/release-entrypoints.test.mjs +26 -0
- package/test/runs.test.ts +116 -0
- package/test/security.test.ts +85 -0
- package/test/setup.test.ts +68 -0
- package/test/software-status.test.ts +31 -0
- package/test/update-attention.test.ts +21 -0
- package/test/updates.test.mjs +282 -0
- package/test/upgrade-pause.test.ts +70 -0
- package/test/workspace.test.ts +80 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { createHash, randomUUID, randomBytes } from 'node:crypto';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
const reserved = new Set(['status','updates','plugins','tools','message','owner','approval','react','setup','help','version']);
|
|
8
|
+
const id = value => { if(typeof value !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(value)) throw Error('Invalid identifier'); return value; };
|
|
9
|
+
const hash = data => createHash('sha256').update(data).digest('hex');
|
|
10
|
+
const json = async file => JSON.parse(await fs.readFile(file,'utf8'));
|
|
11
|
+
const emit = value => console.log(JSON.stringify(value));
|
|
12
|
+
const keys = (object, allowed) => { if(!object || typeof object !== 'object' || Array.isArray(object) || Object.keys(object).some(k=>!allowed.includes(k))) throw Error('Invalid or unknown descriptor fields'); };
|
|
13
|
+
const strings = value => { if(!Array.isArray(value) || value.some(x=>typeof x!=='string' || x.includes('\0'))) throw Error('Expected literal string arguments'); return value; };
|
|
14
|
+
const containerPath = value => { if(typeof value!=='string' || !value.startsWith('/') || value.includes('..') || /[\0\n\r:$]/.test(value) || value.startsWith('/var/run') || value.startsWith('/proc') || value.startsWith('/sys')) throw Error('Invalid container path'); return value; };
|
|
15
|
+
const privateDir = async dir => fs.mkdir(dir,{recursive:true,mode:0o700});
|
|
16
|
+
export async function atomic(file, value) {
|
|
17
|
+
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
18
|
+
await fs.writeFile(tmp,JSON.stringify(value,null,2)+'\n',{mode:0o600,flag:'wx'});
|
|
19
|
+
await fs.rename(tmp,file);
|
|
20
|
+
}
|
|
21
|
+
export async function locked(home, fn) {
|
|
22
|
+
const lock = path.join(home,'registry.lock');
|
|
23
|
+
let handle;
|
|
24
|
+
try { handle=await fs.open(lock,'wx',0o600); }
|
|
25
|
+
catch(error) { if(error.code==='EEXIST') throw Error('Registry busy; inspect registry.lock before recovering an interrupted manager'); throw error; }
|
|
26
|
+
try { await handle.writeFile(JSON.stringify({pid:process.pid})); return await fn(); }
|
|
27
|
+
finally { await handle.close(); await fs.rm(lock); }
|
|
28
|
+
}
|
|
29
|
+
// Snapshot only explicitly packaged files. No symlinks, credentials inferred from cwd, or install scripts.
|
|
30
|
+
export async function snapshot(source) {
|
|
31
|
+
source=await fs.realpath(source);
|
|
32
|
+
const pkg=await json(path.join(source,'package.json'));
|
|
33
|
+
const files=new Map();
|
|
34
|
+
async function add(relative) {
|
|
35
|
+
if(typeof relative!=='string' || path.isAbsolute(relative) || relative.split('/').some(p=>p==='..'||p===''||p==='node_modules'||p==='.git') || /[\0\r\n]/.test(relative)) throw Error('Unsafe package file path');
|
|
36
|
+
const absolute=path.join(source,relative);
|
|
37
|
+
let ancestor=source;for(const part of relative.split('/')) {ancestor=path.join(ancestor,part);if((await fs.lstat(ancestor)).isSymbolicLink())throw Error('Package symlinks are not supported');}
|
|
38
|
+
const stat=await fs.lstat(absolute);
|
|
39
|
+
if(stat.isSymbolicLink()) throw Error('Package symlinks are not supported');
|
|
40
|
+
if(stat.isDirectory()) { for(const child of (await fs.readdir(absolute)).sort()) await add(`${relative}/${child}`); }
|
|
41
|
+
else if(stat.isFile()) { if(stat.size>20*1024*1024) throw Error('Package file too large'); files.set(relative,{data:await fs.readFile(absolute),mode:stat.mode&0o111?0o755:0o644}); }
|
|
42
|
+
else throw Error('Unsupported package file');
|
|
43
|
+
}
|
|
44
|
+
strings(pkg.files);
|
|
45
|
+
for(const file of new Set(['package.json','ez-plugin.json','ez-deployment.json','Dockerfile','.dockerignore',...pkg.files])) await add(file);
|
|
46
|
+
if([...files.values()].reduce((sum,f)=>sum+f.data.length,0)>100*1024*1024) throw Error('Package too large');
|
|
47
|
+
const digest=createHash('sha256');
|
|
48
|
+
for(const [name,file] of [...files].sort(([a],[b])=>a.localeCompare(b))) digest.update(JSON.stringify([name,file.mode,file.data.length])).update(file.data);
|
|
49
|
+
const manifest=JSON.parse(files.get('ez-plugin.json').data), deployment=JSON.parse(files.get('ez-deployment.json').data);
|
|
50
|
+
validate(manifest,deployment,files);
|
|
51
|
+
return {source,files,manifest,deployment,revision:`sha256:${digest.digest('hex')}`};
|
|
52
|
+
}
|
|
53
|
+
export function validate(m,d,files) {
|
|
54
|
+
keys(m,['schemaVersion','id','version','description','commands','skills']);
|
|
55
|
+
if(m.schemaVersion!==1 || (typeof m.version!=='string' || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(m.version))) throw Error('Unsupported manifest version');
|
|
56
|
+
id(m.id); strings(m.skills);
|
|
57
|
+
keys(d,['schemaVersion','services','commands','exports',...(d.schemaVersion===2?['secrets']:[])]);
|
|
58
|
+
if(![1,2].includes(d.schemaVersion) || !d.services || !d.commands) throw Error('Unsupported deployment descriptor');
|
|
59
|
+
for(const [name,s] of Object.entries(d.services)) {
|
|
60
|
+
id(name); keys(s,['buildTarget','image','volumes','workspace','healthcheck','command',...(d.schemaVersion===2?['environment','dependsOn','user','memoryMiB']:[])]);
|
|
61
|
+
if(s.user!==undefined && !/^[1-9][0-9]{0,5}:[1-9][0-9]{0,5}$/.test(s.user)) throw Error('Only explicit non-root UID:GID is supported');
|
|
62
|
+
if(s.memoryMiB!==undefined && (!Number.isInteger(s.memoryMiB)||s.memoryMiB<32||s.memoryMiB>8192)) throw Error('Invalid memory bound');
|
|
63
|
+
if(s.dependsOn) for(const dependency of strings(s.dependsOn)) if(!d.services[dependency]||dependency===name) throw Error('Invalid service dependency');
|
|
64
|
+
for(const [key,value] of Object.entries(s.environment||{})) {
|
|
65
|
+
if(!/^[A-Z][A-Z0-9_]*$/.test(key)) throw Error('Invalid environment name');
|
|
66
|
+
if(typeof value==='string') {if(/[\0$]/.test(value))throw Error('Environment interpolation is unsupported');}
|
|
67
|
+
else {keys(value,['secret','prefix','suffix']);id(value.secret);if(!d.secrets?.includes(value.secret))throw Error('Undeclared secret');for(const part of [value.prefix??'',value.suffix??''])if(typeof part!=='string'||/[\0$]/.test(part))throw Error('Invalid secret interpolation');}
|
|
68
|
+
}
|
|
69
|
+
if(Boolean(s.buildTarget)===Boolean(s.image)) throw Error('Service needs one build target or digest-pinned image');
|
|
70
|
+
if(s.buildTarget) id(s.buildTarget);
|
|
71
|
+
if(s.image && !/^[a-zA-Z0-9./:_-]+@sha256:[a-f0-9]{64}$/.test(s.image)) throw Error('Image must be digest-pinned');
|
|
72
|
+
if(s.workspace!==undefined && typeof s.workspace!=='boolean') throw Error('workspace must be boolean');
|
|
73
|
+
if(s.command) strings(s.command);
|
|
74
|
+
if(!strings(s.healthcheck).length) throw Error('Health check required');
|
|
75
|
+
const targets=new Set();
|
|
76
|
+
for(const [volume,target] of Object.entries(s.volumes||{})) { id(volume);containerPath(target); if(target==='/'||targets.has(target)) throw Error('Duplicate/root mount');targets.add(target); }
|
|
77
|
+
}
|
|
78
|
+
for(const secret of strings(d.secrets||[])) id(secret);
|
|
79
|
+
const visiting=new Set(),visited=new Set();
|
|
80
|
+
function visit(name) {if(visiting.has(name))throw Error('Cyclic service dependency');if(visited.has(name))return;visiting.add(name);for(const dependency of d.services[name].dependsOn||[])visit(dependency);visiting.delete(name);visited.add(name);}
|
|
81
|
+
for(const name of Object.keys(d.services))visit(name);
|
|
82
|
+
if(!Object.keys(d.services).length) throw Error('No services');
|
|
83
|
+
if(JSON.stringify(Object.keys(m.commands).sort())!==JSON.stringify(Object.keys(d.commands).sort())) throw Error('Command bindings must match manifest');
|
|
84
|
+
for(const [alias,c] of Object.entries(m.commands)) {
|
|
85
|
+
id(alias); if(reserved.has(alias)) throw Error('Reserved alias');
|
|
86
|
+
keys(c,['executable','args']); strings(c.args);
|
|
87
|
+
if(!files.has(c.executable)) throw Error('Missing package executable');
|
|
88
|
+
const b=d.commands[alias];keys(b,['service','argv','suffix']);
|
|
89
|
+
if(!d.services[b.service] || !strings(b.argv).length) throw Error('Invalid command service'); strings(b.suffix||[]);
|
|
90
|
+
}
|
|
91
|
+
for(const skill of m.skills) if(!files.has(skill)) throw Error('Missing skill');
|
|
92
|
+
for(const [name,e] of Object.entries(d.exports||{})) {
|
|
93
|
+
id(name);keys(e,['service','path']);if(!d.services[e.service]) throw Error('Invalid export service');containerPath(e.path);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function compose(config, record, secrets={}) {
|
|
97
|
+
const services={}, volumes={};
|
|
98
|
+
for(const [name,s] of Object.entries(record.deployment.services)) {
|
|
99
|
+
const mounts=[];
|
|
100
|
+
for(const [volume,target] of Object.entries(s.volumes||{})) { volumes[volume]={};mounts.push({type:'volume',source:volume,target}); }
|
|
101
|
+
if(s.workspace) mounts.push({type:'bind',source:config.workspace,target:config.workspace,read_only:true});
|
|
102
|
+
services[name]={...(s.image?{image:s.image}:{image:`${record.project}-${name}:${record.revision.slice(7,23)}`,build:{context:record.source,target:s.buildTarget}}),
|
|
103
|
+
init:true,user:s.user||'1000:1000',restart:'unless-stopped',cap_drop:['ALL'],security_opt:['no-new-privileges:true'],tmpfs:['/tmp'],volumes:mounts,
|
|
104
|
+
healthcheck:{test:['CMD',...s.healthcheck],interval:'2s',timeout:'5s',retries:30,...(record.deployment.schemaVersion===2?{start_period:'60s'}:{})},
|
|
105
|
+
...(s.dependsOn?{depends_on:Object.fromEntries(s.dependsOn.map(dep=>[dep,{condition:'service_healthy'}]))}:{}),
|
|
106
|
+
...(s.memoryMiB?{mem_limit:`${s.memoryMiB}m`}:{}),
|
|
107
|
+
...(s.environment?{environment:Object.fromEntries(Object.entries(s.environment).map(([key,value])=>{
|
|
108
|
+
if(typeof value==='string')return [key,value];
|
|
109
|
+
if(!/^[a-f0-9]{64}$/.test(secrets[value.secret]||''))throw Error('Missing or invalid private deployment secret');
|
|
110
|
+
return [key,(value.prefix||'')+secrets[value.secret]+(value.suffix||'')];
|
|
111
|
+
}))}:{}),...(s.command?{command:s.command}:{})};
|
|
112
|
+
}
|
|
113
|
+
return {name:record.project,services,volumes};
|
|
114
|
+
}
|
|
115
|
+
function dockerEnv() {
|
|
116
|
+
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]]));
|
|
117
|
+
}
|
|
118
|
+
export function run(argv,{capture=false,container}={}) {
|
|
119
|
+
return new Promise((resolve,reject)=>{
|
|
120
|
+
const child=spawn('docker',argv,{env:dockerEnv(),stdio:capture?['ignore','pipe','pipe']:['inherit','inherit','inherit']});
|
|
121
|
+
let stdout='',stderr='',cancelled=false;
|
|
122
|
+
if(capture) {child.stdout.on('data',b=>stdout+=b);child.stderr.on('data',b=>stderr+=b);}
|
|
123
|
+
const cancel=signal=>{cancelled=true;child.kill(signal);};
|
|
124
|
+
const term=()=>cancel('SIGTERM'),int=()=>cancel('SIGINT');
|
|
125
|
+
process.on('SIGTERM',term);process.on('SIGINT',int);
|
|
126
|
+
child.once('error',reject);
|
|
127
|
+
child.once('close',async(code,signal)=>{process.off('SIGTERM',term);process.off('SIGINT',int);
|
|
128
|
+
if(cancelled&&container) await run(['rm','-f',container],{capture:true});
|
|
129
|
+
resolve({code:cancelled?130:code??(signal?130:1),stdout,stderr});});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
async function checked(args) {
|
|
133
|
+
const r=await run(args,{capture:true});if(r.code) throw Error(r.stderr||r.stdout||`Docker failed (${r.code})`);return r.stdout;
|
|
134
|
+
}
|
|
135
|
+
const composeArgs = record => ['compose','--project-name',record.project,'--file',record.compose];
|
|
136
|
+
async function registry(home) {
|
|
137
|
+
const r=await json(path.join(home,'registry.json'));
|
|
138
|
+
if(r.schemaVersion!==1 || r.owner!==home || !r.plugins || !r.commands) throw Error('Corrupt registry');
|
|
139
|
+
for(const [name,record] of Object.entries(r.plugins)) {
|
|
140
|
+
id(name);if(record.project!==`ezp-${hash(home).slice(0,16)}-${name}` || record.compose!==path.join(home,'packages',name,'compose.json')) throw Error('Wrong-agent deployment binding');
|
|
141
|
+
}
|
|
142
|
+
for(const [alias,plugin] of Object.entries(r.commands)) if(!r.plugins[plugin]?.deployment?.commands?.[alias]) throw Error('Corrupt command registry');
|
|
143
|
+
return r;
|
|
144
|
+
}
|
|
145
|
+
export async function init(home,workspace,catalogFile,hostConfig) {
|
|
146
|
+
if(typeof home!=='string'||typeof workspace!=='string'||!path.isAbsolute(home)||!path.isAbsolute(workspace)||/[\r\n\0$:,]/.test(home+workspace)) throw Error('Explicit absolute home/workspace required');
|
|
147
|
+
workspace=await fs.realpath(workspace);await privateDir(home);home=await fs.realpath(home);
|
|
148
|
+
if(await fs.lstat(path.join(home,'registry.json')).catch(()=>null)) throw Error('Registry already exists; refusing replacement');
|
|
149
|
+
catalogFile=path.resolve(catalogFile||fileURLToPath(new URL('../../default-plugins.json',import.meta.url)));
|
|
150
|
+
const sources=await json(catalogFile);
|
|
151
|
+
const catalog={};
|
|
152
|
+
for(const [name,source] of Object.entries(sources)) {id(name);if(typeof source!=='string'||!source)throw Error('Catalog source must be a nonempty path');const resolved=path.resolve(path.dirname(catalogFile),source);const p=await snapshot(resolved).catch(error=>{throw Error(`Cannot load reviewed plugin ${name} from ${resolved}: ${error.message}. Supply its checkout or an explicit --catalog file.`)});if(p.manifest.id!==name) throw Error('Catalog ID mismatch');catalog[name]={source:p.source,revision:p.revision};}
|
|
153
|
+
await locked(home,async()=>{
|
|
154
|
+
await atomic(path.join(home,'config.json'),{schemaVersion:1,workspace,catalog});
|
|
155
|
+
await atomic(path.join(home,'registry.json'),{schemaVersion:1,owner:home,plugins:{},commands:{}});
|
|
156
|
+
const bin=path.join(home,'bin');await privateDir(bin);
|
|
157
|
+
await fs.writeFile(path.join(bin,'ez'),`#!${process.execPath}\nimport(${JSON.stringify(new URL('./manager.mjs',import.meta.url).href)}).then(m=>m.main(['--home',${JSON.stringify(home)},...process.argv.slice(2)])).catch(e=>{console.error(e.message);process.exitCode=1});\n`,{mode:0o700,flag:'wx'});
|
|
158
|
+
if(hostConfig) {
|
|
159
|
+
const host=await json(hostConfig);let agent;for(const candidate of host.agents) if(await fs.realpath(candidate.workspace)===workspace) agent=candidate;
|
|
160
|
+
if(!agent) throw Error('Host config does not bind this workspace');
|
|
161
|
+
// npm archives need not include source-tree aliases: use public names
|
|
162
|
+
// and actual entry points from this package's manifest.
|
|
163
|
+
const manifest=await json(new URL('../../package.json',import.meta.url));
|
|
164
|
+
for(const [name,entry] of Object.entries(manifest.bin)) {
|
|
165
|
+
const target=new URL('../../'+entry,import.meta.url);
|
|
166
|
+
await fs.access(target,fs.constants.X_OK);
|
|
167
|
+
await fs.symlink(target,path.join(bin,name));
|
|
168
|
+
}
|
|
169
|
+
for(const file of await fs.readdir(agent.binDir)) {if(file==='ez') throw Error('Existing ez binding collision');if(Object.hasOwn(manifest.bin,file))continue;await fs.symlink(path.join(agent.binDir,file),path.join(bin,file));}
|
|
170
|
+
agent.binDir=bin;agent.toolsHome=home;await atomic(hostConfig,host);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
const index=path.join(workspace,'TOOLS.md');
|
|
174
|
+
const prior=await fs.readFile(index,'utf8').catch(e=>{if(e.code==='ENOENT')return fs.readFile(new URL('../../templates/agent/TOOLS.md',import.meta.url),'utf8');throw e;});
|
|
175
|
+
await fs.writeFile(index,prior+'\n## Registered plugins\n\nUse `'+path.join(home,'bin','ez')+'` for this agent only.\nDiscover reviewed packages with `ez plugins available`; inspect with `ez plugins inspect <id>`.\nOn an authorized installation request, run `ez plugins install <id>`, then `ez plugins start <id>`.\nRead the installed skill paths from `ez plugins list` before onboarding or provider operations.\nUse `ez tools list` for aliases and `ez <alias> --help` for native commands.\nInstallation does not grant send authority. The registry is the only plugin installation, command and lifecycle authority. Do not create standalone provider launchers or deployments.\n',{mode:0o600});
|
|
176
|
+
if(hostConfig && path.basename(hostConfig)==='host-executor.json') await (await import('../updates/binding.mjs')).bindUpdates(home,hostConfig);
|
|
177
|
+
return {ok:true,launcher:path.join(home,'bin','ez'),workspace};
|
|
178
|
+
}
|
|
179
|
+
export async function install(home,config,name,source,revision) {
|
|
180
|
+
id(name);
|
|
181
|
+
const p=await snapshot(source);
|
|
182
|
+
if(p.manifest.id!==name) throw Error('Plugin identity mismatch: requested '+name+', source declares '+p.manifest.id);
|
|
183
|
+
if(p.revision!==revision) throw Error('Source revision mismatch: expected '+revision+', current '+p.revision+'. Inspection is read-only; review its result, then use install --revision HASH or catalog-add with --source PATH --revision HASH to update the reviewed pin.');
|
|
184
|
+
return locked(home,async()=>{
|
|
185
|
+
const r=await registry(home),existing=r.plugins[name];
|
|
186
|
+
if(existing) {if(existing.revision!==revision) throw Error('Different release already installed; uninstall preserves data before replacement');return {ok:true,existing:true,plugin:name};}
|
|
187
|
+
for(const alias of Object.keys(p.manifest.commands)) if(r.commands[alias]) throw Error('CLI alias collision');
|
|
188
|
+
const base=path.join(home,'packages',name),target=path.join(base,revision.slice(7));await privateDir(base);
|
|
189
|
+
const stage=path.join(base,`.stage-${randomUUID()}`);await privateDir(stage);
|
|
190
|
+
try {
|
|
191
|
+
for(const [relative,file] of p.files) {const dest=path.join(stage,relative);await fs.mkdir(path.dirname(dest),{recursive:true,mode:0o755});await fs.writeFile(dest,file.data,{mode:file.mode,flag:'wx'});}
|
|
192
|
+
await fs.chmod(stage,0o755);
|
|
193
|
+
// A previously interrupted install may leave a snapshot. Never silently replace it.
|
|
194
|
+
try { await fs.rename(stage,target); } catch(error) {
|
|
195
|
+
if(!['EEXIST','ENOTEMPTY'].includes(error.code)) throw error;
|
|
196
|
+
if((await snapshot(target)).revision!==revision) throw Error('Interrupted package snapshot differs; inspect before recovery');
|
|
197
|
+
}
|
|
198
|
+
} finally {await fs.rm(stage,{recursive:true,force:true});}
|
|
199
|
+
const record={revision,source:target,project:`ezp-${hash(home).slice(0,16)}-${name}`,manifest:p.manifest,deployment:p.deployment,compose:path.join(base,'compose.json')};
|
|
200
|
+
const secretsFile=path.join(base,'secrets.json');
|
|
201
|
+
let secrets;try{secrets=await json(secretsFile);}catch(error){if(error.code!=='ENOENT')throw error;secrets={};}
|
|
202
|
+
for(const name of p.deployment.secrets||[])if(secrets[name]===undefined)secrets[name]=randomBytes(32).toString('hex');
|
|
203
|
+
if(p.deployment.secrets?.length)await atomic(secretsFile,secrets);
|
|
204
|
+
await atomic(record.compose,compose(config,record,secrets));
|
|
205
|
+
// Build/pull is explicit installation mechanics. No services start and no onboarding is executed.
|
|
206
|
+
for(const [service,s] of Object.entries(record.deployment.services)) await checked([...composeArgs(record),s.image?'pull':'build',service]);
|
|
207
|
+
r.plugins[name]=record;for(const alias of Object.keys(p.manifest.commands)) r.commands[alias]=name;
|
|
208
|
+
await atomic(path.join(home,'registry.json'),r);
|
|
209
|
+
return {ok:true,plugin:name,revision,started:false,skills:p.manifest.skills.map(s=>path.join(target,s))};
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
export async function main(args) {
|
|
213
|
+
const take=flag=>{const n=args.indexOf(flag);if(n<0)return undefined;if(!args[n+1])throw Error(`Missing ${flag}`);return args.splice(n,2)[1];};
|
|
214
|
+
// Only the fixed launcher may supply the leading home binding. Never consume plugin arguments here.
|
|
215
|
+
let home;if(args[0]==='--home') {home=args[1];args=args.slice(2);}
|
|
216
|
+
if(args[0]==='enable-updates') {args.shift();const h=take('--home'),host=take('--host-config');if(args.length||!h||!host)throw Error('Supply --home and --host-config');return emit(await (await import('../updates/binding.mjs')).bindUpdates(h,host));}
|
|
217
|
+
if(args[0]==='init') {args.shift();const options=[take('--home'),take('--workspace'),take('--catalog'),take('--host-config')];if(args.length)throw Error('Unknown init arguments');return emit(await init(...options));}
|
|
218
|
+
if(!home || !path.isAbsolute(home)) throw Error('Use the agent-bound launcher, or init --home /absolute/tools --workspace /absolute/mind --catalog /absolute/catalog.json');
|
|
219
|
+
home=await fs.realpath(home);
|
|
220
|
+
const config=await json(path.join(home,'config.json'));
|
|
221
|
+
if(config.schemaVersion!==1 || !path.isAbsolute(config.workspace)) throw Error('Invalid binding');
|
|
222
|
+
const [group,action,...rest]=args;
|
|
223
|
+
if(group==='status'){if(args.length!==1)throw Error('Use status without arguments');return emit(await (await import('../updates/status.mjs')).status(home));}
|
|
224
|
+
if(group==='updates')return emit(await (await import('../updates/control.mjs')).command(home,args.slice(1)));
|
|
225
|
+
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','<registered CLI> ...'],scope:home});
|
|
226
|
+
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'],uninstall:'Stops and removes containers/network and unregisters aliases; retains all volumes and secrets. No data deletion flag.',scope:home});
|
|
227
|
+
if(group==='plugins'||group==='tools') {
|
|
228
|
+
args=rest;args=args.filter(a=>a!=='--json');
|
|
229
|
+
if(action==='available'&&group==='plugins') return emit(config.catalog);
|
|
230
|
+
const r=await registry(home);
|
|
231
|
+
if(action==='list') return emit(group==='tools'?r.commands:r.plugins);
|
|
232
|
+
if(group==='tools') throw Error('Only tools list is supported; installation registers CLI bindings');
|
|
233
|
+
const name=args.shift();id(name);
|
|
234
|
+
if(action==='inspect'||action==='install'||action==='catalog-add') {
|
|
235
|
+
const source=take('--source')||config.catalog[name]?.source,revision=take('--revision')||config.catalog[name]?.revision;
|
|
236
|
+
if(args.length||!source)throw Error('Supply a known catalog name or --source');
|
|
237
|
+
if(action==='catalog-add') {
|
|
238
|
+
const p=await snapshot(source);if(p.manifest.id!==name||p.revision!==revision)throw Error('Inspect and pin the exact catalog package first');
|
|
239
|
+
return locked(home,async()=>{const latest=await json(path.join(home,'config.json'));latest.catalog[name]={source:p.source,revision:p.revision};await atomic(path.join(home,'config.json'),latest);emit({ok:true,plugin:name,revision:p.revision,installed:false});});
|
|
240
|
+
}
|
|
241
|
+
if(action==='inspect') {const p=await snapshot(source);return emit({id:p.manifest.id,source:p.source,revision:p.revision,catalogRevision:config.catalog[name]?.revision??null,catalogMatches:config.catalog[name]?.source===p.source&&config.catalog[name]?.revision===p.revision,inspection:'Read-only; does not update the catalog pin. After review, pass --revision to install or use catalog-add --source --revision.',manifest:p.manifest,deployment:p.deployment});}
|
|
242
|
+
return emit(await install(home,config,name,source,revision));
|
|
243
|
+
}
|
|
244
|
+
const record=r.plugins[name];if(!record)throw Error('Plugin not installed');
|
|
245
|
+
if(action==='export') {
|
|
246
|
+
const artifact=args.shift(),output=take('--output'),e=record.deployment.exports?.[artifact];
|
|
247
|
+
if(!e||!output||args.length)throw Error('Supply a declared export and --output workspace/file');
|
|
248
|
+
const dest=path.resolve(output),parent=await fs.realpath(path.dirname(dest));
|
|
249
|
+
if(!parent.startsWith(config.workspace+path.sep)&&parent!==config.workspace)throw Error('Export must stay inside the bound workspace');
|
|
250
|
+
const handle=await fs.open(dest,'wx',0o600);await handle.close();
|
|
251
|
+
try {await checked([...composeArgs(record),'cp',`${e.service}:${e.path}`,dest]);await fs.chmod(dest,0o600);}catch(error){await fs.rm(dest,{force:true});throw error;}
|
|
252
|
+
return emit({ok:true,path:dest});
|
|
253
|
+
}
|
|
254
|
+
if(args.length)throw Error('Unknown lifecycle arguments');
|
|
255
|
+
if(action==='logs') return emit({plugin:name,logs:await checked([...composeArgs(record),'logs','--tail','100','--no-color'])});
|
|
256
|
+
if(action==='status') {const output=await checked([...composeArgs(record),'ps','--all','--format','json']);return emit({plugin:name,installedVersion:record.manifest.version,containers:output});}
|
|
257
|
+
if(!['start','stop','uninstall'].includes(action))throw Error('Unknown lifecycle command');
|
|
258
|
+
return locked(home,async()=>{
|
|
259
|
+
const current=await registry(home);if(current.plugins[name]?.revision!==record.revision)throw Error('Plugin changed during lifecycle request');
|
|
260
|
+
await checked([...composeArgs(record),...(action==='start'?['up','-d','--wait']:action==='stop'?['stop']:['down'])]);
|
|
261
|
+
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);}
|
|
262
|
+
emit({ok:true,plugin:name,action,dataPreserved:true});
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
const r=await registry(home),record=r.plugins[r.commands[group]],binding=record?.deployment.commands[group];
|
|
266
|
+
if(!binding)throw Error('Unknown registered CLI');
|
|
267
|
+
// Docker exec does not reliably forward cancellation to the in-container process.
|
|
268
|
+
// Run each client as a one-shot Compose container; docker compose run forwards signals.
|
|
269
|
+
const name=`${record.project}-call-${randomUUID()}`;
|
|
270
|
+
const result=await run([...composeArgs(record),'run','--rm','--no-deps','-T','--name',name,'--entrypoint',binding.argv[0],binding.service,...binding.argv.slice(1),...record.manifest.commands[group].args,...args.slice(1),...(binding.suffix||[])],{container:name});
|
|
271
|
+
process.exitCode=result.code;
|
|
272
|
+
}
|
package/src/react.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { loadControlConfig } from './config.js'
|
|
2
|
+
import { normalizeReactionEmoji, TELEGRAM_REACTIONS } from './reaction.js'
|
|
3
|
+
import { RunStore } from './runs.js'
|
|
4
|
+
|
|
5
|
+
const runId = process.env.EZ_RUN_ID?.trim()
|
|
6
|
+
const args = process.argv.slice(2).filter((arg) => arg !== '--')
|
|
7
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
8
|
+
console.log('Usage: ezenciel-agents-react --emoji <emoji> (e.g. ๐, ๐, ๐ฅ, โค๏ธ, ๐ซก, ๐)')
|
|
9
|
+
process.exit(0)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const emojiFlag = args.findIndex((arg) => arg === '--emoji' || arg === '-e')
|
|
13
|
+
const candidate = emojiFlag >= 0 ? args[emojiFlag + 1]?.trim() : args[0]?.trim()
|
|
14
|
+
const rawEmoji = (candidate && !candidate.startsWith('-')) ? candidate : undefined
|
|
15
|
+
|
|
16
|
+
if (!runId) {
|
|
17
|
+
console.error('EZ_RUN_ID is required')
|
|
18
|
+
process.exit(1)
|
|
19
|
+
}
|
|
20
|
+
if (!rawEmoji) {
|
|
21
|
+
console.error('Usage: ezenciel-agents-react --emoji <emoji> (e.g. ๐, ๐, ๐ฅ, โค๏ธ, ๐ซก, ๐)')
|
|
22
|
+
process.exit(1)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const emoji = normalizeReactionEmoji(rawEmoji)
|
|
26
|
+
if (!emoji) {
|
|
27
|
+
console.error(`Invalid Telegram reaction emoji "${rawEmoji}". Telegram only supports standard reactions (e.g. ${TELEGRAM_REACTIONS.slice(0, 10).join(', ')}, etc.)`)
|
|
28
|
+
process.exit(1)
|
|
29
|
+
}
|
|
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 }))
|
package/src/reaction.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const TELEGRAM_REACTIONS = [
|
|
2
|
+
'๐', '๐', 'โค', '๐ฅ', '๐ฅฐ', '๐', '๐', '๐ค', '๐คฏ', '๐ฑ',
|
|
3
|
+
'๐คฌ', '๐ข', '๐', '๐คฉ', '๐คฎ', '๐ฉ', '๐', '๐', '๐', '๐คก',
|
|
4
|
+
'๐ฅฑ', '๐ฅด', '๐', '๐ณ', 'โคโ๐ฅ', '๐', '๐ญ', '๐ฏ', '๐คฃ', 'โก',
|
|
5
|
+
'๐', '๐', '๐', '๐คจ', '๐', '๐', '๐พ', '๐', '๐', '๐',
|
|
6
|
+
'๐ด', '๐ญ', '๐ค', '๐ป', '๐จโ๐ป', '๐', '๐', '๐', '๐', '๐จ',
|
|
7
|
+
'๐ค', 'โ', '๐ค', '๐ซก', '๐
', '๐', 'โ', '๐
', '๐คช', '๐ฟ',
|
|
8
|
+
'๐', '๐', '๐', '๐ฆ', '๐', '๐', '๐', '๐', '๐พ', '๐คทโโ',
|
|
9
|
+
'๐คท', '๐คทโโ', '๐ก',
|
|
10
|
+
] as const
|
|
11
|
+
|
|
12
|
+
export type TelegramReactionEmoji = (typeof TELEGRAM_REACTIONS)[number]
|
|
13
|
+
|
|
14
|
+
const VALID_SET = new Set<string>(TELEGRAM_REACTIONS)
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Normalizes an emoji for Telegram setMessageReaction.
|
|
18
|
+
* Removes variation selector 16 (\uFE0F) and trims whitespace.
|
|
19
|
+
* Returns the valid Telegram reaction emoji, or undefined if not supported.
|
|
20
|
+
*/
|
|
21
|
+
export const normalizeReactionEmoji = (input?: string | null): TelegramReactionEmoji | undefined => {
|
|
22
|
+
if (!input) return undefined
|
|
23
|
+
const cleaned = input.trim().replace(/\uFE0F/g, '')
|
|
24
|
+
if (VALID_SET.has(cleaned)) {
|
|
25
|
+
return cleaned as TelegramReactionEmoji
|
|
26
|
+
}
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const isSupportedReactionEmoji = (input: string): boolean => {
|
|
31
|
+
return normalizeReactionEmoji(input) !== undefined
|
|
32
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Only for repeatable input reads/transcription. Never wrap an external action or chat send.
|
|
2
|
+
class ResponseError extends Error {
|
|
3
|
+
constructor(
|
|
4
|
+
readonly status: number,
|
|
5
|
+
readonly retryAfter: number,
|
|
6
|
+
) {
|
|
7
|
+
super(`HTTP ${status}`)
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const networkCode = (error: unknown): string | undefined => {
|
|
12
|
+
if (!error || typeof error !== 'object') return
|
|
13
|
+
const e = error as { code?: string; cause?: unknown; errors?: unknown[]; name?: string }
|
|
14
|
+
if (typeof e.code === 'string' && /^[A-Z][A-Z0-9_]+$/.test(e.code)) return e.code
|
|
15
|
+
if (e.name === 'TimeoutError') return 'TIMEOUT'
|
|
16
|
+
return networkCode(e.cause) || e.errors?.map(networkCode).find(Boolean)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function readRequest<T>(
|
|
20
|
+
label: string,
|
|
21
|
+
url: string,
|
|
22
|
+
options: RequestInit,
|
|
23
|
+
decode: (response: Response) => Promise<T>,
|
|
24
|
+
sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
|
25
|
+
): Promise<T> {
|
|
26
|
+
for (let attempt = 1; ; attempt++) {
|
|
27
|
+
try {
|
|
28
|
+
const response = await fetch(url, { ...options, signal: AbortSignal.timeout(60_000) })
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
const header = response.headers.get('retry-after')
|
|
31
|
+
const seconds = header === null ? NaN : Number(header)
|
|
32
|
+
const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(header || '') - Date.now()
|
|
33
|
+
await response.body?.cancel()
|
|
34
|
+
throw new ResponseError(response.status, Number.isFinite(delay) ? Math.max(0, delay) : 0)
|
|
35
|
+
}
|
|
36
|
+
// Include streaming/body failures in the same retry boundary.
|
|
37
|
+
return await decode(response)
|
|
38
|
+
} catch (error) {
|
|
39
|
+
const code = networkCode(error)
|
|
40
|
+
const http = error instanceof ResponseError ? error : undefined
|
|
41
|
+
const transient = http
|
|
42
|
+
? [408, 429, 500, 502, 503, 504].includes(http.status) && http.retryAfter <= 30_000
|
|
43
|
+
: [
|
|
44
|
+
'ECONNRESET',
|
|
45
|
+
'ECONNREFUSED',
|
|
46
|
+
'EAI_AGAIN',
|
|
47
|
+
'ETIMEDOUT',
|
|
48
|
+
'ENETUNREACH',
|
|
49
|
+
'EHOSTUNREACH',
|
|
50
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
51
|
+
'UND_ERR_HEADERS_TIMEOUT',
|
|
52
|
+
'UND_ERR_BODY_TIMEOUT',
|
|
53
|
+
'UND_ERR_SOCKET',
|
|
54
|
+
'TIMEOUT',
|
|
55
|
+
].includes(code || '') ||
|
|
56
|
+
(!code && error instanceof TypeError && error.message === 'fetch failed')
|
|
57
|
+
// Do not include provider bodies, URLs or raw error messages (they can contain credentials).
|
|
58
|
+
const detail =
|
|
59
|
+
http?.message || code || (error instanceof SyntaxError ? 'invalid response JSON' : 'request failed')
|
|
60
|
+
if (!transient || attempt === 3)
|
|
61
|
+
throw new Error(`${label}: ${detail} (after ${attempt} attempt${attempt === 1 ? '' : 's'})`)
|
|
62
|
+
const delay = Math.max(1000 * 2 ** (attempt - 1), http?.retryAfter || 0)
|
|
63
|
+
console.warn(`${label}: ${detail}; retry ${attempt + 1}/3 in ${delay}ms`)
|
|
64
|
+
await sleep(delay)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const downloadTelegramFile = (url: string): Promise<Buffer> =>
|
|
70
|
+
readRequest('Telegram attachment download', url, {}, async (response) =>
|
|
71
|
+
Buffer.from(await response.arrayBuffer()),
|
|
72
|
+
)
|
package/src/reply.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const splitTelegramText = (text: string, maxLength = 4_000): string[] => {
|
|
2
|
+
if (!text) return ['I could not produce a reply.']
|
|
3
|
+
const chunks: string[] = []
|
|
4
|
+
let remaining = text
|
|
5
|
+
while (remaining.length > maxLength) {
|
|
6
|
+
const boundary = Math.max(remaining.lastIndexOf('\n', maxLength), remaining.lastIndexOf(' ', maxLength))
|
|
7
|
+
const cut = boundary > 0 ? boundary : maxLength
|
|
8
|
+
chunks.push(remaining.slice(0, cut).trim())
|
|
9
|
+
remaining = remaining.slice(cut).trimStart()
|
|
10
|
+
}
|
|
11
|
+
if (remaining) chunks.push(remaining)
|
|
12
|
+
return chunks
|
|
13
|
+
}
|