@jc_stack/ez-agents 0.1.0-beta.33 → 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 (65) hide show
  1. package/.env.example +4 -0
  2. package/CHANGELOG.md +25 -0
  3. package/Dockerfile +4 -0
  4. package/README.md +26 -18
  5. package/THIRD_PARTY_NOTICES.md +4 -4
  6. package/compose.yaml +2 -1
  7. package/docker/smoke.mjs +6 -3
  8. package/docs/application-channel.md +6 -0
  9. package/docs/architecture/ai-selection.md +3 -1
  10. package/docs/docker-runtime.md +27 -10
  11. package/docs/host-service.md +17 -6
  12. package/docs/plugins.md +15 -0
  13. package/docs/repair.md +1 -1
  14. package/docs/setup.md +88 -14
  15. package/docs/standalone-cli.md +6 -5
  16. package/package.json +1 -1
  17. package/scripts/smoke-scheduler.ts +2 -1
  18. package/src/agent-install.ts +20 -6
  19. package/src/ai-cli.ts +9 -7
  20. package/src/ai.ts +7 -7
  21. package/src/application-channel.ts +10 -10
  22. package/src/codex-session.ts +5 -3
  23. package/src/config.ts +6 -1
  24. package/src/control-state.ts +12 -1
  25. package/src/executor.ts +91 -9
  26. package/src/host-executor.ts +25 -6
  27. package/src/index.ts +1 -0
  28. package/src/install-config.ts +2 -2
  29. package/src/install-tools.mjs +3 -1
  30. package/src/isolation.ts +25 -0
  31. package/src/menu.ts +9 -6
  32. package/src/plugins/manager.mjs +49 -6
  33. package/src/reply-context.ts +0 -1
  34. package/src/setup.ts +11 -7
  35. package/src/task-executor.ts +4 -1
  36. package/src/updates/runtime.mjs +1 -1
  37. package/src/updates/supervisor.mjs +17 -3
  38. package/src/workspace-confine.ts +38 -0
  39. package/src/workspace.ts +23 -13
  40. package/templates/agent/AGENTS.md +4 -14
  41. package/templates/agent-guidance.md +13 -110
  42. package/templates/agent-purpose.md +1 -0
  43. package/test/agent-guidance.test.ts +8 -12
  44. package/test/agent-install.test.ts +34 -0
  45. package/test/ai-cli.test.ts +18 -0
  46. package/test/ai.test.ts +48 -0
  47. package/test/application-channel.test.ts +5 -2
  48. package/test/application-controls.test.ts +56 -3
  49. package/test/application-only.test.ts +2 -0
  50. package/test/codex-context.test.ts +61 -1
  51. package/test/codex-session.test.ts +6 -1
  52. package/test/config.test.ts +17 -2
  53. package/test/engine-handoff.test.ts +5 -2
  54. package/test/executor.test.ts +38 -1
  55. package/test/failure.test.ts +6 -0
  56. package/test/host-executor.test.ts +6 -0
  57. package/test/install-config.test.ts +17 -6
  58. package/test/install-tools.test.mjs +6 -0
  59. package/test/plugin-manager.test.mjs +24 -3
  60. package/test/scheduler.test.ts +2 -2
  61. package/test/updates.test.mjs +10 -1
  62. package/test/workspace-confine.test.ts +47 -0
  63. package/test/workspace.test.ts +23 -10
  64. package/templates/agent/SOUL.md +0 -11
  65. package/templates/agent/USER.md +0 -5
@@ -2,7 +2,9 @@ import { mkdir, readFile, writeFile, rm, readdir } from 'node:fs/promises'
2
2
  import { isAbsolute, join, resolve, dirname } from 'node:path'
3
3
  import { parseArgs } from 'node:util'
4
4
  import { executorKey } from './executor.js'
5
+ import { isolationTransport, parseIsolationClass, type IsolationClass } from './isolation.js'
5
6
  import { fileURLToPath } from 'node:url'
7
+ import { validateCodexProvider, type CodexProviderBinding } from './executor.js'
6
8
 
7
9
  // Recorded by the installing CLI once; agent creation inherits this binding.
8
10
  export const installationCli = async (root: string, installerCli?: string): Promise<string> => {
@@ -30,18 +32,25 @@ export const installationCli = async (root: string, installerCli?: string): Prom
30
32
  }
31
33
 
