@jc_stack/ez-agents 0.1.0-beta.45 → 0.1.0-beta.46

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-beta.46
4
+
5
+ - Allow private RC/QA candidates to graduate to their matching published beta
6
+ through the existing updater, without permitting a move to an older beta.
7
+
8
+ - Refresh the isolated broker after plugin upgrade or rollback so status reports
9
+ the resulting installed versions rather than its startup inventory. Preserve
10
+ the previous registry snapshot independently during plugin activation.
11
+ Preserve stopped plugins as stopped when Docker retains a created container.
12
+
13
+ - Include the existing Telegram runtime/version summary in authorized private
14
+ channel controls so channel status can show the running relay and host versions.
15
+
3
16
  ## 0.1.0-beta.45
4
17
 
5
18
  - Keep owner chat responsive while a scheduled native turn runs. Foreground
@@ -58,6 +58,9 @@ the ordinary last-selection-wins behavior. Already-admitted runs keep their
58
58
  captured conversation and AI. Uncertain control responses require a fresh read,
59
59
  not blind repetition of `/new`.
60
60
 
61
+ The scope-control response includes `software`, the same current runtime/version
62
+ summary used by Telegram status. It is read-only and does not create a session.
63
+
61
64
  For private application conversations, `GET /v1/scope-control?scope=<encoded-scope>` returns
62
65
  the same catalog and the current scope's public control ID and preset. The scope
63
66
  is the application's original admission scope, resolved under its authenticated
package/docs/plugins.md CHANGED
@@ -157,6 +157,11 @@ snapshots. Reinstallation reuses that data. Plain install does not replace a dif
157
157
  compatible upgrades under saved policy; see [upgrades](upgrades.md). Manual
158
158
  uninstall/reinstall remains available but does not promise schema rollback.
159
159
 
160
+ Installed package files retain their reviewed modes (0644 for ordinary files,
161
+ 0755 for executables and nested directories), including under restrictive umasks
162
+ or inherited default ACLs. The snapshot root remains private (0700); provider
163
+ data and private operation receipts keep their separate permissions.
164
+
160
165
  For another reviewed local package, inspect with `plugins inspect <id> --source
161
166
  /absolute/source`, then install with that source and the returned `--revision
162
167
  sha256:...`. Changed contents fail. To make a reviewed package discoverable without installing,
package/docs/upgrades.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Agent-owned software upgrades
2
2
 
3
+ Private `beta.N.rc.M` and `beta.N.qa.M` candidates may upgrade to the matching
4
+ published `beta.N`, despite SemVer's prerelease ordering. All identity, integrity,
5
+ deployment and owner-policy checks still apply; older betas remain rejected.
6
+
3
7
  For an application's independently deployed frontend, backend or embedded gateway,
4
8
  see [managed applications](managed-applications.md). They use their existing
5
9
  deployment tools and a saved maintenance mandate; this package updater inventories
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc_stack/ez-agents",
3
- "version": "0.1.0-beta.45",
3
+ "version": "0.1.0-beta.46",
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.",
@@ -11,6 +11,7 @@ import { MAX_INCOMING_ATTACHMENT_BYTES, stageChatAttachment } from './files.js'
11
11
  import { assertEffort } from './model-policy.js'
12
12
  import { ApprovalStore } from './approval.js'
13
13
  import { Tasks } from './tasks.js'
14
+ import { softwareStatus } from './software-status.js'
14
15
  import { SpeechCreditsDepletedError } from './audio.js'
15
16
 
16
17
  const hash = (value: string) => createHash('sha256').update(value).digest('hex')
