@jc_stack/ez-agents 0.1.0-beta.23 → 0.1.0-beta.24

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-beta.24
4
+
5
+ - Preserve host-owned private plugin networks across generated Compose services,
6
+ one-shot commands and compatible updates. Bind routes to reviewed plugin
7
+ revisions and retain plugin-local connectivity.
8
+ - Support existing deployments on their first upgrade. Network authorization
9
+ remains outside the agent-writable registry. Existing beta acceptance limits remain.
10
+
3
11
  ## 0.1.0-beta.23
4
12
 
5
13
  - Identify failed publisher API reads and missing release tags without exposing
package/docs/plugins.md CHANGED
@@ -198,12 +198,33 @@ Secrets persist privately across reinstall and are never included in registry
198
198
  responses. Twenty provides a complete v2 backend example.
199
199
 
200
200
  Compose JSON is generated, not accepted from untrusted arbitrary Compose input.
201
- No ports, host network, privileged services, host env inheritance, arbitrary bind mounts,
202
- Docker socket or raw Compose args can be supplied. Project names include a hash
203
- of the canonical registry path; networks and volumes inherit that namespace.
201
+ No ports, host-network mode, privileged services, host env inheritance, arbitrary bind mounts,
202
+ Docker socket or raw Compose args can be supplied. Package descriptors cannot select
203
+ Docker networks. Project names include a hash of the canonical registry path;
204
+ networks and volumes inherit that namespace.
204
205
  Images use release-specific names. All containers drop capabilities and run as
205
206
  UID 1000 by default; v2 can declare another non-root UID:GID. Plugins sharing a profile remain in the same owning deployment.
206
207
 
208
+ ### Host-owned private networks
209
+
210
+ Some private deployments need a reviewed plugin service to reach another private
211
+ Compose project. The installation host may save `pluginNetworkBindings` on that
212
+ agent's entry in its host-owned `host-executor.json`, keyed by plugin id. Each
213
+ route records the explicitly approved reviewed plugin `revisions` and literal
214
+ `{service, network}` entries. The manager re-hashes the installed snapshot
215
+ before accepting a route, then emits it for both persistent services and
216
+ one-shot command containers. Bound services keep their plugin's default network
217
+ as well, so they retain access to plugin-local dependencies. Routes therefore
218
+ survive command-time Compose regeneration and restarts. Before a routed plugin
219
+ update, the host adds the inspected candidate revision to that route; both the
220
+ current and candidate revisions may remain authorized during the transition.
221
+
222
+ This is deployment configuration, not a package-descriptor field or an `ez`
223
+ command: agents and plugins cannot request or alter host networks. The host
224
+ installer owns the binding and must ensure the selected service is stopped
225
+ before changing its network topology. The executor's writable tool registry is
226
+ never a source of network attachment authority.
227
+
207
228
  Commands run in one-shot client containers against their own service's volumes;
208
229
  stdin, stdout, stderr, literal arguments and exit codes are preserved. SIGINT/
209
230
  SIGTERM cancel the Docker call and remove its unique client container. The manager
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc_stack/ez-agents",
3
- "version": "0.1.0-beta.23",
3
+ "version": "0.1.0-beta.24",
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.",
@@ -13,7 +13,8 @@ import { taskWorkspace } from './task-workspace.js'
13
13
  import { packageVersion } from './version.js'
14
14
  import { installedPluginVersions } from './software-status.js'
15
15
 
16
- export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string }
16
+ export type PluginNetworkRoute = { revisions:string[]; bindings:{service:string;network:string}[] }
17
+ export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string; pluginNetworkBindings?: Record<string, PluginNetworkRoute> }
17
18
  export type HostInstallation = { cli: string; agents: HostBinding[] }
18
19
 
