@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.
- package/.env.example +4 -0
- package/CHANGELOG.md +25 -0
- package/Dockerfile +4 -0
- package/README.md +26 -18
- package/THIRD_PARTY_NOTICES.md +4 -4
- package/compose.yaml +2 -1
- package/docker/smoke.mjs +6 -3
- package/docs/application-channel.md +6 -0
- package/docs/architecture/ai-selection.md +3 -1
- package/docs/docker-runtime.md +27 -10
- package/docs/host-service.md +17 -6
- package/docs/plugins.md +15 -0
- package/docs/repair.md +1 -1
- package/docs/setup.md +88 -14
- package/docs/standalone-cli.md +6 -5
- package/package.json +1 -1
- package/scripts/smoke-scheduler.ts +2 -1
- package/src/agent-install.ts +20 -6
- package/src/ai-cli.ts +9 -7
- package/src/ai.ts +7 -7
- package/src/application-channel.ts +10 -10
- package/src/codex-session.ts +5 -3
- package/src/config.ts +6 -1
- package/src/control-state.ts +12 -1
- package/src/executor.ts +91 -9
- package/src/host-executor.ts +25 -6
- package/src/index.ts +1 -0
- package/src/install-config.ts +2 -2
- package/src/install-tools.mjs +3 -1
- package/src/isolation.ts +25 -0
- package/src/menu.ts +9 -6
- package/src/plugins/manager.mjs +49 -6
- package/src/reply-context.ts +0 -1
- package/src/setup.ts +11 -7
- package/src/task-executor.ts +4 -1
- package/src/updates/runtime.mjs +1 -1
- package/src/updates/supervisor.mjs +17 -3
- package/src/workspace-confine.ts +38 -0
- package/src/workspace.ts +23 -13
- package/templates/agent/AGENTS.md +4 -14
- package/templates/agent-guidance.md +13 -110
- package/templates/agent-purpose.md +1 -0
- package/test/agent-guidance.test.ts +8 -12
- package/test/agent-install.test.ts +34 -0
- package/test/ai-cli.test.ts +18 -0
- package/test/ai.test.ts +48 -0
- package/test/application-channel.test.ts +5 -2
- package/test/application-controls.test.ts +56 -3
- package/test/application-only.test.ts +2 -0
- package/test/codex-context.test.ts +61 -1
- package/test/codex-session.test.ts +6 -1
- package/test/config.test.ts +17 -2
- package/test/engine-handoff.test.ts +5 -2
- package/test/executor.test.ts +38 -1
- package/test/failure.test.ts +6 -0
- package/test/host-executor.test.ts +6 -0
- package/test/install-config.test.ts +17 -6
- package/test/install-tools.test.mjs +6 -0
- package/test/plugin-manager.test.mjs +24 -3
- package/test/scheduler.test.ts +2 -2
- package/test/updates.test.mjs +10 -1
- package/test/workspace-confine.test.ts +47 -0
- package/test/workspace.test.ts +23 -10
- package/templates/agent/SOUL.md +0 -11
- package/templates/agent/USER.md +0 -5
package/src/agent-install.ts
CHANGED
|
@@ -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 >
|
|
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
|
-
|
|
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
|
|
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)
|
package/src/codex-session.ts
CHANGED
|
@@ -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 ||
|
|
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',
|
package/src/control-state.ts
CHANGED
|
@@ -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)
|
|
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
|
|
265
|
-
|
|
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
|
-
//
|
|
286
|
-
//
|
|
287
|
-
const authLinks =
|
|
288
|
-
|
|
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({...
|
|
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
|
package/src/host-executor.ts
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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.
|
|
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,
|