32
34
  export const createAgent = async (options: {
33
- root: string; hostRoot: string; composeFile: string; name: string; purpose: string; token: string; cli?: string; image?: string
35
+ root: string; hostRoot: string; composeFile: string; name: string; purpose: string; token: string; cli?: string; image?: string; isolation?: string; codexProvider?: CodexProviderBinding
34
36
  }) => {
35
37
  const { root, hostRoot, composeFile, name, purpose, token } = options
38
+ const requestedIsolation = options.isolation?.trim()
39
+ if (requestedIsolation) parseIsolationClass(requestedIsolation)
36
40
  if (!/^[a-z][a-z0-9-]{0,39}$/.test(name)) throw new Error('Use an agent name of 1–40 lowercase letters, digits or hyphens, starting with a letter.')
37
41
  if (![root, hostRoot, composeFile].every(p => isAbsolute(p) && !/[\r\n\0']/.test(p)))
38
42
  throw new Error('Installation paths must be absolute and contain no newline or single quote.')
39
- if (!purpose.trim() || purpose.length > 12000) throw new Error('Supply a purpose of 1–12000 characters.')
43
+ if (!purpose.trim() || purpose.length > 2000) throw new Error('Supply a concise purpose of 1–2000 characters.')
40
44
  if (!/^\d{5,}:[A-Za-z0-9_-]{20,}$/.test(token)) throw new Error('Supply the BotFather token through stdin.')
41
45
  const image=options.image||'ezenciel-agents:local'
42
46
  if(!/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,255}$/.test(image))throw new Error('Invalid relay image reference')
43
47
  await mkdir(root, {recursive:true, mode:0o700})
44
48
  const cli = await installationCli(root, options.cli)
49
+ const isolation: IsolationClass = requestedIsolation ? parseIsolationClass(requestedIsolation) : cli === 'codex' ? 'isolated' : 'host-capable'
50
+ if (isolation === 'isolated' && cli !== 'codex') throw new Error(`Isolated execution is unavailable for ${cli}; use host-capable isolation`)
51
+ const transport = isolationTransport(isolation)
52
+ const codexProvider = options.codexProvider ? validateCodexProvider(options.codexProvider) : undefined
53
+ if (codexProvider && cli !== 'codex') throw new Error('A Codex provider requires the Codex installation CLI')
45
54
  const directory = join(root, name), deploymentDir = join(hostRoot, name)
46
55
  // Exclusive directory creation: a repeated name never overwrites another agent.
47
56
  await mkdir(directory, {mode:0o700})
@@ -54,6 +63,9 @@ export const createAgent = async (options: {
54
63
  EZ_RELAY_ENV_FILE: join(deploymentDir, 'relay.env'),
55
64
  EZ_AGENT_PURPOSE_FILE: join(deploymentDir, 'purpose.md'),
56
65
  EZ_EXECUTOR_CLI: cli,
66
+ EZ_ISOLATION: isolation,
67
+ EZ_EXECUTOR_TRANSPORT: transport,
68
+ ...(isolation === 'isolated' && cli === 'codex' ? { EZ_CODEX_SANDBOX: 'external' } : {}),
57
69
  EZ_AGENT_WORKSPACE: join(deploymentDir, 'mind'),
58
70
  EZ_CONTROL_DIR: join(deploymentDir, 'control'),
59
71
  EZ_WHATSAPP_IPC_VOLUME: `${project}-whatsapp-ipc`,
@@ -64,8 +76,8 @@ export const createAgent = async (options: {
64
76
  await writeFile(join(directory, 'purpose.md'), purpose.trim()+'\n', {mode:0o644, flag:'wx'})
65
77
  await mkdir(join(directory,'mind'),{mode:0o700})
66
78
  await mkdir(join(directory,'control'),{mode:0o700})
67
- const agent = {name, project, deploymentDir, purpose:purpose.trim(), executor:cli}
68
- const host = {cli,agents:[{name,workspace:join(deploymentDir,'mind'),controlDir:join(deploymentDir,'control'),binDir:join(dirname(composeFile),'bin')}]}
79
+ const agent = {name, project, deploymentDir, purpose:purpose.trim(), executor:cli, isolation}
80
+ const host = {cli,isolation,agents:[{name,workspace:join(deploymentDir,'mind'),controlDir:join(deploymentDir,'control'),binDir:join(dirname(composeFile),'bin'),...(codexProvider?{codexProviders:[codexProvider]}:{})}]}
69
81
  await writeFile(join(directory,'host-executor.json'),JSON.stringify(host,null,2)+'\n',{mode:0o600,flag:'wx'})
70
82
  await writeFile(join(directory, 'agent.json'), JSON.stringify(agent,null,2)+'\n', {mode:0o600, flag:'wx'})
71
83
  return agent
@@ -84,13 +96,15 @@ export const listAgents = async (root: string) => {
84
96
 
85
97
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
86
98
  try {
87
- const {values:v} = parseArgs({options:{name:{type:'string'},purpose:{type:'string'},'host-root':{type:'string'},'compose-file':{type:'string'},'relay-image':{type:'string'},list:{type:'boolean'},cli:{type:'string'},'register-cli':{type:'string'}}})
99
+ const {values:v} = parseArgs({options:{name:{type:'string'},purpose:{type:'string'},isolation:{type:'string'},'host-root':{type:'string'},'compose-file':{type:'string'},'relay-image':{type:'string'},list:{type:'boolean'},cli:{type:'string'},'register-cli':{type:'string'},'codex-provider':{type:'string'},'codex-provider-name':{type:'string'},'codex-base-url':{type:'string'},'codex-env-key':{type:'string'},'codex-model':{type:'string',multiple:true}}})
88
100
  if (v['register-cli']) console.log(JSON.stringify({cli:await installationCli('/installations',v['register-cli'])}))
89
101
  else if (v.list) console.log(JSON.stringify(await listAgents('/installations')))
90
102
  else {
91
103
  let token=''
92
104
  for await (const chunk of process.stdin) { token+=chunk; if(token.length>512) throw new Error('Token input is too long.') }
93
- console.log(JSON.stringify(await createAgent({root:'/installations',hostRoot:v['host-root']||'',composeFile:v['compose-file']||'',name:v.name||'',purpose:v.purpose||'',token:token.trim(),cli:v.cli||'',image:v['relay-image']})))
105
+ const providerValues=[v['codex-provider'],v['codex-provider-name'],v['codex-base-url'],v['codex-env-key'],v['codex-model']]
106
+ const codexProvider=providerValues.some(Boolean)?validateCodexProvider({id:v['codex-provider']||'',name:v['codex-provider-name']||v['codex-provider']||'',baseUrl:v['codex-base-url']||'',envKey:v['codex-env-key']||'',models:v['codex-model']||[]}):undefined
107
+ console.log(JSON.stringify(await createAgent({root:'/installations',hostRoot:v['host-root']||'',composeFile:v['compose-file']||'',name:v.name||'',purpose:v.purpose||'',token:token.trim(),cli:v.cli||'',image:v['relay-image'],isolation:v.isolation,codexProvider})))
94
108
  }
95
109
  } catch (error) { console.error((error as Error).message); process.exitCode=1 }
96
110
  }
package/src/ai-cli.ts CHANGED
@@ -1,27 +1,29 @@
1
1
  import { parseArgs } from 'node:util'
2
2
  import { randomBytes } from 'node:crypto'
3
3
  import { join } from 'node:path'
4
+ import { readFile } from 'node:fs/promises'
4
5
  import { readModels, validateSelection, type AiPreset } from './ai.js'
5
6
  import { ControlStore } from './control-state.js'
6
7
 
7
- const {values,positionals}=parseArgs({allowPositionals:true,options:{help:{type:'boolean'},cli:{type:'string'},model:{type:'string'},effort:{type:'string'}}})
8
+ const {values,positionals}=parseArgs({allowPositionals:true,options:{help:{type:'boolean'},cli:{type:'string'},provider:{type:'string'},model:{type:'string'},effort:{type:'string'}}})
8
9
  if(values.help){
9
- console.log('Usage: ezenciel-agents-ai list | select --cli <installed-cli> [--model <model>] [--effort <effort>]\nChoose only values returned by list. Selection affects subsequent messages; queued work and the installation default are unchanged.')
10
+ console.log('Usage: ezenciel-agents-ai list | select --cli <installed-cli> [--provider <provider>] [--model <model>] [--effort <effort>]\nChoose only values returned by list. Selection affects subsequent messages; queued work and the installation default are unchanged.')
10
11
  process.exit(0)
11
12
  }
12
13
  if (!process.env.EZ_CONTROL_DIR) throw new Error('Use this agent’s bound control directory')
13
- const catalog=await readModels(undefined,undefined,join(process.env.EZ_CONTROL_DIR,'cli','codex'))
14
+ const catalog=await readFile(join(process.env.EZ_CONTROL_DIR,'host-executor','models.json'),'utf8')
15
+ .then(text=>JSON.parse(text)).catch(()=>readModels(undefined,undefined,join(process.env.EZ_CONTROL_DIR!,'cli','codex')))
14
16
  if(positionals[0]==='list')console.log(JSON.stringify(catalog))
15
17
  else if(positionals[0]==='select'){
16
- const preset:AiPreset={id:randomBytes(8).toString('hex'),name:[values.model||values.cli,values.effort].filter(Boolean).join(' · '),cli:values.cli||'',model:values.model,effort:values.effort}
18
+ const preset:AiPreset={id:randomBytes(8).toString('hex'),name:[values.provider,values.model||values.cli,values.effort].filter(Boolean).join(' · '),cli:values.cli||'',provider:values.provider,model:values.model,effort:values.effort}
17
19
  await validateSelection(preset,catalog)
18
20
  const control=new ControlStore(process.env.EZ_CONTROL_DIR,900000)
19
21
  const state=await control.status()
20
22
  if(!state.ai)throw new Error('Agent AI settings are not initialized')
21
- const existing=state.ai.presets.find(p=>p.cli===preset.cli&&p.model===preset.model&&p.effort===preset.effort)
23
+ const existing=state.ai.presets.find(p=>p.cli===preset.cli&&p.provider===preset.provider&&p.model===preset.model&&p.effort===preset.effort)
22
24
  const selected=existing||preset
23
25
  if(!existing)await control.savePreset(selected)
24
26
  const current=state.ai.presets.find(p=>p.id===state.ai!.selectedId)!
25
- await control.selectPreset(selected.id,state.activeSession?.sessionId??null,current.cli!==selected.cli||Boolean(state.activeSession&&!state.activeSession.cli))
27
+ await control.selectPreset(selected.id,state.activeSession?.sessionId??null,current.cli!==selected.cli||current.provider!==selected.provider||Boolean(state.activeSession&&!state.activeSession.cli))
26
28
  console.log(JSON.stringify({selected,defaultUnchanged:true,applies:'subsequent messages; queued work keeps its captured choice'}))
27
- }else throw new Error('Use list or select --cli <installed-cli> [--model <model>] [--effort <effort>]')
29
+ }else throw new Error('Use list or select --cli <installed-cli> [--provider <provider>] [--model <model>] [--effort <effort>]')
package/src/ai.ts CHANGED
@@ -6,26 +6,26 @@ import { join, delimiter } from 'node:path'
6
6
  import { executorKey, resolveExecutor } from './executor.js'
7
7
  import { desktopCodexPath } from './desktop-bridge.js'
8
8
 
9
- export type AiPreset = { id: string; name: string; cli: string; model?: string; effort?: string }
9
+ export type AiPreset = { id: string; name: string; cli: string; provider?: string; model?: string; effort?: string }
10
10
  export type ExecutionChoice = { sessionId: string; preset: AiPreset }
11
- export type ModelChoice = { cli: string; model?: string; name: string; efforts: string[] }
11
+ export type ModelChoice = { cli: string; provider?: string; model?: string; name: string; efforts: string[] }
12
12
  const safe = (s: unknown): s is string => typeof s === 'string' && /^[a-zA-Z0-9_./:-]{1,160}$/.test(s)
13
13
  export const isPreset = (p: unknown): p is AiPreset => {
14
14
  if (!p || typeof p !== 'object') return false
15
15
  const v = p as AiPreset
16
16
  return safe(v.id) && typeof v.name === 'string' && v.name.length > 0 && v.name.length <= 80 &&
17
17
  ['grok', 'codex', 'codex-gui', 'claude', 'opencode', 'agy'].includes(v.cli) &&
18
- (v.model === undefined || safe(v.model)) && (v.effort === undefined || safe(v.effort))
18
+ (v.provider === undefined || safe(v.provider)) && (v.model === undefined || safe(v.model)) && (v.effort === undefined || safe(v.effort))
19
19
  }
20
20
  export const isExecutionChoice = (v: unknown): v is ExecutionChoice => {
21
21
  const c = v as ExecutionChoice | undefined
22
22
  return Boolean(c && /^[0-9a-f-]{36}$/i.test(c.sessionId) && isPreset(c.preset))
23
23
  }
24
- export const presetLabel = (p: AiPreset) => `${p.cli} · ${p.model || 'client default'} · ${p.effort || 'default effort'}`
24
+ export const presetLabel = (p: AiPreset) => `${p.cli}${p.provider ? ` (${p.provider})` : ''} · ${p.model || 'client default'} · ${p.effort || 'default effort'}`
25
25
  // The seed delegates model selection to the native client. Project its resolved
26
26
  // settings for status without pinning future conversations to that snapshot.
27
27
  export const statusPreset = (preset: AiPreset, discovered: AiPreset[]): AiPreset =>
28
- ['codex','codex-gui'].includes(preset.cli) && !preset.model && !preset.effort
28
+ ['codex','codex-gui'].includes(preset.cli) && !preset.provider && !preset.model && !preset.effort
29
29
  ? discovered.find((candidate) => candidate.cli === preset.cli) ?? preset
30
30
  : preset
31
31
  export const initialPreset = (cli: string): AiPreset => {
@@ -87,8 +87,8 @@ export const readModels = async (home = homedir(), available = installed, codexH
87
87
  export const validateSelection = async (p: AiPreset, catalog: ModelChoice[], available = installed): Promise<void> => {
88
88
  assertEffort(p.effort, p.model, p.cli)
89
89
  if (!isPreset(p) || !(await available(p.cli))) throw new Error('This CLI is not installed.')
90
- if (!p.model && !p.effort && p.cli !== 'agy') return
91
- const model = catalog.find((m) => m.cli === p.cli && m.model === p.model)
90
+ if (!p.provider && !p.model && !p.effort && p.cli !== 'agy') return
91
+ const model = catalog.find((m) => m.cli === p.cli && m.provider === p.provider && m.model === p.model)
92
92
  if (!model || (p.effort && !model.efforts.includes(p.effort)))
93
93
  throw new Error('This model/effort is not in the installed client catalog. Refresh the client and try again; no fallback was selected.')
94
94
  }
@@ -105,10 +105,10 @@ export class ApplicationChannel {
105
105
  const owner = await this.sharedBinding(bindingId)
106
106
  const controls = this.options.aiControls
107
107
  if (!controls) throw new Error('Application controls unavailable')
108
- const value = input as {action?: unknown; expectedSession?: unknown; sessionId?: unknown; presetId?: unknown; cli?: unknown; model?: unknown; effort?: unknown}
108
+ const value = input as {action?: unknown; expectedSession?: unknown; sessionId?: unknown; presetId?: unknown; cli?: unknown; provider?: unknown; model?: unknown; effort?: unknown}
109
109
  if (!value || !['new','switch','select','model'].includes(String(value.action)) ||
110
110
  !(value.expectedSession === null || (typeof value.expectedSession === 'string' && /^[a-f0-9-]{36}$/.test(value.expectedSession)))) throw new Error('Invalid application control request')
111
- const fields = {new:[],switch:['sessionId'],select:['presetId'],model:['cli','model','effort']}[value.action as 'new'|'switch'|'select'|'model']!
111
+ const fields = {new:[],switch:['sessionId'],select:['presetId'],model:['cli','provider','model','effort']}[value.action as 'new'|'switch'|'select'|'model']!
112
112
  if (Object.keys(value).some(key => !['action','expectedSession',...fields].includes(key))) throw new Error('Invalid application control fields')
113
113
  const control = new ControlStore(this.options.controlDir, 900000)
114
114
  const expected = value.expectedSession as string | null
@@ -131,7 +131,7 @@ export class ApplicationChannel {
131
131
  await controls.select(preset, expected, guard)
132
132
  }
133
133
  if (value.action === 'model') {
134
- const model = (await controls.catalog()).find(item => item.cli === value.cli && item.model === value.model)
134
+ const model = (await controls.catalog()).find(item => item.cli === value.cli && item.provider === value.provider && item.model === value.model)
135
135
  if (!model || (value.effort !== undefined && (typeof value.effort !== 'string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
136
136
  await this.sharedBinding(bindingId)
137
137
  const preset = await controls.saveSelection(model, value.effort as string | undefined, guard)
@@ -160,10 +160,10 @@ export class ApplicationChannel {
160
160
  const owner = await this.sharedBinding(bindingId, false)
161
161
  const controls = this.options.aiControls
162
162
  if (!controls) throw new Error('Application controls unavailable')
163
- const value = input as {action?:unknown; expectedSession?:unknown; presetId?:unknown; cli?:unknown; model?:unknown; effort?:unknown}
163
+ const value = input as {action?:unknown; expectedSession?:unknown; presetId?:unknown; cli?:unknown; provider?:unknown; model?:unknown; effort?:unknown}
164
164
  if (!value || !['new','select','model'].includes(String(value.action)) ||
165
165
  !(value.expectedSession===null || (typeof value.expectedSession==='string' && /^[a-f0-9-]{36}$/.test(value.expectedSession)))) throw new Error('Invalid application control request')
166
- const fields = {new:[],select:['presetId'],model:['cli','model','effort']}[value.action as 'new'|'select'|'model']!
166
+ const fields = {new:[],select:['presetId'],model:['cli','provider','model','effort']}[value.action as 'new'|'select'|'model']!
167
167
  if (Object.keys(value).some(key=>!['action','expectedSession',...fields].includes(key))) throw new Error('Invalid application control fields')
168
168
  const hashedScope = applicationScope(bindingId,scope)
169
169
  const guard:ControlGuard = {owner,applicationScope:hashedScope,expectedSession:value.expectedSession as string|null,
@@ -180,7 +180,7 @@ export class ApplicationChannel {
180
180
  await controls.validate(preset)
181
181
  }
182
182
  if (value.action==='model') {
183
- const model = (await controls.catalog()).find(item=>item.cli===value.cli && item.model===value.model)
183
+ const model = (await controls.catalog()).find(item=>item.cli===value.cli && item.provider===value.provider && item.model===value.model)
184
184
  if (!model || model.cli==='agy' || (value.effort!==undefined && (typeof value.effort!=='string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
185
185
  preset = await controls.saveSelection(model,value.effort as string|undefined,guard)
186
186
  }
@@ -195,7 +195,7 @@ export class ApplicationChannel {
195
195
  if ('followTelegram' in rest) throw new Error('Invalid application request: choose one conversation option')
196
196
  input = {...rest, followTelegram: followOwner}
197
197
  }
198
- const value = input as { requestId?: unknown; scope?: unknown; text?: unknown; attachment?: { name?: unknown; data?: unknown }; context?: Record<string, unknown>; expectedNativeSessionId?: unknown; activateTelegram?: unknown; followTelegram?: unknown; ai?: { cli?: unknown; model?: unknown; effort?: unknown } }
198
+ const value = input as { requestId?: unknown; scope?: unknown; text?: unknown; attachment?: { name?: unknown; data?: unknown }; context?: Record<string, unknown>; expectedNativeSessionId?: unknown; activateTelegram?: unknown; followTelegram?: unknown; ai?: { cli?: unknown; provider?: unknown; model?: unknown; effort?: unknown } }
199
199
  if (!value || !applicationId(value.requestId) || !applicationId(value.scope) || typeof value.text !== 'string' || (!value.text.trim() && value.attachment === undefined) || value.text.length > 16000 || Object.keys(value).some(key => !['requestId','scope','text','attachment','context','expectedNativeSessionId','activateTelegram','followTelegram','ai'].includes(key))) throw new Error('Invalid application request')
200
200
  if (value.expectedNativeSessionId !== undefined && (typeof value.expectedNativeSessionId !== 'string' || !/^[a-zA-Z0-9_-]{1,160}$/.test(value.expectedNativeSessionId))) throw new Error('Invalid application native session assertion')
201
201
  if (value.activateTelegram !== undefined && typeof value.activateTelegram !== 'boolean') throw new Error('Invalid application Telegram activation')
@@ -204,7 +204,7 @@ export class ApplicationChannel {
204
204
  let requestedPreset: AiPreset | undefined
205
205
  if (value.ai !== undefined) {
206
206
  const candidate = { ...value.ai, id: 'application', name: 'Application selection' }
207
- if (!value.ai || Object.keys(value.ai).some(key => !['cli', 'model', 'effort'].includes(key)) || !isPreset(candidate)) throw new Error('Invalid application AI selection')
207
+ if (!value.ai || Object.keys(value.ai).some(key => !['cli', 'provider', 'model', 'effort'].includes(key)) || !isPreset(candidate)) throw new Error('Invalid application AI selection')
208
208
  assertEffort(candidate.effort, candidate.model, candidate.cli)
209
209
  requestedPreset = candidate
210
210
  }
@@ -227,7 +227,7 @@ export class ApplicationChannel {
227
227
  const existing = await this.runs.get(id)
228
228
  if (existing) {
229
229
  await this.bindings.authorize(existing)
230
- if (requestedPreset && (existing.execution?.preset.cli !== requestedPreset.cli || existing.execution?.preset.model !== requestedPreset.model || existing.execution?.preset.effort !== requestedPreset.effort)) throw new Error('Application request ID conflicts with prior AI selection')
230
+ if (requestedPreset && (existing.execution?.preset.cli !== requestedPreset.cli || existing.execution?.preset.provider !== requestedPreset.provider || existing.execution?.preset.model !== requestedPreset.model || existing.execution?.preset.effort !== requestedPreset.effort)) throw new Error('Application request ID conflicts with prior AI selection')
231
231
  if (Boolean(existing.application?.followTelegram) !== Boolean(value.followTelegram) || existing.application?.scope !== value.scope || (existing.application?.inputText ?? existing.texts[0]) !== value.text || existing.application?.attachmentHash !== attachmentHash) throw new Error('Application request ID conflicts with prior scope or text')
232
232
  return existing // Retried context never replaces already admitted capabilities.
233
233
  }
@@ -252,7 +252,7 @@ export class ApplicationChannel {
252
252
  await this.bindings.authorize(run)
253
253
  const state = await new ControlStore(this.options.controlDir, 900000).status()
254
254
  const session = state.activeSession?.sessionId === run.execution?.sessionId ? state.activeSession : state.sessions?.find(item => item.sessionId === run.execution?.sessionId)
255
- return { ...(session ? { sessionId: session.sessionId, nativeSessionId: session.nativeSessionId, cli: session.cli } : {}), ...(run.scheduled?.originRunId ? {originRunId:run.scheduled.originRunId} : {}), id: run.id, scope: (run.application ?? run.delivery)!.scope, status: run.status, messages: await this.runs.applicationMessages(run.id), approvals:await this.runs.applicationApprovals(run.id), ...(run.status === 'failed' ? { error: 'Agent execution failed; inspect the core run' } : {}) }
255
+ return { ...(session ? { sessionId: session.sessionId, nativeSessionId: session.nativeSessionId, cli: session.cli } : {}), ...(run.execution?.preset ? {preset:run.execution.preset} : {}), ...(run.scheduled?.originRunId ? {originRunId:run.scheduled.originRunId} : {}), id: run.id, scope: (run.application ?? run.delivery)!.scope, status: run.status, messages: await this.runs.applicationMessages(run.id), approvals:await this.runs.applicationApprovals(run.id), ...(run.status === 'failed' ? { error: 'Agent execution failed; inspect the core run' } : {}) }
256
256
  }
257
257
  async deliver(run: RunRecord, item: OutboxItem): Promise<void> {
258
258
  await this.bindings.authorize(run)
@@ -3,9 +3,9 @@ import { spawn, type ChildProcess } from 'node:child_process'
3
3
  import { createInterface } from 'node:readline'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import path from 'node:path'
6
- import { terminateJob } from './executor.js'
6
+ import { terminateJob, validateCodexProvider, type CodexProviderBinding } from './executor.js'
7
7
 
8
- type Options = {workspace:string;controlDir:string;toolsHome?:string;sharedWorkspace?:string;additionalWorkspaces?:string[];model?:string;effort?:string;prompt:string;codexSandbox?:'external'}
8
+ type Options = {workspace:string;controlDir:string;toolsHome?:string;sharedWorkspace?:string;additionalWorkspaces?:string[];model?:string;effort?:string;prompt:string;codexSandbox?:'external';codexProvider?:CodexProviderBinding}
9
9
  type Message = {id?:number;method?:string;params?:any;result?:any;error?:{message:string;code?:number}}
10
10
 
11
11
  // Keep Codex's native session alive. Codex itself starts goal continuation turns;
@@ -13,6 +13,7 @@ type Message = {id?:number;method?:string;params?:any;result?:any;error?:{messag
13
13
  export async function runCodexSession(options:Options, io:{launch?:()=>ChildProcess;emit?:(line:string)=>void}={}):Promise<number> {
14
14
  options = executionDefaults('codex', options)
15
15
  if (options.codexSandbox !== undefined && options.codexSandbox !== 'external') throw new Error('Invalid Codex sandbox selection')
16
+ const provider = options.codexProvider ? validateCodexProvider(options.codexProvider) : undefined
16
17
  const child=io.launch?.() ?? spawn('codex',['app-server','--stdio','--disable','memories','--enable','skip_host_skill_discovery'],{cwd:options.workspace,env:process.env,stdio:['pipe','pipe','pipe']})
17
18
  const emit=io.emit ?? (line=>process.stdout.write(line+'\n'))
18
19
  let id=0,threadId:string|undefined,activeTurn:string|undefined,finished=false,sawTurn=false,hadGoal=false
@@ -73,7 +74,8 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
73
74
  const result=await request('thread/start',{
74
75
  cwd:options.workspace,approvalPolicy:'never',sandbox:options.codexSandbox === 'external' ? 'danger-full-access' : 'workspace-write',model:options.model,
75
76
  config:{project_root_markers:['AGENTS.md','.git'],'sandbox_workspace_write.writable_roots':[options.controlDir,...(options.toolsHome?[options.toolsHome]:[]),...(options.sharedWorkspace?[options.sharedWorkspace]:[]),...(options.additionalWorkspaces ?? [])],
76
- 'sandbox_workspace_write.network_access':Boolean(options.toolsHome),...(options.effort?{model_reasoning_effort:options.effort}:{})},
77
+ 'sandbox_workspace_write.network_access':Boolean(options.toolsHome),...(options.effort?{model_reasoning_effort:options.effort}:{}),
78
+ ...(provider?{model_provider:provider.id,[`model_providers.${provider.id}`]:{name:provider.name,base_url:provider.baseUrl,env_key:provider.envKey,wire_api:'responses',supports_websockets:false}}:{})},
77
79
  })
78
80
  threadId=result.thread?.id
79
81
  if(!threadId)throw new Error('Codex did not return a native thread ID')
package/src/config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parseWebLauncher, type WebLauncher } from './web-launcher.js'
2
2
  import { repairEnabled } from './repair-policy.js'
3
+ import { isolationTransport, resolveIsolation, type IsolationClass } from './isolation.js'
3
4
  import path from 'node:path'
4
5
  import { homedir } from 'node:os'
5
6
 
@@ -15,6 +16,7 @@ export type Config = ControlConfig & {
15
16
  telegramBotToken: string
16
17
  workspace: string
17
18
  executorTimeoutMs: number
19
+ isolation?: IsolationClass
18
20
  codexSandbox?: 'external'
19
21
  codexAutoCompactTokens?: number
20
22
  executorCli: string
@@ -56,9 +58,11 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
56
58
 
57
59
  if (env.EZ_CHANNEL_BACKEND_URL && !env.EZ_CHANNEL_BACKEND_TOKEN?.trim()) throw new Error('EZ_CHANNEL_BACKEND_TOKEN is required')
58
60
  if (env.EZ_APPLICATION_PORT && env.EZ_CHANNEL_BACKEND_URL) throw new Error('Application input requires the native Ez executor, not a channel backend')
61
+ const isolation = resolveIsolation(env)
62
+ const transport = env.EZ_EXECUTOR_TRANSPORT?.trim() || isolationTransport(isolation)
59
63
  const codexSandbox = env.EZ_CODEX_SANDBOX?.trim()
60
64
  if (codexSandbox && codexSandbox !== 'external') throw new Error('EZ_CODEX_SANDBOX must be external or unset')
61
- if (codexSandbox && (env.EZ_CHANNEL_BACKEND_URL || env.EZ_EXECUTOR_TRANSPORT !== 'local')) throw new Error('External Codex sandbox requires native local execution')
65
+ if (codexSandbox && (env.EZ_CHANNEL_BACKEND_URL || transport !== 'local')) throw new Error('External Codex sandbox requires native local execution')
62
66
  const pagerDutyRoutingKey = env.PAGERDUTY_ROUTING_KEY?.trim()
63
67
  const pagerDutyStocksHealthUrl = env.EZ_PAGERDUTY_STOCKS_HEALTH_URL?.trim()
64
68
  if (pagerDutyStocksHealthUrl && !pagerDutyRoutingKey)
@@ -78,6 +82,7 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
78
82
  repairEnabled: repairEnabled(env.EZ_REPAIR_ENABLED),
79
83
  workspace: path.resolve(env.EZ_AGENT_WORKSPACE?.trim() || './agent'),
80
84
  executorTimeoutMs: 0,
85
+ isolation,
81
86
  codexSandbox: codexSandbox === 'external' ? 'external' : undefined,
82
87
  codexAutoCompactTokens: !env.EZ_CODEX_AUTO_COMPACT_TOKENS?.trim() ? undefined : positiveInteger(env.EZ_CODEX_AUTO_COMPACT_TOKENS, 'EZ_CODEX_AUTO_COMPACT_TOKENS'),
83
88
  executorCli: env.EZ_EXECUTOR_CLI?.trim() || 'agy',
@@ -470,7 +470,18 @@ export class ControlStore {
470
470
  const preset = state.ai.presets.find((p) => p.id === state.ai!.selectedId)!
471
471
  state.activeSession ??= { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli }
472
472
  if (!state.activeSession.cli && !state.activeSession.hasStarted) state.activeSession.cli = preset.cli
473
- if (state.activeSession.cli === preset.cli) state.activeSession.preset = preset
473
+ if (state.activeSession.cli === preset.cli) {
474
+ const previous = state.activeSession.preset
475
+ // Same CLI, different provider/model: web/Telegram must not resume the old native thread.
476
+ // Undefined is the native client default and is a distinct selection.
477
+ if (state.activeSession.hasStarted && previous &&
478
+ (previous.provider !== preset.provider || previous.model !== preset.model)) {
479
+ ;(state.sessions ??= []).push(state.activeSession)
480
+ state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli, preset }
481
+ } else {
482
+ state.activeSession.preset = preset
483
+ }
484
+ }
474
485
  if (!state.activeSession.title && !state.activeSession.hasStarted && title?.trim())
475
486
  state.activeSession.title = title.replace(/\s+/g, ' ').trim().slice(0, 80)
476
487
  await this.writeState(state)
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,