@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 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();
@@ -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. Keep one canonical checkout; do not keep
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.43",
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.",
@@ -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;
@@ -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);