@jc_stack/ez-agents 0.1.0-beta.32 → 0.1.0-beta.34

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 (70) hide show
  1. package/.env.example +4 -0
  2. package/AGENTS.md +46 -31
  3. package/CHANGELOG.md +33 -0
  4. package/CONTRIBUTING.md +29 -26
  5. package/Dockerfile +4 -0
  6. package/README.md +26 -18
  7. package/THIRD_PARTY_NOTICES.md +4 -4
  8. package/compose.yaml +2 -1
  9. package/docker/smoke.mjs +6 -3
  10. package/docs/application-channel.md +6 -0
  11. package/docs/architecture/ai-selection.md +3 -1
  12. package/docs/docker-runtime.md +27 -10
  13. package/docs/host-service.md +17 -6
  14. package/docs/plugin-contributions.md +4 -1
  15. package/docs/plugins.md +15 -0
  16. package/docs/repair.md +1 -1
  17. package/docs/setup.md +88 -14
  18. package/docs/standalone-cli.md +6 -5
  19. package/docs/upgrades.md +6 -4
  20. package/package.json +1 -1
  21. package/scripts/smoke-scheduler.ts +2 -1
  22. package/src/agent-install.ts +20 -6
  23. package/src/ai-cli.ts +9 -7
  24. package/src/ai.ts +7 -7
  25. package/src/application-channel.ts +10 -10
  26. package/src/codex-session.ts +5 -3
  27. package/src/config.ts +6 -1
  28. package/src/control-state.ts +12 -1
  29. package/src/executor.ts +91 -9
  30. package/src/host-executor.ts +25 -6
  31. package/src/index.ts +1 -0
  32. package/src/install-config.ts +2 -2
  33. package/src/install-tools.mjs +3 -1
  34. package/src/isolation.ts +25 -0
  35. package/src/menu.ts +9 -6
  36. package/src/plugins/manager.mjs +49 -6
  37. package/src/reply-context.ts +0 -1
  38. package/src/setup.ts +11 -7
  39. package/src/task-executor.ts +4 -1
  40. package/src/updates/control.mjs +11 -1
  41. package/src/updates/runtime.mjs +4 -2
  42. package/src/updates/supervisor.mjs +17 -3
  43. package/src/workspace-confine.ts +38 -0
  44. package/src/workspace.ts +23 -13
  45. package/templates/agent/AGENTS.md +4 -14
  46. package/templates/agent-guidance.md +13 -110
  47. package/templates/agent-purpose.md +1 -0
  48. package/test/agent-guidance.test.ts +8 -12
  49. package/test/agent-install.test.ts +34 -0
  50. package/test/ai-cli.test.ts +18 -0
  51. package/test/ai.test.ts +48 -0
  52. package/test/application-channel.test.ts +5 -2
  53. package/test/application-controls.test.ts +56 -3
  54. package/test/application-only.test.ts +2 -0
  55. package/test/codex-context.test.ts +61 -1
  56. package/test/codex-session.test.ts +6 -1
  57. package/test/config.test.ts +17 -2
  58. package/test/engine-handoff.test.ts +5 -2
  59. package/test/executor.test.ts +38 -1
  60. package/test/failure.test.ts +6 -0
  61. package/test/host-executor.test.ts +6 -0
  62. package/test/install-config.test.ts +17 -6
  63. package/test/install-tools.test.mjs +6 -0
  64. package/test/plugin-manager.test.mjs +24 -3
  65. package/test/scheduler.test.ts +2 -2
  66. package/test/updates.test.mjs +34 -2
  67. package/test/workspace-confine.test.ts +47 -0
  68. package/test/workspace.test.ts +23 -10
  69. package/templates/agent/SOUL.md +0 -11
  70. package/templates/agent/USER.md +0 -5
package/src/executor.ts CHANGED
@@ -4,6 +4,7 @@ import { RunStore } from './runs.js'
4
4
  import { startTaskExecutor } from './task-executor.js'
5
5
  import { requireOwnerExecution } from './execution-authority.js'
6
6
  import { mkdtemp, rm, writeFile, mkdir, symlink, readFile } from 'node:fs/promises'
7
+ import { accessSync, constants as fsConstants } from 'node:fs'
7
8
  import { tmpdir, homedir } from 'node:os'
8
9
  import path from 'node:path'
9
10
  import { spawn, type ChildProcess } from 'node:child_process'
@@ -11,6 +12,7 @@ import { processSnapshot, matchingProcessIds } from './process-tree.js'
11
12
  import { createInterface } from 'node:readline'
12
13
  import { fileURLToPath } from 'node:url'
13
14
  import { DESKTOP_UNAVAILABLE } from './desktop-bridge.js'
15
+ import { macosWorkspaceProfile, workspaceSiblingDenies } from './workspace-confine.js'
14
16
 
