@jc_stack/ez-agents 0.1.0-beta.32 → 0.1.0-beta.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +4 -0
- package/AGENTS.md +46 -31
- package/CHANGELOG.md +33 -0
- package/CONTRIBUTING.md +29 -26
- 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/plugin-contributions.md +4 -1
- 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/docs/upgrades.md +6 -4
- 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/control.mjs +11 -1
- package/src/updates/runtime.mjs +4 -2
- 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 +34 -2
- 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/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,
|
package/src/install-config.ts
CHANGED
|
@@ -11,7 +11,7 @@ const inside = (parent: string, child: string) => {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
// Configuration only: no provider calls, owner approval, service start, or AI turn.
|
|
14
|
-
export const configureInstallation = async (directory: string, cli: string, token?: string) => {
|
|
14
|
+
export const configureInstallation = async (directory: string, cli: string, token?: string, purposeFile?: string) => {
|
|
15
15
|
let executor: string
|
|
16
16
|
try { executor = resolveExecutor(cli).name }
|
|
17
17
|
catch { throw new Error('Unsupported executor. See ezenciel-agents-setup status.') }
|
|
@@ -44,7 +44,7 @@ export const configureInstallation = async (directory: string, cli: string, toke
|
|
|
44
44
|
if (!quote) throw new Error('Existing .env has a value requiring manual quoting; it was not changed.')
|
|
45
45
|
return `${key}=${quote}${value}${quote}\n`
|
|
46
46
|
}).join('')
|
|
47
|
-
const created = await initializeWorkspace(workspace)
|
|
47
|
+
const created = await initializeWorkspace(workspace, purposeFile)
|
|
48
48
|
const temporary = `${envFile}.${randomUUID()}.tmp`
|
|
49
49
|
try {
|
|
50
50
|
await writeFile(temporary, content, { mode: 0o600, flag: 'wx' })
|
package/src/install-tools.mjs
CHANGED
|
@@ -49,7 +49,9 @@ export async function installationStatus(deployment) {
|
|
|
49
49
|
const paired=Boolean(owner&&Number.isSafeInteger(owner.telegramUserId)&&owner.telegramUserId>0&&Number.isSafeInteger(owner.telegramChatId)&&(owner.kind==='group'?owner.telegramChatId<0:owner.kind===undefined&&owner.telegramChatId>0)&&Number.isFinite(Date.parse(owner.pairedAt)));
|
|
50
50
|
const relay=await read(path.join(control,'heartbeat.json')).catch(absent),host=await read(path.join(control,'host-executor/heartbeat.json')).catch(absent);
|
|
51
51
|
const fresh=(h,ms)=>Boolean(h&&Number.isFinite(h.at)&&h.at<=Date.now()+1000&&Date.now()-h.at<ms);
|
|
52
|
-
|
|
52
|
+
let isolated=false;
|
|
53
|
+
try { isolated=(await read(path.join(deployment,'agent.json'))).isolation==='isolated'; } catch { isolated=false; }
|
|
54
|
+
const runtimeReady=Boolean(relay?.polling&&fresh(relay,20000)&&(isolated||fresh(host,15000)));
|
|
53
55
|
let reply=null;
|
|
54
56
|
if(paired)for(const name of await fs.readdir(path.join(control,'outbox')).catch(e=>{if(e.code==='ENOENT')return [];throw e;})) {
|
|
55
57
|
if(!name.endsWith('.sent.json'))continue;
|
package/src/isolation.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type IsolationClass = 'isolated' | 'host-capable'
|
|
2
|
+
|
|
3
|
+
export const isolationTransport = (isolation: IsolationClass): 'local' | 'host' =>
|
|
4
|
+
isolation === 'host-capable' ? 'host' : 'local'
|
|
5
|
+
|
|
6
|
+
export const parseIsolationClass = (value: string): IsolationClass => {
|
|
7
|
+
if (value !== 'isolated' && value !== 'host-capable') throw new Error('Isolation must be isolated or host-capable')
|
|
8
|
+
return value
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Isolated runs the native CLI in the relay. Host-capable reuses the installer UID.
|
|
12
|
+
// Unlabeled host transport stays host-capable so existing deployments are not flipped.
|
|
13
|
+
export const resolveIsolation = (env: NodeJS.ProcessEnv = process.env): IsolationClass => {
|
|
14
|
+
const labeled = env.EZ_ISOLATION?.trim()
|
|
15
|
+
if (labeled) parseIsolationClass(labeled)
|
|
16
|
+
const transport = env.EZ_EXECUTOR_TRANSPORT?.trim() || ''
|
|
17
|
+
if (env.EZ_CHANNEL_BACKEND_URL?.trim()) {
|
|
18
|
+
if (labeled === 'host-capable' || transport === 'host') throw new Error('Host-capable isolation requires the host native CLI, not a channel backend')
|
|
19
|
+
return labeled ? parseIsolationClass(labeled) : 'isolated'
|
|
20
|
+
}
|
|
21
|
+
const isolation = labeled ? parseIsolationClass(labeled) : transport === 'host' ? 'host-capable' : 'isolated'
|
|
22
|
+
const expected = isolationTransport(isolation)
|
|
23
|
+
if ((transport || expected) !== expected) throw new Error(`Isolation ${isolation} requires EZ_EXECUTOR_TRANSPORT=${expected}`)
|
|
24
|
+
return isolation
|
|
25
|
+
}
|
package/src/menu.ts
CHANGED
|
@@ -23,7 +23,7 @@ export const mainKeyboard = () => new InlineKeyboard()
|
|
|
23
23
|
const clientLabel = (cli: string) => cli === 'codex-gui' ? 'codex-gui (desktop)' : cli
|
|
24
24
|
|
|
25
25
|
const matchesModel = (preset: AiPreset, model: ModelChoice) =>
|
|
26
|
-
model.cli === preset.cli && (preset.model === undefined || model.model === preset.model) &&
|
|
26
|
+
model.cli === preset.cli && model.provider === preset.provider && (preset.model === undefined || model.model === preset.model) &&
|
|
27
27
|
(preset.effort === undefined || model.efforts.includes(preset.effort))
|
|
28
28
|
|
|
29
29
|
// Short-lived opaque button IDs: no model names or executable arguments from callbacks.
|
|
@@ -38,8 +38,11 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
38
38
|
assertEffort(preset.effort, preset.model, preset.cli)
|
|
39
39
|
if (preset.id === initial.id) return
|
|
40
40
|
if (preset.id.startsWith('detected_')) {
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
if (host) await validateSelection(preset, await catalog(), async name => (await catalog()).some(model => model.cli === name))
|
|
42
|
+
else {
|
|
43
|
+
const detected = await discoverDefaults(workspace, { codexHome })
|
|
44
|
+
if (!detected.some((p) => p.id === preset.id)) throw new Error('Client settings changed. Refresh available AIs and select the updated choice.')
|
|
45
|
+
}
|
|
43
46
|
} else await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
|
|
44
47
|
}
|
|
45
48
|
const buttons = new Map<string, { expires: number; action: (ctx: Context) => Promise<void> }>()
|
|
@@ -56,7 +59,7 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
56
59
|
const state = (await control.status()).ai
|
|
57
60
|
if (!state) throw new Error('AI settings not initialized')
|
|
58
61
|
const current = state.presets.find((p) => p.id === state.selectedId)!
|
|
59
|
-
const fresh = current.cli !== preset.cli || Boolean(session && !session.cli)
|
|
62
|
+
const fresh = current.cli !== preset.cli || current.provider !== preset.provider || Boolean(session && !session.cli)
|
|
60
63
|
if (!await control.selectPreset(preset.id, expectedSession, fresh, guard)) throw new Error('AI binding changed. Refresh available AIs before trying again.')
|
|
61
64
|
return { preset, fresh }
|
|
62
65
|
}
|
|
@@ -116,9 +119,9 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
116
119
|
const state = (await control.status()).ai
|
|
117
120
|
if (!state) throw new Error('AI settings not initialized')
|
|
118
121
|
const candidate: AiPreset = { id: randomBytes(8).toString('hex'),
|
|
119
|
-
name: `${model.name}${effort ? ` · ${effort}` : ''}`.slice(0, 80), cli: model.cli, model: model.model, effort }
|
|
122
|
+
name: `${model.name}${effort ? ` · ${effort}` : ''}`.slice(0, 80), cli: model.cli, provider:model.provider, model: model.model, effort }
|
|
120
123
|
const stored = persistedPreset(candidate)
|
|
121
|
-
const existing = state.presets.find((preset) => preset.cli === stored.cli && preset.model === stored.model && preset.effort === stored.effort)
|
|
124
|
+
const existing = state.presets.find((preset) => preset.cli === stored.cli && preset.provider === stored.provider && preset.model === stored.model && preset.effort === stored.effort)
|
|
122
125
|
const preset = existing ?? candidate
|
|
123
126
|
await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
|
|
124
127
|
await control.savePreset(preset, guard)
|
package/src/plugins/manager.mjs
CHANGED
|
@@ -188,10 +188,49 @@ export function folderMounts(config, record) {
|
|
|
188
188
|
}
|
|
189
189
|
return mounts;
|
|
190
190
|
}
|
|
191
|
-
|
|
191
|
+
async function hostFolderRoots(config, record, home) {
|
|
192
|
+
const configuredHost = config.hostConfig ?? (typeof config.deploymentDir === 'string' && path.isAbsolute(config.deploymentDir) ? path.join(config.deploymentDir, 'host-executor.json') : undefined);
|
|
193
|
+
if (configuredHost === undefined) return null;
|
|
194
|
+
if (!home || typeof configuredHost !== 'string' || !path.isAbsolute(configuredHost)) throw Error('Invalid host folder binding');
|
|
195
|
+
home = realpathSync(home);
|
|
196
|
+
const hostConfig = realpathSync(configuredHost);
|
|
197
|
+
if (hostConfig !== configuredHost || path.basename(hostConfig) !== 'host-executor.json' || childOf(hostConfig, home))
|
|
198
|
+
throw Error('Host folder bindings require an external host config');
|
|
199
|
+
const host = JSON.parse(readFileSync(hostConfig, 'utf8'));
|
|
200
|
+
if (!host || typeof host !== 'object' || !Array.isArray(host.agents)) throw Error('Invalid host folder bindings');
|
|
201
|
+
const candidates = host.agents.filter(agent => agent && typeof agent === 'object' && typeof agent.toolsHome === 'string' && realpathSync(agent.toolsHome) === home);
|
|
202
|
+
if (candidates.length !== 1) throw Error('Host folder binding does not belong to this registry');
|
|
203
|
+
const agent = candidates[0], deployment = path.dirname(hostConfig);
|
|
204
|
+
if (typeof agent.workspace !== 'string' || typeof agent.controlDir !== 'string' ||
|
|
205
|
+
realpathSync(agent.workspace) !== config.workspace || realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
|
|
206
|
+
realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir) ||
|
|
207
|
+
childOf(hostConfig, config.workspace) || childOf(hostConfig, realpathSync(agent.controlDir)))
|
|
208
|
+
throw Error('Invalid host folder binding deployment');
|
|
209
|
+
const configured = agent.pluginFolderRoots;
|
|
210
|
+
if (configured !== undefined && (!configured || typeof configured !== 'object' || Array.isArray(configured)))
|
|
211
|
+
throw Error('Invalid host folder roots');
|
|
212
|
+
for (const [plugin, roots] of Object.entries(configured || {})) {
|
|
213
|
+
id(plugin); strings(roots);
|
|
214
|
+
for (const root of roots) {
|
|
215
|
+
if (!path.isAbsolute(root) || root === '/' || realpathSync(root) !== root) throw Error('Invalid host folder root');
|
|
216
|
+
if (!((await fs.stat(root)).isDirectory())) throw Error('Invalid host folder root');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const roots = [config.workspace];
|
|
220
|
+
if (agent.sharedWorkspace !== undefined) {
|
|
221
|
+
if (typeof agent.sharedWorkspace !== 'string' || !path.isAbsolute(agent.sharedWorkspace)) throw Error('Invalid shared workspace folder root');
|
|
222
|
+
roots.push(realpathSync(agent.sharedWorkspace));
|
|
223
|
+
}
|
|
224
|
+
roots.push(...(configured?.[record.manifest.id] || []).map(root => realpathSync(root)));
|
|
225
|
+
return roots;
|
|
226
|
+
}
|
|
227
|
+
export async function checkFolders(config, record, home) {
|
|
228
|
+
const allowed = await hostFolderRoots(config, record, home);
|
|
192
229
|
for (const { source } of folderMounts(config, record)) {
|
|
193
230
|
if (await fs.realpath(source) !== source || !(await fs.stat(source)).isDirectory())
|
|
194
231
|
throw Error('Folder source must remain an existing real directory');
|
|
232
|
+
if (allowed && !allowed.some(root => childOf(source, root)))
|
|
233
|
+
throw Error('Folder source is not granted to this agent');
|
|
195
234
|
}
|
|
196
235
|
}
|
|
197
236
|
const childOf = (candidate, parent) => candidate === parent || candidate.startsWith(parent + path.sep);
|
|
@@ -346,7 +385,7 @@ export async function prepareCommand(home,alias,args,{revision,exclude,publish,i
|
|
|
346
385
|
const record=r.plugins[r.commands[alias]],binding=record?.deployment.commands[alias];
|
|
347
386
|
if(!binding||r.commands[alias]===exclude)throw Error('Unknown or unavailable registered CLI');
|
|
348
387
|
if(revision!==undefined&&revision!==record.revision)throw Error('Plugin changed; discover again');
|
|
349
|
-
await checkFolders(config,record);
|
|
388
|
+
await checkFolders(config,record,home);
|
|
350
389
|
const secrets=await json(path.join(home,'packages',record.manifest.id,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
|
|
351
390
|
await atomic(record.compose,await compose(config,record,secrets,home));
|
|
352
391
|
const container=`${record.project}-call-${randomUUID()}`;
|
|
@@ -431,6 +470,10 @@ export async function main(args) {
|
|
|
431
470
|
home=await fs.realpath(home);
|
|
432
471
|
const config=await json(path.join(home,'config.json'));
|
|
433
472
|
if(config.schemaVersion!==1 || !path.isAbsolute(config.workspace)) throw Error('Invalid binding');
|
|
473
|
+
if(config.hostConfig || config.deploymentDir) {
|
|
474
|
+
const cwd=await fs.realpath(process.cwd());
|
|
475
|
+
if(!childOf(cwd,config.workspace)) throw Error(`Registry belongs to ${config.workspace}; invoke its launcher only from that owning workspace`);
|
|
476
|
+
}
|
|
434
477
|
const [group,action,...rest]=args;
|
|
435
478
|
if(group==='status'){if(args.length!==1)throw Error('Use status without arguments');await registry(home);return emit(await (await import('../updates/status.mjs')).status(home));}
|
|
436
479
|
if(group==='updates')return emit(await (await import('../updates/control.mjs')).command(home,args.slice(1)));
|
|
@@ -439,7 +482,7 @@ export async function main(args) {
|
|
|
439
482
|
return (await import('./connection.mjs')).connect(home,rest[0],rest.slice(1),{publish:port,serve:true});
|
|
440
483
|
}
|
|
441
484
|
if(group==='tools'&&action==='connect')return (await import('./connection.mjs')).connect(home,rest[0],rest.slice(1));
|
|
442
|
-
if(group==='--help'||!group) return emit({commands:['status','updates check|policy|prepare|apply|status','plugins available|catalog-add|list|inspect|install|start|stop|status|logs|uninstall|export','tools list [--details]|exposure|connect <alias> <args...>|serve <host-port:container-port> <alias> <args...>','<registered CLI> ...'],scope:home});
|
|
485
|
+
if(group==='--help'||!group) return emit({commands:['status','updates check|policy|prepare|apply|status','plugins available|catalog-add|list|inspect|install|start|stop|status|logs|uninstall|export','tools list [--details]|exposure|connect <alias> <args...>|serve <host-port:container-port> <alias> <args...>','<registered CLI> ...'],foreignWorkspace:'Relay-bound registries run only from their owning agent workspace',scope:home});
|
|
443
486
|
if(group==='plugins'&&(!action||args.includes('--help'))) return emit({commands:['available','list','inspect <id>','install <id>','start <id>','stop <id>','status <id>','logs <id>','uninstall <id>','catalog-add <id> --source PATH --revision HASH','export <id> <artifact> --output PATH','folder-bind <id> --service NAME --source PATH --target PATH [--writable]','folder-unbind <id> --service NAME --target PATH','folders <id>','shared-enable <id> <service>','shared-disable <id> <service>','shared-status <id> <service>'],uninstall:'Stops and removes containers/network and unregisters aliases; retains all volumes and secrets. No data deletion flag.',scope:home});
|
|
444
487
|
if(group==='plugins'||group==='tools') {
|
|
445
488
|
args=rest;args=args.filter(a=>a!=='--json');
|
|
@@ -490,7 +533,7 @@ export async function main(args) {
|
|
|
490
533
|
const folders=(settings.folders?.[name] || []).filter(f => f.service !== service || f.target !== target);
|
|
491
534
|
if(action === 'folder-bind') folders.push({service,source,target,...(writable ? {writable:true} : {})});
|
|
492
535
|
settings.folders={...settings.folders,[name]:folders};
|
|
493
|
-
await checkFolders(settings,latest);
|
|
536
|
+
await checkFolders(settings,latest,home);
|
|
494
537
|
const secrets=await json(path.join(home,'packages',name,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
|
|
495
538
|
const generated=await compose(settings,latest,secrets,home);
|
|
496
539
|
await atomic(path.join(home,'config.json'),settings);
|
|
@@ -506,7 +549,7 @@ export async function main(args) {
|
|
|
506
549
|
const current = await registry(home), latest = current.plugins[name];
|
|
507
550
|
if (latest?.revision !== record.revision) throw Error('Plugin changed during shared service request');
|
|
508
551
|
const currentConfig=await json(path.join(home,'config.json'));
|
|
509
|
-
await checkFolders(currentConfig, latest);
|
|
552
|
+
await checkFolders(currentConfig, latest,home);
|
|
510
553
|
const result = action === 'shared-enable' ? await sharedService(latest, key, 'enable', run) : { state: 'detached' };
|
|
511
554
|
latest.sharedEnabled = [...new Set([...(latest.sharedEnabled || []).filter(k => k !== key), ...(action === 'shared-enable' ? [key] : [])])];
|
|
512
555
|
const secrets = await json(path.join(home, 'packages', name, 'secrets.json')).catch(e => { if (e.code === 'ENOENT') return {}; throw e; });
|
|
@@ -534,7 +577,7 @@ export async function main(args) {
|
|
|
534
577
|
const current=await registry(home);if(current.plugins[name]?.revision!==record.revision)throw Error('Plugin changed during lifecycle request');
|
|
535
578
|
if(action==='start') {
|
|
536
579
|
const currentConfig=await json(path.join(home,'config.json'));
|
|
537
|
-
await checkFolders(currentConfig,current.plugins[name]);
|
|
580
|
+
await checkFolders(currentConfig,current.plugins[name],home);
|
|
538
581
|
const secrets=await json(path.join(home,'packages',name,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
|
|
539
582
|
await atomic(record.compose,await compose(currentConfig,current.plugins[name],secrets,home));
|
|
540
583
|
}
|
package/src/reply-context.ts
CHANGED
|
@@ -20,7 +20,6 @@ export async function ownerConversationContext(controlDir: string, run: RunRecor
|
|
|
20
20
|
try { const item = JSON.parse(await readFile(join(controlDir, 'outbox', file), 'utf8')); if (item.chatId === run.chatId && recentResults.some(r => r.id === item.runId)) messages.push({ runId: item.runId, text: item.text, createdAt: item.createdAt }) } catch {}
|
|
21
21
|
}
|
|
22
22
|
return { request: run.texts, selectedAI: run.execution?.preset, recent: recent.map(r => ({ id: r.id, texts: r.texts.join('\n').slice(-1600), status: r.status })), replies: messages.sort((a,b) => String(a.createdAt).localeCompare(String(b.createdAt))).slice(-8).map(m => ({...m,text:String(m.text || '').slice(-2400)})),
|
|
23
|
-
agent: workspace ? await snapshot(join(workspace, 'SOUL.md')) : undefined, owner: workspace ? await snapshot(join(workspace, 'USER.md')) : undefined,
|
|
24
23
|
work: await Promise.all(active.map(async r => ({ id: r.id, name: r.scheduled?.id, status: r.status, startedAt: r.startedAt, endedAt: r.endedAt,
|
|
25
24
|
request: r.texts.join('\n').slice(0,800), exitCode: r.exitCode, failureReason: r.failureReason, interrupted: r.interrupted,
|
|
26
25
|
hostStarted: await snapshot(join(controlDir, 'host-executor', r.id + '.process.json')) ? true : await snapshot(join(controlDir, 'host-executor', r.id + '.request.json')) ? false : undefined,
|
package/src/setup.ts
CHANGED
|
@@ -113,7 +113,7 @@ export const runCli = async (): Promise<void> => {
|
|
|
113
113
|
const envFilePath = path.resolve(process.cwd(), '.env')
|
|
114
114
|
|
|
115
115
|
if (args[0] === '--help' || args[0] === '-h') {
|
|
116
|
-
console.log('Usage: ezenciel-agents-setup configure <executor> [--token-stdin] | service | init | status | <executor>\
|
|
116
|
+
console.log('Usage: ezenciel-agents-setup configure <executor> [--purpose-file PATH] [--token-stdin] | service | init [--purpose-file PATH] | status | <executor>\nFresh workspace setup requires a concise purpose file written from the owner request. configure writes private .env paths and seeds missing mind files; it does not start or pair the bot.\n--token-stdin accepts the BotFather token without putting it in command arguments or output.\nservice starts only the Docker Compose relay bound by docker.env in the current directory. It does not install the host-executor service; follow docs/host-service.md. It does not pair the owner.\nFollow docs/setup.md through a real Telegram reply; package installation alone is incomplete.')
|
|
117
117
|
return
|
|
118
118
|
}
|
|
119
119
|
|
|
@@ -124,10 +124,13 @@ export const runCli = async (): Promise<void> => {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
if (args[0] === 'configure') {
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
const tail = args.slice(2), purposeAt = tail.indexOf('--purpose-file'), tokenAt = tail.indexOf('--token-stdin')
|
|
128
|
+
const purposeFile = purposeAt >= 0 ? tail[purposeAt + 1] : undefined
|
|
129
|
+
const consumed = new Set([...(purposeAt >= 0 ? [purposeAt, purposeAt + 1] : []), ...(tokenAt >= 0 ? [tokenAt] : [])])
|
|
130
|
+
if (!args[1] || (purposeAt >= 0 && !purposeFile) || tail.some((_, index) => !consumed.has(index)))
|
|
131
|
+
throw new Error('Usage: ezenciel-agents-setup configure <executor> [--purpose-file PATH] [--token-stdin]')
|
|
129
132
|
let token: string | undefined
|
|
130
|
-
if (
|
|
133
|
+
if (tokenAt >= 0) {
|
|
131
134
|
if (process.stdin.isTTY) throw new Error('Supply the token through stdin, not a command argument.')
|
|
132
135
|
let input = ''
|
|
133
136
|
for await (const chunk of process.stdin) {
|
|
@@ -136,14 +139,15 @@ export const runCli = async (): Promise<void> => {
|
|
|
136
139
|
}
|
|
137
140
|
token = input.trim()
|
|
138
141
|
}
|
|
139
|
-
console.log(JSON.stringify(await configureInstallation(process.cwd(), args[1], token)))
|
|
142
|
+
console.log(JSON.stringify(await configureInstallation(process.cwd(), args[1], token, purposeFile && path.resolve(purposeFile))))
|
|
140
143
|
return
|
|
141
144
|
}
|
|
142
145
|
|
|
143
146
|
if (args[0] === 'init') {
|
|
144
|
-
if (args.length !== 1
|
|
147
|
+
if (args.length !== 1 && !(args.length === 3 && args[1] === '--purpose-file'))
|
|
148
|
+
throw new Error('Usage: ezenciel-agents-setup init [--purpose-file PATH] (uses EZ_AGENT_WORKSPACE or ./agent)')
|
|
145
149
|
const workspace = path.resolve(process.env.EZ_AGENT_WORKSPACE?.trim() || './agent')
|
|
146
|
-
const created = await initializeWorkspace(workspace)
|
|
150
|
+
const created = await initializeWorkspace(workspace, args[2] && path.resolve(args[2]))
|
|
147
151
|
const config = loadControlConfig()
|
|
148
152
|
await new ControlStore(config.controlDir, config.pairingTtlMs).syncClientPresets(
|
|
149
153
|
chatPreset(await readActiveExecutor(envFilePath)), await discoverDefaults(workspace,
|
package/src/task-executor.ts
CHANGED
|
@@ -53,7 +53,10 @@ export async function startTaskExecutor(options: ExecutorOptions) {
|
|
|
53
53
|
await mkdir(directory, { mode: 0o700 }); await mkdir(home, { mode: 0o700 })
|
|
54
54
|
const catalog = await promisify(execFile)('codex', ['debug', 'models', '--bundled'], { env: environment, maxBuffer: 4 * 1024 * 1024 })
|
|
55
55
|
await writeFile(join(temporary, 'models.json'), JSON.stringify(taskModelCatalog(JSON.parse(catalog.stdout))), { mode: 0o600 })
|
|
56
|
-
|
|
56
|
+
const auth = process.env.EZ_ISOLATION === 'isolated'
|
|
57
|
+
? join(options.controlDir, 'cli', 'codex', 'auth.json')
|
|
58
|
+
: join(homedir(), '.codex', 'auth.json')
|
|
59
|
+
await symlink(auth, join(home, 'auth.json'))
|
|
57
60
|
const broker = [process.execPath, '--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)),
|
|
58
61
|
fileURLToPath(new URL('./task-mcp.ts', import.meta.url)), options.controlDir, options.runId, options.toolsHome ?? '', JSON.stringify(task.capabilities ?? [])]
|
|
59
62
|
const prompt = JSON.stringify({event: run.external ? 'correspondence_received' : 'task_activated', taskId: run.taskId})
|
package/src/updates/control.mjs
CHANGED
|
@@ -44,6 +44,14 @@ export async function check(home) {
|
|
|
44
44
|
}
|
|
45
45
|
return results;
|
|
46
46
|
}
|
|
47
|
+
const canonical=value=>Array.isArray(value)?value.map(canonical):value&&typeof value==='object'?Object.fromEntries(Object.keys(value).sort().map(key=>[key,canonical(value[key])])):value;
|
|
48
|
+
const same=(left,right)=>JSON.stringify(canonical(left))===JSON.stringify(canonical(right));
|
|
49
|
+
function additiveCommands(previous,next) {
|
|
50
|
+
const before={...previous,commands:{}},after={...next,commands:{}};
|
|
51
|
+
if(!same(before,after))return false;
|
|
52
|
+
for(const [name,command] of Object.entries(previous.commands||{}))if(!same(next.commands?.[name],command))return false;
|
|
53
|
+
return Object.values(next.commands||{}).every(command=>next.services?.[command.service]);
|
|
54
|
+
}
|
|
47
55
|
export async function eligibility(home,target,root,automatic) {
|
|
48
56
|
const old=await installed(home,target),pkg=await read(path.join(root,'package.json')),kind=target==='main'?'main':'plugin';
|
|
49
57
|
if(pkg.name!==old.pkg.name)throw Error('Package identity mismatch');
|
|
@@ -54,7 +62,9 @@ export async function eligibility(home,target,root,automatic) {
|
|
|
54
62
|
if(kind==='plugin') {
|
|
55
63
|
const s=await snapshot(root);revision=s.revision;
|
|
56
64
|
if(s.manifest.id!==target||s.manifest.version!==pkg.version)throw Error('Plugin identity/version mismatch');
|
|
57
|
-
if(
|
|
65
|
+
if(!additiveCommands(old.record.deployment,s.deployment))throw Error('Deployment permissions/layout changed; a separately reviewed migration is required');
|
|
66
|
+
const registry=await read(path.join(home,'registry.json'));
|
|
67
|
+
for(const alias of Object.keys(s.manifest.commands))if(registry.commands[alias]&®istry.commands[alias]!==target)throw Error('CLI alias collision');
|
|
58
68
|
}else {
|
|
59
69
|
for(const file of ['compose.yaml','compose.whatsapp.yaml'])if(!Buffer.from(await fs.readFile(path.join(root,file))).equals(await fs.readFile(path.join(old.root,file))))throw Error('Runtime deployment changed; a separately reviewed migration is required');
|
|
60
70
|
}
|
package/src/updates/runtime.mjs
CHANGED
|
@@ -111,7 +111,7 @@ export async function perform(home,job,hooks) {
|
|
|
111
111
|
const secrets=await read(path.join(home,'packages',job.target,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
|
|
112
112
|
const s=await snapshot(root),candidate={...old,source:root,revision:s.revision,manifest:s.manifest,deployment:s.deployment,sharedRevisions:s.sharedRevisions};
|
|
113
113
|
for (const key of old.sharedEnabled || []) if (sharedIdentity(old, key).fingerprint !== sharedIdentity(candidate, key).fingerprint) throw Error('Shared worker changed; disable this client and coordinate an explicit shared worker upgrade before updating');
|
|
114
|
-
await checkFolders(config,candidate);
|
|
114
|
+
await checkFolders(config,candidate,home);
|
|
115
115
|
const stage={...candidate,compose:path.join(dir,'compose.json')};await atomic(stage.compose,await compose(config,stage,secrets,home));
|
|
116
116
|
for(const [service,spec] of Object.entries(stage.deployment.services))await run('docker',[...pluginArgs(stage),spec.image?'pull':'build',service]);
|
|
117
117
|
const running=Boolean((await run('docker',[...pluginArgs(old),'ps','-q'])).trim());
|
|
@@ -122,7 +122,9 @@ export async function perform(home,job,hooks) {
|
|
|
122
122
|
for(const name of volumes)await backupVolume(run,old,name,backup);
|
|
123
123
|
await atomic(old.compose,await compose(config,candidate,secrets,home));
|
|
124
124
|
if(running)await run('docker',[...pluginArgs(candidate),'up','-d','--wait','--wait-timeout','90','--no-build']);
|
|
125
|
-
r.plugins[job.target]=candidate;
|
|
125
|
+
r.plugins[job.target]=candidate;
|
|
126
|
+
for(const alias of Object.keys(candidate.manifest.commands))r.commands[alias]=job.target;
|
|
127
|
+
await atomic(path.join(home,'registry.json'),r);
|
|
126
128
|
job.runtimeVerified=running;
|
|
127
129
|
}
|
|
128
130
|
job.status='completed';job.endedAt=new Date().toISOString();await save();return job;
|