19
20
  export const serveHostExecutor = async (installation: HostInstallation, signal: AbortSignal, launch = startExecutorJob) => {
@@ -5,9 +5,11 @@ 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 { readFileSync, realpathSync } from 'node:fs';
8
9
 
9
10
  const reserved = new Set(['status','updates','plugins','tools','message','owner','approval','react','setup','help','version']);
10
11
  const id = value => { if(typeof value !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(value)) throw Error('Invalid identifier'); return value; };
12
+ const dockerNetwork = value => { if(typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(value)) throw Error('Invalid Docker network'); return value; };
11
13
  const hash = data => createHash('sha256').update(data).digest('hex');
12
14
  const json = async file => JSON.parse(await fs.readFile(file,'utf8'));
13
15
  const emit = value => console.log(JSON.stringify(value));
@@ -142,9 +144,67 @@ export async function checkFolders(config, record) {
142
144
  throw Error('Folder source must remain an existing real directory');
143
145
  }
144
146
  }
145
- export function compose(config, record, secrets={}) {
146
- const services={}, volumes={};
147
+ const childOf = (candidate, parent) => candidate === parent || candidate.startsWith(parent + path.sep);
148
+ // Host-owned private network bindings remain separate from portable package descriptors.
149
+ // The executor may write toolsHome, so the binding source must stay in its host config.
150
+ export async function hostNetworkBindings(config, record, home) {
151
+ const configuredHost = config.hostConfig ?? (typeof config.deploymentDir === 'string' && path.isAbsolute(config.deploymentDir) ? path.join(config.deploymentDir, 'host-executor.json') : undefined);
152
+ if (configuredHost === undefined) return [];
153
+ if (!home || typeof configuredHost !== 'string' || !path.isAbsolute(configuredHost)) throw Error('Invalid host network binding');
154
+ home = realpathSync(home);
155
+ const hostConfig = realpathSync(configuredHost);
156
+ if (hostConfig !== configuredHost || path.basename(hostConfig) !== 'host-executor.json' || childOf(hostConfig, home))
157
+ throw Error('Host network bindings require an external host config');
158
+ const host = JSON.parse(readFileSync(hostConfig, 'utf8'));
159
+ if (!host || typeof host !== 'object' || !Array.isArray(host.agents)) throw Error('Invalid host network bindings');
160
+ const candidates = host.agents.filter(agent => agent && typeof agent === 'object' && typeof agent.toolsHome === 'string' && realpathSync(agent.toolsHome) === home);
161
+ if (candidates.length !== 1) throw Error('Host network binding does not belong to this registry');
162
+ const agent = candidates[0], deployment = path.dirname(hostConfig);
163
+ if (typeof agent.workspace !== 'string' || typeof agent.controlDir !== 'string' ||
164
+ realpathSync(agent.workspace) !== config.workspace || realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
165
+ realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir) ||
166
+ childOf(hostConfig, config.workspace) || childOf(hostConfig, realpathSync(agent.controlDir)))
167
+ throw Error('Invalid host network binding deployment');
168
+ const configured = agent.pluginNetworkBindings;
169
+ if (configured !== undefined && (!configured || typeof configured !== 'object' || Array.isArray(configured)))
170
+ throw Error('Invalid host network bindings');
171
+ const route = configured?.[record.manifest?.id];
172
+ if (route === undefined) return [];
173
+ keys(route, ['revisions', 'bindings']);
174
+ const trusted = await snapshot(record.source);
175
+ if (record.revision !== trusted.revision || record.source !== trusted.source ||
176
+ JSON.stringify(record.manifest) !== JSON.stringify(trusted.manifest) ||
177
+ JSON.stringify(record.deployment) !== JSON.stringify(trusted.deployment))
178
+ throw Error('Registry plugin identity is not the reviewed source');
179
+ if (record.manifest.id !== trusted.manifest.id) throw Error('Registry plugin identity is not the reviewed source');
180
+ if (!Array.isArray(route.revisions) || route.revisions.some(revision => typeof revision !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(revision)) ||
181
+ !route.revisions.includes(trusted.revision)) throw Error('Host network binding is not pinned to the reviewed plugin revision');
182
+ const bindings = route.bindings;
183
+ if (!Array.isArray(bindings)) throw Error('Invalid host network bindings');
184
+ const seen = new Set();
185
+ for (const binding of bindings) {
186
+ keys(binding, ['service', 'network']);
187
+ id(binding.service); dockerNetwork(binding.network);
188
+ if (!record.deployment.services[binding.service]) throw Error('Unknown host network service');
189
+ const key = `${binding.service}\0${binding.network}`;
190
+ if (seen.has(key)) throw Error('Duplicate host network binding');
191
+ seen.add(key);
192
+ }
193
+ return bindings;
194
+ }
195
+ export async function compose(config, record, secrets={}, home) {
196
+ const services={}, volumes={}, networks={}, serviceNetworks=new Map();
147
197
  const folders = folderMounts(config, record);
198
+ for (const binding of await hostNetworkBindings(config, record, home)) {
199
+ // A service with an explicit network list loses Compose's implicit default.
200
+ // Keep the plugin's own network so bound services retain sibling access.
201
+ networks.default = {};
202
+ const key = `host-${hash(binding.network).slice(0,16)}`;
203
+ networks[key] = { external: true, name: binding.network };
204
+ const attached = serviceNetworks.get(binding.service) || ['default'];
205
+ if (!attached.includes(key)) attached.push(key);
206
+ serviceNetworks.set(binding.service, attached);
207
+ }
148
208
  for(const [name,s] of Object.entries(record.deployment.services)) {
149
209
  const mounts=[];
150
210
  for(const [volume,target] of Object.entries(s.volumes||{})) { volumes[volume]={};mounts.push({type:'volume',source:volume,target}); }
@@ -156,13 +216,14 @@ export function compose(config, record, secrets={}) {
156
216
  ...(s.dependsOn?{depends_on:Object.fromEntries(s.dependsOn.map(dep=>[dep,{condition:'service_healthy'}]))}:{}),
157
217
  ...(s.memoryMiB?{mem_limit:`${s.memoryMiB}m`}:{}),
158
218
  ...(s.cpus?{cpus:s.cpus}:{}),
219
+ ...(serviceNetworks.get(name)?.length?{networks:serviceNetworks.get(name)}:{}),
159
220
  ...(s.environment?{environment:Object.fromEntries(Object.entries(s.environment).map(([key,value])=>{
160
221
  if(typeof value==='string')return [key,value];
161
222
  if(!/^[a-f0-9]{64}$/.test(secrets[value.secret]||''))throw Error('Missing or invalid private deployment secret');
162
223
  return [key,(value.prefix||'')+secrets[value.secret]+(value.suffix||'')];
163
224
  }))}:{}),...(s.command?{command:s.command}:{})};
164
225
  }
165
- return attachShared({name:record.project,services,volumes}, record);
226
+ return attachShared({name:record.project,services,volumes,...(Object.keys(networks).length?{networks}:{})}, record);
166
227
  }
