@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/Dockerfile +3 -1
  3. package/bin/ez +3 -1
  4. package/compose.yaml +2 -0
  5. package/docker/entrypoint.sh +4 -1
  6. package/docker/smoke.mjs +92 -1
  7. package/docs/application-channel.md +6 -2
  8. package/docs/architecture/ai-selection.md +7 -4
  9. package/docs/architecture/authority-boundaries.md +15 -4
  10. package/docs/docker-runtime.md +25 -2
  11. package/docs/local-qa.md +5 -0
  12. package/docs/plugin-guide.md +10 -2
  13. package/docs/plugins.md +22 -2
  14. package/docs/selective-monitoring.md +26 -12
  15. package/docs/upgrades.md +5 -1
  16. package/package.json +1 -1
  17. package/src/agent-install.ts +5 -3
  18. package/src/ai-cli.ts +13 -4
  19. package/src/ai.ts +7 -3
  20. package/src/audio.ts +4 -2
  21. package/src/client-defaults.ts +7 -4
  22. package/src/control-state.ts +12 -8
  23. package/src/conversation-menu.ts +5 -2
  24. package/src/executor.ts +5 -5
  25. package/src/menu.ts +9 -7
  26. package/src/plugin-broker.mjs +2 -0
  27. package/src/plugins/manager.mjs +43 -9
  28. package/src/plugins/native-tasks.d.mts +1 -0
  29. package/src/plugins/native-tasks.mjs +13 -0
  30. package/src/task-cli.ts +1 -1
  31. package/src/task-executor.ts +7 -4
  32. package/src/tasks.ts +54 -16
  33. package/src/updates/control.mjs +5 -3
  34. package/src/updates/runtime.mjs +27 -7
  35. package/test/agent-install.test.ts +6 -0
  36. package/test/ai-cli.test.ts +6 -1
  37. package/test/ai.test.ts +69 -14
  38. package/test/application-controls.test.ts +2 -1
  39. package/test/conversation-menu.test.ts +2 -0
  40. package/test/conversations.test.ts +26 -0
  41. package/test/plugin-broker.test.mjs +13 -1
  42. package/test/plugin-manager.test.mjs +58 -1
  43. package/test/scheduler-host.test.ts +1 -0
  44. package/test/task-native.test.ts +2 -4
  45. package/test/tasks.test.ts +107 -3
  46. package/test/updates.test.mjs +55 -3
  47. package/test/upgrade-pause.test.ts +10 -2
@@ -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 fs.mkdir(directory,{recursive:true,mode:0o700});
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 fs.mkdir(dir,{mode:0o700});
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;}
@@ -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
- const image=`ez-upgrade-${job.sha256.slice(0,24)}`;
113
- await run('docker',['build','--target','runtime','-t',image,root]);
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
- await run('docker',[...relayArgs(config),'stop','relay']);await hooks.stopHost();
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||'')&&current.Image===pinned&&current.State?.Running===true&&current.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')
@@ -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(),state)
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 switch does not resume the previous native thread', async () => {
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.notEqual(next.sessionId, started.sessionId)
31
+ assert.equal(next.sessionId, started.sessionId)
32
32
  assert.equal(next.preset.model, luna.model)
33
- assert.equal((await store.executionSession(next)).hasStarted, false)
34
- assert.equal((await store.executionSession(next)).nativeSessionId, undefined)
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
- await store.saveNativeSession(next.sessionId, 'native-luna')
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.notEqual(defaultChoice.sessionId, next.sessionId)
41
+ assert.equal(defaultChoice.sessionId, next.sessionId)
42
42
  assert.equal(defaultChoice.preset.model, undefined)
43
- await store.saveNativeSession(defaultChoice.sessionId, 'native-default')
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.notEqual(explicitAgain.sessionId, defaultChoice.sessionId)
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: false, sessionId: next.sessionId,
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.includes('resume'), false)
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, /recent choice|installed client/i)
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, /Selected for this conversation/)
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.notEqual(modelChoice.sessionId,old.sessionId)
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 assert.rejects(compose(config,record,{},f.home),/Isolated broker cannot use host network bindings/);
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')
@@ -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', 'propose', { sourceId: 'fixture', conversationId: 'contact-a', purpose: 'Book dinner without payment', context: 'Two people at 7pm', hours: 1 })
38
- await new ApprovalStore(root).recordDecision(proposal.id, 'approved', 101)
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 = [