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

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 (50) hide show
  1. package/.dockerignore +2 -0
  2. package/CHANGELOG.md +30 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/Dockerfile +13 -9
  5. package/docker/engine-pnpm-workspace.yaml +4 -0
  6. package/docker/smoke.mjs +1 -1
  7. package/docs/application-channel.md +3 -0
  8. package/docs/architecture/ai-selection.md +8 -2
  9. package/docs/architecture/authority-boundaries.md +2 -2
  10. package/docs/development-and-testing.md +1 -1
  11. package/docs/docker-runtime.md +10 -0
  12. package/docs/plugin-guide.md +1 -1
  13. package/docs/plugins.md +6 -1
  14. package/docs/releasing.md +1 -1
  15. package/docs/scheduling.md +17 -3
  16. package/docs/upgrades.md +7 -1
  17. package/package.json +4 -3
  18. package/pnpm-workspace.yaml +7 -0
  19. package/scripts/smoke-scheduler.ts +4 -1
  20. package/src/ai.ts +20 -2
  21. package/src/application-channel.ts +3 -1
  22. package/src/delivery-socket.ts +15 -1
  23. package/src/host-executor.ts +5 -0
  24. package/src/index.ts +3 -1
  25. package/src/install-tools.mjs +11 -5
  26. package/src/model-policy.ts +4 -0
  27. package/src/plugins/manager.mjs +7 -1
  28. package/src/runs.ts +4 -1
  29. package/src/schedule-cli.ts +12 -3
  30. package/src/scheduler.ts +28 -3
  31. package/src/task-executor.ts +2 -2
  32. package/src/updates/control.mjs +2 -1
  33. package/src/updates/runtime.mjs +15 -6
  34. package/src/updates/supervisor.mjs +23 -13
  35. package/test/ai.test.ts +45 -5
  36. package/test/application-controls.test.ts +3 -0
  37. package/test/channel-owner.test.ts +2 -2
  38. package/test/desktop-bridge.test.ts +2 -1
  39. package/test/failure.test.ts +4 -4
  40. package/test/host-executor.test.ts +4 -4
  41. package/test/install-tools.test.mjs +6 -5
  42. package/test/native-tasks.test.ts +1 -1
  43. package/test/plugin-manager.test.mjs +15 -0
  44. package/test/schedule-cli.test.ts +47 -3
  45. package/test/scheduled-tasks.test.ts +2 -2
  46. package/test/scheduler-host.test.ts +14 -3
  47. package/test/scheduler-relay.test.ts +47 -2
  48. package/test/scheduler.test.ts +21 -1
  49. package/test/tasks.test.ts +1 -1
  50. package/test/updates.test.mjs +68 -8