@@ -72,7 +73,7 @@ export class ApplicationChannel {
72
73
  private admissions = Promise.resolve()
73
74
  readonly bindings: ApplicationBindings
74
75
  constructor(private options: {
75
- controlDir: string; workspace?: string; initial: AiPreset
76
+ controlDir: string; workspace?: string; isolation?: string; initial: AiPreset
76
77
  wake: () => void
77
78
  cancel: (id: string) => Promise<void>
78
79
  speech?: (text: string, language: 'en' | 'es') => Promise<{ buffer: Buffer; mimeType: string }>
@@ -180,6 +181,7 @@ export class ApplicationChannel {
180
181
  await this.sharedBinding(bindingId, false)
181
182
  return {ai:{...ai, selectedId:session?.preset?.id ?? ai.selectedId,
182
183
  presets:session?.preset ? [...ai.presets.filter(item=>item.id!==session.preset!.id),session.preset] : ai.presets},
184
+ software: await softwareStatus(this.options.controlDir, this.options.isolation),
183
185
  models, activeSessionId:session?.sessionId ?? null, sessions:session ? [{id:session.sessionId,title:sessionTitle(session),cli:session.cli,archived:false}] : []}
184
186
  }
185
187
  async changeScopeControls(bindingId: string, scope: string, input: unknown) {
package/src/index.ts CHANGED
@@ -100,7 +100,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
100
100
  }
101
101
 
102
102
  const applicationChannel = new ApplicationChannel({
103
- controlDir: config.controlDir, workspace: config.workspace, initial: aiMenu.initial,
103
+ controlDir: config.controlDir, workspace: config.workspace, isolation: config.isolation, initial: aiMenu.initial,
104
104
  aiControls: aiMenu,
105
105
  speech: config.geminiApiKey ? (text, language) => synthesizeSpeech(text, {
106
106
  geminiApiKey: config.geminiApiKey,
@@ -553,7 +553,13 @@ export async function install(home,config,name,source,revision) {
553
553
  const base=path.join(home,'packages',name),target=path.join(base,revision.slice(7));await privateDir(base);
554
554
  const stage=path.join(base,`.stage-${randomUUID()}`);await privateDir(stage);
555
555
  try {
556
- 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'});}
556
+ for(const [relative,file] of p.files) {
557
+ const dest=path.join(stage,relative);await fs.mkdir(path.dirname(dest),{recursive:true,mode:0o755});
558
+ // Package modes are part of the reviewed snapshot; umask/default ACLs
559
+ // must not make Docker COPY inputs unreadable to the runtime user.
560
+ for(let dir=path.dirname(dest);dir!==stage;dir=path.dirname(dir))await fs.chmod(dir,0o755);
561
+ await fs.writeFile(dest,file.data,{mode:file.mode,flag:'wx'});await fs.chmod(dest,file.mode);
562
+ }
557
563
  // A previously interrupted install may leave a snapshot. Never silently replace it.
558
564
  try { await fs.rename(stage,target); } catch(error) {
559
565
  if(!['EEXIST','ENOTEMPTY'].includes(error.code)) throw error;
@@ -10,8 +10,9 @@ export const missing = error => {if(error.code!=='ENOENT')throw error;return nul
10
10
  export const targetId = value => {if(value!=='main'&&!/^[a-z][a-z0-9-]{0,39}$/.test(value))throw Error('Invalid update target');return value;};
11
11
  const jobId=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
12
12
  const compatibilityBuild=/^(.+)\.compat\.\d+$/;
13
+ const qaBuild=/^(\d+\.\d+\.\d+-beta\.\d+)\.(?:rc|qa)\.[1-9]\d*$/;
13
14
  const upgradeable=(candidate,installed)=>{
14
- const match=compatibilityBuild.exec(installed);
15
+ const match=compatibilityBuild.exec(installed)||qaBuild.exec(installed);
15
16
  return newer(candidate,installed)||Boolean(match&&match[1]===candidate);
16
17
  };
17
18
  export const updateHome = home => path.join(home,'updates');
@@ -61,6 +61,10 @@ export async function relayServices(config) {
61
61
  const env = await fs.readFile(path.join(config.deploymentDir,'docker.env'),'utf8').catch(() => '');
62
62
  return parseEnv(env).EZ_EXECUTOR_TRANSPORT === 'local' ? ['relay','plugin-broker'] : ['relay'];
63
63
  }
64
+ async function refreshBroker(config, run) {
65
+ if ((await relayServices(config)).includes('plugin-broker'))
66
+ await run('docker', [...relayArgs(config), 'restart', 'plugin-broker']);
67
+ }
64
68
  const healthCodes = new Set(['RELAY_UNREADABLE','RELAY_NOT_POLLING','RELAY_STALE','HOST_UNREADABLE','HOST_STALE','PLUGIN_BROKER_UNREADABLE']);
65
69
  async function healthEvidence(config,run) {
66
70
  const id=(await run('docker',[...relayArgs(config),'ps','-q','relay'])).trim();
@@ -145,8 +149,8 @@ export async function perform(home,job,hooks) {
145
149
  await checkFolders(config,candidate,home);
146
150
  const stage={...candidate,compose:path.join(dir,'compose.json')};await atomic(stage.compose,await compose(config,stage,secrets,home));
147
151
  for(const [service,spec] of Object.entries(stage.deployment.services))await run('docker',[...pluginArgs(stage),spec.image?'pull':'build',service]);
148
- const running=Boolean((await run('docker',[...pluginArgs(old),'ps','-q'])).trim());
149
- job.rollback={record:old,registry:r,compose:await read(old.compose),running};await save();
152
+ const running=Boolean((await run('docker',[...pluginArgs(old),'ps','--status','running','-q'])).trim());
153
+ job.rollback={record:old,registry:structuredClone(r),compose:await read(old.compose),running};await save();
150
154
  await run('docker',[...pluginArgs(old),'stop']);
151
155
  const backup=path.join(dir,'backup');await fs.mkdir(backup,{mode:0o700});
152
156
  const volumes=new Set(Object.values(old.deployment.services).flatMap(s=>Object.keys(s.volumes||{})));
@@ -156,6 +160,7 @@ export async function perform(home,job,hooks) {
156
160
  r.plugins[job.target]=candidate;
157
161
  for(const alias of Object.keys(candidate.manifest.commands))r.commands[alias]=job.target;
158
162
  await atomic(path.join(home,'registry.json'),r);
163
+ await refreshBroker(config,run);
159
164
  job.runtimeVerified=running;
160
165
  }
161
166
  job.status='completed';job.endedAt=new Date().toISOString();await save();
@@ -192,6 +197,7 @@ export async function recover(home,job,hooks) {
192
197
  const b=job.rollback;
193
198
  await run('docker',[...pluginArgs(b.record),'stop']);
194
199
  await atomic(b.record.compose,b.compose);await atomic(path.join(home,'registry.json'),b.registry);
200
+ await refreshBroker(config,run);
195
201
  if(b.running)await run('docker',[...pluginArgs(b.record),'up','-d','--wait','--wait-timeout','90','--no-build']);
196
202
  }
197
203
  job.status='rolled-back';
@@ -8,6 +8,7 @@ import { ApplicationChannel, applicationScope } from '../src/application-channel
8
8
  import { ControlStore } from '../src/control-state.js'
9
9
  import { createAiMenu } from '../src/menu.js'
10
10
  import { RunStore } from '../src/runs.js'
11
+ import { softwareStatus } from '../src/software-status.js'
11
12
 
12
13
  test('shared controls validate detected host presets against the host catalog', async t => {
13
14
  const root = await mkdtemp(join(tmpdir(), 'ez-host-controls-'))
@@ -67,6 +68,8 @@ test('private scope reset and model controls preserve running sessions and share
67
68
  ...(body===undefined?{}:{body:JSON.stringify(body)}),
68
69
  })
69
70
  const before = await (await call()).json() as any
71
+ assert.deepEqual(before.software,await softwareStatus(root))
72
+ assert.match(before.software[0],/^Relay: running · /)
70
73
  assert.equal(before.activeSessionId,run.execution!.sessionId)
71
74
  assert.equal(JSON.stringify(before).includes('native-private'),false)
72
75
  assert.equal((await channel.scopeControls(second.bindingId,'exercise')).activeSessionId,null)
@@ -46,6 +46,21 @@ async function fixture(t) {
46
46
  const call=(...args)=>exec(process.execPath,[bin,'--home',home,...args],{env});
47
47
  return {root,source,home,deploymentDir,workspace,control,hostConfig,fake,log,manifest,deployment,env,call};
48
48
  }
49
+ test('install preserves reviewed package modes under a restrictive umask',async t=>{
50
+ const f=await fixture(t);
51
+ await fs.mkdir(path.join(f.source,'nested'));await fs.writeFile(path.join(f.source,'nested','note.md'),'Synthetic');
52
+ const pkg=JSON.parse(await fs.readFile(path.join(f.source,'package.json'),'utf8'));pkg.files.push('nested');
53
+ await fs.writeFile(path.join(f.source,'package.json'),JSON.stringify(pkg));await fs.chmod(path.join(f.source,'client.mjs'),0o755);
54
+ const p=await snapshot(f.source);await init(f.home,f.workspace);
55
+ const argv=['--home',f.home,'plugins','install','sample','--source',f.source,'--revision',p.revision];
56
+ const script=`process.umask(0o077);const m=await import(${JSON.stringify(new URL('../src/plugins/manager.mjs',import.meta.url).href)});await m.main(${JSON.stringify(argv)});`;
57
+ await exec(process.execPath,['--input-type=module','-e',script],{env:f.env});
58
+ const record=JSON.parse(await fs.readFile(path.join(f.home,'registry.json'),'utf8')).plugins.sample;
59
+ for(const [relative,file]of p.files)assert.equal((await fs.stat(path.join(record.source,relative))).mode&0o777,file.mode,relative);
60
+ assert.equal((await fs.stat(path.join(record.source,'nested'))).mode&0o777,0o755);
61
+ assert.equal((await fs.stat(record.source)).mode&0o777,0o700);
62
+ assert.equal((await snapshot(record.source)).revision,p.revision);
63
+ });
49
64
  test('persistent connection reuses one container and releases registry lock between frames',async t=>{
50
65
  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);
51
66
  await fs.writeFile(path.join(f.fake,'docker'),`#!${process.execPath}\nif(process.argv[2]==='container')process.exit(0);require('readline').createInterface({input:process.stdin}).on('line',line=>{const f=JSON.parse(line);console.log(JSON.stringify(f.coreResponse?{reply:f.coreResponse}:{coreRequest:{id:f.id,method:'tools.list',params:{}}}));});`,{mode:0o700});
@@ -276,6 +276,16 @@ test('compatibility bridge hands off any matching canonical beta release',async
276
276
  await atomic(path.join(f.source,'package.json'),{...candidate,version:'0.1.0-beta.34'});
277
277
  await assert.rejects(eligibility(f.home,'main',f.source,false),/newer/);
278
278
  });
279
+ test('private RC and QA builds graduate only to their matching beta release',async t=>{
280
+ const f=await fixture(t),prior=await read(path.join(f.old,'package.json')),candidate=await read(path.join(f.source,'package.json'));
281
+ for(const suffix of ['rc.7','qa.4']) {
282
+ await atomic(path.join(f.old,'package.json'),{...prior,version:'0.1.0-beta.46.'+suffix});
283
+ await atomic(path.join(f.source,'package.json'),{...candidate,version:'0.1.0-beta.46'});
284
+ assert.equal((await eligibility(f.home,'main',f.source,true)).pkg.version,'0.1.0-beta.46');
285
+ await atomic(path.join(f.source,'package.json'),{...candidate,version:'0.1.0-beta.45'});
286
+ await assert.rejects(eligibility(f.home,'main',f.source,false),/newer/);
287
+ }
288
+ });
279
289
  test('reviewed Library beta14 command migration is exact and fail-closed',()=>{
280
290
  const manifest=(version,document)=>({schemaVersion:1,id:'library',version,description:'library',commands:{library:{executable:'bin/ez-library.mjs',args:[]},'library-query':{executable:'bin/ez-library.mjs',args:[],channelQuery:true},...(document?{'library-document':{executable:'bin/ez-library.mjs',args:[],channelQuery:true}}:{})},skills:['skills/library/SKILL.md']});
281
291
  const deployment=(query,document)=>({schemaVersion:3,services:{library:{buildTarget:'runtime',volumes:{data:'/state'},healthcheck:['node','/app/bin/ez-library.mjs','--version'],memoryMiB:4096,cpus:2}},commands:{'library-query':{service:'library',argv:['node','/app/bin/ez-library.mjs',query]},...(document?{'library-document':{service:'library',argv:['node','/app/bin/ez-library.mjs','document']}}:{})},sharedServices:{}});
@@ -372,6 +382,33 @@ test('plugin updates register additive command routes on an unchanged deployment
372
382
  const dispatch=await prepareCommand(f.home,'query',[]);assert.deepEqual(dispatch.argv.slice(-4),['node','sample','/app/bin/example.mjs','query']);
373
383
  assert(r.calls.some(call=>call.includes('up')));
374
384
  });
385
+ test('isolated plugin replacement refreshes broker version inventory after registry activation',async t=>{
386
+ const f=await fixture(t,'plugin');
387
+ await fs.appendFile(path.join(f.config.deploymentDir,'docker.env'),'EZ_EXECUTOR_TRANSPORT=local\n');
388
+ const job=await queued(f),r=runtime(f),execute=r.execute;
389
+ let observed;
390
+ r.execute=async(command,args,options)=>{
391
+ if(args.includes('restart')&&args.at(-1)==='plugin-broker') observed=(await read(path.join(f.home,'registry.json'))).plugins.sample.manifest.version;
392
+ return execute(command,args,options);
393
+ };
394
+ assert.equal((await perform(f.home,job,r)).status,'completed');
395
+ assert.equal(observed,'0.1.1');
396
+ assert.equal(r.calls.filter(call=>call.includes('restart')).length,1);
397
+ const interrupted=await read(path.join(jobPath(f.home,job.id),'job.json'));interrupted.status='applying';
398
+ assert.equal((await perform(f.home,interrupted,r)).status,'rolled-back');
399
+ assert.equal(observed,'0.1.0');
400
+ assert.equal(r.calls.filter(call=>call.includes('restart')).length,2);
401
+ });
402
+ test('plugin update preserves a created but never running service as stopped',async t=>{
403
+ const f=await fixture(t,'plugin'),job=await queued(f),r=runtime(f),execute=r.execute;
404
+ r.execute=async(command,args,options)=>{
405
+ if(args.includes('ps')) {r.calls.push([command,...args]);return args.includes('--status')?'':'created-container';}
406
+ return execute(command,args,options);
407
+ };
408
+ assert.equal((await perform(f.home,job,r)).status,'completed');
409
+ assert.equal(job.rollback.running,false);
410
+ assert.equal(r.calls.some(call=>call.includes('up')),false);
411
+ });
375
412
  test('plugin command additions ignore object key order but reject collisions and changed routes',async t=>{
376
413
  const f=await fixture(t,'plugin'),deployment=await read(path.join(f.source,'ez-deployment.json'));
377
414
  deployment.services.sample={healthcheck:deployment.services.sample.healthcheck,volumes:deployment.services.sample.volumes,buildTarget:deployment.services.sample.buildTarget};