167
228
  function dockerEnv() {
168
229
  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]]));
@@ -205,14 +266,14 @@ async function registry(home) {
205
266
  export async function init(home,workspace,catalogFile,hostConfig,standalone=false) {
206
267
  if(standalone && hostConfig) throw Error('Standalone setup cannot bind a relay host config');
207
268
  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');
208
- workspace=await fs.realpath(workspace);await privateDir(home);home=await fs.realpath(home);
269
+ workspace=await fs.realpath(workspace);await privateDir(home);home=await fs.realpath(home);if(hostConfig)hostConfig=await fs.realpath(hostConfig);
209
270
  if(await fs.lstat(path.join(home,'registry.json')).catch(()=>null)) throw Error('Registry already exists; refusing replacement');
210
271
  catalogFile=path.resolve(catalogFile||fileURLToPath(new URL('../../default-plugins.json',import.meta.url)));
211
272
  const sources=await json(catalogFile);
212
273
  const catalog={};
213
274
  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};}
214
275
  await locked(home,async()=>{
215
- await atomic(path.join(home,'config.json'),{schemaVersion:1,workspace,catalog});
276
+ await atomic(path.join(home,'config.json'),{schemaVersion:1,workspace,catalog,...(hostConfig&&path.basename(hostConfig)==='host-executor.json'?{hostConfig}:{})});
216
277
  await atomic(path.join(home,'registry.json'),{schemaVersion:1,owner:home,plugins:{},commands:{}});
217
278
  const bin=path.join(home,'bin');await privateDir(bin);
218
279
  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'});
@@ -262,7 +323,7 @@ export async function install(home,config,name,source,revision) {
262
323
  let secrets;try{secrets=await json(secretsFile);}catch(error){if(error.code!=='ENOENT')throw error;secrets={};}
263
324
  for(const name of p.deployment.secrets||[])if(secrets[name]===undefined)secrets[name]=randomBytes(32).toString('hex');
264
325
  if(p.deployment.secrets?.length)await atomic(secretsFile,secrets);
265
- await atomic(record.compose,compose(config,record,secrets));
326
+ await atomic(record.compose,await compose(config,record,secrets,home));
266
327
  // Build/pull is explicit installation mechanics. No services start and no onboarding is executed.
267
328
  for(const [service,s] of Object.entries(record.deployment.services)) await checked([...composeArgs(record),s.image?'pull':'build',service]);
268
329
  r.plugins[name]=record;for(const alias of Object.keys(p.manifest.commands)) r.commands[alias]=name;
@@ -327,7 +388,7 @@ export async function main(args) {
327
388
  settings.folders={...settings.folders,[name]:folders};
328
389
  await checkFolders(settings,latest);
329
390
  const secrets=await json(path.join(home,'packages',name,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
330
- const generated=compose(settings,latest,secrets);
391
+ const generated=await compose(settings,latest,secrets,home);
331
392
  await atomic(path.join(home,'config.json'),settings);
332
393
  await atomic(latest.compose,generated);
333
394
  return emit({ok:true,plugin:name,folders,readOnly:true,started:false});
@@ -345,7 +406,7 @@ export async function main(args) {
345
406
  const result = action === 'shared-enable' ? await sharedService(latest, key, 'enable', run) : { state: 'detached' };
346
407
  latest.sharedEnabled = [...new Set([...(latest.sharedEnabled || []).filter(k => k !== key), ...(action === 'shared-enable' ? [key] : [])])];
347
408
  const secrets = await json(path.join(home, 'packages', name, 'secrets.json')).catch(e => { if (e.code === 'ENOENT') return {}; throw e; });
348
- await atomic(latest.compose, compose(currentConfig, latest, secrets));
409
+ await atomic(latest.compose, await compose(currentConfig, latest, secrets, home));
349
410
  // Persist the binding before recreating clients; start can recover an interrupted recreation.
350
411
  await atomic(path.join(home, 'registry.json'), current);
351
412
  await checked([...composeArgs(latest), 'up', '-d', '--wait']);
@@ -371,7 +432,7 @@ export async function main(args) {
371
432
  const currentConfig=await json(path.join(home,'config.json'));
372
433
  await checkFolders(currentConfig,current.plugins[name]);
373
434
  const secrets=await json(path.join(home,'packages',name,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
374
- await atomic(record.compose,compose(currentConfig,current.plugins[name],secrets));
435
+ await atomic(record.compose,await compose(currentConfig,current.plugins[name],secrets,home));
375
436
  }
376
437
  await checked([...composeArgs(record),...(action==='start'?['up','-d','--wait']:action==='stop'?['stop']:['down'])]);
377
438
  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);}
@@ -384,7 +445,7 @@ export async function main(args) {
384
445
  if(!binding)throw Error('Unknown registered CLI');
385
446
  await checkFolders(config,record);
386
447
  const secrets=await json(path.join(home,'packages',record.manifest.id,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
387
- await atomic(record.compose,compose(config,record,secrets));
448
+ await atomic(record.compose,await compose(config,record,secrets,home));
388
449
  // Docker exec does not reliably forward cancellation to the in-container process.
389
450
  // Run each client as a one-shot Compose container; docker compose run forwards signals.
390
451
  const name=`${record.project}-call-${randomUUID()}`;
@@ -11,7 +11,7 @@ export async function bindUpdates(home,hostConfig,packageRoot=fileURLToPath(new
11
11
  if(await fs.realpath(path.join(deploymentDir,'mind'))!==config.workspace||await fs.realpath(path.join(deploymentDir,'control'))!==host.agents[0].controlDir)throw Error('Noncanonical deployment state paths');
12
12
  packageRoot=await fs.realpath(packageRoot);
13
13
  await fs.mkdir(path.join(home,'updates'),{recursive:true,mode:0o700});
14
- await atomic(path.join(home,'config.json'),{...config,packageRoot:packageRoot.replace(/\/$/,''),deploymentDir});
14
+ await atomic(path.join(home,'config.json'),{...config,hostConfig,packageRoot:packageRoot.replace(/\/$/,''),deploymentDir});
15
15
  const pkg=await read(path.join(packageRoot,'package.json')),bin=path.join(home,'bin');
16
16
  // Every invocation resolves the active root. Already-running workers retain
17
17
  // their old code; the next worker receives the new package after activation.
@@ -58,7 +58,7 @@ function envValue(text,key,value) {
58
58
  }
59
59
  async function backupVolume(run,record,name,directory) {
60
60
  const services=Object.entries(record.deployment.services),service=services.find(([,s])=>s.volumes?.[name])?.[0];
61
- const c=compose({workspace:'/unused'},record),image=c.services[service].image;
61
+ const c=await compose({workspace:'/unused'},record),image=c.services[service].image;
62
62
  // No writable profile mount, network, Docker socket, provider command or secrets.
63
63
  const found=(await run('docker',['volume','ls','--format','{{.Name}}','--filter',`name=^${record.project}_${name}$`])).trim();
64
64
  if(!found)return; // Installed but never started: no profile exists yet.
@@ -112,7 +112,7 @@ export async function perform(home,job,hooks) {
112
112
  const s=await snapshot(root),candidate={...old,source:root,revision:s.revision,manifest:s.manifest,deployment:s.deployment,sharedRevisions:s.sharedRevisions};
113
113
  for (const key of old.sharedEnabled || []) if (sharedIdentity(old, key).fingerprint !== sharedIdentity(candidate, key).fingerprint) throw Error('Shared worker changed; disable this client and coordinate an explicit shared worker upgrade before updating');
114
114
  await checkFolders(config,candidate);
115
- const stage={...candidate,compose:path.join(dir,'compose.json')};await atomic(stage.compose,compose(config,stage,secrets));
115
+ const stage={...candidate,compose:path.join(dir,'compose.json')};await atomic(stage.compose,await compose(config,stage,secrets,home));
116
116
  for(const [service,spec] of Object.entries(stage.deployment.services))await run('docker',[...pluginArgs(stage),spec.image?'pull':'build',service]);
117
117
  const running=Boolean((await run('docker',[...pluginArgs(old),'ps','-q'])).trim());
118
118
  job.rollback={record:old,registry:r,compose:await read(old.compose),running};await save();
@@ -120,7 +120,7 @@ export async function perform(home,job,hooks) {
120
120
  const backup=path.join(dir,'backup');await fs.mkdir(backup,{mode:0o700});
121
121
  const volumes=new Set(Object.values(old.deployment.services).flatMap(s=>Object.keys(s.volumes||{})));
122
122
  for(const name of volumes)await backupVolume(run,old,name,backup);
123
- await atomic(old.compose,compose(config,candidate,secrets));
123
+ await atomic(old.compose,await compose(config,candidate,secrets,home));
124
124
  if(running)await run('docker',[...pluginArgs(candidate),'up','-d','--wait','--wait-timeout','90','--no-build']);
125
125
  r.plugins[job.target]=candidate;await atomic(path.join(home,'registry.json'),r);
126
126
  job.runtimeVerified=running;
@@ -30,8 +30,8 @@ for(const cleanup of ['success','failure','already-removed']) test(`cancel remov
30
30
  });
31
31
  async function fixture(t) {
32
32
  const root=await fs.mkdtemp(path.join(tmpdir(),'ez-tools-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));
33
- const source=path.join(root,'source'),home=path.join(root,'tools'),workspace=path.join(root,'mind'),fake=path.join(root,'fake');
34
- for(const d of [source,workspace,fake])await fs.mkdir(d);
33
+ const source=path.join(root,'source'),home=path.join(root,'tools'),deploymentDir=path.join(root,'deployment'),workspace=path.join(deploymentDir,'mind'),control=path.join(deploymentDir,'control'),hostConfig=path.join(deploymentDir,'host-executor.json'),fake=path.join(root,'fake');
34
+ for(const d of [source,workspace,control,fake])await fs.mkdir(d,{recursive:true});
35
35
  const manifest={schemaVersion:1,id:'sample',version:'0.1.0',description:'Synthetic',commands:{sample:{executable:'client.mjs',args:[]}},skills:['SKILL.md']};
36
36
  const deployment={schemaVersion:1,services:{sample:{buildTarget:'runtime',volumes:{data:'/data'},workspace:true,healthcheck:['node','--version']}},commands:{sample:{service:'sample',argv:['node','/app/client.mjs'],suffix:[]}}};
37
37
  for(const [name,value]of Object.entries({'package.json':JSON.stringify({files:['client.mjs','SKILL.md']}),'ez-plugin.json':JSON.stringify(manifest),'ez-deployment.json':JSON.stringify(deployment),'Dockerfile':'FROM scratch AS runtime','.dockerignore':'','client.mjs':'','SKILL.md':'Synthetic'}))await fs.writeFile(path.join(source,name),value);
@@ -39,7 +39,7 @@ async function fixture(t) {
39
39
  await fs.writeFile(path.join(fake,'docker'),`#!${process.execPath}\nrequire('fs').appendFileSync(${JSON.stringify(log)},JSON.stringify({argv:process.argv.slice(2),secret:process.env.TELEGRAM_BOT_TOKEN})+'\\n');\nif(process.argv.includes('run')){console.log(JSON.stringify(process.argv.slice(process.argv.indexOf('/app/client.mjs')+1)));process.exit(process.argv.includes('fail')?17:0)}\n`,{mode:0o700});
40
40
  const env={...process.env,PATH:fake+path.delimiter+process.env.PATH,TELEGRAM_BOT_TOKEN:'must-not-leak'};
41
41
  const call=(...args)=>exec(process.execPath,[bin,'--home',home,...args],{env});
42
- return {root,source,home,workspace,fake,log,manifest,deployment,env,call};
42
+ return {root,source,home,deploymentDir,workspace,control,hostConfig,fake,log,manifest,deployment,env,call};
43
43
  }
44
44
  test('catalog paths resolve relative to the catalog and pin each new agent independently',async t=>{
45
45
  const f=await fixture(t),catalog=path.join(f.root,'defaults.json');
@@ -95,13 +95,45 @@ test('registry corruption, active writer lock and alias collision fail closed',a
95
95
  });
96
96
  test('Compose resources are namespaced, private, and restricted to owning workspace',async t=>{
97
97
  const f=await fixture(t),p=await snapshot(f.source);
98
- const c=compose({workspace:f.workspace},{source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:p.deployment});
98
+ const c=await compose({workspace:f.workspace},{source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:p.deployment});
99
99
  assert.equal(c.name,'ezp-synthetic');assert.equal(c.services.sample.ports,undefined);assert.equal(c.services.sample.privileged,undefined);assert.equal(c.services.sample.volumes[1].read_only,true);assert.deepEqual(c.services.sample.cap_drop,['ALL']);
100
100
  });
101
+ test('host-owned private networks survive registered-call Compose regeneration',async t=>{
102
+ const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);
103
+ await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
104
+ const config=JSON.parse(await fs.readFile(path.join(f.home,'config.json'),'utf8'));
105
+ const binding={service:'sample',network:'stocks-vm_default'};
106
+ const host={cli:'synthetic',agents:[{name:'sample',workspace:f.workspace,controlDir:f.control,binDir:path.join(f.home,'bin'),toolsHome:f.home,pluginNetworkBindings:{sample:{revisions:[p.revision],bindings:[binding]}}}]};
107
+ await fs.writeFile(f.hostConfig,JSON.stringify(host));
108
+ config.hostConfig=await fs.realpath(f.hostConfig);
109
+ await fs.writeFile(path.join(f.home,'config.json'),JSON.stringify(config));
110
+ const file=path.join(f.home,'packages/sample/compose.json');await fs.writeFile(file,'{}');
111
+ await f.call('sample','read');
112
+ const c=JSON.parse(await fs.readFile(file,'utf8'));
113
+ assert.deepEqual(c.networks.default,{});
114
+ const [key,network]=Object.entries(c.networks).find(([name])=>name!=='default');
115
+ assert.equal(network.name,'stocks-vm_default');assert.equal(network.external,true);
116
+ assert.deepEqual(c.services.sample.networks,['default',key]);
117
+ const compromised={...config,hostNetworkBindings:{sample:[{service:'sample',network:'untrusted_default'}]}};
118
+ assert.deepEqual((await compose(compromised,p,{},f.home)).services.sample.networks,['default',key]);
119
+ const upgraded={...config,hostConfig:undefined,deploymentDir:path.dirname(config.hostConfig)};
120
+ assert.deepEqual((await compose(upgraded,p,{},f.home)).services.sample.networks,['default',key]);
121
+ const unsafeHost=path.join(f.home,'host-executor.json');await fs.writeFile(unsafeHost,JSON.stringify(host));
122
+ await assert.rejects(compose({...config,hostConfig:unsafeHost},p,{},f.home),/external host config/);
123
+ const forged=structuredClone(p);forged.manifest.id='forged';
124
+ host.agents[0].pluginNetworkBindings={forged:{revisions:[p.revision],bindings:[binding]}};await fs.writeFile(f.hostConfig,JSON.stringify(host));
125
+ await assert.rejects(compose(config,forged,{},f.home),/reviewed source/);
126
+ for(const bindings of [
127
+ {sample:[{service:'missing',network:'stocks-vm_default'}]},
128
+ {sample:[{service:'sample',network:'bad/network'}]},
129
+ {sample:[{service:'sample',network:'stocks-vm_default'},{service:'sample',network:'stocks-vm_default'}]},
130
+ 'not-a-binding-map'
131
+ ]) { host.agents[0].pluginNetworkBindings=bindings==='not-a-binding-map'?bindings:{sample:{revisions:[p.revision],bindings}};await fs.writeFile(f.hostConfig,JSON.stringify(host));await assert.rejects(compose(config,{source:p.source,project:'ezp-synthetic',revision:p.revision,manifest:p.manifest,deployment:p.deployment},{},f.home),/host network|Docker network/); }
132
+ });
101
133
  test('host onboarding binds local ez without replacing global commands',async t=>{
102
134
  const f=await fixture(t),native=path.join(f.root,'native'),config=path.join(f.root,'host.json');await fs.mkdir(native);await fs.writeFile(path.join(native,'native-tool'),'hello');
103
135
  await fs.writeFile(config,JSON.stringify({cli:'synthetic',agents:[{name:'demo',workspace:f.workspace,binDir:native}]}));
104
- await init(f.home,f.workspace,undefined,config);const host=JSON.parse(await fs.readFile(config));assert.equal(host.agents[0].binDir,path.join(await fs.realpath(f.home),'bin'));assert.equal(host.agents[0].toolsHome,await fs.realpath(f.home));assert.equal(await fs.readFile(path.join(host.agents[0].binDir,'native-tool'),'utf8'),'hello');
136
+ await init(f.home,f.workspace,undefined,config);const host=JSON.parse(await fs.readFile(config));assert.equal(host.agents[0].binDir,path.join(await fs.realpath(f.home),'bin'));assert.equal(host.agents[0].toolsHome,await fs.realpath(f.home));assert.equal((JSON.parse(await fs.readFile(path.join(f.home,'config.json'),'utf8'))).hostConfig,undefined);assert.equal(await fs.readFile(path.join(host.agents[0].binDir,'native-tool'),'utf8'),'hello');
105
137
  await assert.rejects(init(f.home,f.workspace),/exists/);
106
138
  });
107
139
  test('host install exposes runnable public commands without source aliases',async t=>{
@@ -128,10 +160,10 @@ test('v2 supports bounded dependency graphs and generated private secrets',async
128
160
  d.services.database={image:'example/database@sha256:'+'a'.repeat(64),user:'999:999',healthcheck:['check'],memoryMiB:128,cpus:2,environment:{PASSWORD:{secret:'db-password'}}};
129
161
  d.services.sample.dependsOn=['database'];d.services.sample.environment={URL:{secret:'db-password',prefix:'db://',suffix:'@database'}};
130
162
  validate(f.manifest,d,p.files);
131
- const c=compose({workspace:f.workspace},{source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:d},{'db-password':'b'.repeat(64)});
163
+ const c=await compose({workspace:f.workspace},{source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:d},{'db-password':'b'.repeat(64)});
132
164
  assert.equal(c.services.database.user,'999:999');assert.equal(c.services.sample.depends_on.database.condition,'service_healthy');assert.equal(c.services.sample.environment.URL,'db://'+'b'.repeat(64)+'@database');
133
- assert.equal(c.services.database.mem_limit,'128m');assert.throws(()=>compose({workspace:f.workspace}, {source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:d}),/Missing/);
134
- assert.equal(c.services.database.cpus,2);assert.throws(()=>compose({workspace:f.workspace}, {source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:d}),/Missing/);
165
+ assert.equal(c.services.database.mem_limit,'128m');await assert.rejects(compose({workspace:f.workspace}, {source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:d}),/Missing/);
166
+ assert.equal(c.services.database.cpus,2);await assert.rejects(compose({workspace:f.workspace}, {source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:d}),/Missing/);
135
167
  for(const change of [x=>x.services.database.user='0:0',x=>x.services.database.cpus=0.01,x=>x.services.database.cpus=9,x=>x.services.database.environment.PASSWORD={secret:'undeclared'},x=>x.services.database.environment.PASSWORD='${HOST_SECRET}',x=>x.services.database.dependsOn=['sample'],x=>x.services.sample.dependsOn=['missing'],x=>x.services.sample.ports=['9999:9999']]){const bad=structuredClone(d);change(bad);assert.throws(()=>validate(f.manifest,bad,p.files));}
136
168
  const old=structuredClone(d);old.schemaVersion=1;assert.throws(()=>validate(f.manifest,old,p.files));
137
169
  });
@@ -252,9 +284,9 @@ test('folder bindings survive compatible descriptors and reject incompatible upd
252
284
  const f=await fixture(t);const p=await snapshot(f.source);
253
285
  const record={...p,project:'ezp-test-sample'};
254
286
  const config={workspace:f.workspace,folders:{sample:[{service:'sample',source:f.workspace,target:'/data/files'}]}};
255
- assert.equal(compose(config,record).services.sample.volumes.find(v=>v.target==='/data/files').read_only,true);
287
+ assert.equal((await compose(config,record)).services.sample.volumes.find(v=>v.target==='/data/files').read_only,true);
256
288
  const next=structuredClone(record);next.deployment.services.sample.volumes={data:'/new-state'};
257
- assert.throws(()=>compose(config,next),/child of a declared volume/);
289
+ await assert.rejects(compose(config,next),/child of a declared volume/);
258
290
  });
259
291
 
260
292
  test('folder rebind rejects every live project container including one-shots',async t=>{
@@ -44,7 +44,7 @@ async function fixture(t,kind='main') {
44
44
  if(kind==='plugin') {
45
45
  const s=await snapshot(old),base=path.join(home,'packages',target);await fs.mkdir(base,{recursive:true});
46
46
  record={revision:s.revision,source:old,project:`ezp-${digest(home).slice(0,16)}-${target}`,manifest:s.manifest,deployment:s.deployment,compose:path.join(base,'compose.json')};
47
- await atomic(record.compose,compose(config,record));
47
+ await atomic(record.compose,await compose(config,record,{},home));
48
48
  }
49
49
  await atomic(path.join(home,'registry.json'),{schemaVersion:1,owner:home,plugins:record?{sample:record}:{},commands:record?{sample:'sample'}:{}});
50
50
  await fs.cp(old,source,{recursive:true});pkg.version='0.1.1';await atomic(path.join(source,'package.json'),pkg);