@jc_stack/ez-agents 0.1.0-beta.42 → 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 +24 -0
- package/Dockerfile +3 -1
- package/bin/ez +3 -1
- package/compose.yaml +2 -0
- package/docker/entrypoint.sh +4 -1
- package/docker/smoke.mjs +92 -1
- package/docs/application-channel.md +6 -2
- package/docs/architecture/ai-selection.md +7 -4
- package/docs/architecture/authority-boundaries.md +15 -4
- package/docs/docker-runtime.md +25 -2
- package/docs/local-qa.md +5 -0
- package/docs/plugin-guide.md +10 -2
- package/docs/plugins.md +22 -2
- package/docs/selective-monitoring.md +26 -12
- package/docs/upgrades.md +5 -1
- package/package.json +1 -1
- package/src/agent-install.ts +5 -3
- package/src/ai-cli.ts +13 -4
- package/src/ai.ts +7 -3
- package/src/audio.ts +4 -2
- package/src/client-defaults.ts +7 -4
- package/src/control-state.ts +12 -8
- package/src/conversation-menu.ts +5 -2
- package/src/executor.ts +5 -5
- package/src/menu.ts +9 -7
- package/src/plugin-broker.mjs +2 -0
- package/src/plugins/manager.mjs +43 -9
- package/src/plugins/native-tasks.d.mts +1 -0
- package/src/plugins/native-tasks.mjs +13 -0
- package/src/task-cli.ts +1 -1
- package/src/task-executor.ts +7 -4
- package/src/tasks.ts +54 -16
- package/src/updates/control.mjs +5 -3
- package/src/updates/runtime.mjs +27 -7
- package/test/agent-install.test.ts +6 -0
- package/test/ai-cli.test.ts +6 -1
- package/test/ai.test.ts +69 -14
- package/test/application-controls.test.ts +2 -1
- package/test/conversation-menu.test.ts +2 -0
- package/test/conversations.test.ts +26 -0
- package/test/plugin-broker.test.mjs +13 -1
- package/test/plugin-manager.test.mjs +58 -1
- package/test/scheduler-host.test.ts +1 -0
- package/test/task-native.test.ts +2 -4
- package/test/tasks.test.ts +107 -3
- package/test/updates.test.mjs +55 -3
- package/test/upgrade-pause.test.ts +10 -2
package/src/updates/control.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { parseEnv } from 'node:util';
|
|
5
|
-
import { atomic, locked, snapshot } from '../plugins/manager.mjs';
|
|
5
|
+
import { atomic, locked, snapshot, privateDir, stewardOwned } from '../plugins/manager.mjs';
|
|
6
6
|
import { digest, extract, newer, compatible, version, releaseContract, registryVersion, registryCandidate, download } from './artifact.mjs';
|
|
7
7
|
|
|
8
8
|
export const read = async file => JSON.parse(await fs.readFile(file,'utf8'));
|
|
@@ -129,17 +129,19 @@ export async function eligibility(home,target,root,automatic) {
|
|
|
129
129
|
}
|
|
130
130
|
export async function prepare(home,target,{file,release}) {
|
|
131
131
|
targetId(target);if(Boolean(file)===Boolean(release))throw Error('Supply one --file tarball or --version exact-version');
|
|
132
|
-
const {directory}=await state(home);await
|
|
132
|
+
const {directory}=await state(home);await privateDir(directory);
|
|
133
133
|
const old=await installed(home,target);
|
|
134
134
|
let data,origin;
|
|
135
135
|
if(file){data=await fs.readFile(file);origin={type:'local'};}
|
|
136
136
|
else {version(release);const pkg=await registryVersion(old.pkg.name,release);data=await download(pkg);origin={type:'npm',name:pkg.name,version:pkg.version,integrity:pkg.dist.integrity};}
|
|
137
|
-
const id=randomUUID(),dir=path.join(directory,id),root=path.join(dir,'package');await
|
|
137
|
+
const id=randomUUID(),dir=path.join(directory,id),root=path.join(dir,'package');await privateDir(dir);
|
|
138
138
|
try {
|
|
139
139
|
await extract(data,root);
|
|
140
|
+
await stewardOwned(root);
|
|
140
141
|
const next=await eligibility(home,target,root,false);
|
|
141
142
|
if(origin.type==='npm'&&next.pkg.version!==origin.version)throw Error('Artifact version differs from registry metadata');
|
|
142
143
|
await fs.writeFile(path.join(dir,'candidate.tgz'),data,{mode:0o600,flag:'wx'});
|
|
144
|
+
await stewardOwned(path.join(dir,'candidate.tgz'));
|
|
143
145
|
const job={id,target,source:root,releaseNotes:path.join(root,'CHANGELOG.md'),status:'prepared',version:next.pkg.version,previousVersion:next.old.pkg.version,previousRoot:next.old.root,sha256:digest(data),origin,...(next.deploymentMigration?{deploymentMigration:next.deploymentMigration}:{}),createdAt:new Date().toISOString()};
|
|
144
146
|
await atomic(path.join(dir,'job.json'),job);return job;
|
|
145
147
|
} catch(error){await fs.rm(dir,{recursive:true,force:true});throw error;}
|
package/src/updates/runtime.mjs
CHANGED
|
@@ -17,15 +17,15 @@ export function execute(command,args,options={}) {
|
|
|
17
17
|
return new Promise((resolve,reject)=>{
|
|
18
18
|
const invocation=process.env.EZ_DOCKER_COMPOSE==='standalone'&&command==='docker'&&args[0]==='compose'
|
|
19
19
|
? {command:'docker-compose',args:args.slice(1)} : {command,args};
|
|
20
|
-
const child=spawn(invocation.command,invocation.args,{cwd:options.cwd,env:environment(),stdio:['ignore','pipe','pipe']});let output='';
|
|
20
|
+
const child=spawn(invocation.command,invocation.args,{cwd:options.cwd,env:environment(),stdio:['ignore','pipe','pipe']});let output='',stdout='';
|
|
21
21
|
const timer=setTimeout(()=>child.kill('SIGKILL'),options.timeout||600000);
|
|
22
22
|
const file=options.outputFile?createWriteStream(options.outputFile,{flags:'wx',mode:0o600}):null;
|
|
23
23
|
const written=file?new Promise((resolve,reject)=>{file.once('finish',resolve);file.once('error',reject);}):Promise.resolve();
|
|
24
|
-
if(file)child.stdout.pipe(file);else child.stdout.on('data',b=>{output=(output+b).slice(-16000);});
|
|
24
|
+
if(file)child.stdout.pipe(file);else child.stdout.on('data',b=>{output=(output+b).slice(-16000);if(options.stdoutOnly)stdout=(stdout+b).slice(-16000);});
|
|
25
25
|
child.stderr.on('data',b=>{output=(output+b).slice(-16000);});
|
|
26
26
|
void written.catch(()=>child.kill('SIGKILL'));
|
|
27
27
|
child.once('error',error=>{clearTimeout(timer);reject(error);});
|
|
28
|
-
child.once('close',async code=>{clearTimeout(timer);try{await written;if(code!==0)throw Error(`${invocation.command} failed (${code}): ${output}`);resolve(output);}catch(error){reject(error);}});
|
|
28
|
+
child.once('close',async code=>{clearTimeout(timer);try{await written;if(code!==0)throw Error(`${invocation.command} failed (${code}): ${output}`);resolve(options.stdoutOnly?stdout:output);}catch(error){reject(error);}});
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
31
|
export async function textAtomic(file,text) {
|
|
@@ -109,8 +109,18 @@ export async function perform(home,job,hooks) {
|
|
|
109
109
|
await run(process.execPath,['--import',path.join(root,'node_modules/tsx/dist/loader.mjs'),path.join(root,'bin/ezenciel-agents.mjs'),'--version'],{cwd:root});
|
|
110
110
|
try { await replaceGuidance(root, config.workspace, run); }
|
|
111
111
|
catch(error) { console.error(`Workspace guidance replacement failed; upgrade continues: ${error.message}`); }
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
// Preserve operator-owned native CLI pins from the effective deployment.
|
|
113
|
+
// Building only the package defaults silently downgrades those clients.
|
|
114
|
+
const deployment=JSON.parse(await run('docker',[...relayArgs(config),'config','--format','json'],{stdoutOnly:true}));
|
|
115
|
+
const args=deployment.services?.relay?.build?.args||{};
|
|
116
|
+
if(typeof args!=='object'||Array.isArray(args))throw Error('Invalid relay build arguments');
|
|
117
|
+
const buildArgs=Object.keys(args).sort().flatMap(key=>{
|
|
118
|
+
const value=args[key];
|
|
119
|
+
if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)||typeof value!=='string'||value.includes('\0'))throw Error('Unresolved relay build argument');
|
|
120
|
+
return ['--build-arg',`${key}=${value}`];
|
|
121
|
+
});
|
|
122
|
+
const image=`ez-upgrade-${digest(JSON.stringify([job.sha256,buildArgs])).slice(0,24)}`;
|
|
123
|
+
await run('docker',['build','--target','runtime',...buildArgs,'-t',image,root]);
|
|
114
124
|
const envFile=path.join(config.deploymentDir,'docker.env'),oldEnv=await fs.readFile(envFile,'utf8');
|
|
115
125
|
const cid=(await run('docker',[...relayArgs(config),'ps','-q','relay'])).trim();
|
|
116
126
|
if(!cid||/\s/.test(cid))throw Error('Expected one running relay');
|
|
@@ -162,11 +172,21 @@ export async function recover(home,job,hooks) {
|
|
|
162
172
|
try {
|
|
163
173
|
if(job.rollback) {
|
|
164
174
|
if(job.target==='main') {
|
|
165
|
-
|
|
175
|
+
// Keep a healthy instance of the exact pinned image when a failed
|
|
176
|
+
// activation retained it, even if Docker has removed its image index.
|
|
177
|
+
const pinned=parseEnv(job.rollback.env).EZ_RELAY_IMAGE;
|
|
178
|
+
const retained=await (async()=>{
|
|
179
|
+
const id=(await run('docker',[...relayArgs(config),'ps','-q','relay'])).trim();
|
|
180
|
+
if(!/^[a-f0-9]{12,64}$/i.test(id))return false;
|
|
181
|
+
const current=JSON.parse(await run('docker',['inspect','--format','{{json .}}',id]));
|
|
182
|
+
return /^sha256:[a-f0-9]{64}$/.test(pinned||'')&¤t.Image===pinned&¤t.State?.Running===true&¤t.State?.Health?.Status==='healthy';
|
|
183
|
+
})().catch(()=>false);
|
|
184
|
+
if(!retained)await run('docker',[...relayArgs(config),'stop','relay']);
|
|
185
|
+
await hooks.stopHost();
|
|
166
186
|
await textAtomic(path.join(config.deploymentDir,'docker.env'),job.rollback.env);
|
|
167
187
|
await bindUpdates(home,path.join(config.deploymentDir,'host-executor.json'),job.rollback.packageRoot);
|
|
168
188
|
await hooks.startHost(job.rollback.packageRoot);
|
|
169
|
-
await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build',...(await relayServices(config))]);
|
|
189
|
+
if(!retained)await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build',...(await relayServices(config))]);
|
|
170
190
|
} else {
|
|
171
191
|
const b=job.rollback;
|
|
172
192
|
await run('docker',[...pluginArgs(b.record),'stop']);
|
|
@@ -24,6 +24,9 @@ test('new agents receive independent projects, secrets and plugin bindings; dupl
|
|
|
24
24
|
const b=parseEnv(await readFile(join(root,'work/docker.env'),'utf8'))
|
|
25
25
|
assert.equal(a.EZ_ISOLATION,'host-capable')
|
|
26
26
|
assert.equal(a.EZ_EXECUTOR_TRANSPORT,'host')
|
|
27
|
+
assert.equal(a.EZ_PLUGIN_BROKER_SOCKET,undefined)
|
|
28
|
+
assert.equal(a.EZ_PLUGIN_BROKER_HOST_CONFIG,undefined)
|
|
29
|
+
assert.equal(a.COMPOSE_PROFILES,undefined)
|
|
27
30
|
assert.equal(b.EZ_ISOLATION,'host-capable')
|
|
28
31
|
assert.equal(b.EZ_EXECUTOR_TRANSPORT,'host')
|
|
29
32
|
assert.equal(JSON.parse(await readFile(join(root,'family/agent.json'),'utf8')).isolation,'host-capable')
|
|
@@ -74,6 +77,9 @@ test('package installer records its CLI before any agent exists; creation inheri
|
|
|
74
77
|
assert.equal(env.EZ_ISOLATION,'isolated')
|
|
75
78
|
assert.equal(env.EZ_EXECUTOR_TRANSPORT,'local')
|
|
76
79
|
assert.equal(env.EZ_CODEX_SANDBOX,'external')
|
|
80
|
+
assert.equal(env.EZ_PLUGIN_BROKER_SOCKET,'/private/agents/shopper/control/plugin-broker.sock')
|
|
81
|
+
assert.equal(env.EZ_PLUGIN_BROKER_HOST_CONFIG,'/private/agents/shopper/host-executor.json')
|
|
82
|
+
assert.equal(env.COMPOSE_PROFILES,'isolated')
|
|
77
83
|
assert.equal(env.EZ_DELIVERY_TCP_PORT,undefined)
|
|
78
84
|
assert.equal(env.COMPOSE_FILE,'/opt/ez/compose.yaml')
|
|
79
85
|
assert.equal(JSON.parse(await readFile(join(root,'shopper/host-executor.json'),'utf8')).agents[0].toolsHome,'/private/agents/shopper/tools')
|
package/test/ai-cli.test.ts
CHANGED
|
@@ -23,8 +23,13 @@ test('explicit CLI/model selection preserves installation default and rejects un
|
|
|
23
23
|
const state=await store.status();assert.equal(state.ai?.defaultId,'initial')
|
|
24
24
|
assert.equal(state.ai?.presets.find(p=>p.id===state.ai?.selectedId)?.cli,'codex')
|
|
25
25
|
assert.equal(state.activeSession?.cli,'codex')
|
|
26
|
+
const setDefault=spawnSync(process.execPath,[bin,'default'],{env,encoding:'utf8'})
|
|
27
|
+
assert.equal(setDefault.status,0,setDefault.stderr)
|
|
28
|
+
const updated=await store.status()
|
|
29
|
+
assert.equal(updated.ai?.defaultId,updated.ai?.selectedId)
|
|
30
|
+
assert.equal(updated.activeSession?.sessionId,state.activeSession?.sessionId)
|
|
26
31
|
assert.notEqual(call('unavailable').status,0)
|
|
27
|
-
assert.deepEqual(await store.status(),
|
|
32
|
+
assert.deepEqual(await store.status(),updated)
|
|
28
33
|
}finally{await rm(root,{recursive:true,force:true})}
|
|
29
34
|
})
|
|
30
35
|
|
package/test/ai.test.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { InboxStore } from '../src/inbox.js'
|
|
|
11
11
|
import { executionDefaults } from '../src/model-policy.js'
|
|
12
12
|
import type { Update } from 'grammy/types'
|
|
13
13
|
|
|
14
|
-
test('same-client model
|
|
14
|
+
test('same-client model and default changes preserve history; provider changes require a fresh conversation', async () => {
|
|
15
15
|
const dir = await mkdtemp(join(tmpdir(), 'ez-ai-model-switch-'))
|
|
16
16
|
try {
|
|
17
17
|
const store = new ControlStore(dir, 1000)
|
|
@@ -28,34 +28,59 @@ test('same-client model switch does not resume the previous native thread', asyn
|
|
|
28
28
|
await store.savePreset(luna)
|
|
29
29
|
await store.selectPreset(luna.id, started.sessionId, false)
|
|
30
30
|
const next = await store.captureChoice(initialPreset('codex'))
|
|
31
|
-
assert.
|
|
31
|
+
assert.equal(next.sessionId, started.sessionId)
|
|
32
32
|
assert.equal(next.preset.model, luna.model)
|
|
33
|
-
assert.equal((await store.executionSession(next)).hasStarted,
|
|
34
|
-
assert.equal((await store.executionSession(next)).nativeSessionId,
|
|
33
|
+
assert.equal((await store.executionSession(next)).hasStarted, true)
|
|
34
|
+
assert.equal((await store.executionSession(next)).nativeSessionId, 'native-deepseek')
|
|
35
35
|
assert.equal((await store.executionSession(started)).nativeSessionId, 'native-deepseek')
|
|
36
|
-
|
|
36
|
+
assert.equal(started.preset.model, deepseek.model)
|
|
37
37
|
const clientDefault = initialPreset('codex')
|
|
38
38
|
await store.savePreset(clientDefault)
|
|
39
39
|
await store.selectPreset(clientDefault.id, next.sessionId, false)
|
|
40
40
|
const defaultChoice = await store.captureChoice(clientDefault)
|
|
41
|
-
assert.
|
|
41
|
+
assert.equal(defaultChoice.sessionId, next.sessionId)
|
|
42
42
|
assert.equal(defaultChoice.preset.model, undefined)
|
|
43
|
-
await store.
|
|
43
|
+
assert.equal((await store.executionSession(defaultChoice)).nativeSessionId, 'native-deepseek')
|
|
44
44
|
await store.selectPreset(deepseek.id, defaultChoice.sessionId, false)
|
|
45
45
|
const explicitAgain = await store.captureChoice(clientDefault)
|
|
46
|
-
assert.
|
|
46
|
+
assert.equal(explicitAgain.sessionId, defaultChoice.sessionId)
|
|
47
47
|
assert.equal(explicitAgain.preset.model, deepseek.model)
|
|
48
|
-
await store.saveNativeSession(explicitAgain.sessionId, 'native-explicit')
|
|
49
48
|
const openrouter = { ...deepseek, id: 'openrouter-deepseek', name: 'OpenRouter DeepSeek', provider: 'openrouter' }
|
|
50
49
|
await store.savePreset(openrouter)
|
|
51
|
-
await store.selectPreset(openrouter.id, explicitAgain.sessionId, false)
|
|
50
|
+
assert.equal(await store.selectPreset(openrouter.id, explicitAgain.sessionId, false), false)
|
|
51
|
+
await store.selectPreset(openrouter.id, explicitAgain.sessionId, true)
|
|
52
52
|
const providerChoice = await store.captureChoice(clientDefault)
|
|
53
53
|
assert.notEqual(providerChoice.sessionId, explicitAgain.sessionId)
|
|
54
54
|
assert.equal(providerChoice.preset.provider, 'openrouter')
|
|
55
55
|
const args = EXECUTOR_REGISTRY.codex.buildArgs({
|
|
56
|
-
workspace: dir, isResume:
|
|
56
|
+
workspace: dir, isResume: true, sessionId: (await store.executionSession(next)).nativeSessionId,
|
|
57
|
+
model: next.preset.model, effort: next.preset.effort,
|
|
57
58
|
}, '', '')
|
|
58
|
-
assert.equal(args.
|
|
59
|
+
assert.equal(args[args.indexOf('resume') + 1], 'native-deepseek')
|
|
60
|
+
assert.equal(args[args.indexOf('--model') + 1], luna.model)
|
|
61
|
+
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('OpenCode keeps same-provider models together and separates providers encoded in model IDs', async () => {
|
|
65
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-ai-provider-switch-'))
|
|
66
|
+
try {
|
|
67
|
+
const store = new ControlStore(dir, 1000)
|
|
68
|
+
const first = { id: 'first', name: 'First', cli: 'opencode', model: 'opencode-go/first' }
|
|
69
|
+
const second = { ...first, id: 'second', name: 'Second', model: 'opencode-go/second' }
|
|
70
|
+
const other = { ...first, id: 'other', name: 'Other', model: 'openrouter/other' }
|
|
71
|
+
const choice = await store.captureChoice(first)
|
|
72
|
+
await store.saveNativeSession(choice.sessionId, 'ses_original')
|
|
73
|
+
const menu = createAiMenu(store, 'opencode', async () => [first, second, other].map(p => ({ ...p, efforts: [] })), dir, undefined, async () => true)
|
|
74
|
+
await store.savePreset(second)
|
|
75
|
+
assert.equal((await menu.select(second, choice.sessionId)).fresh, false)
|
|
76
|
+
assert.equal((await store.captureChoice(first)).sessionId, choice.sessionId)
|
|
77
|
+
await store.savePreset(other)
|
|
78
|
+
assert.equal(await store.selectPreset(other.id, choice.sessionId), false)
|
|
79
|
+
assert.equal((await menu.select(other, choice.sessionId)).fresh, true)
|
|
80
|
+
const next = await store.captureChoice(first)
|
|
81
|
+
assert.notEqual(next.sessionId, choice.sessionId)
|
|
82
|
+
assert.equal((await store.executionSession(next)).nativeSessionId, undefined)
|
|
83
|
+
assert.equal((await store.executionSession(choice)).nativeSessionId, 'ses_original')
|
|
59
84
|
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
60
85
|
})
|
|
61
86
|
|
|
@@ -181,7 +206,7 @@ test('Choose AI lists recent choices and installed clients before model and effo
|
|
|
181
206
|
},
|
|
182
207
|
})
|
|
183
208
|
await menu.list(context() as never)
|
|
184
|
-
assert.match(replies.at(-1)!.text, /
|
|
209
|
+
assert.match(replies.at(-1)!.text, /Current: codex · fixture-model · medium/)
|
|
185
210
|
assert.deepEqual(replies.at(-1)!.buttons.map((button) => button.text), [
|
|
186
211
|
'✓ Recent · codex · Recent', 'claude', 'codex', 'codex-gui (desktop)', 'Refresh available AIs',
|
|
187
212
|
])
|
|
@@ -200,7 +225,7 @@ test('Choose AI lists recent choices and installed clients before model and effo
|
|
|
200
225
|
assert.deepEqual({ cli: selected.cli, model: selected.model, effort: selected.effort }, {
|
|
201
226
|
cli: 'codex', model: 'fixture-model', effort: 'high',
|
|
202
227
|
})
|
|
203
|
-
assert.match(replies.at(-1)!.text, /
|
|
228
|
+
assert.match(replies.at(-1)!.text, /Saved for this conversation. History kept/)
|
|
204
229
|
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
205
230
|
})
|
|
206
231
|
|
|
@@ -537,3 +562,33 @@ test('curated models respect installed clients and provider scope', async () =>
|
|
|
537
562
|
assert.equal(catalog.some(entry => entry.cli === 'pi' && !entry.model), false)
|
|
538
563
|
} finally { await rm(control, { recursive: true, force: true }) }
|
|
539
564
|
})
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
test('private channel model choices do not consume owner preset slots', async () => {
|
|
568
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-private-ai-capacity-'))
|
|
569
|
+
try {
|
|
570
|
+
const store = new ControlStore(dir, 1000)
|
|
571
|
+
await store.requestPairing(42, 42); await store.approveOwner(42)
|
|
572
|
+
const initial = initialPreset('codex')
|
|
573
|
+
await store.captureChoice(initial)
|
|
574
|
+
for (let n = 1; n < 12; n++) await store.savePreset({...initial, id:`saved-${n}`, name:`Saved ${n}`})
|
|
575
|
+
const before = (await store.status()).ai!
|
|
576
|
+
const model = {cli:'codex', model:'gpt-5.6-luna', name:'Luna', efforts:['low','max']}
|
|
577
|
+
const menu = createAiMenu(store, 'codex', async () => [model], dir, undefined, async () => true)
|
|
578
|
+
const scope = 'a'.repeat(64)
|
|
579
|
+
const choice = await store.captureApplicationChoice(initial, scope)
|
|
580
|
+
await store.saveNativeSession(choice.sessionId, 'private-native-session')
|
|
581
|
+
const guard = {owner:(await store.status()).owner!, applicationScope:scope, expectedSession:choice.sessionId, authorize:async()=>{}}
|
|
582
|
+
const preset = await menu.saveSelection(model, 'low', guard)
|
|
583
|
+
await store.changeApplicationSession(scope, guard, preset)
|
|
584
|
+
const readback = (await store.applicationSession(scope))!
|
|
585
|
+
assert.equal(readback.sessionId, choice.sessionId)
|
|
586
|
+
assert.equal(readback.nativeSessionId, 'private-native-session')
|
|
587
|
+
assert.equal(readback.preset?.model, model.model)
|
|
588
|
+
assert.equal(readback.preset?.effort, 'low')
|
|
589
|
+
assert.deepEqual((await store.status()).ai, before)
|
|
590
|
+
await assert.rejects(menu.saveSelection(model, 'max'), /at most 12/)
|
|
591
|
+
await assert.rejects(store.changeApplicationSession(scope, {...guard, expectedSession:null}, preset), /Conversation changed/)
|
|
592
|
+
assert.deepEqual((await store.applicationSession(scope))?.preset, readback.preset)
|
|
593
|
+
} finally { await rm(dir, {recursive:true, force:true}) }
|
|
594
|
+
})
|
|
@@ -145,7 +145,8 @@ test('application controls share native choices, protect hidden scopes and prese
|
|
|
145
145
|
assert.equal(selected.status,200)
|
|
146
146
|
const modelChoice = await control.captureChoice(menu.initial)
|
|
147
147
|
assert.equal(modelChoice.preset.effort,'high')
|
|
148
|
-
assert.
|
|
148
|
+
assert.equal(modelChoice.sessionId,old.sessionId)
|
|
149
|
+
assert.equal((await control.executionSession(modelChoice)).nativeSessionId,'native-private-id')
|
|
149
150
|
assert.deepEqual((await new RunStore(root).get(run.id))!.execution,old)
|
|
150
151
|
const reset = await request({action:'new',expectedSession:modelChoice.sessionId})
|
|
151
152
|
assert.equal(reset.status,200)
|
|
@@ -32,6 +32,8 @@ test('empty placeholders stay out of history and Back edits one panel without cr
|
|
|
32
32
|
await menu.handle({...ctx, callbackQuery:{data, message:{message_id:1}}} as unknown as Context)
|
|
33
33
|
}
|
|
34
34
|
await click(`chat:open:${choice.sessionId}`)
|
|
35
|
+
assert.match(text, /AI: grok · client default · default effort/)
|
|
36
|
+
assert.ok(keyboard!.inline_keyboard.flat().some(b => 'callback_data' in b && b.callback_data === 'menu:ai'))
|
|
35
37
|
const before = await control.status()
|
|
36
38
|
for (let n = 0; n < 5; n++) { await click('chat:list:0:0'); await click(`chat:open:${choice.sessionId}`) }
|
|
37
39
|
assert.deepEqual(await control.status(), before)
|
|
@@ -13,6 +13,32 @@ const fixture = async (work: (store: ControlStore, dir: string) => Promise<void>
|
|
|
13
13
|
finally { await rm(dir, { recursive: true, force: true }) }
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
test('named conversations remember model and effort immediately across switches and restart', async () => fixture(async (store, dir) => {
|
|
17
|
+
const astra = { id: 'astra', name: 'Astra Medium', cli: 'codex', model: 'gpt-6-astra', effort: 'medium' }
|
|
18
|
+
const sol = { id: 'sol', name: 'Sol High', cli: 'codex', model: 'gpt-6-sol', effort: 'high' }
|
|
19
|
+
const first = await store.captureChoice(astra, 'Research')
|
|
20
|
+
await store.saveNativeSession(first.sessionId, 'native_research')
|
|
21
|
+
await store.resetSession()
|
|
22
|
+
const second = await store.captureChoice(astra, 'Writing')
|
|
23
|
+
await store.saveNativeSession(second.sessionId, 'native_writing')
|
|
24
|
+
await store.savePreset(sol)
|
|
25
|
+
await store.selectPreset(sol.id, second.sessionId)
|
|
26
|
+
// No message is needed to save the new choice.
|
|
27
|
+
assert.deepEqual((await store.getActiveSession())!.preset, sol)
|
|
28
|
+
const restarted = new ControlStore(dir, 1000)
|
|
29
|
+
await restarted.switchSession(first.sessionId)
|
|
30
|
+
assert.deepEqual(await restarted.captureChoice(astra), first)
|
|
31
|
+
await restarted.switchSession(second.sessionId)
|
|
32
|
+
assert.deepEqual(await restarted.captureChoice(astra), { sessionId: second.sessionId, preset: sol })
|
|
33
|
+
await restarted.selectPreset(astra.id, second.sessionId)
|
|
34
|
+
assert.equal((await restarted.getActiveSession())!.title, 'Writing')
|
|
35
|
+
assert.equal((await restarted.getActiveSession())!.nativeSessionId, 'native_writing')
|
|
36
|
+
assert.equal((await restarted.listSessions()).length, 2)
|
|
37
|
+
// Work admitted before the selection keeps its original choice and binding.
|
|
38
|
+
assert.deepEqual(second.preset, astra)
|
|
39
|
+
assert.equal((await restarted.executionSession(second)).nativeSessionId, 'native_writing')
|
|
40
|
+
}))
|
|
41
|
+
|
|
16
42
|
test('switching after restart restores native context and model while queued choices stay pinned', async () => fixture(async (store, dir) => {
|
|
17
43
|
const first = await store.captureChoice(initialPreset('codex'), 'Client launch')
|
|
18
44
|
await store.saveNativeSession(first.sessionId, 'native_first')
|
|
@@ -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);
|
|
@@ -147,6 +155,7 @@ test('broker rejects foreign agent bindings and unsafe management or request fie
|
|
|
147
155
|
const candidate = path.join(source, 'candidate.tgz');
|
|
148
156
|
await fs.writeFile(candidate, 'candidate');
|
|
149
157
|
await assert.doesNotReject(validateBrokerRequest({ version: 1, id: randomUUID(), operation: 'manager', runId: 'r_broker', args: ['updates', 'prepare', 'sample', '--file', candidate] }, f.workspace));
|
|
158
|
+
await assert.rejects(validateBrokerRequest({ version: 1, id: randomUUID(), operation: 'manager', runId: 'r_broker', args: ['updates', 'prepare', 'main', '--file', candidate] }, f.workspace), /operator preparation/);
|
|
150
159
|
await assert.rejects(validateBrokerRequest({ version: 1, id: randomUUID(), operation: 'manager', runId: 'r_broker', args: ['updates', 'prepare', 'sample', '--file', '/tmp/candidate.tgz'] }, f.workspace), /agent workspace/);
|
|
151
160
|
await assert.rejects(validateBrokerRequest({ version: 1, id: randomUUID(), operation: 'manager', runId: 'r_broker', args: ['updates', 'prepare', 'sample', '--version', '1.0.1'] }, f.workspace), /not allowed/);
|
|
152
161
|
await assert.rejects(validateBrokerRequest({ version: 1, id: randomUUID(), operation: 'invoke', runId: 'r_broker', alias: 'sample', revision, capability: 'short', args: [] }), /capability/);
|
|
@@ -161,6 +170,9 @@ test('broker rejects resolve and invoke for a revoked run', async t => {
|
|
|
161
170
|
try {
|
|
162
171
|
await waitForSocket(f.socket);
|
|
163
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);
|
|
164
176
|
const resolved = await request(f.socket, { version: 1, id: randomUUID(), operation: 'resolve', runId: f.runId, alias: 'sample' });
|
|
165
177
|
assert.equal(resolved.ok, false);
|
|
166
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);
|
|
@@ -288,7 +302,17 @@ test('isolated broker allows unbound plugins when another plugin has host bindin
|
|
|
288
302
|
const c=await compose(config,record,{},f.home);
|
|
289
303
|
assert.equal(c.version,'3.8');assert.equal(c.networks,undefined);
|
|
290
304
|
await fs.writeFile(hostConfig,JSON.stringify({cli:'codex',isolation:'isolated',agents:[{name:'sample',workspace,controlDir:control,binDir:path.join(f.root,'bin'),toolsHome:f.home,pluginNetworkBindings:{sample:{revisions:[p.revision],bindings:[{service:'sample',network:'sample_default'}]}}}]}));
|
|
291
|
-
await
|
|
305
|
+
const bound=await compose(config,record,{},f.home);
|
|
306
|
+
const [network]=Object.entries(bound.networks).filter(([name])=>name!=='default');
|
|
307
|
+
assert.deepEqual(network[1],{external:true,name:'sample_default'});
|
|
308
|
+
assert.deepEqual(bound.services.sample.networks,['default',network[0]]);
|
|
309
|
+
const host=JSON.parse(await fs.readFile(hostConfig,'utf8'));
|
|
310
|
+
host.agents[0].pluginNetworkBindings.sample.revisions=['sha256:'+'f'.repeat(64)];
|
|
311
|
+
await fs.writeFile(hostConfig,JSON.stringify(host));
|
|
312
|
+
await assert.rejects(compose(config,record,{},f.home),/not pinned to the reviewed plugin revision/);
|
|
313
|
+
host.isolation='host-capable';
|
|
314
|
+
await fs.writeFile(hostConfig,JSON.stringify(host));
|
|
315
|
+
await assert.rejects(compose(config,record,{},f.home),/requires an isolated host binding/);
|
|
292
316
|
} finally {
|
|
293
317
|
if(previous===undefined)delete process.env.EZ_DOCKER_COMPOSE;else process.env.EZ_DOCKER_COMPOSE=previous;
|
|
294
318
|
}
|
|
@@ -343,6 +367,39 @@ test('host install exposes runnable public commands without source aliases',asyn
|
|
|
343
367
|
assert.match(result.stdout,/Usage: ezenciel-agents-message/);
|
|
344
368
|
const application=await exec(path.join(f.home,'bin','ezenciel-agents-application'),['--help'],{env:{...process.env,PATH:path.dirname(process.execPath)+path.delimiter+process.env.PATH}});
|
|
345
369
|
assert.match(application.stdout,/--token-file/);
|
|
370
|
+
const help=JSON.parse((await f.call('--help')).stdout);
|
|
371
|
+
const scheduler=help.native.find(item=>item.argv[0].endsWith('/ezenciel-agents-schedule'));
|
|
372
|
+
assert.deepEqual(scheduler.argv,[path.join(await fs.realpath(f.home),'bin','ezenciel-agents-schedule'),'--help']);
|
|
373
|
+
const discovered=await exec(scheduler.argv[0],scheduler.argv.slice(1),{env:f.env});
|
|
374
|
+
assert.match(discovered.stdout,/list \| runs \| show ID/);
|
|
375
|
+
assert.ok(help.native.some(item=>item.argv[0].endsWith('/ezenciel-agents-message')));
|
|
376
|
+
await fs.unlink(scheduler.argv[0]);
|
|
377
|
+
assert.equal(JSON.parse((await f.call('--help')).stdout).native.some(item=>item.argv[0]===scheduler.argv[0]),false);
|
|
378
|
+
});
|
|
379
|
+
test('host onboarding accepts the creator package bin but rejects unrelated ez collisions',async t=>{
|
|
380
|
+
for(const packaged of [true,false]) {
|
|
381
|
+
const f=await fixture(t),config=path.join(f.root,'host.json'),native=packaged?await fs.realpath(new URL('../bin',import.meta.url)):path.join(f.root,'native');
|
|
382
|
+
if(!packaged){await fs.mkdir(native);await fs.writeFile(path.join(native,'ez'),'unrelated');}
|
|
383
|
+
await fs.writeFile(config,JSON.stringify({cli:'codex',agents:[{name:'demo',workspace:f.workspace,binDir:native}]}));
|
|
384
|
+
if(!packaged){await assert.rejects(init(f.home,f.workspace,undefined,config),/Existing ez binding collision/);continue;}
|
|
385
|
+
await init(f.home,f.workspace,undefined,config);
|
|
386
|
+
assert.equal((await fs.lstat(path.join(f.home,'bin','ez'))).isSymbolicLink(),false);
|
|
387
|
+
assert.ok(JSON.parse((await f.call('--help')).stdout).native.some(item=>item.argv[0].endsWith('/ezenciel-agents-schedule')));
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
test('standalone help does not advertise uninstalled native controls',async t=>{
|
|
391
|
+
const f=await fixture(t);await init(f.home,f.workspace);
|
|
392
|
+
assert.deepEqual(JSON.parse((await f.call('--help')).stdout).native,[]);
|
|
393
|
+
});
|
|
394
|
+
test('isolated help discovers runnable native controls without a broker or run',async()=>{
|
|
395
|
+
const client=new URL('../bin/ez',import.meta.url).pathname;
|
|
396
|
+
const env={PATH:process.env.PATH};
|
|
397
|
+
const help=JSON.parse((await exec(process.execPath,[client,'--help'],{env})).stdout);
|
|
398
|
+
const scheduler=help.native.find(item=>item.argv[0].endsWith('/ezenciel-agents-schedule'));
|
|
399
|
+
assert.ok(scheduler);
|
|
400
|
+
const result=await exec(scheduler.argv[0],scheduler.argv.slice(1),{env});
|
|
401
|
+
assert.match(result.stdout,/list \| runs \| show ID/);
|
|
402
|
+
await assert.rejects(exec(process.execPath,[client,'tools','list'],{env}),/EZ_RUN_ID is required/);
|
|
346
403
|
});
|
|
347
404
|
test('copying another agent registry is rejected before any Docker operation',async t=>{
|
|
348
405
|
const f=await fixture(t),other=path.join(f.root,'other');await init(f.home,f.workspace);await init(other,f.workspace);
|
|
@@ -36,6 +36,7 @@ test('host transport serializes owner schedules and chat in the bound mind',asyn
|
|
|
36
36
|
assert.ok(await exists('tg_1.request.json'))
|
|
37
37
|
assert.equal(await exists('tg_1.events'),'')
|
|
38
38
|
assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
|
|
39
|
+
await until(async()=>(await exists(id+'.events')).includes('"stream":"stdout"'))
|
|
39
40
|
const scheduled=JSON.parse(stdout(await exists(id+'.events')))
|
|
40
41
|
assert.equal(scheduled.cwd,await realpath(workspace))
|
|
41
42
|
assert.equal(scheduled.agents,'bound-owner-instructions')
|
package/test/task-native.test.ts
CHANGED
|
@@ -11,7 +11,6 @@ import { Tasks } from '../src/tasks.js'
|
|
|
11
11
|
import { ownerRun } from './helpers/owner-run.js'
|
|
12
12
|
import { EventSources } from '../src/event-sources.js'
|
|
13
13
|
import { ControlStore } from '../src/control-state.js'
|
|
14
|
-
import { ApprovalStore } from '../src/approval.js'
|
|
15
14
|
import { RunStore } from '../src/runs.js'
|
|
16
15
|
import { taskRequests } from '../src/task-rpc.js'
|
|
17
16
|
|
|
@@ -34,9 +33,8 @@ test('native restricted task has only bounded MCP tools, ignores private guidanc
|
|
|
34
33
|
await ownerRun(root, 'owner')
|
|
35
34
|
await new EventSources(root).register('fixture', `${root}/p.sock`, (await new ControlStore(root, 900000).status()).owner!)
|
|
36
35
|
const tasks = new Tasks(root), drain = taskRequests(tasks)
|
|
37
|
-
const proposal: any = await tasks.ownerCall('owner', '
|
|
38
|
-
|
|
39
|
-
await tasks.decide(proposal.id)
|
|
36
|
+
const proposal: any = await tasks.ownerCall('owner', 'start', { sourceId: 'fixture', conversationId: 'contact-a', purpose: 'Book dinner without payment', context: 'Two people at 7pm', hours: 1 })
|
|
37
|
+
assert.equal(proposal.state, 'active')
|
|
40
38
|
const runs = new RunStore(root), run = (await runs.list()).find(r => r.taskId)!
|
|
41
39
|
await runs.patch(run.id, { status: 'running' })
|
|
42
40
|
const sequence = [
|