@jc_stack/ez-agents 0.1.0-beta.43 → 0.1.0-beta.44
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/CHANGELOG.md +9 -0
- package/bin/ez +1 -1
- package/docs/plugin-guide.md +10 -2
- package/docs/plugins.md +6 -0
- package/package.json +1 -1
- package/src/plugin-broker.mjs +1 -0
- package/src/plugins/manager.mjs +18 -1
- package/test/plugin-broker.test.mjs +12 -1
- package/test/plugin-manager.test.mjs +14 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-beta.44
|
|
4
|
+
|
|
5
|
+
- Let isolated native agents read declared installed plugin skills through the
|
|
6
|
+
existing authorized broker. Discovery supplies literal skill-read commands,
|
|
7
|
+
so tools such as EzStudy work without copying package instructions into the
|
|
8
|
+
agent workspace or rebuilding native context.
|
|
9
|
+
- Keep skill reads bound to the installed revision and active authorized run;
|
|
10
|
+
reject undeclared paths, traversal, symlinks and oversized files.
|
|
11
|
+
|
|
3
12
|
## 0.1.0-beta.43
|
|
4
13
|
|
|
5
14
|
- Keep model and reasoning settings with each named conversation and preserve
|
package/bin/ez
CHANGED
|
@@ -88,7 +88,7 @@ const manager = async args => {
|
|
|
88
88
|
const main = async () => {
|
|
89
89
|
const args = process.argv.slice(2);
|
|
90
90
|
if (!args.length || args[0] === '--help' || args[0] === '-h') {
|
|
91
|
-
process.stdout.write(JSON.stringify({commands:['tools list [--details]','<registered CLI> ...'],native:await installedNativeHelp(fileURLToPath(new URL('.',import.meta.url)))})+'\n');
|
|
91
|
+
process.stdout.write(JSON.stringify({commands:['tools list [--details]','tools skill <plugin> <declared-path>','<registered CLI> ...'],native:await installedNativeHelp(fileURLToPath(new URL('.',import.meta.url)))})+'\n');
|
|
92
92
|
return;
|
|
93
93
|
}
|
|
94
94
|
const stdin = ['plugins', 'tools', 'updates', 'status'].includes(args[0]) ? '' : await readStdin();
|
package/docs/plugin-guide.md
CHANGED
|
@@ -57,7 +57,16 @@ At minimum, provide:
|
|
|
57
57
|
machine-readable commands, stable exit codes, and readback for writes.
|
|
58
58
|
|
|
59
59
|
The manager snapshots the package according to `package.json.files` plus the
|
|
60
|
-
required manifests and Docker inputs.
|
|
60
|
+
required manifests and Docker inputs. `ez tools list --details` includes
|
|
61
|
+
`skillReads`: literal command argument arrays for reading each installed skill.
|
|
62
|
+
Use `ez tools skill <plugin> <declared-path>` to return its text and pinned
|
|
63
|
+
revision. The isolated executor cannot open private host package paths directly;
|
|
64
|
+
the authorized broker reads only manifest-declared regular skill files, without
|
|
65
|
+
copying them into the agent workspace. Skill reads reject symlinks and files over
|
|
66
|
+
64 KiB. The existing generic discovery footer remains accurate; no plugin-specific
|
|
67
|
+
usage snippet or per-turn instruction is needed for this read contract.
|
|
68
|
+
|
|
69
|
+
Keep one canonical checkout; do not keep
|
|
61
70
|
permanent packaged copies or a second registry. See the full
|
|
62
71
|
[plugin contribution requirements](plugin-contributions.md) and the
|
|
63
72
|
[plugin manager contract](plugins.md#deployment-descriptors).
|
|
@@ -199,4 +208,3 @@ hash.
|
|
|
199
208
|
|
|
200
209
|
For contribution, security, and removal details, follow the links above and the
|
|
201
210
|
plugin's own `CONTRIBUTING.md`, `SECURITY.md`, and README.
|
|
202
|
-
|
package/docs/plugins.md
CHANGED
|
@@ -400,6 +400,12 @@ and `skills`. Descriptions are limited to 200 characters in this view; full
|
|
|
400
400
|
instructions stay in the skill. Installation, replacement and removal are reflected
|
|
401
401
|
on the next read, without hooks, LLM calls or a cached inventory file.
|
|
402
402
|
|
|
403
|
+
The additive `skillReads` field supplies literal argument arrays for
|
|
404
|
+
`ez tools skill <plugin> <declared-path>`. This read returns JSON with the
|
|
405
|
+
installed plugin revision, declared path and skill content. Use it in isolated
|
|
406
|
+
agents, where host package paths are private. It reads only the installed
|
|
407
|
+
manifest's skills; arbitrary files, symlinks and files over 64 KiB are rejected.
|
|
408
|
+
|
|
403
409
|
`ez tools list` retains its alias mapping for existing clients. Native AGENTS.md
|
|
404
410
|
contains only the agent-bound discovery shortcut. New workspaces do not seed
|
|
405
411
|
TOOLS.md, SOUL.md, USER.md, or an `inbox/`/`work/` layout; upgrades preserve
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jc_stack/ez-agents",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.44",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A lightweight foundation for persistent business AI assistants using existing AI harnesses, workspaces and plugins.",
|
package/src/plugin-broker.mjs
CHANGED
|
@@ -59,6 +59,7 @@ const validateManagerArgs = async (value, workspace) => {
|
|
|
59
59
|
if (!args.length) throw Error('Missing broker management command');
|
|
60
60
|
const [group, action, name, extra, ...tail] = args;
|
|
61
61
|
if (group === 'tools' && action === 'list' && (args.length === 2 || args.length === 3 && name === '--details')) return args;
|
|
62
|
+
if (group === 'tools' && action === 'skill' && IDENTIFIER.test(name) && args.length === 4) return args;
|
|
62
63
|
if (group === 'tools' && action === 'exposure' && args.length === 2) return args;
|
|
63
64
|
if (group === 'plugins' && action === 'available' && args.length === 2) return args;
|
|
64
65
|
if (group === 'plugins' && action === 'list' && args.length === 2) return args;
|
package/src/plugins/manager.mjs
CHANGED
|
@@ -600,17 +600,34 @@ export async function main(args) {
|
|
|
600
600
|
return (await import('./connection.mjs')).connect(home,rest[0],rest.slice(1),{publish:port,serve:true});
|
|
601
601
|
}
|
|
602
602
|
if(group==='tools'&&action==='connect')return (await import('./connection.mjs')).connect(home,rest[0],rest.slice(1));
|
|
603
|
-
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> ...'],native:await installedNativeHelp(path.join(home,'bin')),foreignWorkspace:'Relay-bound registries run only from their owning agent workspace',scope:home});
|
|
603
|
+
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]|skill <plugin> <declared-path>|exposure|connect <alias> <args...>|serve <host-port:container-port> <alias> <args...>','<registered CLI> ...'],native:await installedNativeHelp(path.join(home,'bin')),foreignWorkspace:'Relay-bound registries run only from their owning agent workspace',scope:home});
|
|
604
604
|
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});
|
|
605
605
|
if(group==='plugins'||group==='tools') {
|
|
606
606
|
args=rest;args=args.filter(a=>a!=='--json');
|
|
607
607
|
if(action==='available'&&group==='plugins') return emit(config.catalog);
|
|
608
608
|
const r=await registry(home);
|
|
609
|
+
if(group==='tools' && action==='skill') {
|
|
610
|
+
if(args.length!==2)throw Error('Use tools skill <plugin> <declared-path>');
|
|
611
|
+
const [name,skill]=args,record=r.plugins[id(name)];
|
|
612
|
+
if(!record || !record.manifest.skills.includes(skill))throw Error('Unknown installed plugin skill');
|
|
613
|
+
if(path.isAbsolute(skill) || skill.split('/').some(part=>!part || part==='.' || part==='..') || /[\0\r\n]/.test(skill))throw Error('Unsafe skill path');
|
|
614
|
+
const source=await fs.realpath(record.source);
|
|
615
|
+
if(source!==record.source || !childOf(source,home))throw Error('Skill source must stay inside installed packages');
|
|
616
|
+
let file=source;
|
|
617
|
+
for(const part of skill.split('/')) {
|
|
618
|
+
file=path.join(file,part);
|
|
619
|
+
if((await fs.lstat(file)).isSymbolicLink())throw Error('Skill symlinks are not supported');
|
|
620
|
+
}
|
|
621
|
+
const stat=await fs.stat(file);
|
|
622
|
+
if(!stat.isFile() || stat.size>64*1024)throw Error('Skill must be a regular file of at most 64 KiB');
|
|
623
|
+
return emit({plugin:name,revision:record.revision,path:skill,content:await fs.readFile(file,'utf8')});
|
|
624
|
+
}
|
|
609
625
|
if(action==='list') {
|
|
610
626
|
if(group==='tools' && args.length===1 && args[0]==='--details')return emit(Object.fromEntries(Object.entries(r.plugins).map(([name,p])=>[name,{
|
|
611
627
|
description:typeof p.manifest.description==='string'?p.manifest.description.replace(/\s+/g,' ').trim().slice(0,200):'',
|
|
612
628
|
commands:Object.keys(p.manifest.commands).map(alias=>`ez ${alias} --help`),
|
|
613
629
|
skills:p.manifest.skills.map(skill=>path.join(p.source,skill)),
|
|
630
|
+
skillReads:p.manifest.skills.map(skill=>['ez','tools','skill',name,skill]),
|
|
614
631
|
}])));
|
|
615
632
|
if(args.length)throw Error('Use tools list [--details] or plugins list');
|
|
616
633
|
return emit(group==='tools'?r.commands:r.plugins);
|
|
@@ -42,11 +42,12 @@ async function fixture(t) {
|
|
|
42
42
|
revision,
|
|
43
43
|
source,
|
|
44
44
|
project: `ezp-${hash(realHome).slice(0, 16)}-sample`,
|
|
45
|
-
manifest: { schemaVersion: 1, id: 'sample', version: '1.0.0', commands: { sample: { executable: 'client.mjs', args: [] } }, skills: [] },
|
|
45
|
+
manifest: { schemaVersion: 1, id: 'sample', version: '1.0.0', commands: { sample: { executable: 'client.mjs', args: [] } }, skills: ['SKILL.md'] },
|
|
46
46
|
deployment: { schemaVersion: 1, services: { sample: { buildTarget: 'runtime', volumes: {}, workspace: false, healthcheck: ['node', '--version'] } }, commands: { sample: { service: 'sample', argv: ['node', '/app/client.mjs'], suffix: [] } }, exports: {} },
|
|
47
47
|
compose: path.join(home, 'packages', 'sample', 'compose.json'),
|
|
48
48
|
};
|
|
49
49
|
await fs.writeFile(path.join(home, 'registry.json'), JSON.stringify({ schemaVersion: 1, owner: realHome, plugins: { sample: record }, commands: { sample: 'sample' } }) + '\n', { mode: 0o600 });
|
|
50
|
+
await fs.writeFile(path.join(source, 'SKILL.md'), '# Synthetic authoring format\n');
|
|
50
51
|
const fake = path.join(root, 'fake');
|
|
51
52
|
await fs.mkdir(fake, { mode: 0o700 });
|
|
52
53
|
await fs.writeFile(path.join(fake, 'docker'), `#!${process.execPath}
|
|
@@ -103,6 +104,13 @@ test('isolated broker authenticates an owned run, pins plugin revision, and writ
|
|
|
103
104
|
const detailed = await request(f.socket, { version: 1, id: randomUUID(), operation: 'manager', runId: f.runId, args: ['tools', 'list', '--details'] });
|
|
104
105
|
assert.equal(detailed.ok, true);
|
|
105
106
|
assert.deepEqual(JSON.parse(detailed.stdout).sample.commands, ['ez sample --help']);
|
|
107
|
+
assert.deepEqual(JSON.parse(detailed.stdout).sample.skillReads, [['ez', 'tools', 'skill', 'sample', 'SKILL.md']]);
|
|
108
|
+
const skill = await request(f.socket, { version: 1, id: randomUUID(), operation: 'manager', runId: f.runId, args: ['tools', 'skill', 'sample', 'SKILL.md'] });
|
|
109
|
+
assert.equal(skill.code, 0);
|
|
110
|
+
assert.deepEqual(JSON.parse(skill.stdout), { plugin: 'sample', revision, path: 'SKILL.md', content: '# Synthetic authoring format\n' });
|
|
111
|
+
const privateFile = await request(f.socket, { version: 1, id: randomUUID(), operation: 'manager', runId: f.runId, args: ['tools', 'skill', 'sample', '../../config.json'] });
|
|
112
|
+
assert.notEqual(privateFile.code, 0);
|
|
113
|
+
assert.equal(privateFile.stdout, '');
|
|
106
114
|
const resolved = await request(f.socket, { version: 1, id: randomUUID(), operation: 'resolve', runId: f.runId, alias: 'sample' });
|
|
107
115
|
assert.equal(resolved.ok, true);
|
|
108
116
|
assert.equal(resolved.revision, revision);
|
|
@@ -162,6 +170,9 @@ test('broker rejects resolve and invoke for a revoked run', async t => {
|
|
|
162
170
|
try {
|
|
163
171
|
await waitForSocket(f.socket);
|
|
164
172
|
await new RunStore(f.controlDir).patch(f.runId, { status: 'cancelled' });
|
|
173
|
+
const skill = await request(f.socket, { version: 1, id: randomUUID(), operation: 'manager', runId: f.runId, args: ['tools', 'skill', 'sample', 'SKILL.md'] });
|
|
174
|
+
assert.equal(skill.ok, false);
|
|
175
|
+
assert.equal(skill.stdout, undefined);
|
|
165
176
|
const resolved = await request(f.socket, { version: 1, id: randomUUID(), operation: 'resolve', runId: f.runId, alias: 'sample' });
|
|
166
177
|
assert.equal(resolved.ok, false);
|
|
167
178
|
const invoked = await request(f.socket, { version: 1, id: randomUUID(), operation: 'invoke', runId: f.runId, alias: 'sample', revision, capability: 'a'.repeat(64), args: [] });
|
|
@@ -104,12 +104,26 @@ test('installed snippets follow install, upgrade and uninstall without files or
|
|
|
104
104
|
const before=await fs.readFile(f.log,'utf8'),index=await details();
|
|
105
105
|
assert.equal(index.sample.description,description);assert.deepEqual(index.sample.commands,['ez sample --help']);
|
|
106
106
|
assert.equal(await fs.readFile(index.sample.skills[0],'utf8'),'Synthetic');
|
|
107
|
+
assert.deepEqual(index.sample.skillReads,[['ez','tools','skill','sample','SKILL.md']]);
|
|
108
|
+
assert.deepEqual(JSON.parse((await f.call('tools','skill','sample','SKILL.md')).stdout),{plugin:'sample',revision:p.revision,path:'SKILL.md',content:'Synthetic'});
|
|
107
109
|
assert.equal(await fs.readFile(f.log,'utf8'),before);
|
|
108
110
|
assert.deepEqual(JSON.parse((await f.call('tools','list')).stdout),{sample:'sample'});
|
|
109
111
|
await f.call('plugins','uninstall','sample');assert.deepEqual(await details(),{});
|
|
110
112
|
}
|
|
111
113
|
await assert.rejects(fs.access(path.join(f.workspace,'TOOLS.md')),{code:'ENOENT'});
|
|
112
114
|
});
|
|
115
|
+
test('skill reads reject undeclared paths, traversal, symlinks and oversized files',async t=>{
|
|
116
|
+
const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
|
|
117
|
+
for(const requested of ['../config.json','/etc/passwd','client.mjs'])await assert.rejects(f.call('tools','skill','sample',requested),/Unknown installed plugin skill/);
|
|
118
|
+
await assert.rejects(f.call('tools','skill','foreign','SKILL.md'),/Unknown installed plugin skill/);
|
|
119
|
+
const r=JSON.parse(await fs.readFile(path.join(f.home,'registry.json'),'utf8')),file=path.join(r.plugins.sample.source,'SKILL.md');
|
|
120
|
+
await fs.rm(file);await fs.symlink(path.join(f.home,'config.json'),file);
|
|
121
|
+
await assert.rejects(f.call('tools','skill','sample','SKILL.md'),/symlink/);
|
|
122
|
+
await fs.rm(file);await fs.writeFile(file,'x'.repeat(64*1024+1));
|
|
123
|
+
await assert.rejects(f.call('tools','skill','sample','SKILL.md'),/64 KiB/);
|
|
124
|
+
r.plugins.sample.manifest.skills=['../config.json'];await fs.writeFile(path.join(f.home,'registry.json'),JSON.stringify(r));
|
|
125
|
+
await assert.rejects(f.call('tools','skill','sample','../config.json'),/Unsafe skill path/);
|
|
126
|
+
});
|
|
113
127
|
test('application plugin context is namespaced, authorized, and absent from ordinary commands',async t=>{
|
|
114
128
|
const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
|
|
115
129
|
const home=await fs.realpath(f.home);
|