15
17
  export type ExecutorOptions = {
16
18
  repairEnabled?: boolean
@@ -28,11 +30,21 @@ export type ExecutorOptions = {
28
30
  eventSource?: string
29
31
  model?: string
30
32
  effort?: string
33
+ provider?: string
31
34
  codexSandbox?: 'external'
32
35
  codexAutoCompactTokens?: number
36
+ codexProvider?: CodexProviderBinding
33
37
  onSession?: (id: string) => Promise<void>
34
38
  }
35
39
 
40
+ export type CodexProviderBinding = {
41
+ id: string
42
+ name: string
43
+ baseUrl: string
44
+ envKey: string
45
+ models: string[]
46
+ }
47
+
36
48
  const allowedEnvironmentKeys = [
37
49
  'HOME',
38
50
  'LANG',
@@ -71,12 +83,27 @@ export const executorJobEnv = (
71
83
 
72
84
  export const grokJobEnv = executorJobEnv
73
85
 
86
+ // Package binDir is Ez tools (ezenciel-agents-message). Native CLIs come from the
87
+ // host PATH so one agent's wrapper cannot retarget another agent's CODEX_HOME.
88
+ export const resolveHostCommand = (command: string, pathValue = process.env.PATH): string => {
89
+ if (!command || command.includes(path.sep) || command.includes('/')) return command
90
+ for (const directory of (pathValue ?? '').split(path.delimiter)) {
91
+ if (!directory) continue
92
+ const candidate = path.join(directory, command)
93
+ try {
94
+ accessSync(candidate, fsConstants.X_OK)
95
+ return candidate
96
+ } catch { /* try the next PATH entry */ }
97
+ }
98
+ throw new Error(`Native CLI ${command} is not executable on the host PATH`)
99
+ }
100
+
74
101
  export type CliAdapter = {
75
102
  name: string
76
103
  command: string
77
104
  description: string
78
105
  buildArgs: (
79
- options: Pick<ExecutorOptions, 'workspace' | 'sessionId' | 'isResume' | 'model' | 'effort' | 'toolsHome' | 'sharedWorkspace' | 'additionalWorkspaces' | 'codexAutoCompactTokens' | 'codexSandbox'> & { controlDir?: string },
106
+ options: Pick<ExecutorOptions, 'workspace' | 'sessionId' | 'isResume' | 'model' | 'effort' | 'toolsHome' | 'sharedWorkspace' | 'additionalWorkspaces' | 'codexAutoCompactTokens' | 'codexSandbox' | 'codexProvider'> & { controlDir?: string },
80
107
  promptFile: string,
81
108
  promptText: string,
82
109
  ) => string[]
@@ -88,6 +115,13 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
88
115
  buildArgs: (opts, _file, prompt) => {
89
116
  if (opts.codexSandbox !== undefined && opts.codexSandbox !== 'external') throw new Error('Invalid Codex sandbox selection')
90
117
  const args = ['exec', '--skip-git-repo-check', '--json', '--sandbox', opts.codexSandbox === 'external' ? 'danger-full-access' : 'workspace-write', '--disable', 'memories', '--enable', 'skip_host_skill_discovery', '-c', 'approval_policy="never"']
118
+ if (opts.codexProvider) {
119
+ const provider = validateCodexProvider(opts.codexProvider)
120
+ args.push(
121
+ '-c', `model_provider=${JSON.stringify(provider.id)}`,
122
+ '-c', `model_providers.${provider.id}={name=${JSON.stringify(provider.name)},base_url=${JSON.stringify(provider.baseUrl)},env_key=${JSON.stringify(provider.envKey)},wire_api="responses",supports_websockets=false}`,
123
+ )
124
+ }
91
125
  const limit = opts.codexAutoCompactTokens
92
126
  if (limit !== undefined) {
93
127
  if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error('Invalid Codex compaction token limit')
@@ -194,6 +228,32 @@ export const executorKey = (name?: string): string => {
194
228
  return resolvedKey
195
229
  }
196
230
 
231
+ const providerToken = (value: unknown, label: string, pattern: RegExp, limit = 160): string => {
232
+ if (typeof value !== 'string' || value.length > limit || !pattern.test(value)) throw new Error(`Invalid Codex provider ${label}`)
233
+ return value
234
+ }
235
+
236
+ export const validateCodexProvider = (value: CodexProviderBinding): CodexProviderBinding => {
237
+ if (!value || typeof value !== 'object') throw new Error('Invalid Codex provider binding')
238
+ const id = providerToken(value.id, 'id', /^[a-z][a-z0-9_-]{0,31}$/)
239
+ const name = providerToken(value.name, 'name', /^[^\r\n\0]{1,80}$/, 80)
240
+ const envKey = validateCodexProviderEnvironmentKey(value.envKey)
241
+ let url: URL
242
+ try { url = new URL(value.baseUrl) } catch { throw new Error('Invalid Codex provider URL') }
243
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) throw new Error('Invalid Codex provider URL')
244
+ const models = value.models?.map(model => providerToken(model, 'model', /^[a-zA-Z0-9_./:-]{1,160}$/))
245
+ if (!models?.length || models.length > 64 || new Set(models).size !== models.length) throw new Error('Invalid Codex provider models')
246
+ return {id,name,baseUrl:url.toString().replace(/\/$/,''),envKey,models}
247
+ }
248
+
249
+ export const validateCodexProviderEnvironmentKey = (value: unknown): string => {
250
+ const envKey = providerToken(value, 'environment key', /^[A-Z][A-Z0-9_]{1,63}$/, 64)
251
+ if (allowedEnvironmentKeys.includes(envKey as typeof allowedEnvironmentKeys[number]) || envKey === 'CODEX_HOME' || envKey === 'NODE_OPTIONS' ||
252
+ envKey.startsWith('EZ_') || envKey.startsWith('TELEGRAM_') || envKey.startsWith('PAGERDUTY_'))
253
+ throw new Error('Reserved Codex provider environment key')
254
+ return envKey
255
+ }
256
+
197
257
  export const resolveExecutor = (name?: string): CliAdapter => EXECUTOR_REGISTRY[executorKey(name)]
198
258
 
199
259
  export const grokInvocation = (
@@ -261,15 +321,27 @@ export const startExecutorJob = async (
261
321
 
262
322
  const adapter = resolveExecutor(options.cli)
263
323
  const command = adapter.command
264
- const args = host || nativeSession || key === 'codex-gui' ? [] : adapter.buildArgs(options, promptFile, promptText)
265
- const invocation = host
324
+ const denies = process.platform === 'darwin' && !host && !gui && options.codexSandbox !== 'external'
325
+ ? workspaceSiblingDenies(options.workspace) : []
326
+ // Codex must not apply a nested Seatbelt; this process is already confined.
327
+ const launchOptions = denies.length && command === 'codex' ? { ...options, codexSandbox: 'external' as const } : options
328
+ const args = host || nativeSession || key === 'codex-gui' ? [] : adapter.buildArgs(launchOptions, promptFile, promptText)
329
+ let invocation = host
266
330
  ? executorInvocation(process.execPath, ['--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)), fileURLToPath(new URL('./host-executor-client.ts', import.meta.url)), options.controlDir, options.runId])
267
331
  : nativeSession
268
332
  ? executorInvocation(process.execPath, ['--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)), fileURLToPath(new URL('./codex-session.ts', import.meta.url))])
269
333
  : gui
270
334
  ? executorInvocation(process.execPath, ['--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)), fileURLToPath(new URL('./desktop-bridge.ts', import.meta.url))])
271
- : executorInvocation(command, args)
335
+ : executorInvocation(resolveHostCommand(command, executorEnvironment().PATH), args)
272
336
  const environment = executorJobEnv(options)
337
+ if (!host && !gui && key === 'codex' && options.provider && !options.codexProvider)
338
+ throw new Error('Selected Codex provider is not configured for this agent')
339
+ if (!host && !gui && key === 'codex' && options.codexProvider) {
340
+ const provider = validateCodexProvider(options.codexProvider)
341
+ const secret = process.env[provider.envKey]
342
+ if (!secret) throw new Error(`Missing Codex provider credential ${provider.envKey}`)
343
+ environment[provider.envKey] = secret
344
+ }
273
345
  if (!host && !gui && command === 'codex') {
274
346
  // Share the existing authentication, never the user's memory/config/sessions.
275
347
  const base = path.join(options.controlDir, 'cli', 'codex')
@@ -282,16 +354,26 @@ export const startExecutorJob = async (
282
354
  try{await writeFile(path.join(home,'config.toml'),await readFile(path.join(base,'config.toml')),{flag:'wx',mode:0o600})}
283
355
  catch(error){if(!['ENOENT','EEXIST'].includes((error as NodeJS.ErrnoException).code || ''))throw error}
284
356
  }
285
- // Tasks inherit this agent's auth binding, including an operator-provisioned
286
- // private credential after host migration. Never replace an existing binding.
287
- const authLinks = [[path.join(base, 'auth.json'), path.join(homedir(), '.codex', 'auth.json')]]
288
- if (nativeSession) authLinks.push([path.join(home, 'auth.json'), path.join(base, 'auth.json')])
357
+ // Isolated agents keep auth in this CODEX_HOME. Host-capable may link the
358
+ // installer login. Never replace an existing binding.
359
+ const authLinks = process.env.EZ_ISOLATION === 'isolated'
360
+ ? nativeSession ? [[path.join(home, 'auth.json'), path.join(base, 'auth.json')]] : []
361
+ : [[path.join(base, 'auth.json'), path.join(homedir(), '.codex', 'auth.json')],
362
+ ...(nativeSession ? [[path.join(home, 'auth.json'), path.join(base, 'auth.json')]] : [])]
289
363
  for (const [link, target] of authLinks) {
290
364
  try { await symlink(target, link) }
291
365
  catch(error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error }
292
366
  }
293
367
  environment.CODEX_HOME = home
294
368
  }
369
+ if (denies.length) {
370
+ const profile = path.join(outputDirectory, 'workspace.sb')
371
+ await writeFile(profile, macosWorkspaceProfile(denies, [
372
+ options.controlDir, options.binDir, options.toolsHome, options.sharedWorkspace,
373
+ ...(options.additionalWorkspaces ?? []), outputDirectory,
374
+ ].filter((value): value is string => Boolean(value))), { mode: 0o600 })
375
+ invocation = { command: 'sandbox-exec', args: ['-f', profile, invocation.command, ...invocation.args] }
376
+ }
295
377
  const child = spawn(invocation.command, invocation.args, {
296
378
  cwd: options.workspace,
297
379
  env: environment,
@@ -307,7 +389,7 @@ export const startExecutorJob = async (
307
389
  })
308
390
  child.stdin?.end(host
309
391
  ? JSON.stringify({texts,options:{...options,onSession:undefined,codexSandbox:undefined}})
310
- : nativeSession ? JSON.stringify({...options,onSession:undefined,prompt:promptText})
392
+ : nativeSession ? JSON.stringify({...launchOptions,onSession:undefined,prompt:promptText})
311
393
  : gui ? JSON.stringify({prompt:promptText,options:{...options,onSession:undefined}})
312
394
  : ['codex', 'claude'].includes(key) ? promptText : undefined)
313
395
  const timeout = options.timeoutMs > 0 ? setTimeout(() => terminateJob(child), options.timeoutMs) : undefined
@@ -7,7 +7,8 @@ import { mkdir, readFile, writeFile, readdir, rename, rm, appendFile, realpath }
7
7
  import path from 'node:path'
8
8
  import { isHostRunId } from './host-executor-protocol.js'
9
9
  import { fileURLToPath } from 'node:url'
10
- import { startExecutorJob, terminateJob, resolveExecutor, type ExecutorOptions } from './executor.js'
10
+ import { startExecutorJob, terminateJob, resolveExecutor, validateCodexProvider, type CodexProviderBinding, type ExecutorOptions } from './executor.js'
11
+ import { parseIsolationClass, type IsolationClass } from './isolation.js'
11
12
  import { readModels, validateSelection } from './ai.js'
12
13
  import type { ChildProcess } from 'node:child_process'
13
14
  import { taskWorkspace } from './task-workspace.js'
@@ -16,12 +17,14 @@ import { installedPluginVersions } from './software-status.js'
16
17
  import { processSnapshot } from './process-tree.js'
17
18
 
18
19
  export type PluginNetworkRoute = { revisions:string[]; bindings:{service:string;network:string}[] }
19
- export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string; additionalWorkspaces?: string[]; pluginNetworkBindings?: Record<string, PluginNetworkRoute> }
20
- export type HostInstallation = { cli: string; agents: HostBinding[] }
20
+ export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string; additionalWorkspaces?: string[]; pluginNetworkBindings?: Record<string, PluginNetworkRoute>; pluginFolderRoots?: Record<string,string[]>; codexProviders?: CodexProviderBinding[] }
21
+ export type HostInstallation = { cli: string; isolation?: IsolationClass; agents: HostBinding[] }
21
22
 
22
23
  const processStart = async (pid:number) => (await processSnapshot()).get(pid)?.birth
23
24
 
24
25
  export const serveHostExecutor = async (installation: HostInstallation, signal: AbortSignal, launch = startExecutorJob) => {
26
+ if (installation.isolation !== undefined) parseIsolationClass(installation.isolation)
27
+ if (installation.isolation === 'isolated') throw new Error('Isolated agents run the native CLI in the relay; do not start host transport')
25
28
  resolveExecutor(installation.cli)
26
29
  if (new Set(installation.agents.map(a=>a.workspace)).size !== installation.agents.length ||
27
30
  new Set(installation.agents.map(a=>a.controlDir)).size !== installation.agents.length)
@@ -31,10 +34,17 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
31
34
  const locks: string[] = []
32
35
  const sharedWorkspaces = new Map<HostBinding, string>()
33
36
  const additionalWorkspaces = new Map<HostBinding, string[]>()
34
- const catalog = (agent: HostBinding) => readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'))
37
+ const catalog = async (agent: HostBinding) => {
38
+ const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'))
39
+ const providers = (agent.codexProviders ?? []).map(validateCodexProvider)
40
+ const declared = providers.flatMap(provider => provider.models.map(model => ({cli:'codex',provider:provider.id,model,name:`${provider.name} · ${model}`.slice(0,80),efforts:[]})))
41
+ return [...discovered, ...declared]
42
+ }
35
43
  try {
36
44
  for (const agent of installation.agents) {
37
45
  if (![agent.workspace,agent.controlDir,agent.binDir].every(path.isAbsolute)) throw new Error('Host bindings require absolute paths')
46
+ const providers=(agent.codexProviders ?? []).map(validateCodexProvider)
47
+ if (new Set(providers.map(provider=>provider.id)).size !== providers.length) throw new Error('Codex provider IDs must be unique')
38
48
  if (agent.sharedWorkspace && !path.isAbsolute(agent.sharedWorkspace)) throw new Error('Shared workspace requires an absolute path')
39
49
  if (agent.sharedWorkspace) sharedWorkspaces.set(agent, await realpath(agent.sharedWorkspace))
40
50
  if (agent.additionalWorkspaces) {
@@ -129,10 +139,19 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
129
139
  const opts=request.options as ExecutorOptions
130
140
  const cli = opts.cli || installation.cli
131
141
  resolveExecutor(cli)
132
- if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,model:opts.model,effort:opts.effort},await catalog(agent))
142
+ const provider = cli === 'codex' && opts.provider
143
+ ? (agent.codexProviders ?? []).map(validateCodexProvider).find(candidate=>candidate.id===opts.provider)
144
+ : undefined
145
+ if (opts.provider && !provider) throw new Error('Selected Codex provider is not installed for this agent')
146
+ const model = opts.model
147
+ if (provider && !model) throw new Error('Selected Codex provider requires a declared model')
148
+ if (provider && model && !provider.models.includes(model)) throw new Error('Selected model is not declared for this Codex provider')
149
+ if (!provider && model && (agent.codexProviders ?? []).some(binding=>binding.models.includes(model)))
150
+ throw new Error('Selected Codex provider is required for this model')
151
+ if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,model,effort:opts.effort},await catalog(agent))
133
152
  const options:ExecutorOptions={workspace:run?.scheduled ? await taskWorkspace(agent.workspace,id) : agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
134
153
  runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
135
- sessionId:opts.sessionId,isResume:opts.isResume,eventSource:opts.eventSource,model:opts.model,effort:opts.effort,codexAutoCompactTokens:opts.codexAutoCompactTokens}
154
+ sessionId:opts.sessionId,isResume:opts.isResume,eventSource:opts.eventSource,model,effort:opts.effort,provider:opts.provider,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider}
136
155
  job=await launch(request.texts,options)
137
156
  active.set(base,job.child)
138
157
  await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid}),{mode:0o600})
package/src/index.ts CHANGED
@@ -240,6 +240,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
240
240
  cli: selected.cli,
241
241
  model: selected.model,
242
242
  effort: selected.effort,
243
+ provider: selected.provider,
243
244
  codexAutoCompactTokens: config.codexAutoCompactTokens,
244
245
  codexSandbox: !run.taskId && selected.cli === 'codex' ? config.codexSandbox : undefined,
245
246
  sessionId: session.nativeSessionId || session.sessionId,
@@ -11,7 +11,7 @@ const inside = (parent: string, child: string) => {
11
11
  }
12
12
 
13
13
  // Configuration only: no provider calls, owner approval, service start, or AI turn.
14
- export const configureInstallation = async (directory: string, cli: string, token?: string) => {
14
+ export const configureInstallation = async (directory: string, cli: string, token?: string, purposeFile?: string) => {
15
15
  let executor: string
16
16
  try { executor = resolveExecutor(cli).name }
17
17
  catch { throw new Error('Unsupported executor. See ezenciel-agents-setup status.') }
@@ -44,7 +44,7 @@ export const configureInstallation = async (directory: string, cli: string, toke
44
44
  if (!quote) throw new Error('Existing .env has a value requiring manual quoting; it was not changed.')
45
45
  return `${key}=${quote}${value}${quote}\n`
46
46
  }).join('')
47
- const created = await initializeWorkspace(workspace)
47
+ const created = await initializeWorkspace(workspace, purposeFile)
48
48
  const temporary = `${envFile}.${randomUUID()}.tmp`
49
49
  try {
50
50
  await writeFile(temporary, content, { mode: 0o600, flag: 'wx' })
@@ -49,7 +49,9 @@ export async function installationStatus(deployment) {
49
49
  const paired=Boolean(owner&&Number.isSafeInteger(owner.telegramUserId)&&owner.telegramUserId>0&&Number.isSafeInteger(owner.telegramChatId)&&(owner.kind==='group'?owner.telegramChatId<0:owner.kind===undefined&&owner.telegramChatId>0)&&Number.isFinite(Date.parse(owner.pairedAt)));
50
50
  const relay=await read(path.join(control,'heartbeat.json')).catch(absent),host=await read(path.join(control,'host-executor/heartbeat.json')).catch(absent);
51
51
  const fresh=(h,ms)=>Boolean(h&&Number.isFinite(h.at)&&h.at<=Date.now()+1000&&Date.now()-h.at<ms);
52
- const runtimeReady=Boolean(relay?.polling&&fresh(relay,20000)&&fresh(host,15000));
52
+ let isolated=false;
53
+ try { isolated=(await read(path.join(deployment,'agent.json'))).isolation==='isolated'; } catch { isolated=false; }
54
+ const runtimeReady=Boolean(relay?.polling&&fresh(relay,20000)&&(isolated||fresh(host,15000)));
53
55
  let reply=null;
54
56
  if(paired)for(const name of await fs.readdir(path.join(control,'outbox')).catch(e=>{if(e.code==='ENOENT')return [];throw e;})) {
55
57
  if(!name.endsWith('.sent.json'))continue;
@@ -0,0 +1,25 @@
1
+ export type IsolationClass = 'isolated' | 'host-capable'
2
+
3
+ export const isolationTransport = (isolation: IsolationClass): 'local' | 'host' =>
4
+ isolation === 'host-capable' ? 'host' : 'local'
5
+
6
+ export const parseIsolationClass = (value: string): IsolationClass => {
7
+ if (value !== 'isolated' && value !== 'host-capable') throw new Error('Isolation must be isolated or host-capable')
8
+ return value
9
+ }
10
+
11
+ // Isolated runs the native CLI in the relay. Host-capable reuses the installer UID.
12
+ // Unlabeled host transport stays host-capable so existing deployments are not flipped.
13
+ export const resolveIsolation = (env: NodeJS.ProcessEnv = process.env): IsolationClass => {
14
+ const labeled = env.EZ_ISOLATION?.trim()
15
+ if (labeled) parseIsolationClass(labeled)
16
+ const transport = env.EZ_EXECUTOR_TRANSPORT?.trim() || ''
17
+ if (env.EZ_CHANNEL_BACKEND_URL?.trim()) {
18
+ if (labeled === 'host-capable' || transport === 'host') throw new Error('Host-capable isolation requires the host native CLI, not a channel backend')
19
+ return labeled ? parseIsolationClass(labeled) : 'isolated'
20
+ }
21
+ const isolation = labeled ? parseIsolationClass(labeled) : transport === 'host' ? 'host-capable' : 'isolated'
22
+ const expected = isolationTransport(isolation)
23
+ if ((transport || expected) !== expected) throw new Error(`Isolation ${isolation} requires EZ_EXECUTOR_TRANSPORT=${expected}`)
24
+ return isolation
25
+ }
package/src/menu.ts CHANGED
@@ -23,7 +23,7 @@ export const mainKeyboard = () => new InlineKeyboard()
23
23
  const clientLabel = (cli: string) => cli === 'codex-gui' ? 'codex-gui (desktop)' : cli
24
24
 
25
25
  const matchesModel = (preset: AiPreset, model: ModelChoice) =>
26
- model.cli === preset.cli && (preset.model === undefined || model.model === preset.model) &&
26
+ model.cli === preset.cli && model.provider === preset.provider && (preset.model === undefined || model.model === preset.model) &&
27
27
  (preset.effort === undefined || model.efforts.includes(preset.effort))
28
28
 
29
29
  // Short-lived opaque button IDs: no model names or executable arguments from callbacks.
@@ -38,8 +38,11 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
38
38
  assertEffort(preset.effort, preset.model, preset.cli)
39
39
  if (preset.id === initial.id) return
40
40
  if (preset.id.startsWith('detected_')) {
41
- const detected = await discoverDefaults(workspace, { codexHome })
42
- if (!detected.some((p) => p.id === preset.id)) throw new Error('Client settings changed. Refresh available AIs and select the updated choice.')
41
+ if (host) await validateSelection(preset, await catalog(), async name => (await catalog()).some(model => model.cli === name))
42
+ else {
43
+ const detected = await discoverDefaults(workspace, { codexHome })
44
+ if (!detected.some((p) => p.id === preset.id)) throw new Error('Client settings changed. Refresh available AIs and select the updated choice.')
45
+ }
43
46
  } else await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
44
47
  }
45
48
  const buttons = new Map<string, { expires: number; action: (ctx: Context) => Promise<void> }>()
@@ -56,7 +59,7 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
56
59
  const state = (await control.status()).ai
57
60
  if (!state) throw new Error('AI settings not initialized')
58
61
  const current = state.presets.find((p) => p.id === state.selectedId)!
59
- const fresh = current.cli !== preset.cli || Boolean(session && !session.cli)
62
+ const fresh = current.cli !== preset.cli || current.provider !== preset.provider || Boolean(session && !session.cli)
60
63
  if (!await control.selectPreset(preset.id, expectedSession, fresh, guard)) throw new Error('AI binding changed. Refresh available AIs before trying again.')
61
64
  return { preset, fresh }
62
65
  }
@@ -116,9 +119,9 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
116
119
  const state = (await control.status()).ai
117
120
  if (!state) throw new Error('AI settings not initialized')
118
121
  const candidate: AiPreset = { id: randomBytes(8).toString('hex'),
119
- name: `${model.name}${effort ? ` · ${effort}` : ''}`.slice(0, 80), cli: model.cli, model: model.model, effort }
122
+ name: `${model.name}${effort ? ` · ${effort}` : ''}`.slice(0, 80), cli: model.cli, provider:model.provider, model: model.model, effort }
120
123
  const stored = persistedPreset(candidate)
121
- const existing = state.presets.find((preset) => preset.cli === stored.cli && preset.model === stored.model && preset.effort === stored.effort)
124
+ const existing = state.presets.find((preset) => preset.cli === stored.cli && preset.provider === stored.provider && preset.model === stored.model && preset.effort === stored.effort)
122
125
  const preset = existing ?? candidate
123
126
  await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
124
127
  await control.savePreset(preset, guard)
@@ -188,10 +188,49 @@ export function folderMounts(config, record) {
188
188
  }
189
189
  return mounts;
190
190
  }
191
- export async function checkFolders(config, record) {
191
+ async function hostFolderRoots(config, record, home) {
192
+ const configuredHost = config.hostConfig ?? (typeof config.deploymentDir === 'string' && path.isAbsolute(config.deploymentDir) ? path.join(config.deploymentDir, 'host-executor.json') : undefined);
193
+ if (configuredHost === undefined) return null;
194
+ if (!home || typeof configuredHost !== 'string' || !path.isAbsolute(configuredHost)) throw Error('Invalid host folder binding');
195
+ home = realpathSync(home);
196
+ const hostConfig = realpathSync(configuredHost);
197
+ if (hostConfig !== configuredHost || path.basename(hostConfig) !== 'host-executor.json' || childOf(hostConfig, home))
198
+ throw Error('Host folder bindings require an external host config');
199
+ const host = JSON.parse(readFileSync(hostConfig, 'utf8'));
200
+ if (!host || typeof host !== 'object' || !Array.isArray(host.agents)) throw Error('Invalid host folder bindings');
201
+ const candidates = host.agents.filter(agent => agent && typeof agent === 'object' && typeof agent.toolsHome === 'string' && realpathSync(agent.toolsHome) === home);
202
+ if (candidates.length !== 1) throw Error('Host folder binding does not belong to this registry');
203
+ const agent = candidates[0], deployment = path.dirname(hostConfig);
204
+ if (typeof agent.workspace !== 'string' || typeof agent.controlDir !== 'string' ||
205
+ realpathSync(agent.workspace) !== config.workspace || realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
206
+ realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir) ||
207
+ childOf(hostConfig, config.workspace) || childOf(hostConfig, realpathSync(agent.controlDir)))
208
+ throw Error('Invalid host folder binding deployment');
209
+ const configured = agent.pluginFolderRoots;
210
+ if (configured !== undefined && (!configured || typeof configured !== 'object' || Array.isArray(configured)))
211
+ throw Error('Invalid host folder roots');
212
+ for (const [plugin, roots] of Object.entries(configured || {})) {
213
+ id(plugin); strings(roots);
214
+ for (const root of roots) {
215
+ if (!path.isAbsolute(root) || root === '/' || realpathSync(root) !== root) throw Error('Invalid host folder root');
216
+ if (!((await fs.stat(root)).isDirectory())) throw Error('Invalid host folder root');
217
+ }
218
+ }
219
+ const roots = [config.workspace];
220
+ if (agent.sharedWorkspace !== undefined) {
221
+ if (typeof agent.sharedWorkspace !== 'string' || !path.isAbsolute(agent.sharedWorkspace)) throw Error('Invalid shared workspace folder root');
222
+ roots.push(realpathSync(agent.sharedWorkspace));
223
+ }
224
+ roots.push(...(configured?.[record.manifest.id] || []).map(root => realpathSync(root)));
225
+ return roots;
226
+ }
227
+ export async function checkFolders(config, record, home) {
228
+ const allowed = await hostFolderRoots(config, record, home);
192
229
  for (const { source } of folderMounts(config, record)) {
193
230
  if (await fs.realpath(source) !== source || !(await fs.stat(source)).isDirectory())
194
231
  throw Error('Folder source must remain an existing real directory');
232
+ if (allowed && !allowed.some(root => childOf(source, root)))
233
+ throw Error('Folder source is not granted to this agent');
195
234
  }
196
235
  }
197
236
  const childOf = (candidate, parent) => candidate === parent || candidate.startsWith(parent + path.sep);
@@ -346,7 +385,7 @@ export async function prepareCommand(home,alias,args,{revision,exclude,publish,i
346
385
  const record=r.plugins[r.commands[alias]],binding=record?.deployment.commands[alias];
347
386
  if(!binding||r.commands[alias]===exclude)throw Error('Unknown or unavailable registered CLI');
348
387
  if(revision!==undefined&&revision!==record.revision)throw Error('Plugin changed; discover again');
349
- await checkFolders(config,record);
388
+ await checkFolders(config,record,home);
350
389
  const secrets=await json(path.join(home,'packages',record.manifest.id,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
351
390
  await atomic(record.compose,await compose(config,record,secrets,home));
352
391
  const container=`${record.project}-call-${randomUUID()}`;
@@ -431,6 +470,10 @@ export async function main(args) {
431
470
  home=await fs.realpath(home);
432
471
  const config=await json(path.join(home,'config.json'));
433
472
  if(config.schemaVersion!==1 || !path.isAbsolute(config.workspace)) throw Error('Invalid binding');
473
+ if(config.hostConfig || config.deploymentDir) {
474
+ const cwd=await fs.realpath(process.cwd());
475
+ if(!childOf(cwd,config.workspace)) throw Error(`Registry belongs to ${config.workspace}; invoke its launcher only from that owning workspace`);
476
+ }
434
477
  const [group,action,...rest]=args;
435
478
  if(group==='status'){if(args.length!==1)throw Error('Use status without arguments');await registry(home);return emit(await (await import('../updates/status.mjs')).status(home));}
436
479
  if(group==='updates')return emit(await (await import('../updates/control.mjs')).command(home,args.slice(1)));
@@ -439,7 +482,7 @@ export async function main(args) {
439
482
  return (await import('./connection.mjs')).connect(home,rest[0],rest.slice(1),{publish:port,serve:true});
440
483
  }
441
484
  if(group==='tools'&&action==='connect')return (await import('./connection.mjs')).connect(home,rest[0],rest.slice(1));
442
- if(group==='--help'||!group) return emit({commands:['status','updates check|policy|prepare|apply|status','plugins available|catalog-add|list|inspect|install|start|stop|status|logs|uninstall|export','tools list [--details]|exposure|connect <alias> <args...>|serve <host-port:container-port> <alias> <args...>','<registered CLI> ...'],scope:home});
485
+ if(group==='--help'||!group) return emit({commands:['status','updates check|policy|prepare|apply|status','plugins available|catalog-add|list|inspect|install|start|stop|status|logs|uninstall|export','tools list [--details]|exposure|connect <alias> <args...>|serve <host-port:container-port> <alias> <args...>','<registered CLI> ...'],foreignWorkspace:'Relay-bound registries run only from their owning agent workspace',scope:home});
443
486
  if(group==='plugins'&&(!action||args.includes('--help'))) return emit({commands:['available','list','inspect <id>','install <id>','start <id>','stop <id>','status <id>','logs <id>','uninstall <id>','catalog-add <id> --source PATH --revision HASH','export <id> <artifact> --output PATH','folder-bind <id> --service NAME --source PATH --target PATH [--writable]','folder-unbind <id> --service NAME --target PATH','folders <id>','shared-enable <id> <service>','shared-disable <id> <service>','shared-status <id> <service>'],uninstall:'Stops and removes containers/network and unregisters aliases; retains all volumes and secrets. No data deletion flag.',scope:home});
444
487
  if(group==='plugins'||group==='tools') {
445
488
  args=rest;args=args.filter(a=>a!=='--json');
@@ -490,7 +533,7 @@ export async function main(args) {
490
533
  const folders=(settings.folders?.[name] || []).filter(f => f.service !== service || f.target !== target);
491
534
  if(action === 'folder-bind') folders.push({service,source,target,...(writable ? {writable:true} : {})});
492
535
  settings.folders={...settings.folders,[name]:folders};
493
- await checkFolders(settings,latest);
536
+ await checkFolders(settings,latest,home);
494
537
  const secrets=await json(path.join(home,'packages',name,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
495
538
  const generated=await compose(settings,latest,secrets,home);
496
539
  await atomic(path.join(home,'config.json'),settings);
@@ -506,7 +549,7 @@ export async function main(args) {
506
549
  const current = await registry(home), latest = current.plugins[name];
507
550
  if (latest?.revision !== record.revision) throw Error('Plugin changed during shared service request');
508
551
  const currentConfig=await json(path.join(home,'config.json'));
509
- await checkFolders(currentConfig, latest);
552
+ await checkFolders(currentConfig, latest,home);
510
553
  const result = action === 'shared-enable' ? await sharedService(latest, key, 'enable', run) : { state: 'detached' };
511
554
  latest.sharedEnabled = [...new Set([...(latest.sharedEnabled || []).filter(k => k !== key), ...(action === 'shared-enable' ? [key] : [])])];
512
555
  const secrets = await json(path.join(home, 'packages', name, 'secrets.json')).catch(e => { if (e.code === 'ENOENT') return {}; throw e; });
@@ -534,7 +577,7 @@ export async function main(args) {
534
577
  const current=await registry(home);if(current.plugins[name]?.revision!==record.revision)throw Error('Plugin changed during lifecycle request');
535
578
  if(action==='start') {
536
579
  const currentConfig=await json(path.join(home,'config.json'));
537
- await checkFolders(currentConfig,current.plugins[name]);
580
+ await checkFolders(currentConfig,current.plugins[name],home);
538
581
  const secrets=await json(path.join(home,'packages',name,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
539
582
  await atomic(record.compose,await compose(currentConfig,current.plugins[name],secrets,home));
540
583
  }
@@ -20,7 +20,6 @@ export async function ownerConversationContext(controlDir: string, run: RunRecor
20
20
  try { const item = JSON.parse(await readFile(join(controlDir, 'outbox', file), 'utf8')); if (item.chatId === run.chatId && recentResults.some(r => r.id === item.runId)) messages.push({ runId: item.runId, text: item.text, createdAt: item.createdAt }) } catch {}
21
21
  }
22
22
  return { request: run.texts, selectedAI: run.execution?.preset, recent: recent.map(r => ({ id: r.id, texts: r.texts.join('\n').slice(-1600), status: r.status })), replies: messages.sort((a,b) => String(a.createdAt).localeCompare(String(b.createdAt))).slice(-8).map(m => ({...m,text:String(m.text || '').slice(-2400)})),
23
- agent: workspace ? await snapshot(join(workspace, 'SOUL.md')) : undefined, owner: workspace ? await snapshot(join(workspace, 'USER.md')) : undefined,
24
23
  work: await Promise.all(active.map(async r => ({ id: r.id, name: r.scheduled?.id, status: r.status, startedAt: r.startedAt, endedAt: r.endedAt,
25
24
  request: r.texts.join('\n').slice(0,800), exitCode: r.exitCode, failureReason: r.failureReason, interrupted: r.interrupted,
26
25
  hostStarted: await snapshot(join(controlDir, 'host-executor', r.id + '.process.json')) ? true : await snapshot(join(controlDir, 'host-executor', r.id + '.request.json')) ? false : undefined,
package/src/setup.ts CHANGED
@@ -113,7 +113,7 @@ export const runCli = async (): Promise<void> => {
113
113
  const envFilePath = path.resolve(process.cwd(), '.env')
114
114
 
115
115
  if (args[0] === '--help' || args[0] === '-h') {
116
- console.log('Usage: ezenciel-agents-setup configure <executor> [--token-stdin] | service | init | status | <executor>\nconfigure writes private .env paths and seeds missing mind files; it does not start or pair the bot.\n--token-stdin accepts the BotFather token without putting it in command arguments or output.\nservice starts only the Docker Compose relay bound by docker.env in the current directory. It does not install the host-executor service; follow docs/host-service.md. It does not pair the owner.\nFollow docs/setup.md through a real Telegram reply; package installation alone is incomplete.')
116
+ console.log('Usage: ezenciel-agents-setup configure <executor> [--purpose-file PATH] [--token-stdin] | service | init [--purpose-file PATH] | status | <executor>\nFresh workspace setup requires a concise purpose file written from the owner request. configure writes private .env paths and seeds missing mind files; it does not start or pair the bot.\n--token-stdin accepts the BotFather token without putting it in command arguments or output.\nservice starts only the Docker Compose relay bound by docker.env in the current directory. It does not install the host-executor service; follow docs/host-service.md. It does not pair the owner.\nFollow docs/setup.md through a real Telegram reply; package installation alone is incomplete.')
117
117
  return
118
118
  }
119
119
 
@@ -124,10 +124,13 @@ export const runCli = async (): Promise<void> => {
124
124
  }
125
125
 
126
126
  if (args[0] === 'configure') {
127
- if (!args[1] || args.length > 3 || (args[2] && args[2] !== '--token-stdin'))
128
- throw new Error('Usage: ezenciel-agents-setup configure <executor> [--token-stdin]')
127
+ const tail = args.slice(2), purposeAt = tail.indexOf('--purpose-file'), tokenAt = tail.indexOf('--token-stdin')
128
+ const purposeFile = purposeAt >= 0 ? tail[purposeAt + 1] : undefined
129
+ const consumed = new Set([...(purposeAt >= 0 ? [purposeAt, purposeAt + 1] : []), ...(tokenAt >= 0 ? [tokenAt] : [])])
130
+ if (!args[1] || (purposeAt >= 0 && !purposeFile) || tail.some((_, index) => !consumed.has(index)))
131
+ throw new Error('Usage: ezenciel-agents-setup configure <executor> [--purpose-file PATH] [--token-stdin]')
129
132
  let token: string | undefined
130
- if (args[2]) {
133
+ if (tokenAt >= 0) {
131
134
  if (process.stdin.isTTY) throw new Error('Supply the token through stdin, not a command argument.')
132
135
  let input = ''
133
136
  for await (const chunk of process.stdin) {
@@ -136,14 +139,15 @@ export const runCli = async (): Promise<void> => {
136
139
  }
137
140
  token = input.trim()
138
141
  }
139
- console.log(JSON.stringify(await configureInstallation(process.cwd(), args[1], token)))
142
+ console.log(JSON.stringify(await configureInstallation(process.cwd(), args[1], token, purposeFile && path.resolve(purposeFile))))
140
143
  return
141
144
  }
142
145
 
143
146
  if (args[0] === 'init') {
144
- if (args.length !== 1) throw new Error('Usage: ezenciel-agents-setup init (uses EZ_AGENT_WORKSPACE or ./agent)')
147
+ if (args.length !== 1 && !(args.length === 3 && args[1] === '--purpose-file'))
148
+ throw new Error('Usage: ezenciel-agents-setup init [--purpose-file PATH] (uses EZ_AGENT_WORKSPACE or ./agent)')
145
149
  const workspace = path.resolve(process.env.EZ_AGENT_WORKSPACE?.trim() || './agent')
146
- const created = await initializeWorkspace(workspace)
150
+ const created = await initializeWorkspace(workspace, args[2] && path.resolve(args[2]))
147
151
  const config = loadControlConfig()
148
152
  await new ControlStore(config.controlDir, config.pairingTtlMs).syncClientPresets(
149
153
  chatPreset(await readActiveExecutor(envFilePath)), await discoverDefaults(workspace,
@@ -53,7 +53,10 @@ export async function startTaskExecutor(options: ExecutorOptions) {
53
53
  await mkdir(directory, { mode: 0o700 }); await mkdir(home, { mode: 0o700 })
54
54
  const catalog = await promisify(execFile)('codex', ['debug', 'models', '--bundled'], { env: environment, maxBuffer: 4 * 1024 * 1024 })
55
55
  await writeFile(join(temporary, 'models.json'), JSON.stringify(taskModelCatalog(JSON.parse(catalog.stdout))), { mode: 0o600 })
56
- await symlink(join(homedir(), '.codex', 'auth.json'), join(home, 'auth.json'))
56
+ const auth = process.env.EZ_ISOLATION === 'isolated'
57
+ ? join(options.controlDir, 'cli', 'codex', 'auth.json')
58
+ : join(homedir(), '.codex', 'auth.json')
59
+ await symlink(auth, join(home, 'auth.json'))
57
60
  const broker = [process.execPath, '--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)),
58
61
  fileURLToPath(new URL('./task-mcp.ts', import.meta.url)), options.controlDir, options.runId, options.toolsHome ?? '', JSON.stringify(task.capabilities ?? [])]
59
62
  const prompt = JSON.stringify({event: run.external ? 'correspondence_received' : 'task_activated', taskId: run.taskId})
@@ -44,6 +44,14 @@ export async function check(home) {
44
44
  }
45
45
  return results;
46
46
  }
47
+ const canonical=value=>Array.isArray(value)?value.map(canonical):value&&typeof value==='object'?Object.fromEntries(Object.keys(value).sort().map(key=>[key,canonical(value[key])])):value;
48
+ const same=(left,right)=>JSON.stringify(canonical(left))===JSON.stringify(canonical(right));
49
+ function additiveCommands(previous,next) {
50
+ const before={...previous,commands:{}},after={...next,commands:{}};
51
+ if(!same(before,after))return false;
52
+ for(const [name,command] of Object.entries(previous.commands||{}))if(!same(next.commands?.[name],command))return false;
53
+ return Object.values(next.commands||{}).every(command=>next.services?.[command.service]);
54
+ }
47
55
  export async function eligibility(home,target,root,automatic) {
48
56
  const old=await installed(home,target),pkg=await read(path.join(root,'package.json')),kind=target==='main'?'main':'plugin';
49
57
  if(pkg.name!==old.pkg.name)throw Error('Package identity mismatch');
@@ -54,7 +62,9 @@ export async function eligibility(home,target,root,automatic) {
54
62
  if(kind==='plugin') {
55
63
  const s=await snapshot(root);revision=s.revision;
56
64
  if(s.manifest.id!==target||s.manifest.version!==pkg.version)throw Error('Plugin identity/version mismatch');
57
- if(JSON.stringify(s.deployment)!==JSON.stringify(old.record.deployment))throw Error('Deployment permissions/layout changed; a separately reviewed migration is required');
65
+ if(!additiveCommands(old.record.deployment,s.deployment))throw Error('Deployment permissions/layout changed; a separately reviewed migration is required');
66
+ const registry=await read(path.join(home,'registry.json'));
67
+ for(const alias of Object.keys(s.manifest.commands))if(registry.commands[alias]&&registry.commands[alias]!==target)throw Error('CLI alias collision');
58
68
  }else {
59
69
  for(const file of ['compose.yaml','compose.whatsapp.yaml'])if(!Buffer.from(await fs.readFile(path.join(root,file))).equals(await fs.readFile(path.join(old.root,file))))throw Error('Runtime deployment changed; a separately reviewed migration is required');
60
70
  }
@@ -111,7 +111,7 @@ export async function perform(home,job,hooks) {
111
111
  const secrets=await read(path.join(home,'packages',job.target,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
112
112
  const s=await snapshot(root),candidate={...old,source:root,revision:s.revision,manifest:s.manifest,deployment:s.deployment,sharedRevisions:s.sharedRevisions};
113
113
  for (const key of old.sharedEnabled || []) if (sharedIdentity(old, key).fingerprint !== sharedIdentity(candidate, key).fingerprint) throw Error('Shared worker changed; disable this client and coordinate an explicit shared worker upgrade before updating');
114
- await checkFolders(config,candidate);
114
+ await checkFolders(config,candidate,home);
115
115
  const stage={...candidate,compose:path.join(dir,'compose.json')};await atomic(stage.compose,await compose(config,stage,secrets,home));
116
116
  for(const [service,spec] of Object.entries(stage.deployment.services))await run('docker',[...pluginArgs(stage),spec.image?'pull':'build',service]);
117
117
  const running=Boolean((await run('docker',[...pluginArgs(old),'ps','-q'])).trim());
@@ -122,7 +122,9 @@ export async function perform(home,job,hooks) {
122
122
  for(const name of volumes)await backupVolume(run,old,name,backup);
123
123
  await atomic(old.compose,await compose(config,candidate,secrets,home));
124
124
  if(running)await run('docker',[...pluginArgs(candidate),'up','-d','--wait','--wait-timeout','90','--no-build']);
125
- r.plugins[job.target]=candidate;await atomic(path.join(home,'registry.json'),r);
125
+ r.plugins[job.target]=candidate;
126
+ for(const alias of Object.keys(candidate.manifest.commands))r.commands[alias]=job.target;
127
+ await atomic(path.join(home,'registry.json'),r);
126
128
  job.runtimeVerified=running;
127
129
  }
128
130
  job.status='completed';job.endedAt=new Date().toISOString();await save();return job;