@jc_stack/ez-agents 0.1.0-beta.47 → 0.1.0-beta.48
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 +3 -0
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +21 -0
- package/Dockerfile +5 -1
- package/THIRD_PARTY_NOTICES.md +6 -3
- package/bin/ez +7 -0
- package/docker/broker-readiness.mjs +54 -0
- package/docker/broker-update-smoke.mjs +86 -0
- package/docker/run.ts +1 -1
- package/docs/application-channel.md +32 -1
- package/docs/architecture/ai-selection.md +18 -3
- package/docs/docker-runtime.md +29 -0
- package/docs/isolated-plugin-update-readiness.md +66 -0
- package/docs/releasing.md +4 -0
- package/docs/scheduling.md +100 -6
- package/docs/selective-monitoring.md +13 -0
- package/docs/upgrades.md +35 -0
- package/docs/work-preflight.md +9 -0
- package/package.json +1 -1
- package/src/ai.ts +22 -3
- package/src/application-channel.ts +43 -3
- package/src/application-cli.ts +7 -1
- package/src/application-origin.ts +2 -0
- package/src/audio.ts +37 -22
- package/src/client-defaults.ts +3 -2
- package/src/config.ts +9 -0
- package/src/control-state.ts +3 -1
- package/src/delivery-socket-client.d.mts +2 -0
- package/src/delivery-socket-client.mjs +2 -0
- package/src/delivery-socket.ts +24 -1
- package/src/event-sources.ts +2 -2
- package/src/executor.ts +72 -1
- package/src/files.ts +47 -3
- package/src/host-executor.ts +38 -29
- package/src/index.ts +41 -18
- package/src/install-tools.mjs +8 -5
- package/src/menu.ts +4 -2
- package/src/plugin-broker.mjs +47 -4
- package/src/plugins/manager.mjs +11 -4
- package/src/run-evidence.ts +78 -0
- package/src/runs.ts +12 -1
- package/src/schedule-cli.ts +69 -8
- package/src/schedule-preflight.ts +23 -0
- package/src/scheduled-tasks.ts +16 -12
- package/src/scheduler.ts +79 -18
- package/src/scripts.ts +167 -0
- package/src/setup.ts +2 -2
- package/src/task-capability.ts +17 -1
- package/src/task-executor.ts +10 -7
- package/src/task-mcp.ts +20 -8
- package/src/tasks.ts +89 -9
- package/src/updates/runtime.mjs +46 -7
- package/test/ai.test.ts +40 -0
- package/test/application-channel.test.ts +16 -1
- package/test/audio.test.ts +19 -0
- package/test/config.test.ts +8 -0
- package/test/executor.test.ts +37 -0
- package/test/files.test.ts +44 -0
- package/test/host-executor.test.ts +45 -2
- package/test/install-json.test.mjs +21 -0
- package/test/plugin-broker.test.mjs +64 -0
- package/test/plugin-manager.test.mjs +20 -0
- package/test/private-speech-config.test.ts +22 -0
- package/test/run-evidence.test.ts +78 -0
- package/test/schedule-preflight.test.ts +49 -0
- package/test/scheduler-host.test.ts +7 -0
- package/test/scheduler-relay.test.ts +38 -3
- package/test/scheduler.test.ts +1 -1
- package/test/script-schedules.test.ts +219 -0
- package/test/task-native.test.ts +50 -12
- package/test/tasks.test.ts +97 -4
- package/test/updates.test.mjs +45 -9
package/src/executor.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { createInterface } from 'node:readline'
|
|
|
11
11
|
import { fileURLToPath } from 'node:url'
|
|
12
12
|
import { DESKTOP_UNAVAILABLE } from './desktop-bridge.js'
|
|
13
13
|
import { macosWorkspaceProfile, workspaceSiblingDenies } from './workspace-confine.js'
|
|
14
|
+
import { Scripts, type ScriptRunRef } from './scripts.js'
|
|
14
15
|
|
|
15
16
|
export type ExecutorOptions = {
|
|
16
17
|
repairEnabled?: boolean
|
|
@@ -38,8 +39,12 @@ export type ExecutorOptions = {
|
|
|
38
39
|
codexAutoCompactTokens?: number
|
|
39
40
|
codexProvider?: CodexProviderBinding
|
|
40
41
|
onSession?: (id: string) => Promise<void>
|
|
42
|
+
// Registered script occurrence: invoke it directly, never a native CLI.
|
|
43
|
+
script?: ScriptLaunch
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
export type ScriptLaunch = ScriptRunRef & { scheduleId: string; scheduleRevision: string; dueAt: string }
|
|
47
|
+
|
|
43
48
|
export type CodexProviderBinding = {
|
|
44
49
|
id: string
|
|
45
50
|
name: string
|
|
@@ -71,7 +76,7 @@ export const executorEnvironment = (environment: NodeJS.ProcessEnv = process.env
|
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
export const executorJobEnv = (
|
|
74
|
-
options: Pick<ExecutorOptions, 'runId' | 'controlDir' | 'binDir' | 'toolsHome' | 'repairEnabled'
|
|
79
|
+
options: Pick<ExecutorOptions, 'runId' | 'controlDir' | 'binDir' | 'toolsHome' | 'repairEnabled'> & { workspace?: string },
|
|
75
80
|
environment: NodeJS.ProcessEnv = process.env,
|
|
76
81
|
): NodeJS.ProcessEnv => {
|
|
77
82
|
const base = executorEnvironment(environment)
|
|
@@ -81,6 +86,7 @@ export const executorJobEnv = (
|
|
|
81
86
|
PATH: pathValue,
|
|
82
87
|
EZ_RUN_ID: options.runId,
|
|
83
88
|
EZ_CONTROL_DIR: options.controlDir,
|
|
89
|
+
...(options.workspace ? { EZ_AGENT_WORKSPACE: options.workspace } : {}),
|
|
84
90
|
EZ_DELIVERY_SOCKET: deliverySocketPath(options.controlDir),
|
|
85
91
|
EZ_REPAIR_ENABLED: String(options.repairEnabled !== false),
|
|
86
92
|
...(options.toolsHome ? {BUILDX_CONFIG:path.join(options.toolsHome,'buildx')} : {}),
|
|
@@ -155,6 +161,8 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
|
|
|
155
161
|
args.push('--session-id', opts.sessionId)
|
|
156
162
|
}
|
|
157
163
|
args.push('--print', '--dangerously-skip-permissions')
|
|
164
|
+
for (const directory of [opts.controlDir, opts.sharedWorkspace, ...(opts.additionalWorkspaces ?? []), opts.toolsHome])
|
|
165
|
+
if (directory) args.push('--add-dir', directory)
|
|
158
166
|
if (opts.model) args.push('--model', opts.model)
|
|
159
167
|
if (opts.effort) args.push('--effort', opts.effort)
|
|
160
168
|
return args
|
|
@@ -285,6 +293,9 @@ export const opencodeDataHome = (controlDir: string): string | undefined => {
|
|
|
285
293
|
} catch { return undefined }
|
|
286
294
|
}
|
|
287
295
|
|
|
296
|
+
// Agent-bound Claude Code configuration directory (CLAUDE_CONFIG_DIR).
|
|
297
|
+
export const claudeConfigHome = (controlDir: string): string => path.join(controlDir, 'cli', 'claude')
|
|
298
|
+
|
|
288
299
|
// Pi resolves its provider/model catalog from an agent dir (settings.json,
|
|
289
300
|
// auth.json). Only a path is ever returned, never a secret.
|
|
290
301
|
export const piAgentDir = (controlDir: string): string | undefined => {
|
|
@@ -323,6 +334,7 @@ export const startExecutorJob = async (
|
|
|
323
334
|
texts: string[],
|
|
324
335
|
options: ExecutorOptions,
|
|
325
336
|
): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; stdout: string }> => {
|
|
337
|
+
if (options.script && process.env.EZ_EXECUTOR_TRANSPORT !== 'host') return startScriptJob(options as ExecutorOptions & { script: ScriptLaunch })
|
|
326
338
|
options = executionDefaults(executorKey(options.cli), options)
|
|
327
339
|
// Routing is caller-owned: a restricted task run goes to the task runner
|
|
328
340
|
// unless the host transport must ship it across the boundary first.
|
|
@@ -387,6 +399,32 @@ export const startExecutorJob = async (
|
|
|
387
399
|
}
|
|
388
400
|
environment.CODEX_HOME = home
|
|
389
401
|
}
|
|
402
|
+
if (!host && !gui && key === 'claude') {
|
|
403
|
+
// Mirror the Codex binding: agent-bound settings, memory, plugins and
|
|
404
|
+
// sessions; never the user's personal ~/.claude configuration.
|
|
405
|
+
const home = claudeConfigHome(options.controlDir)
|
|
406
|
+
await mkdir(home, {recursive:true,mode:0o700})
|
|
407
|
+
environment.CLAUDE_CONFIG_DIR = home
|
|
408
|
+
// Host-capable agents share the installer login only. An empty secure
|
|
409
|
+
// storage dir makes Claude use its default credential store (macOS
|
|
410
|
+
// Keychain entry or ~/.claude/.credentials.json) in place, so token
|
|
411
|
+
// refreshes stay shared rather than diverging through a copy. Isolated
|
|
412
|
+
// agents keep their own login inside CLAUDE_CONFIG_DIR. The host executor
|
|
413
|
+
// runs unlabelled, so this matches the Codex auth-link check, not
|
|
414
|
+
// resolveIsolation().
|
|
415
|
+
if (process.env.EZ_ISOLATION !== 'isolated') environment.CLAUDE_SECURESTORAGE_CONFIG_DIR = ''
|
|
416
|
+
// Optional owner-provisioned long-lived token (`claude setup-token`). It
|
|
417
|
+
// never refreshes, so one file can be copied to every agent without the
|
|
418
|
+
// agents invalidating each other's OAuth session.
|
|
419
|
+
const token = await readFile(path.join(home, 'oauth-token'), 'utf8').then(value => value.trim(), (error: NodeJS.ErrnoException) => {
|
|
420
|
+
if (error.code === 'ENOENT') return undefined
|
|
421
|
+
throw error
|
|
422
|
+
})
|
|
423
|
+
if (token !== undefined) {
|
|
424
|
+
if (!/^[A-Za-z0-9._-]{20,512}$/.test(token)) throw new Error('Invalid Claude token binding')
|
|
425
|
+
environment.CLAUDE_CODE_OAUTH_TOKEN = token
|
|
426
|
+
}
|
|
427
|
+
}
|
|
390
428
|
if (!host && !gui && key === 'opencode') {
|
|
391
429
|
// Point OpenCode at the agent-bound data home (cli/opencode/auth.json)
|
|
392
430
|
// when the owner provisioned it. Host runs keep the installer login;
|
|
@@ -459,6 +497,39 @@ export const startExecutorJob = async (
|
|
|
459
497
|
|
|
460
498
|
export const startGrokJob = startExecutorJob
|
|
461
499
|
|
|
500
|
+
// Spawn a verified registered script with the executor identity, environment
|
|
501
|
+
// allowlist and workspace confinement. No shell, model, prompt or native session.
|
|
502
|
+
export const startScriptJob = async (
|
|
503
|
+
options: ExecutorOptions & { script: ScriptLaunch },
|
|
504
|
+
): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; stdout: string }> => {
|
|
505
|
+
const { script } = options
|
|
506
|
+
for (const value of [options.runId, script.scheduleId, script.scheduleRevision]) if (!/^[a-zA-Z0-9_-]+$/.test(value)) throw new Error('Invalid script run binding')
|
|
507
|
+
if (!Number.isFinite(Date.parse(script.dueAt))) throw new Error('Invalid script due time')
|
|
508
|
+
const environment: NodeJS.ProcessEnv = {
|
|
509
|
+
...executorJobEnv(options),
|
|
510
|
+
EZ_SCHEDULE_ID: script.scheduleId, EZ_SCHEDULE_REVISION: script.scheduleRevision, EZ_DUE_AT: script.dueAt,
|
|
511
|
+
EZ_SCRIPT_ID: script.id, EZ_SCRIPT_REVISION: script.revision, EZ_SCRIPT_SHA256: script.sha256,
|
|
512
|
+
}
|
|
513
|
+
const verified = await new Scripts(options.controlDir).verifiedInvocation(script, options.workspace, environment.PATH)
|
|
514
|
+
let invocation = executorInvocation(verified.command, verified.args)
|
|
515
|
+
let profileDirectory: string | undefined
|
|
516
|
+
const denies = process.platform === 'darwin' ? workspaceSiblingDenies(options.workspace) : []
|
|
517
|
+
if (denies.length) {
|
|
518
|
+
profileDirectory = await mkdtemp(path.join(tmpdir(), 'ezenciel-agents-'))
|
|
519
|
+
const profile = path.join(profileDirectory, 'workspace.sb')
|
|
520
|
+
await writeFile(profile, macosWorkspaceProfile(denies, [options.controlDir, options.binDir, options.toolsHome, options.sharedWorkspace,
|
|
521
|
+
...(options.additionalWorkspaces ?? [])].filter((value): value is string => Boolean(value))), { mode: 0o600 })
|
|
522
|
+
invocation = { command: 'sandbox-exec', args: ['-f', profile, invocation.command, ...invocation.args] }
|
|
523
|
+
}
|
|
524
|
+
const cleanup = async () => { if (profileDirectory) await rm(profileDirectory, { recursive: true, force: true }) }
|
|
525
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
526
|
+
cwd: options.workspace, env: environment, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32', shell: false,
|
|
527
|
+
})
|
|
528
|
+
await new Promise<void>((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject) })
|
|
529
|
+
.catch(async (error) => { await cleanup(); throw error })
|
|
530
|
+
return { child, cleanup, stdout: '' }
|
|
531
|
+
}
|
|
532
|
+
|
|
462
533
|
// Structured client events only. Model text is never interpreted or sent to chat.
|
|
463
534
|
export const nativeSessionId = (cli: string, line: string): string | undefined => {
|
|
464
535
|
try {
|
package/src/files.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile, realpath, stat } from 'node:fs/promises'
|
|
1
|
+
import { mkdir, readFile, writeFile, realpath, stat, mkdtemp, readdir, rm, unlink } from 'node:fs/promises'
|
|
2
|
+
import { execFile } from 'node:child_process'
|
|
3
|
+
import { promisify } from 'node:util'
|
|
2
4
|
import { randomUUID } from 'node:crypto'
|
|
3
5
|
import path from 'node:path'
|
|
4
6
|
|
|
5
|
-
export type DetectedFileType = 'pdf' | 'jpeg' | 'png' | 'webp' | 'text' | 'unknown'
|
|
7
|
+
export type DetectedFileType = 'pdf' | 'jpeg' | 'png' | 'webp' | 'text' | 'mp4' | 'unknown'
|
|
6
8
|
|
|
7
9
|
export const detectFileType = (buffer: Buffer): DetectedFileType => {
|
|
8
10
|
if (buffer.length >= 5 && buffer.subarray(0, 5).toString('ascii') === '%PDF-') {
|
|
@@ -31,6 +33,9 @@ export const detectFileType = (buffer: Buffer): DetectedFileType => {
|
|
|
31
33
|
) {
|
|
32
34
|
return 'webp'
|
|
33
35
|
}
|
|
36
|
+
// ISO-BMFF also carries HEIF/AVIF stills and audio-only M4A; only video brands are MP4.
|
|
37
|
+
if (buffer.length >= 12 && buffer.subarray(4,8).toString('ascii') === 'ftyp' &&
|
|
38
|
+
!/^(hei[cmsx]|hev[cmsx]|mif1|msf1|avi[fs]|M4[ABP] |F4A )$/.test(buffer.subarray(8,12).toString('latin1'))) return 'mp4'
|
|
34
39
|
try {
|
|
35
40
|
new TextDecoder('utf-8', { fatal: true }).decode(buffer)
|
|
36
41
|
if (!buffer.includes(0)) return 'text'
|
|
@@ -108,7 +113,46 @@ export const stageChatAttachment = async (stagingRoot: string, name: string, byt
|
|
|
108
113
|
if (!bytes.length || bytes.length > MAX_INCOMING_ATTACHMENT_BYTES || type === 'unknown' ||
|
|
109
114
|
(type === 'text' && !/\.(txt|md|markdown)$/i.test(name))) throw new Error('Invalid application attachment: unsupported type or size')
|
|
110
115
|
const staged = await stageIncomingFile(stagingRoot, name, bytes)
|
|
111
|
-
const kind = ['jpeg', 'png', 'webp'].includes(staged.fileType) ? 'image' : 'document'
|
|
116
|
+
const kind = ['jpeg', 'png', 'webp'].includes(staged.fileType) ? 'image' : staged.fileType === 'mp4' ? 'video' : 'document'
|
|
112
117
|
return { text: `[Attached ${kind} staged at ${staged.fullPath} (type: ${staged.fileType}, size: ${bytes.length} bytes)]${comment ? `\n\nCaption: ${comment}` : ''}`,
|
|
113
118
|
attachment: {path: staged.fullPath, type: staged.fileType}, fullPath: staged.fullPath }
|
|
114
119
|
}
|
|
120
|
+
|
|
121
|
+
// Shared inbound staging/validation with Telegram; restricted correspondence
|
|
122
|
+
// can read only this already-authorized input, never arbitrary workspace paths.
|
|
123
|
+
export async function readChatAttachment(stagingRoot: string, name: string, bytes: Buffer) {
|
|
124
|
+
const staged = await stageChatAttachment(stagingRoot, name, bytes, '')
|
|
125
|
+
// Untrusted media parsers never inherit relay secrets.
|
|
126
|
+
const execute = (command: string, args: string[], options: {timeout:number;maxBuffer:number}) =>
|
|
127
|
+
promisify(execFile)(command, args, {...options, env: {PATH: process.env.PATH ?? '/usr/bin:/bin'}})
|
|
128
|
+
try {
|
|
129
|
+
if (['jpeg','png','webp'].includes(staged.attachment.type)) return {name:sanitizeFileName(name),type:staged.attachment.type,bytes:bytes.length,image:{data:bytes.toString('base64'),mimeType:`image/${staged.attachment.type}`}}
|
|
130
|
+
if (staged.attachment.type === 'mp4') {
|
|
131
|
+
const directory = await mkdtemp(path.join(stagingRoot,'video-'))
|
|
132
|
+
try {
|
|
133
|
+
const probe = JSON.parse((await execute('ffprobe',['-v','error','-protocol_whitelist','file,pipe','-show_entries','format=duration:stream=codec_type','-of','json',staged.fullPath],{timeout:3000,maxBuffer:8000})).stdout)
|
|
134
|
+
const durationSeconds = Number(probe.format?.duration)
|
|
135
|
+
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0 || durationSeconds > 120 || !probe.streams?.some((stream:{codec_type:string})=>stream.codec_type==='video')) throw Error('Video must contain a visual track and be at most 120 seconds')
|
|
136
|
+
await execute('ffmpeg',['-v','error','-nostdin','-protocol_whitelist','file,pipe','-enable_drefs','0','-use_absolute_path','0','-i',staged.fullPath,'-an','-vf',`fps=fps=${8/durationSeconds}:start_time=0,scale=640:-2`,'-frames:v','8','-q:v','3',path.join(directory,'frame-%02d.jpg')],{timeout:20000,maxBuffer:8000})
|
|
137
|
+
const names=(await readdir(directory)).filter(name=>/^frame-\d{2}\.jpg$/.test(name)).sort()
|
|
138
|
+
const images=[];let total=0
|
|
139
|
+
for (const frame of names) {
|
|
140
|
+
const image=await readFile(path.join(directory,frame));total+=image.length
|
|
141
|
+
if (total>1024*1024 || detectFileType(image)!=='jpeg') throw Error('Video frames exceed the bounded image limit')
|
|
142
|
+
images.push({data:image.toString('base64'),mimeType:'image/jpeg'})
|
|
143
|
+
}
|
|
144
|
+
if (!images.length) throw Error('Video has no decodable frames')
|
|
145
|
+
return {name:sanitizeFileName(name),type:'mp4',bytes:bytes.length,durationSeconds,frames:images.map((_,index)=>({frame:index+1,seconds:index*durationSeconds/8})),images}
|
|
146
|
+
} finally { await rm(directory,{recursive:true,force:true}) }
|
|
147
|
+
}
|
|
148
|
+
const limit = 256000
|
|
149
|
+
const text = staged.attachment.type === 'text' ? new TextDecoder('utf-8', {fatal:true}).decode(bytes)
|
|
150
|
+
: (await execute('pdftotext', ['-enc','UTF-8','-layout',staged.fullPath,'-'], {timeout:20000,maxBuffer:limit + 1}).catch(error => {
|
|
151
|
+
if ((error as {code?:unknown}).code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') throw Error('Extracted document exceeds the 256000-byte text limit')
|
|
152
|
+
throw error
|
|
153
|
+
})).stdout
|
|
154
|
+
if (!text.trim()) throw Error('Document has no extractable text; scanned PDFs require OCR')
|
|
155
|
+
if (Buffer.byteLength(text) > limit) throw Error('Extracted document exceeds the 256000-byte text limit')
|
|
156
|
+
return { name: sanitizeFileName(name), type: staged.attachment.type, bytes: bytes.length, text }
|
|
157
|
+
} finally { await unlink(staged.fullPath) }
|
|
158
|
+
}
|
package/src/host-executor.ts
CHANGED
|
@@ -52,7 +52,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
52
52
|
let catalogRefresh: Promise<void> | undefined
|
|
53
53
|
const locks: string[] = []
|
|
54
54
|
const sharedWorkspaces = new Map<HostBinding, string>()
|
|
55
|
-
const activeWorkspaces = new Map<HostBinding, { lanes:
|
|
55
|
+
const activeWorkspaces = new Map<HostBinding, { lanes: Map<string, boolean>; release?: () => Promise<void> }>()
|
|
56
56
|
const additionalWorkspaces = new Map<HostBinding, string[]>()
|
|
57
57
|
const catalog = async (agent: HostBinding) => {
|
|
58
58
|
const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'),
|
|
@@ -161,18 +161,18 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
161
161
|
const base=path.join(directory,id)
|
|
162
162
|
const scheduled = Boolean(run?.scheduled)
|
|
163
163
|
let workspace = activeWorkspaces.get(agent)
|
|
164
|
-
if (workspace
|
|
164
|
+
if (!scheduled && workspace && [...workspace.lanes.values()].includes(false)) continue
|
|
165
165
|
if (!workspace) {
|
|
166
166
|
const release = agent.toolsHome
|
|
167
167
|
? await (await import('./plugins/workspace-lease.mjs')).workspaceLease(agent.toolsHome,{kind:'native',runId:id}) : undefined
|
|
168
168
|
if (agent.toolsHome && !release) continue
|
|
169
|
-
workspace = { lanes: new
|
|
169
|
+
workspace = { lanes: new Map(), release }
|
|
170
170
|
activeWorkspaces.set(agent, workspace)
|
|
171
171
|
}
|
|
172
|
-
workspace.lanes.
|
|
173
|
-
// Keep plugin workspace invocations excluded until
|
|
172
|
+
workspace.lanes.set(id, scheduled)
|
|
173
|
+
// Keep plugin workspace invocations excluded until all native jobs finish.
|
|
174
174
|
const releaseWorkspace = async () => {
|
|
175
|
-
workspace.lanes.delete(
|
|
175
|
+
workspace.lanes.delete(id)
|
|
176
176
|
if (!workspace.lanes.size) {
|
|
177
177
|
activeWorkspaces.delete(agent)
|
|
178
178
|
await workspace.release?.()
|
|
@@ -195,30 +195,39 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
195
195
|
await new Tasks(agent.controlDir).authorize(run, false)
|
|
196
196
|
} else await authorizeRun(agent.controlDir, path.basename(base))
|
|
197
197
|
const opts=request.options as ExecutorOptions
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (
|
|
198
|
+
// Script occurrences come from the authorized run record, never the request.
|
|
199
|
+
if (run?.script) {
|
|
200
|
+
if (!run.scheduled) throw new Error('Script runs require a scheduled occurrence')
|
|
201
|
+
const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),
|
|
202
|
+
runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
|
|
203
|
+
script:{...run.script,scheduleId:run.scheduled.id,scheduleRevision:run.scheduled.revision,dueAt:run.scheduled.dueAt}}
|
|
204
|
+
job=await launch(request.texts,options)
|
|
205
|
+
} else {
|
|
206
|
+
if (run?.scheduled) {
|
|
207
|
+
assertScheduledModel(run.execution?.preset.model)
|
|
208
|
+
if (opts.model !== run.execution!.preset.model) throw new Error('Scheduled run model does not match its saved task model')
|
|
209
|
+
}
|
|
210
|
+
const cli = opts.cli || installation.cli
|
|
211
|
+
resolveExecutor(cli)
|
|
212
|
+
const provider = cli === 'codex' && opts.provider
|
|
213
|
+
? (agent.codexProviders ?? []).map(validateCodexProvider).find(candidate=>candidate.id===opts.provider)
|
|
214
|
+
: undefined
|
|
215
|
+
if (opts.provider && !provider) throw new Error('Selected Codex provider is not installed for this agent')
|
|
216
|
+
const model = opts.model
|
|
217
|
+
if (provider && !model) throw new Error('Selected Codex provider requires a declared model')
|
|
218
|
+
if (provider && model && !provider.models.includes(model)) throw new Error('Selected model is not declared for this Codex provider')
|
|
219
|
+
if (!provider && model && (agent.codexProviders ?? []).some(binding=>binding.models.includes(model)))
|
|
220
|
+
throw new Error('Selected Codex provider is required for this model')
|
|
221
|
+
// Admission and execution must use the same catalog snapshot. A
|
|
222
|
+
// transient native-client probe failure must not reject a model
|
|
223
|
+
// that the host just advertised to the relay and application.
|
|
224
|
+
if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,model,effort:opts.effort},JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')))
|
|
225
|
+
const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
|
|
226
|
+
runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
|
|
227
|
+
sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
|
|
228
|
+
promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
|
|
229
|
+
job=await launch(request.texts,options)
|
|
201
230
|
}
|
|
202
|
-
const cli = opts.cli || installation.cli
|
|
203
|
-
resolveExecutor(cli)
|
|
204
|
-
const provider = cli === 'codex' && opts.provider
|
|
205
|
-
? (agent.codexProviders ?? []).map(validateCodexProvider).find(candidate=>candidate.id===opts.provider)
|
|
206
|
-
: undefined
|
|
207
|
-
if (opts.provider && !provider) throw new Error('Selected Codex provider is not installed for this agent')
|
|
208
|
-
const model = opts.model
|
|
209
|
-
if (provider && !model) throw new Error('Selected Codex provider requires a declared model')
|
|
210
|
-
if (provider && model && !provider.models.includes(model)) throw new Error('Selected model is not declared for this Codex provider')
|
|
211
|
-
if (!provider && model && (agent.codexProviders ?? []).some(binding=>binding.models.includes(model)))
|
|
212
|
-
throw new Error('Selected Codex provider is required for this model')
|
|
213
|
-
// Admission and execution must use the same catalog snapshot. A
|
|
214
|
-
// transient native-client probe failure must not reject a model
|
|
215
|
-
// that the host just advertised to the relay and application.
|
|
216
|
-
if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,model,effort:opts.effort},JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')))
|
|
217
|
-
const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
|
|
218
|
-
runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
|
|
219
|
-
sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
|
|
220
|
-
promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
|
|
221
|
-
job=await launch(request.texts,options)
|
|
222
231
|
active.set(base,job.child)
|
|
223
232
|
await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid}),{mode:0o600})
|
|
224
233
|
if(signal.aborted)terminateJob(job.child)
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { dispatchChannel } from './channel-backend.js'
|
|
|
11
11
|
import { randomUUID } from 'node:crypto'
|
|
12
12
|
import { stat } from 'node:fs/promises'
|
|
13
13
|
import { Scheduler } from './scheduler.js'
|
|
14
|
+
import { Scripts } from './scripts.js'
|
|
15
|
+
import { redactFailure } from './failure.js'
|
|
14
16
|
import { ownedScheduledTasks, scheduledTaskDetailText, scheduledTasksText } from './scheduled-tasks.js'
|
|
15
17
|
import { EventSources, eventRunId, batchReady, type SourceEvent } from './event-sources.js'
|
|
16
18
|
import {removeTaskAttachments} from './task-attachments.js'
|
|
@@ -25,7 +27,7 @@ import { InboxStore, type IncomingItem } from './inbox.js'
|
|
|
25
27
|
import { loadConfig, type Config } from './config.js'
|
|
26
28
|
import { ControlStore, telegramOwner, ownerId, ownerEpoch } from './control-state.js'
|
|
27
29
|
import { ApprovalStore } from './approval.js'
|
|
28
|
-
import { startExecutorJob, terminateJob, opencodeDataHome } from './executor.js'
|
|
30
|
+
import { startExecutorJob, terminateJob, opencodeDataHome, claudeConfigHome } from './executor.js'
|
|
29
31
|
import { assertScheduledModel } from './model-policy.js'
|
|
30
32
|
import { RunStore, type RunRecord } from './runs.js'
|
|
31
33
|
import { splitTelegramText } from './reply.js'
|
|
@@ -44,7 +46,7 @@ import { softwareStatus } from './software-status.js'
|
|
|
44
46
|
export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
45
47
|
const safeError = (error: unknown): string => {
|
|
46
48
|
let message = error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'
|
|
47
|
-
for (const secret of [config.channelBackendToken, config.telegramBotToken, config.geminiApiKey, config.openaiApiKey]) {
|
|
49
|
+
for (const secret of [config.channelBackendToken, config.telegramBotToken, config.geminiApiKey, config.openaiApiKey, config.openrouterApiKey]) {
|
|
48
50
|
if (secret) message = message.replaceAll(secret, '[redacted]')
|
|
49
51
|
}
|
|
50
52
|
return message
|
|
@@ -60,7 +62,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
60
62
|
const scheduler = new Scheduler(config.controlDir)
|
|
61
63
|
const background = new Map<string, ChildProcess>()
|
|
62
64
|
const completions = new Set<Promise<void>>()
|
|
63
|
-
const
|
|
65
|
+
const speechOptions = { geminiApiKey: config.geminiApiKey, openrouterApiKey: config.openrouterApiKey, speechProvider: config.speechProvider, speechModel: config.speechModel }
|
|
66
|
+
const speechConfigured = config.speechProvider === 'openrouter' ? Boolean(config.openrouterApiKey) : Boolean(config.geminiApiKey)
|
|
67
|
+
const tasks = new Tasks(config.controlDir, speechConfigured ? (text) => synthesizeSpeech(text, { ...speechOptions, voice: config.speechVoiceEn, signal: AbortSignal.timeout(20000) }) : undefined)
|
|
64
68
|
const processTaskRequests = taskRequests(tasks)
|
|
65
69
|
let taskWork: Promise<void> | undefined
|
|
66
70
|
const drainTaskRequests = (): Promise<void> => {
|
|
@@ -103,8 +107,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
103
107
|
const applicationChannel = new ApplicationChannel({
|
|
104
108
|
controlDir: config.controlDir, workspace: config.workspace, isolation: config.isolation, initial: aiMenu.initial,
|
|
105
109
|
aiControls: aiMenu,
|
|
106
|
-
speech:
|
|
107
|
-
|
|
110
|
+
speech: speechConfigured ? (text, language) => synthesizeSpeech(text, {
|
|
111
|
+
...speechOptions,
|
|
108
112
|
voice: language === 'es' ? config.speechVoiceEs : config.speechVoiceEn,
|
|
109
113
|
format: 'wav',
|
|
110
114
|
}) : undefined,
|
|
@@ -207,8 +211,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
207
211
|
}
|
|
208
212
|
if (run.scheduled && !(await scheduler.get(run.scheduled.id)).enabled) return
|
|
209
213
|
// A long scheduled turn must not occupy the owner's chat stream.
|
|
210
|
-
//
|
|
211
|
-
if (run.scheduled ? background.size
|
|
214
|
+
// Foreground stays serial; scheduled jobs have independent native sessions.
|
|
215
|
+
if (run.scheduled ? background.size >= (config.scheduledConcurrency ?? 6) : activeChild !== null) return
|
|
212
216
|
let texts = run.texts
|
|
213
217
|
if (run.external) {
|
|
214
218
|
// Availability failures leave durable queued work for a later check.
|
|
@@ -235,14 +239,23 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
235
239
|
try {
|
|
236
240
|
const launchStarted = performance.now()
|
|
237
241
|
const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
|
|
238
|
-
|
|
242
|
+
// A registered script occurrence: no model, prompt or native session.
|
|
243
|
+
const script = run.script && run.scheduled
|
|
244
|
+
? { registration: await new Scripts(config.controlDir).owned(run.script.id, owner), ref: run.script, scheduled: run.scheduled }
|
|
245
|
+
: undefined
|
|
246
|
+
if (run.script && !script) throw new Error('Script runs require a scheduled occurrence')
|
|
247
|
+
if (!started.execution && !run.taskId && !script) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
|
|
239
248
|
const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled)
|
|
240
249
|
const session = startsOwnSession
|
|
241
250
|
? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
|
|
242
251
|
: await control.executionSession(started.execution!)
|
|
243
|
-
const selected = run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
|
|
244
|
-
if (run.scheduled) assertScheduledModel(selected.model)
|
|
245
|
-
const { child, cleanup } = await launch(texts, {
|
|
252
|
+
const selected = script ? initialPreset('codex') : run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
|
|
253
|
+
if (run.scheduled && !script) assertScheduledModel(selected.model)
|
|
254
|
+
const { child, cleanup } = await launch(texts, script ? {
|
|
255
|
+
workspace: config.workspace, timeoutMs: 0, repairEnabled: config.repairEnabled, runId: started.id,
|
|
256
|
+
controlDir: config.controlDir, binDir,
|
|
257
|
+
script: { ...script.ref, scheduleId: script.scheduled.id, scheduleRevision: script.scheduled.revision, dueAt: script.scheduled.dueAt },
|
|
258
|
+
} : {
|
|
246
259
|
workspace: config.workspace,
|
|
247
260
|
timeoutMs: config.executorTimeoutMs,
|
|
248
261
|
repairEnabled: config.repairEnabled,
|
|
@@ -265,9 +278,14 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
265
278
|
const executionStarted = performance.now()
|
|
266
279
|
// Attach before disk writes: a fast child can close while PID persistence
|
|
267
280
|
// is pending, and Node drains its remaining pipes during process close.
|
|
268
|
-
let failureReason = 'executor-exit', errorTail = '', interrupted = false
|
|
281
|
+
let failureReason = 'executor-exit', errorTail = '', interrupted = false, outputTail = '', timedOut = false
|
|
282
|
+
// Script output is bounded diagnostics for run inspection, never chat.
|
|
283
|
+
if (script) child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { outputTail = (outputTail + chunk).slice(-16384) })
|
|
284
|
+
const scriptTimer = script ? setTimeout(() => { timedOut = true; terminateJob(child) }, script.registration.timeoutSeconds * 1000) : undefined
|
|
285
|
+
child.once('close', () => clearTimeout(scriptTimer))
|
|
269
286
|
child.stderr?.setEncoding('utf8').on('data', (chunk: string) => {
|
|
270
287
|
errorTail = (errorTail + chunk).slice(-16384)
|
|
288
|
+
if (script) outputTail = (outputTail + chunk).slice(-16384)
|
|
271
289
|
if (chunk.includes('Host CLI executor is offline')) failureReason = 'host-executor-offline'
|
|
272
290
|
if (chunk.includes('Host executor client interrupted by')) {
|
|
273
291
|
failureReason = 'host-executor-transport-interrupted'
|
|
@@ -288,7 +306,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
288
306
|
console.info('run started', {
|
|
289
307
|
run_id: started.id,
|
|
290
308
|
pid: child.pid,
|
|
291
|
-
cli: selected.cli,
|
|
309
|
+
cli: script ? 'script' : selected.cli,
|
|
310
|
+
...(script ? { script: script.ref.id, script_revision: script.ref.revision } : {}),
|
|
292
311
|
session: session.sessionId,
|
|
293
312
|
isResume: session.hasStarted,
|
|
294
313
|
})
|
|
@@ -306,8 +325,12 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
306
325
|
try {
|
|
307
326
|
await cleanup()
|
|
308
327
|
if (code === 0 && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(session.sessionId)
|
|
309
|
-
const cancelled = ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id))
|
|
310
|
-
|
|
328
|
+
const cancelled = !timedOut && (ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id)))
|
|
329
|
+
if (timedOut) failureReason = 'script-timeout'
|
|
330
|
+
const failed = !cancelled && (code !== 0 || timedOut)
|
|
331
|
+
await runs.patch(started.id, { status: cancelled ? 'cancelled' : failed ? 'failed' : 'completed', endedAt: new Date().toISOString(), exitCode: code,
|
|
332
|
+
...(script ? { output: redactFailure(safeError(outputTail)), ...(timedOut ? { timedOut } : {}) } : {}),
|
|
333
|
+
...(failed ? { failureReason, interrupted, failure: await failureEvidence(config.controlDir, safeError(timedOut ? `Script exceeded its ${script!.registration.timeoutSeconds}s timeout and was terminated` : errorTail || `Executor exited with ${code === null ? 'a signal' : `code ${code}`}`)) } : {}) })
|
|
311
334
|
await releaseExternal(run)
|
|
312
335
|
} catch (error) {
|
|
313
336
|
await runs.patch(started.id, { status: ownerStopped.has(child) ? 'cancelled' : 'failed', failureReason: 'session-finalization', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
|
|
@@ -587,8 +610,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
587
610
|
} else if (item.type === 'voice' && item.voiceText) {
|
|
588
611
|
await bot!.api.sendChatAction(item.chatId, 'record_voice')
|
|
589
612
|
const { buffer } = await synthesizeSpeech(item.voiceText, {
|
|
590
|
-
|
|
591
|
-
openaiApiKey: config.openaiApiKey,
|
|
613
|
+
...speechOptions,
|
|
592
614
|
voice: config.speechVoiceEn,
|
|
593
615
|
})
|
|
594
616
|
await paceSend()
|
|
@@ -719,7 +741,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
719
741
|
const delivery = await runs.deliveryStatus()
|
|
720
742
|
const ai = await control.aiState(aiMenu.initial)
|
|
721
743
|
const selected = ai.presets.find((p) => p.id === ai.selectedId)!
|
|
722
|
-
const discovered = await discoverDefaults(config.workspace, { codexHome, nativeCodexFallback: true })
|
|
744
|
+
const discovered = await discoverDefaults(config.workspace, { codexHome, claudeHome: claudeConfigHome(config.controlDir), nativeCodexFallback: true })
|
|
723
745
|
const displayedSelected = statusPreset(selected, discovered)
|
|
724
746
|
const scheduled = all.filter(run => run.scheduled && run.status === 'running').length
|
|
725
747
|
const queued = all.filter(run => run.status === 'queued').length
|
|
@@ -743,6 +765,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
743
765
|
'',
|
|
744
766
|
'Work',
|
|
745
767
|
`Current: ${running ? (waitingForHost ? 'waiting for the workspace' : 'running') : 'idle'}`,
|
|
768
|
+
`Scheduled limit: ${config.scheduledConcurrency ?? 6}`,
|
|
746
769
|
`Background: ${scheduled ? `${scheduled} scheduled task${scheduled === 1 ? '' : 's'} running` : 'none'}`,
|
|
747
770
|
`Queue: ${queued || incoming.pending ? `${queued} run${queued === 1 ? '' : 's'}; ${incoming.pending} incoming message${incoming.pending === 1 ? '' : 's'}` : 'empty'}`,
|
|
748
771
|
...(attention.length ? ['', 'Needs attention', ...attention] : []),
|
package/src/install-tools.mjs
CHANGED
|
@@ -16,13 +16,14 @@ export const hostEnvironment=()=>Object.fromEntries(['HOME','PATH','LANG','LC_AL
|
|
|
16
16
|
async function atomic(file,value) {
|
|
17
17
|
const tmp=file+'.'+randomUUID()+'.tmp';await fs.writeFile(tmp,JSON.stringify(value)+'\n',{mode:0o600,flag:'wx'});await fs.rename(tmp,file);
|
|
18
18
|
}
|
|
19
|
-
export function run(command,args,{cwd,log,timeout=15000}={}) {
|
|
19
|
+
export function run(command,args,{cwd,log,timeout=15000,stdoutOnly=false}={}) {
|
|
20
20
|
return new Promise((resolve,reject)=>{
|
|
21
|
-
const child=spawn(command,args,{cwd,env:hostEnvironment(),stdio:['ignore',log??'pipe',log??'pipe']});let output='';
|
|
22
|
-
|
|
21
|
+
const child=spawn(command,args,{cwd,env:hostEnvironment(),stdio:['ignore',log??'pipe',log??'pipe']});let output='',stdout='';
|
|
22
|
+
child.stdout?.on('data',b=>{output=(output+b).slice(-200000);if(stdoutOnly)stdout=(stdout+b).slice(-200000);});
|
|
23
|
+
child.stderr?.on('data',b=>output=(output+b).slice(-200000));
|
|
23
24
|
const timer=setTimeout(()=>child.kill('SIGTERM'),timeout);
|
|
24
25
|
child.once('error',e=>{clearTimeout(timer);reject(e);});
|
|
25
|
-
child.once('close',code=>{clearTimeout(timer);if(code!==0)reject(Error(`${command} failed (${code})${log===undefined?': '+output.trim():''}`));else resolve(output.trim());});
|
|
26
|
+
child.once('close',code=>{clearTimeout(timer);if(code!==0)reject(Error(`${command} failed (${code})${log===undefined?': '+output.trim():''}`));else resolve((stdoutOnly?stdout:output).trim());});
|
|
26
27
|
});
|
|
27
28
|
}
|
|
28
29
|
export const defaultHome=()=>path.join(process.env.XDG_DATA_HOME||path.join(homedir(),'.local/share'),'ez');
|
|
@@ -115,7 +116,9 @@ export async function migrateLedger({ deployment } = {}) {
|
|
|
115
116
|
}
|
|
116
117
|
|
|
117
118
|
export async function latestCodexVersion(invoke,manager={command:"npm",args:[]}) {
|
|
118
|
-
const
|
|
119
|
+
const output=await invoke(manager.command,[...manager.args,'view','@openai/codex@latest','version','--json','--registry','https://registry.npmjs.org/'],{stdoutOnly:true});
|
|
120
|
+
let version;
|
|
121
|
+
try {version=JSON.parse(output);}catch {throw Error('Codex version discovery returned invalid JSON on stdout');}
|
|
119
122
|
if(typeof version!=='string'||!/^\d+\.\d+\.\d+$/.test(version))throw Error('Invalid latest Codex version');
|
|
120
123
|
return version;
|
|
121
124
|
}
|
package/src/menu.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { InlineKeyboard, type Context } from 'grammy'
|
|
|
6
6
|
import { ControlStore, type ControlGuard } from './control-state.js'
|
|
7
7
|
import { chatPreset, installed, persistedPreset, presetLabel, presetProvider, readModels, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
|
|
8
8
|
import { discoverDefaults } from './client-defaults.js'
|
|
9
|
+
import { claudeConfigHome } from './executor.js'
|
|
9
10
|
|
|
10
11
|
export const mainCommands = [
|
|
11
12
|
{ command: 'new', description: 'New conversation' },
|
|
@@ -38,14 +39,15 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
38
39
|
// directory, but the default catalog probed the process home instead. Bind it
|
|
39
40
|
// to the same agent-bound home so /ai lists the models Codex can actually run.
|
|
40
41
|
else if (codexHome && catalog === readModels) catalog = () => readModels(undefined, isInstalled, codexHome, undefined, opencodeDataHome, undefined, process.env.EZ_CONTROL_DIR)
|
|
41
|
-
const
|
|
42
|
+
const claudeHome = process.env.EZ_CONTROL_DIR ? claudeConfigHome(process.env.EZ_CONTROL_DIR) : undefined
|
|
43
|
+
const refresh = async () => control.syncClientPresets(initial, host ? [] : await discoverDefaults(workspace, { codexHome, claudeHome }))
|
|
42
44
|
const validate = async (preset: AiPreset) => {
|
|
43
45
|
assertEffort(preset.effort, preset.model, preset.cli)
|
|
44
46
|
if (preset.id === initial.id) return
|
|
45
47
|
if (preset.id.startsWith('detected_')) {
|
|
46
48
|
if (host) await validateSelection(preset, await catalog(), async name => (await catalog()).some(model => model.cli === name))
|
|
47
49
|
else {
|
|
48
|
-
const detected = await discoverDefaults(workspace, { codexHome })
|
|
50
|
+
const detected = await discoverDefaults(workspace, { codexHome, claudeHome })
|
|
49
51
|
if (!detected.some((p) => p.id === preset.id)) throw new Error('Client settings changed. Refresh available AIs and select the updated choice.')
|
|
50
52
|
}
|
|
51
53
|
} else await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
|
package/src/plugin-broker.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { combinePreflight } from './schedule-preflight.js';
|
|
1
2
|
import * as fs from 'node:fs/promises';
|
|
3
|
+
import {callDeliverySocket} from './delivery-socket-client.mjs';
|
|
2
4
|
import path from 'node:path';
|
|
3
5
|
import { createServer } from 'node:net';
|
|
4
6
|
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
|
@@ -223,23 +225,37 @@ const managerCommand = async (binding, args, signal, runId) => new Promise((reso
|
|
|
223
225
|
});
|
|
224
226
|
});
|
|
225
227
|
|
|
226
|
-
const
|
|
228
|
+
const authorizeTaskLaunch=async(binding,request)=>{
|
|
229
|
+
const input=JSON.parse(request.stdin)
|
|
230
|
+
if(Object.keys(input).some(k=>!['taskId','taskToken','expiresAt'].includes(k)) || typeof input.taskToken!=='string' || !/^[a-f0-9]{64}$/.test(input.taskToken))throw Error('Invalid task launch input')
|
|
231
|
+
const ticketHash=createHash('sha256').update(input.taskToken).digest('hex')
|
|
232
|
+
const grant=await callDeliverySocket(deliverySocketPath(binding.controlDir),{op:'taskApplication',payload:{runId:request.runId,ticketHash}})
|
|
233
|
+
if(input.taskId!==grant.taskId || input.expiresAt!==grant.expiresAt)throw Error('Task launch authority mismatch')
|
|
234
|
+
return grant.launch
|
|
235
|
+
}
|
|
236
|
+
const invokePlugin = async (binding, request, signal, scheduledPreflight = false) => {
|
|
227
237
|
const startedAt = new Date().toISOString();
|
|
228
238
|
let plugin = request.alias;
|
|
229
239
|
let revision = request.revision;
|
|
230
240
|
let result;
|
|
231
241
|
let failure;
|
|
232
242
|
try {
|
|
233
|
-
|
|
243
|
+
if (!scheduledPreflight) {
|
|
244
|
+
if(request.operation==='task-launch') {
|
|
245
|
+
const launch=await authorizeTaskLaunch(binding,request)
|
|
246
|
+
if(launch.command!==request.alias || JSON.stringify(launch.args)!==JSON.stringify(request.args))throw Error('Task launch command changed')
|
|
247
|
+
}else await authorize(binding, request.runId);
|
|
248
|
+
}
|
|
234
249
|
const current = await registry(binding.home);
|
|
235
250
|
const selected = recordForAlias(current, request.alias);
|
|
251
|
+
if (scheduledPreflight && (!selected.command.exposure || selected.command.exposure.changesRecords !== false || selected.command.exposure.sendsExternally !== false)) throw Error('Scheduled preflight requires an explicitly read-only plugin command');
|
|
236
252
|
plugin = selected.plugin;
|
|
237
253
|
revision = selected.record.revision;
|
|
238
254
|
if (selected.record.revision !== request.revision) throw Error('Plugin changed; discover again');
|
|
239
255
|
const command = await prepareCommand(binding.home, request.alias, request.args, {
|
|
240
256
|
revision: request.revision,
|
|
241
257
|
invocation: true,
|
|
242
|
-
environment: { EZ_CONTROL_DIR: binding.controlDir, EZ_RUN_ID: request.runId, EZ_DELIVERY_SOCKET: process.env.EZ_DELIVERY_SOCKET ?? deliverySocketPath(binding.controlDir) },
|
|
258
|
+
environment: { EZ_CONTROL_DIR: binding.controlDir, ...(scheduledPreflight ? {} : {EZ_RUN_ID: request.runId}), EZ_DELIVERY_SOCKET: process.env.EZ_DELIVERY_SOCKET ?? deliverySocketPath(binding.controlDir) },
|
|
243
259
|
});
|
|
244
260
|
try {
|
|
245
261
|
result = await dockerRun(command.argv, {
|
|
@@ -282,8 +298,17 @@ const invokePlugin = async (binding, request, signal) => {
|
|
|
282
298
|
|
|
283
299
|
export async function validateBrokerRequest(request, workspace) {
|
|
284
300
|
if (!request || typeof request !== 'object' || request.version !== 1 || !REQUEST_ID.test(request.id)) throw Error('Invalid plugin broker request');
|
|
285
|
-
if
|
|
301
|
+
if(request.operation === 'preflight') {
|
|
302
|
+
validId(request.scheduleId,RUN_ID,'schedule ID'); validId(request.revision,/^[a-f0-9-]{36}$/,'schedule revision');
|
|
303
|
+
if(Object.keys(request).some(k=>!['version','id','operation','scheduleId','revision'].includes(k))) throw Error('Unexpected schedule preflight field');
|
|
304
|
+
return request;
|
|
305
|
+
}
|
|
306
|
+
if (!['resolve', 'invoke', 'manager','task-launch'].includes(request.operation)) throw Error('Unknown plugin broker operation');
|
|
286
307
|
validId(request.runId, RUN_ID, 'plugin run ID');
|
|
308
|
+
if(request.operation==='task-launch') {
|
|
309
|
+
if(Object.keys(request).some(k=>!['version','id','operation','runId','stdin'].includes(k)))throw Error('Unexpected task launch field')
|
|
310
|
+
boundedString(request.stdin,1024,'task launch stdin');return request
|
|
311
|
+
}
|
|
287
312
|
if (request.operation === 'manager') {
|
|
288
313
|
await validateManagerArgs(request.args, workspace);
|
|
289
314
|
if (Object.keys(request).some(key => !['version', 'id', 'operation', 'runId', 'args'].includes(key))) throw Error('Unexpected broker management field');
|
|
@@ -328,6 +353,18 @@ export async function servePluginBroker(binding, signal = new AbortController().
|
|
|
328
353
|
(async () => {
|
|
329
354
|
try {
|
|
330
355
|
await validateBrokerRequest(request, binding.workspace);
|
|
356
|
+
if (request.operation === 'preflight') {
|
|
357
|
+
const condition=await (await import('./delivery-socket-client.mjs')).callDeliverySocket(deliverySocketPath(binding.controlDir),{op:'schedulePreflight',payload:{scheduleId:request.scheduleId,revision:request.revision}});
|
|
358
|
+
const values=[];
|
|
359
|
+
for(const check of condition.checks) {
|
|
360
|
+
const selected=recordForAlias(await registry(binding.home),check.alias);
|
|
361
|
+
const result=await invokePlugin(binding,{alias:check.alias,args:check.args,revision:selected.record.revision,runId:`preflight_${request.scheduleId}`,id:randomUUID()},abort.signal,true);
|
|
362
|
+
if(!result.ok || result.code!==0) throw Error('Scheduled plugin preflight unavailable');
|
|
363
|
+
values.push(JSON.parse(result.stdout));
|
|
364
|
+
}
|
|
365
|
+
response(socket,{version:1,id:request.id,ok:true,result:combinePreflight(values)});
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
331
368
|
if (request.operation === 'resolve') {
|
|
332
369
|
await authorize(binding, request.runId);
|
|
333
370
|
const selected = recordForAlias(await registry(binding.home), request.alias);
|
|
@@ -338,6 +375,12 @@ export async function servePluginBroker(binding, signal = new AbortController().
|
|
|
338
375
|
response(socket, { version: 1, id: request.id, ok: true, plugin: selected.plugin, alias: request.alias, revision: selected.record.revision, capability });
|
|
339
376
|
return;
|
|
340
377
|
}
|
|
378
|
+
if(request.operation==='task-launch') {
|
|
379
|
+
const launch=await authorizeTaskLaunch(binding,request)
|
|
380
|
+
const selected=recordForAlias(await registry(binding.home),launch.command)
|
|
381
|
+
const result=await invokePlugin(binding,{...request,alias:launch.command,args:launch.args,revision:selected.record.revision},abort.signal)
|
|
382
|
+
response(socket,{version:1,id:request.id,...result});return
|
|
383
|
+
}
|
|
341
384
|
if (request.operation === 'manager') {
|
|
342
385
|
await authorize(binding, request.runId);
|
|
343
386
|
const result = await managerCommand(binding, request.args, abort.signal, request.runId);
|