@@ -16,12 +16,13 @@ import { type Trigger } from './schedule-time.js'
16
16
  async function main() {
17
17
  const { values:v, positionals:[action='list',id] } = parseArgs({allowPositionals:true,options:{
18
18
  cli:{type:'string'}, model:{type:'string'}, effort:{type:'string'},
19
- all:{type:'boolean'}, limit:{type:'string'}, when:{type:'string'}, status:{type:'string'}, diagnosis:{type:'string'}, recovery:{type:'string'}, outcome:{type:'string'}, 'failed-at':{type:'string'},
19
+ all:{type:'boolean'}, limit:{type:'string'}, key:{type:'string'}, when:{type:'string'}, status:{type:'string'}, diagnosis:{type:'string'}, recovery:{type:'string'}, outcome:{type:'string'}, 'failed-at':{type:'string'},
20
20
  name:{type:'string'}, text:{type:'string'}, 'text-file':{type:'string'}, at:{type:'string'}, now:{type:'boolean'},
21
21
  cron:{type:'string'}, timezone:{type:'string'}, 'every-seconds':{type:'string'}, start:{type:'string'}, until:{type:'string'}, help:{type:'boolean'},
22
22
  }})
23
23
  if(v.help){console.log(`ezenciel-agents-schedule list | runs | show ID | pause ID | resume ID | remove ID | cancel RUN_ID
24
24
  failures [--all] [--limit N] | run RUN_ID | context
25
+ trigger SCHEDULE_ID --key REQUEST_KEY
25
26
  review RUN_ID --failed-at ISO --status resolved|attention --diagnosis TEXT --recovery TEXT --outcome TEXT
26
27
  create [ID] | edit ID --name NAME (--text TEXT | --text-file FILE)
27
28
  --now | --at ISO_WITH_OFFSET | --every-seconds N | --cron 'MIN HOUR DAY MONTH WEEKDAY' --timezone IANA
@@ -30,7 +31,9 @@ async function main() {
30
31
  Context reads the current run only.
31
32
  Failures default to unreviewed owner runs. Review records a diagnosis; it never changes execution status or retries work.
32
33
  A conditional review schedule consumes no model run when there are no unreviewed failures.
33
- New tasks inherit the selected engine settings. Omitted model/effort uses native defaults; edit preserves existing settings unless overridden.
34
+ New tasks capture the selected engine settings; edit preserves existing settings unless overridden.
35
+ Every task requires a concrete saved model. Supply --model if the selected settings have none; existing tasks never fall back to chat or client defaults.
36
+ Trigger runs an existing task once with its saved instructions, AI and delivery binding in a fresh session. Reuse the request key after an uncertain result; the regular schedule is unchanged.
34
37
  Creates a scheduled task. Instructions are text, never shell commands.
35
38
  Use --now to run once. Run completion is not delivery proof.
36
39
  Edit replaces the full schedule. Pause/remove affect future work; cancel stops a particular run.
@@ -95,7 +98,13 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
95
98
  }
96
99
  }else if(action==='list')result=await Promise.all((await scheduler.list()).filter(owned).map(show))
97
100
  else if(action==='runs')result=(await runs.list()).filter(r=>r.scheduled && r.scheduled.pairedAt===owner.pairedAt && ownsRun(owner,r))
98
- else if(action==='create' || action==='edit'){
101
+ else if(action==='trigger'){
102
+ if(!id || !v.key)throw new Error('Trigger requires SCHEDULE_ID and --key REQUEST_KEY')
103
+ if(Object.keys(v).some(key=>key!=='key'))throw new Error('Trigger uses saved task settings; overrides are not allowed')
104
+ const schedule=await scheduler.get(id)
105
+ if(!owned(schedule))throw new Error('Schedule ownership mismatch')
106
+ result=await callDeliverySocket(socketPath,{op:'triggerSchedule',payload:{scheduleId:id,revision:schedule.revision,key:v.key,...(caller ? {callerRunId:caller.id} : {})}})
107
+ }else if(action==='create' || action==='edit'){
99
108
  if(action==='edit' && (!id || !owned(await scheduler.get(id))))throw new Error('Unknown schedule')
100
109
  if(action==='create' && id && (await scheduler.list()).some(s=>s.id===id))throw new Error('Schedule exists; use edit')
101
110
  if([v.now,v.at,v.cron,v['every-seconds']].filter(Boolean).length!==1)throw new Error('Choose exactly one trigger')
package/src/scheduler.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { assertEffort } from './model-policy.js'
1
+ import { assertEffort, assertScheduledModel } from './model-policy.js'
2
2
  import { needsFailureReview } from './failure.js'
3
3
  import { mkdir, readFile, readdir, writeFile, rename, link, rm } from 'node:fs/promises'
4
4
  import { randomUUID, createHash } from 'node:crypto'
@@ -112,6 +112,7 @@ export class Scheduler {
112
112
  if (input.when !== undefined && input.when !== 'unreviewed-failures') throw new Error('Unknown schedule condition')
113
113
  const execution: ExecutionChoice = {...input.execution, preset: persistedPreset(input.execution.preset)}
114
114
  if (!input.name || !input.text?.trim() || !isExecutionChoice(execution)) throw new Error('Schedule needs name, text and an AI selection')
115
+ assertScheduledModel(execution.preset.model)
115
116
  assertEffort(execution.preset.effort, execution.preset.model, execution.preset.cli)
116
117
  const s: Schedule = {...input, execution, trigger:validateTrigger(input.trigger),version:1,revision:randomUUID()}
117
118
  const now = Date.now()
@@ -159,6 +160,29 @@ export class Scheduler {
159
160
  try { await readFile(join(this.dir,assertId(runId)+'.cancel')); return true }
160
161
  catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return false; throw e }
161
162
  }
163
+ async trigger(id: string, revision: string, key: string, owner: Owner, runs: RunStore): Promise<RunRecord> {
164
+ const s=await this.get(id)
165
+ if (!sameOwner(s.owner,owner) || !s.enabled || s.revision !== revision) throw new Error('Schedule is disabled, changed or outside this owner binding')
166
+ assertScheduledModel(s.execution.preset.model)
167
+ if (s.delivery) {
168
+ const binding=(await new ApplicationBindings(this.controlDir).list()).find(b=>b.bindingId===s.delivery!.bindingId)
169
+ if (!binding || !sameOwner(binding.owner,owner)) throw new Error('Schedule delivery binding is unavailable')
170
+ } else if (!owner.telegramChatId || owner.telegramChatId !== s.owner.telegramChatId || owner.telegramUserId !== s.owner.telegramUserId || (owner.telegramLinkedAt ?? owner.pairedAt) !== (s.owner.telegramLinkedAt ?? s.owner.pairedAt)) throw new Error('Schedule Telegram binding is unavailable')
171
+ const runId='r_schedule_manual_'+createHash('sha256').update(JSON.stringify([s.id,ownerId(owner),ownerEpoch(owner),assertId(key)])).digest('hex')
172
+ const existing=await runs.get(runId)
173
+ if (existing) {
174
+ if (!ownsRun(owner,existing) || existing.scheduled?.revision !== s.revision) throw new Error('Request key belongs to a different task revision or owner')
175
+ return existing
176
+ }
177
+ const all=await runs.list()
178
+ if (all.some(r=>holdsSchedule(s,r))) throw new Error('Inspect the held occurrence and edit the schedule before retrying')
179
+ if (s.when === 'unreviewed-failures' && !all.some(r=>needsFailureReview(r) && ownsRun(owner,r) && (!r.scheduled || r.scheduled.pairedAt===owner.pairedAt))) throw new Error('Schedule condition is not met')
180
+ const dueAt=new Date().toISOString()
181
+ return runs.create({id:runId,ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),
182
+ ...(s.delivery ? {delivery:s.delivery} : {chatId:s.owner.telegramChatId,telegramUserId:s.owner.telegramUserId,telegramEpoch:s.owner.telegramLinkedAt ?? s.owner.pairedAt}),
183
+ texts:[`[schedule ${s.id} due ${dueAt}]`,s.text],execution:{...s.execution,sessionId:randomUUID()},
184
+ scheduled:{id:s.id,revision:s.revision,dueAt,pairedAt:s.owner.pairedAt,...(s.originRunId ? {originRunId:s.originRunId} : {})}},true)
185
+ }
162
186
  async recover(runs: RunStore) {
163
187
  for (const run of await runs.list()) {
164
188
  if (!run.scheduled || run.status !== 'running') continue
@@ -180,6 +204,7 @@ export class Scheduler {
180
204
  try {
181
205
  const next = await this.pendingOccurrence(s)
182
206
  if (next === null || next > now) continue
207
+ assertScheduledModel(s.execution.preset.model)
183
208
  // One occurrence at a time. A failed reviewer stops this revision just like
184
209
  // interrupted work: retain its receipt until an explicit schedule edit.
185
210
  if ((await runs.list()).some(r => r.scheduled?.id === s.id &&
@@ -193,10 +218,10 @@ export class Scheduler {
193
218
  ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),
194
219
  ...(s.delivery ? {delivery:s.delivery} : {chatId:s.owner.telegramChatId,telegramUserId:s.owner.telegramUserId,telegramEpoch:s.owner.telegramLinkedAt ?? s.owner.pairedAt}),
195
220
  texts:[`[schedule ${s.id} due ${dueAt}]`, s.text],execution:s.execution,
196
- scheduled:{id:s.id,revision:s.revision,dueAt,pairedAt:s.owner.pairedAt,...(s.originRunId?{originRunId:s.originRunId}:{})}})
221
+ scheduled:{id:s.id,revision:s.revision,dueAt,pairedAt:s.owner.pairedAt,...(s.originRunId?{originRunId:s.originRunId}:{})}},true)
197
222
  // A restart between run creation and this cursor write sees the same occurrence ID.
198
223
  await atomic(cursor,{next:future})
199
- } catch { console.error('Schedule dispatch failed',s.id) }
224
+ } catch (error) { console.error('Schedule dispatch failed',s.id,error instanceof Error ? error.message : 'Unknown error') }
200
225
  }
201
226
  }
202
227
  }
@@ -11,9 +11,9 @@ import { executorEnvironment, executorInvocation, terminateJob, type ExecutorOpt
11
11
 
12
12
  // This adapter is deliberately version-pinned: a new native tool default needs
13
13
  // a fresh tool-inventory audit before external correspondence can use it.
14
- export const TASK_CODEX_VERSION = '0.153.4'
14
+ export const TASK_CODEX_VERSION = '0.160.0'
15
15
  export const taskDisabledFeatures = ['apps', 'browser_use', 'computer_use', 'in_app_browser', 'image_generation',
16
- 'memories', 'multi_agent', 'multi_agent_v2', 'hooks', 'shell_tool', 'unified_exec', 'code_mode', 'code_mode_host',
16
+ 'memories', 'goals', 'multi_agent', 'multi_agent_v2', 'hooks', 'shell_tool', 'unified_exec', 'code_mode', 'code_mode_host',
17
17
  'skill_search', 'skill_mcp_dependency_install', 'tool_suggest', 'workspace_dependencies', 'view_image']
18
18
  // Model catalog defaults can override disabled feature flags (for example,
19
19
  // code-only tools and v2 collaboration). Use the audited direct-tool surface.
@@ -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');
@@ -1,3 +1,4 @@
1
+ import { latestCodexVersion } from '../install-tools.mjs';
1
2
  import { sharedIdentity } from '../plugins/shared.mjs';
2
3
  import * as fs from 'node:fs/promises';
3
4
  import { createWriteStream } from 'node:fs';
@@ -61,6 +62,10 @@ export async function relayServices(config) {
61
62
  const env = await fs.readFile(path.join(config.deploymentDir,'docker.env'),'utf8').catch(() => '');
62
63
  return parseEnv(env).EZ_EXECUTOR_TRANSPORT === 'local' ? ['relay','plugin-broker'] : ['relay'];
63
64
  }
65
+ async function refreshBroker(config, run) {
66
+ if ((await relayServices(config)).includes('plugin-broker'))
67
+ await run('docker', [...relayArgs(config), 'restart', 'plugin-broker']);
68
+ }
64
69
  const healthCodes = new Set(['RELAY_UNREADABLE','RELAY_NOT_POLLING','RELAY_STALE','HOST_UNREADABLE','HOST_STALE','PLUGIN_BROKER_UNREADABLE']);
65
70
  async function healthEvidence(config,run) {
66
71
  const id=(await run('docker',[...relayArgs(config),'ps','-q','relay'])).trim();
@@ -109,13 +114,15 @@ export async function perform(home,job,hooks) {
109
114
  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
115
  try { await replaceGuidance(root, config.workspace, run); }
111
116
  catch(error) { console.error(`Workspace guidance replacement failed; upgrade continues: ${error.message}`); }
112
- // Preserve operator-owned native CLI pins from the effective deployment.
113
- // Building only the package defaults silently downgrades those clients.
117
+ // Resolve managed Codex at activation; old deployment pins cannot override latest.
118
+ // Preserve unrelated operator build arguments.
114
119
  const deployment=JSON.parse(await run('docker',[...relayArgs(config),'config','--format','json'],{stdoutOnly:true}));
115
120
  const args=deployment.services?.relay?.build?.args||{};
116
121
  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];
122
+ job.codexVersion=await latestCodexVersion(run,job.packageManager);await save();
123
+ const effective={...args,CODEX_CLI_VERSION:job.codexVersion};
124
+ const buildArgs=Object.keys(effective).sort().flatMap(key=>{
125
+ const value=effective[key];
119
126
  if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)||typeof value!=='string'||value.includes('\0'))throw Error('Unresolved relay build argument');
120
127
  return ['--build-arg',`${key}=${value}`];
121
128
  });
@@ -145,8 +152,8 @@ export async function perform(home,job,hooks) {
145
152
  await checkFolders(config,candidate,home);
146
153
  const stage={...candidate,compose:path.join(dir,'compose.json')};await atomic(stage.compose,await compose(config,stage,secrets,home));
147
154
  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();
155
+ const running=Boolean((await run('docker',[...pluginArgs(old),'ps','--status','running','-q'])).trim());
156
+ job.rollback={record:old,registry:structuredClone(r),compose:await read(old.compose),running};await save();
150
157
  await run('docker',[...pluginArgs(old),'stop']);
151
158
  const backup=path.join(dir,'backup');await fs.mkdir(backup,{mode:0o700});
152
159
  const volumes=new Set(Object.values(old.deployment.services).flatMap(s=>Object.keys(s.volumes||{})));
@@ -156,6 +163,7 @@ export async function perform(home,job,hooks) {
156
163
  r.plugins[job.target]=candidate;
157
164
  for(const alias of Object.keys(candidate.manifest.commands))r.commands[alias]=job.target;
158
165
  await atomic(path.join(home,'registry.json'),r);
166
+ await refreshBroker(config,run);
159
167
  job.runtimeVerified=running;
160
168
  }
161
169
  job.status='completed';job.endedAt=new Date().toISOString();await save();
@@ -192,6 +200,7 @@ export async function recover(home,job,hooks) {
192
200
  const b=job.rollback;
193
201
  await run('docker',[...pluginArgs(b.record),'stop']);
194
202
  await atomic(b.record.compose,b.compose);await atomic(path.join(home,'registry.json'),b.registry);
203
+ await refreshBroker(config,run);
195
204
  if(b.running)await run('docker',[...pluginArgs(b.record),'up','-d','--wait','--wait-timeout','90','--no-build']);
196
205
  }
197
206
  job.status='rolled-back';
@@ -38,6 +38,20 @@ export async function idle(control) {
38
38
  const status=await callDeliverySocket(path.join(control,'delivery.sock'),{op:'status'},2000).catch(()=>null);
39
39
  return !status||status.running===0;
40
40
  }
41
+ // Pause admission only after work is idle, and release it if a turn raced the
42
+ // check. A queued upgrade must never hold the owner's chat behind a long turn.
43
+ export async function withIdleUpgrade(control,job,signal,apply,{isIdle=idle,wait=sleep}={}) {
44
+ if(signal.aborted||!await isIdle(control))return;
45
+ const pause=path.join(control,'upgrade-pause.json');
46
+ await atomic(pause,{id:job.id});
47
+ try {
48
+ for(let n=0;n<3;n++) {
49
+ await wait(500);
50
+ if(signal.aborted||!await isIdle(control))return;
51
+ }
52
+ if(!signal.aborted)return await apply();
53
+ } finally {await fs.rm(pause,{force:true});}
54
+ }
41
55
  export async function queueAutomatic(home,available) {
42
56
  const existing=await jobs(home);
43
57
  if(existing.some(job=>['queued','applying','recovery-required'].includes(job.status)))return null;
@@ -103,19 +117,15 @@ export async function supervise(deployment,signal,{discover=check}={}) {
103
117
  if(!isolated && child?.exitCode!==null&&child?.exitCode!==undefined)throw Error('Host transport exited; supervisor service should restart');
104
118
  const pending=(await jobs(home)).find(j=>j.status==='queued');
105
119
  if(pending) {
106
- await atomic(pause,{id:pending.id});
107
- // Relay admission is paused; drain the requesting turn and any already-claimed job.
108
- let quiet=0;
109
- while(!signal.aborted&&quiet<3){quiet=await idle(agent.controlDir)?quiet+1:0;await sleep(500);}
110
- if(signal.aborted)break;
111
- let result;
112
- try {
113
- result=await locked(home,async()=>{const latest=await read(path.join(jobPath(home,pending.id),'job.json'));return perform(home,latest,{stopHost,startHost});});
114
- } catch(error) {
115
- // A pre-switch rejection is terminal. Applying jobs keep their journal for recovery.
116
- const latest=await read(path.join(jobPath(home,pending.id),'job.json'));
117
- if(latest.status==='queued'){latest.status='failed';latest.error=error.message;await atomic(path.join(jobPath(home,pending.id),'job.json'),latest);}else throw error;
118
- } finally {await fs.rm(pause,{force:true});}
120
+ const result=await withIdleUpgrade(agent.controlDir,pending,signal,async()=>{
121
+ try {
122
+ return await locked(home,async()=>{const latest=await read(path.join(jobPath(home,pending.id),'job.json'));return perform(home,latest,{stopHost,startHost});});
123
+ } catch(error) {
124
+ // A pre-switch rejection is terminal. Applying jobs keep their journal for recovery.
125
+ const latest=await read(path.join(jobPath(home,pending.id),'job.json'));
126
+ if(latest.status==='queued'){latest.status='failed';latest.error=error.message;await atomic(path.join(jobPath(home,pending.id),'job.json'),latest);}else throw error;
127
+ }
128
+ });
119
129
  if(result?.status==='completed') {
120
130
  if(pending.target==='main')return;
121
131
  nextCheck=0;
package/test/ai.test.ts CHANGED
@@ -4,13 +4,49 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import { ControlStore } from '../src/control-state.js'
7
- import { initialPreset, chatPreset, readModels, isPreset, readOpencodeModels, readCuratedModels, validateSelection, opencodeProviderAllowlist } from '../src/ai.js'
7
+ import { initialPreset, chatPreset, readModels, readCodexCatalog, isPreset, readOpencodeModels, readCuratedModels, validateSelection, opencodeProviderAllowlist } from '../src/ai.js'
8
8
  import { createAiMenu } from '../src/menu.js'
9
9
  import { EXECUTOR_REGISTRY, nativeSessionId } from '../src/executor.js'
10
10
  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
+ const noCodex = async (): Promise<string> => { throw new Error('Native discovery unavailable') }
15
+
16
+ test('Codex discovery refreshes through the bound native CLI without curated overrides or a turn', async () => {
17
+ const home = await mkdtemp(join(tmpdir(), 'ez-native-catalog-'))
18
+ const previousToken = process.env.TELEGRAM_BOT_TOKEN
19
+ try {
20
+ process.env.TELEGRAM_BOT_TOKEN = 'must-not-reach-native-client'
21
+ await writeFile(join(home, 'models_cache.json'), JSON.stringify({ models: [
22
+ { slug: 'old-model', visibility: 'list' },
23
+ ] }))
24
+ const calls: string[][] = []
25
+ const run = async (args: string[], env: NodeJS.ProcessEnv) => {
26
+ calls.push(args)
27
+ assert.equal(env.CODEX_HOME, home)
28
+ assert.equal(env.TELEGRAM_BOT_TOKEN, undefined)
29
+ return JSON.stringify({ models: [
30
+ { slug: 'new-model', display_name: 'New model', visibility: 'list',
31
+ supported_reasoning_levels: [{ effort: 'ultra' }], model_messages: 'untrusted', api_key: 'secret' },
32
+ { slug: 'hidden-model', visibility: 'hide' },
33
+ ] })
34
+ }
35
+ assert.deepEqual(await readModels(home, async cli => cli === 'codex', home,
36
+ undefined, undefined, undefined, undefined, run), [
37
+ { cli: 'codex', model: 'new-model', name: 'New model', efforts: ['ultra'] },
38
+ ])
39
+ assert.deepEqual(calls, [['debug', 'models']])
40
+ for (const unavailable of [noCodex, async () => 'not json', async () => '{"models":[]}']) {
41
+ assert.deepEqual(await readCodexCatalog(home, unavailable), { models: [{ slug: 'old-model', visibility: 'list' }] })
42
+ }
43
+ } finally {
44
+ if (previousToken === undefined) delete process.env.TELEGRAM_BOT_TOKEN
45
+ else process.env.TELEGRAM_BOT_TOKEN = previousToken
46
+ await rm(home, { recursive: true, force: true })
47
+ }
48
+ })
49
+
14
50
  test('same-client model and default changes preserve history; provider changes require a fresh conversation', async () => {
15
51
  const dir = await mkdtemp(join(tmpdir(), 'ez-ai-model-switch-'))
16
52
  try {
@@ -173,7 +209,7 @@ test('model catalog projects native metadata only, excluding hidden entries and
173
209
  supported_reasoning_levels: [{ effort: 'high' }, { effort: 'xhigh' }, { effort: 'max' }] },
174
210
  { slug: 'hidden-model', visibility: 'hide' },
175
211
  ] }))
176
- assert.deepEqual(await readModels(home, async (cli) => cli === 'codex'), [
212
+ assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', undefined, undefined, undefined, undefined, undefined, noCodex), [
177
213
  { cli: 'codex', model: 'fixture-model', name: 'Fixture', efforts: ['medium'] },
178
214
  { cli: 'codex', model: 'gpt-5.6-luna', name: 'Luna', efforts: ['high', 'xhigh', 'max'] },
179
215
  ])
@@ -258,7 +294,7 @@ test('model catalog can read an agent-bound Codex home', async () => {
258
294
  { slug: 'gpt-6-astra', display_name: 'GPT-6 Astra', visibility: 'list',
259
295
  supported_reasoning_levels: [{ effort: 'low' }] },
260
296
  ] }))
261
- assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', codexHome), [
297
+ assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', codexHome, undefined, undefined, undefined, undefined, noCodex), [
262
298
  { cli: 'codex', model: 'gpt-6-astra', name: 'GPT-6 Astra', efforts: ['low'] },
263
299
  ])
264
300
  } finally {
@@ -270,7 +306,10 @@ test('model catalog can read an agent-bound Codex home', async () => {
270
306
  test('Choose AI lists the agent-bound Codex home instead of only the desktop fallback', async () => {
271
307
  const dir = await mkdtemp(join(tmpdir(), 'ez-ai-menu-codex-home-'))
272
308
  const codexHome = await mkdtemp(join(tmpdir(), 'ez-ai-menu-cache-'))
309
+ const previousPath = process.env.PATH
273
310
  try {
311
+ await writeFile(join(codexHome, 'codex'), '#!/bin/sh\nexit 1\n', { mode: 0o700 })
312
+ process.env.PATH = `${codexHome}:${previousPath ?? ''}`
274
313
  await writeFile(join(codexHome, 'models_cache.json'), JSON.stringify({ models: [
275
314
  { slug: 'fixture-bound-model', display_name: 'Bound Fixture', visibility: 'list',
276
315
  supported_reasoning_levels: [{ effort: 'medium' }] },
@@ -298,6 +337,7 @@ test('Choose AI lists the agent-bound Codex home instead of only the desktop fal
298
337
  await menu.handle(context(client.callback_data) as never)
299
338
  assert.deepEqual(replies.at(-1)!.buttons.map((button) => button.text), ['Bound Fixture', 'Back to clients'])
300
339
  } finally {
340
+ process.env.PATH = previousPath
301
341
  await rm(dir, { recursive: true, force: true })
302
342
  await rm(codexHome, { recursive: true, force: true })
303
343
  }
@@ -522,7 +562,7 @@ test('model catalog merges user-curated entries ahead of discovered ones', async
522
562
  const installedOnly = async (cli: string) => cli === 'codex' || cli === 'pi'
523
563
  const noRunner = async (_args: string[]): Promise<string> => { throw new Error('no native CLI in test') }
524
564
  const catalog = await readModels(home, installedOnly, join(home, '.codex'),
525
- noRunner, undefined, undefined, control)
565
+ noRunner, undefined, undefined, control, noCodex)
526
566
  assert.deepEqual(catalog.filter((m) => m.cli === 'pi'), [
527
567
  { cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', name: 'Pi · Muse Spark 1.3', efforts: ['xhigh'] },
528
568
  { cli: 'pi', name: 'Pi · client default', efforts: [] },
@@ -538,7 +578,7 @@ test('model catalog merges user-curated entries ahead of discovered ones', async
538
578
  assert.deepEqual(await readCuratedModels(join(control, 'missing-dir')), [])
539
579
  await writeFile(join(control, 'ai-models.json'), 'not json')
540
580
  assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', join(home, '.codex'),
541
- noRunner, undefined, undefined, control), [
581
+ noRunner, undefined, undefined, control, noCodex), [
542
582
  { cli: 'codex', model: 'fixture-model', name: 'Fixture', efforts: ['medium'] },
543
583
  ])
544
584
  } finally {
@@ -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)
@@ -85,7 +85,7 @@ test('web owner uses the standard schedule CLI, executor and outbox with Telegra
85
85
  await writeFile(fixture,`import {spawnSync} from 'node:child_process';
86
86
  if(process.env.TELEGRAM_BOT_TOKEN)throw Error('secret leak');
87
87
  const scheduled=process.env.EZ_RUN_ID.startsWith('r_schedule_');
88
- if(!scheduled){const r=spawnSync(process.execPath,[${JSON.stringify(scheduleCli)},'create','followup','--now','--name','Followup','--text','Finish the requested work'],{env:process.env,encoding:'utf8'});if(r.status)throw Error(r.stderr)}
88
+ if(!scheduled){const r=spawnSync(process.execPath,[${JSON.stringify(scheduleCli)},'create','followup','--model','fixture-model','--now','--name','Followup','--text','Finish the requested work'],{env:process.env,encoding:'utf8'});if(r.status)throw Error(r.stderr)}
89
89
  const r=spawnSync(process.execPath,[${JSON.stringify(messageCli)},'--text',scheduled?'Scheduled reply':'Chat reply'],{env:process.env,encoding:'utf8'});if(r.status)throw Error(r.stderr);`)
90
90
  EXECUTOR_REGISTRY.grok={...original,command:process.execPath,buildArgs:()=>['--import',require.resolve('tsx'),fixture]}
91
91
  const config=loadConfig({EZ_APPLICATION_PORT:'8787',EZ_CONTROL_DIR:root,EZ_AGENT_WORKSPACE:root,EZ_EXECUTOR_CLI:'grok'})
@@ -151,7 +151,7 @@ for (const mode of ['owner','web','telegram']) test(`${mode} revocation stops it
151
151
  await runs.create({id:'r_telegram',chatId:42,telegramUserId:42,ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),texts:['Wait'],execution:await control.captureChoice(initialPreset('grok'))})
152
152
  } else {
153
153
  const binding=(await relay.applicationChannel.bindings.register('web',secret(),owner))!
154
- await scheduler.save({id:'wait',name:'Wait',text:'Wait',enabled:true,trigger:{at:new Date(Date.now()+1000).toISOString()},owner,delivery:{bindingId:binding.bindingId,scope:'main'},execution:await control.captureChoice(initialPreset('grok'))})
154
+ await scheduler.save({id:'wait',name:'Wait',text:'Wait',enabled:true,trigger:{at:new Date(Date.now()+1000).toISOString()},owner,delivery:{bindingId:binding.bindingId,scope:'main'},execution:await control.captureChoice({...initialPreset('grok'),model:'fixture-model'})})
155
155
  await scheduler.tick(owner,runs,Date.now()+2000)
156
156
  }
157
157
  await relay.drainSources()
@@ -52,7 +52,8 @@ test('codex-gui is a distinct preset and catalog entry', async () => {
52
52
  await writeFile(path.join(home, '.codex/models_cache.json'), JSON.stringify({ models: [
53
53
  { slug: 'fixture-model', display_name: 'Fixture', visibility: 'list', supported_reasoning_levels: [{ effort: 'medium' }] },
54
54
  ] }))
55
- const models = await readModels(home, async (cli) => cli === 'codex' || cli === 'codex-gui')
55
+ const models = await readModels(home, async (cli) => cli === 'codex' || cli === 'codex-gui', undefined, undefined, undefined, undefined, undefined,
56
+ async () => { throw new Error('Native discovery unavailable') })
56
57
  assert.ok(models.some((model) => model.cli === 'codex' && model.model === 'fixture-model'))
57
58
  assert.ok(models.some((model) => model.cli === 'codex-gui' && model.model === 'fixture-model'))
58
59
  } finally { await rm(home, { recursive: true, force: true }) }
@@ -100,7 +100,7 @@ test('failure capture, diagnosis, verified recovery and conditional quiet next t
100
100
  })
101
101
  try{
102
102
  await control.requestPairing(101,101);await control.approveOwner(101)
103
- const owner=(await control.status()).owner!,execution=await control.captureChoice(initialPreset('grok')),start=Date.now()+2000
103
+ const owner=(await control.status()).owner!,execution=await control.captureChoice({...initialPreset('grok'),model:'fixture-model'}),start=Date.now()+2000
104
104
  await scheduler.save({id:'review',name:'Review failures',text:'Review failures',trigger:{everySeconds:60,start:new Date(start).toISOString()},when:'unreviewed-failures',enabled:true,owner,execution})
105
105
  await scheduler.tick(owner,runs,start)
106
106
  assert.equal((await runs.list()).length,0,'no model job for an empty inbox')
@@ -251,7 +251,7 @@ for (const intake of [true,false]) test(`relay shutdown terminates an in-flight
251
251
  await control.requestPairing(101,101);const owner=await control.approveOwner(101)
252
252
  if(intake) await relay.bot.handleUpdate({update_id:91,message:{message_id:91,date:0,text:'fixture',from:{id:101,is_bot:false,first_name:'Fixture'},chat:{id:101,type:'private',first_name:'Fixture'}}})
253
253
  else {
254
- const scheduler=new Scheduler(dir),at=Date.now()+1000,execution=await control.captureChoice(initialPreset('grok'))
254
+ const scheduler=new Scheduler(dir),at=Date.now()+1000,execution=await control.captureChoice({...initialPreset('grok'),model:'fixture-model'})
255
255
  await scheduler.save({id:'fixture',name:'Fixture',text:'fixture',trigger:{at:new Date(at).toISOString()},enabled:true,owner,execution})
256
256
  await scheduler.tick(owner,runs,at)
257
257
  }
@@ -276,7 +276,7 @@ test('group members can inspect failures and wake review without exposing other
276
276
  const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
277
277
  const runs=new RunStore(dir),control=new ControlStore(dir,900000),scheduler=new Scheduler(dir)
278
278
  await control.requestPairing(101,-123,'Fixture');const owner=await control.approveOwner(-123,true)
279
- const execution=await control.captureChoice(initialPreset('grok'))
279
+ const execution=await control.captureChoice({...initialPreset('grok'),model:'fixture-model'})
280
280
  await runs.create({id:'tg_1',chatId:-123,telegramUserId:202,texts:['failed'],execution})
281
281
  await runs.patch('tg_1',{status:'failed',endedAt:new Date().toISOString()})
282
282
  await runs.create({id:'tg_2',chatId:-124,telegramUserId:202,texts:['private'],execution})
@@ -343,7 +343,7 @@ test('failed reviewer readback exposes its stop through show, list and resume un
343
343
  const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
344
344
  const control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
345
345
  await control.requestPairing(101,101);const owner=await control.approveOwner(101)
346
- const execution=await control.captureChoice(initialPreset('grok')),now=Date.now()+1000
346
+ const execution=await control.captureChoice({...initialPreset('grok'),model:'fixture-model'}),now=Date.now()+1000
347
347
  const s=await scheduler.save({id:'review',name:'Review',text:'Review failures',trigger:{everySeconds:60,start:new Date(now).toISOString()},when:'unreviewed-failures',enabled:true,owner,execution})
348
348
  await runs.create({id:'r_original',chatId:101,telegramUserId:101,texts:['Work'],execution});await runs.patch('r_original',{status:'failed'})
349
349
  await scheduler.tick(owner,runs,now)
@@ -265,13 +265,13 @@ test('one installed CLI executes two agent bindings with separate minds and sani
265
265
  await assert.rejects(serveHostExecutor({cli:'grok',agents},new AbortController().signal),/already running/)
266
266
  const directory=path.join(agents[0].controlDir,'host-executor')
267
267
  const runs=new RunStore(agents[0].controlDir)
268
- await runs.create({id:'r_hold',chatId:101,telegramUserId:101,texts:['test'],scheduled:{id:'held',revision:'v1',dueAt:new Date().toISOString(),pairedAt:new Date().toISOString()}})
268
+ await runs.create({id:'r_hold',execution:{sessionId:crypto.randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'grok',model:'fixture-model'}},chatId:101,telegramUserId:101,texts:['test'],scheduled:{id:'held',revision:'v1',dueAt:new Date().toISOString(),pairedAt:new Date().toISOString()}})
269
269
  await runs.patch('r_hold',{status:'running'})
270
- await writeFile(path.join(directory,'r_hold.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok'}}))
270
+ await writeFile(path.join(directory,'r_hold.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok',model:'fixture-model'}}))
271
271
  for(let n=0;n<100;n++){try{await readFile(path.join(directory,'r_hold.process.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
272
- await new RunStore(agents[0].controlDir).create({id:'r_schedule_queued',chatId:101,telegramUserId:101,texts:['test'],scheduled:{id:'shared',revision:'v1',dueAt:new Date().toISOString(),pairedAt:new Date().toISOString()}})
272
+ await new RunStore(agents[0].controlDir).create({id:'r_schedule_queued',execution:{sessionId:crypto.randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'grok',model:'fixture-model'}},chatId:101,telegramUserId:101,texts:['test'],scheduled:{id:'shared',revision:'v1',dueAt:new Date().toISOString(),pairedAt:new Date().toISOString()}})
273
273
  await new RunStore(agents[0].controlDir).patch('r_schedule_queued',{status:'running'})
274
- await writeFile(path.join(directory,'r_schedule_queued.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok',sharedWorkspace:'/wrong'}}))
274
+ await writeFile(path.join(directory,'r_schedule_queued.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok',model:'fixture-model',sharedWorkspace:'/wrong'}}))
275
275
  const otherDirectory=path.join(agents[1].controlDir,'host-executor')
276
276
  await ownerRun(agents[1].controlDir,'r_other_shared')
277
277
  await writeFile(path.join(otherDirectory,'r_other_shared.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok'}}))
@@ -79,19 +79,20 @@ test('isolated deployments need no host ledger endpoint',async t=>{
79
79
  });
80
80
  test('same-artifact build retries do not spawn a second Docker build and reuse verified completed image',async t=>{
81
81
  const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
82
- let started,finish,count=0;const running=new Promise(r=>started=r),held=new Promise(r=>finish=r);
83
- const invoke=async(cmd,args)=>{if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);if(args[0]==='build'){count++;started();await held;}return 'sha256:fixture';};
82
+ let started,finish,count=0,latest='0.160.0';const running=new Promise(r=>started=r),held=new Promise(r=>finish=r);
83
+ const invoke=async(cmd,args)=>{if(cmd==='npm'&&args?.[0]==='view')return JSON.stringify(latest);if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);if(args[0]==='build'){count++;started();await held;}return 'sha256:fixture';};
84
84
  const first=build({home,source},invoke);await running;
85
85
  assert.equal((await build({home,source},invoke)).state,'busy-or-interrupted');assert.equal(count,1);
86
86
  finish();const result=await first;assert.equal(result.state,'completed');
87
87
  assert.equal((await build({home,source},invoke)).reused,true);assert.equal(count,1);
88
88
  await fs.writeFile(path.join(source,'package.json'),'{"changed":true}');await build({home,source},invoke);assert.equal(count,2);
89
+ latest='0.160.1';const newer=await build({home,source},invoke);assert.equal(count,3);assert.equal(newer.codexVersion,latest);assert.notEqual(newer.image,result.image);
89
90
  });
90
91
  test('source checkouts build RCs only: clean reviewed commit plus increasing label',async t=>{
91
92
  const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
92
93
  let inside=true,status='',built=[],toplevel=source;const sha='a'.repeat(40);
93
94
  const invoke=async(cmd,args)=>{
94
- if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);
95
+ if(cmd==='npm'&&args?.[0]==='view')return '"0.160.0"';if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);
95
96
  if(cmd==='git'){
96
97
  if(!inside)throw Error('not a git repository');
97
98
  if(args[2]==='rev-parse'&&args[3]==='--show-toplevel')return toplevel;
@@ -120,13 +121,13 @@ test('source checkouts build RCs only: clean reviewed commit plus increasing lab
120
121
  });
121
122
  test('a failed build releases its own lock and leaves a failure receipt for diagnosis',async t=>{
122
123
  const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
123
- const invoke=async(cmd)=>{if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);throw Error('Synthetic build failed');};
124
+ const invoke=async(cmd,args)=>{if(cmd==='npm'&&args?.[0]==='view')return '"0.160.0"';if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);throw Error('Synthetic build failed');};
124
125
  await assert.rejects(build({home,source},invoke),/Synthetic/);
125
126
  const [id]=await fs.readdir(path.join(home,'builds'));const dir=path.join(home,'builds',id);
126
127
  assert.equal(JSON.parse(await fs.readFile(path.join(dir,'status.json'))).state,'failed');await assert.rejects(fs.access(path.join(dir,'lock')));
127
128
  });
128
129
  test('source changes during a build cannot produce a reusable success receipt',async t=>{
129
130
  const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
130
- const invoke=async(cmd,args)=>{if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);if(args[0]==='build')await fs.writeFile(path.join(source,'package.json'),'{"changed":true}');return 'image';};
131
+ const invoke=async(cmd,args)=>{if(cmd==='npm'&&args?.[0]==='view')return '"0.160.0"';if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);if(args[0]==='build')await fs.writeFile(path.join(source,'package.json'),'{"changed":true}');return 'image';};
131
132
  await assert.rejects(build({home,source},invoke),/changed during build/);
132
133
  });
@@ -20,7 +20,7 @@ test('native tasks use verified control binding, sanitized environment and uncha
20
20
  const denied=await nativeTasks(home,['list']);assert.notEqual(denied.code,0);assert.match(denied.stderr,/Pair an owner/)
21
21
  const control=new ControlStore(controlDir,1000);await control.requestPairing(101,101);await control.approveOwner(101)
22
22
  const text='Read scan; $(must-not-run) /goal literal'
23
- const saved=await nativeTasks(home,['create','native-fixture','--now','--text',text]);assert.equal(saved.code,0,saved.stderr)
23
+ const saved=await nativeTasks(home,['create','native-fixture','--model','fixture-model','--now','--text',text]);assert.equal(saved.code,0,saved.stderr)
24
24
  const value=JSON.parse(saved.stdout);assert.equal(value.text,text);assert.equal(value.execution.preset.cli,'grok')
25
25
  assert.equal(JSON.parse((await nativeTasks(home,['show','native-fixture'])).stdout).text,text)
26
26
  assert.equal((await nativeTasks(home,['remove','native-fixture'])).code,0)
@@ -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});