@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 +13 -0
- package/docs/application-channel.md +3 -0
- package/docs/plugins.md +5 -0
- package/docs/upgrades.md +4 -0
- package/package.json +1 -1
- package/src/application-channel.ts +3 -1
- package/src/index.ts +1 -1
- package/src/plugins/manager.mjs +7 -1
- package/src/updates/control.mjs +2 -1
- package/src/updates/runtime.mjs +8 -2
- package/test/application-controls.test.ts +3 -0
- package/test/plugin-manager.test.mjs +15 -0
- package/test/updates.test.mjs +37 -0
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.
|
|
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,
|
package/src/plugins/manager.mjs
CHANGED
|
@@ -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) {
|
|
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;
|
package/src/updates/control.mjs
CHANGED
|
@@ -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');
|
package/src/updates/runtime.mjs
CHANGED
|
@@ -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});
|
package/test/updates.test.mjs
CHANGED
|
@@ -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};
|