@jc_stack/ez-agents 0.1.0-beta.49 → 0.1.0-beta.51
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/AGENTS.md +8 -6
- package/CHANGELOG.md +26 -0
- package/README.md +9 -5
- package/bin/ez +16 -7
- package/docs/architecture/ai-selection.md +4 -0
- package/docs/docker-runtime.md +28 -2
- package/docs/plugin-guide.md +3 -3
- package/docs/plugins.md +13 -10
- package/docs/scheduling.md +62 -22
- package/docs/setup.md +2 -2
- package/docs/standalone-cli.md +4 -4
- package/docs/upgrades.md +16 -15
- package/package.json +4 -2
- package/src/agent-guidance.ts +2 -2
- package/src/ai-cli.ts +4 -2
- package/src/ai.ts +17 -5
- package/src/application-channel.ts +12 -8
- package/src/control-state.ts +10 -4
- package/src/delivery-socket-client.mjs +15 -1
- package/src/delivery-socket.ts +18 -4
- package/src/executor.ts +63 -10
- package/src/host-executor-client.ts +23 -8
- package/src/host-executor.ts +63 -32
- package/src/index.ts +157 -35
- package/src/menu.ts +3 -2
- package/src/plugins/manager.mjs +43 -40
- package/src/plugins/workspace-lease.mjs +1 -1
- package/src/runs.ts +67 -3
- package/src/schedule-cli.ts +24 -15
- package/src/scheduled-tasks.ts +53 -17
- package/src/scheduler.ts +7 -8
- package/src/session-close.ts +17 -0
- package/src/setup-fallback.ts +62 -25
- package/src/tools-line.d.mts +1 -0
- package/src/tools-line.mjs +3 -0
- package/src/updates/binding.mjs +4 -10
- package/src/updates/control.mjs +3 -2
- package/src/updates/runtime.mjs +25 -9
- package/src/updates/supervisor.mjs +105 -16
- package/src/workspace.ts +12 -1
- package/test/ai-cli.test.ts +23 -1
- package/test/ai.test.ts +72 -4
- package/test/application-channel.test.ts +24 -0
- package/test/application-controls.test.ts +4 -1
- package/test/delivery-transport.test.ts +41 -2
- package/test/executor.test.ts +32 -4
- package/test/host-executor.test.ts +70 -3
- package/test/intake-relay.test.ts +71 -3
- package/test/model-policy.test.ts +1 -1
- package/test/plugin-broker.test.mjs +48 -1
- package/test/plugin-manager.test.mjs +52 -38
- package/test/run-handoff.test.ts +136 -0
- package/test/schedule-cli.test.ts +24 -1
- package/test/scheduled-tasks.test.ts +45 -15
- package/test/session-close.test.ts +17 -0
- package/test/setup-fallback.test.ts +194 -16
- package/test/updates.test.mjs +55 -12
- package/test/workspace.test.ts +6 -0
package/src/ai.ts
CHANGED
|
@@ -12,7 +12,6 @@ import { AUTH_PROFILE_CLIS, claudeAuthEnvironment, cliHome, isAuthProfile, listA
|
|
|
12
12
|
export type AiPreset = { id: string; name: string; cli: string; provider?: string; authProfile?: string; model?: string; effort?: string }
|
|
13
13
|
// Optional ordered fallbacks are tried only for a scheduled occurrence that never started work.
|
|
14
14
|
export type ExecutionChoice = { sessionId: string; preset: AiPreset; fallbacks?: AiPreset[] }
|
|
15
|
-
export const MAX_FALLBACKS = 3
|
|
16
15
|
export type ModelChoice = { cli: string; provider?: string; authProfile?: string; model?: string; name: string; efforts: string[] }
|
|
17
16
|
type Engine = { cli?: string; authProfile?: string }
|
|
18
17
|
// A native conversation belongs to one client and one credential home.
|
|
@@ -34,7 +33,7 @@ export const isPreset = (p: unknown): p is AiPreset => {
|
|
|
34
33
|
export const isExecutionChoice = (v: unknown): v is ExecutionChoice => {
|
|
35
34
|
const c = v as ExecutionChoice | undefined
|
|
36
35
|
return Boolean(c && /^[0-9a-f-]{36}$/i.test(c.sessionId) && isPreset(c.preset) &&
|
|
37
|
-
(c.fallbacks === undefined || (Array.isArray(c.fallbacks) && c.fallbacks.length > 0 && c.fallbacks.
|
|
36
|
+
(c.fallbacks === undefined || (Array.isArray(c.fallbacks) && c.fallbacks.length > 0 && c.fallbacks.every(isPreset))))
|
|
38
37
|
}
|
|
39
38
|
export const presetLabel = (p: AiPreset) => `${p.cli}${p.authProfile ? `@${p.authProfile}` : ''}${p.provider ? ` (${p.provider})` : ''} · ${p.model || 'client default'} · ${p.effort || 'default effort'}`
|
|
40
39
|
// OpenCode encodes its provider in the native provider/model identifier.
|
|
@@ -264,6 +263,21 @@ export const opencodeProviderAllowlist = (env: NodeJS.ProcessEnv = process.env):
|
|
|
264
263
|
return validateOpencodeProviders([...new Set(raw.split(',').map((p) => p.trim()).filter(Boolean))])
|
|
265
264
|
}
|
|
266
265
|
|
|
266
|
+
// Shared by native menus and application controls; a rejected choice never mutates state.
|
|
267
|
+
export const catalogSelection = (p: {cli?: unknown; provider?: unknown; authProfile?: unknown; model?: unknown; effort?: unknown}, catalog: ModelChoice[]): ModelChoice => {
|
|
268
|
+
const candidate = {...p, id: 'selection', name: 'Selection'}
|
|
269
|
+
if (!isPreset(candidate)) throw new Error('Invalid AI selection fields')
|
|
270
|
+
const model = catalog.find(m => sameEngine(m, candidate) && m.provider === candidate.provider && m.model === candidate.model)
|
|
271
|
+
if (!model) {
|
|
272
|
+
if (!catalog.some(m => sameEngine(m, candidate)))
|
|
273
|
+
throw new Error(`No models advertised for ${candidate.cli}${candidate.authProfile ? `@${candidate.authProfile}` : ''} in the installed client catalog; current selection unchanged.`)
|
|
274
|
+
throw new Error(`Model ${candidate.model ?? '(client default)'} is not in the installed client catalog for ${candidate.cli}; current selection unchanged.`)
|
|
275
|
+
}
|
|
276
|
+
if (candidate.effort !== undefined && !model.efforts.includes(candidate.effort))
|
|
277
|
+
throw new Error(`Effort ${candidate.effort} is not supported for ${candidate.cli} ${candidate.model ?? '(client default)'}. Supported efforts: ${model.efforts.join(', ') || 'none (omit effort)'}; current selection unchanged.`)
|
|
278
|
+
return model
|
|
279
|
+
}
|
|
280
|
+
|
|
267
281
|
export const validateSelection = async (p: AiPreset, catalog: ModelChoice[], available = installed): Promise<void> => {
|
|
268
282
|
assertEffort(p.effort, p.model, p.cli)
|
|
269
283
|
if (!isPreset(p)) throw new Error('This CLI is not installed.')
|
|
@@ -273,9 +287,7 @@ export const validateSelection = async (p: AiPreset, catalog: ModelChoice[], ava
|
|
|
273
287
|
if (!catalog.some((m) => sameEngine(m, p))) throw new Error('This auth profile is not provisioned for the selected CLI.')
|
|
274
288
|
return
|
|
275
289
|
}
|
|
276
|
-
|
|
277
|
-
if (!model || (p.effort && !model.efforts.includes(p.effort)))
|
|
278
|
-
throw new Error('This model/effort is not in the installed client catalog. Refresh the client and try again; no fallback was selected.')
|
|
290
|
+
catalogSelection(p, catalog)
|
|
279
291
|
}
|
|
280
292
|
|
|
281
293
|
export type AuthProfileStatus = { cli: string; authProfile: string | null; provisioned: boolean; sharesHostLogin: boolean
|
|
@@ -2,11 +2,11 @@ import { createServer, type Server, type IncomingMessage, type ServerResponse }
|
|
|
2
2
|
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
3
3
|
import { mkdir, readFile, writeFile, rename, open, unlink, stat, chown } from 'node:fs/promises'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
|
-
import { ControlStore, sessionTitle, telegramOwner, type ControlGuard, type Owner, sameOwner, validOwner, ownerId, ownerEpoch } from './control-state.js'
|
|
5
|
+
import { ControlStore, sessionTitle, telegramOwner, type ControlGuard, type Owner, type SessionState, sameOwner, validOwner, ownerId, ownerEpoch } from './control-state.js'
|
|
6
6
|
import { RunStore, type RunRecord, type OutboxItem } from './runs.js'
|
|
7
7
|
import { ownsRun } from './identity.js'
|
|
8
8
|
import { applicationId, validApplicationOrigin } from './application-origin.js'
|
|
9
|
-
import { isPreset, sameChoice,
|
|
9
|
+
import { isPreset, sameChoice, catalogSelection, type AiPreset, type ModelChoice } from './ai.js'
|
|
10
10
|
import { MAX_INCOMING_ATTACHMENT_BYTES, stageChatAttachment } from './files.js'
|
|
11
11
|
import { assertEffort } from './model-policy.js'
|
|
12
12
|
import { ApprovalStore } from './approval.js'
|
|
@@ -97,6 +97,8 @@ export class ApplicationChannel {
|
|
|
97
97
|
constructor(private options: {
|
|
98
98
|
controlDir: string; workspace?: string; isolation?: string; initial: AiPreset
|
|
99
99
|
wake: () => void
|
|
100
|
+
// Queues the replaced conversation's silent close turn (session-close.ts).
|
|
101
|
+
closeSession?: (session: SessionState | null) => Promise<void>
|
|
100
102
|
cancel: (id: string) => Promise<void>
|
|
101
103
|
speech?: (text: string, language: 'en' | 'es') => Promise<{ buffer: Buffer; mimeType: string }>
|
|
102
104
|
createTelegramPairing?: (bindingId: string, owner: Owner) => Promise<{ connected: true } | { connected: false; url: string; expiresAt: string }>
|
|
@@ -155,7 +157,7 @@ export class ApplicationChannel {
|
|
|
155
157
|
const owner = await this.sharedBinding(bindingId)
|
|
156
158
|
const controls = this.options.aiControls
|
|
157
159
|
if (!controls) throw new Error('Application controls unavailable')
|
|
158
|
-
const value = input as {action?: unknown; expectedSession?: unknown; sessionId?: unknown; presetId?: unknown; cli?: unknown; provider?: unknown; model?: unknown; effort?: unknown}
|
|
160
|
+
const value = input as {action?: unknown; expectedSession?: unknown; sessionId?: unknown; presetId?: unknown; cli?: unknown; provider?: unknown; authProfile?: unknown; model?: unknown; effort?: unknown}
|
|
159
161
|
if (!value || !['new','switch','select','model'].includes(String(value.action)) ||
|
|
160
162
|
!(value.expectedSession === null || (typeof value.expectedSession === 'string' && /^[a-f0-9-]{36}$/.test(value.expectedSession)))) throw new Error('Invalid application control request')
|
|
161
163
|
const fields = {new:[],switch:['sessionId'],select:['presetId'],model:['cli','provider','authProfile','model','effort']}[value.action as 'new'|'switch'|'select'|'model']!
|
|
@@ -168,7 +170,11 @@ export class ApplicationChannel {
|
|
|
168
170
|
const live = (await this.bindings.list()).find(item=>item.bindingId===bindingId)
|
|
169
171
|
if (!live?.shareTelegram || !sameOwner(owner, live.owner)) throw new Error('Application authority revoked')
|
|
170
172
|
}}
|
|
171
|
-
if (value.action === 'new')
|
|
173
|
+
if (value.action === 'new') {
|
|
174
|
+
const previous = await control.getActiveSession()
|
|
175
|
+
await control.resetSession(expected, guard)
|
|
176
|
+
await this.options.closeSession?.(previous)
|
|
177
|
+
}
|
|
172
178
|
if (value.action === 'switch') {
|
|
173
179
|
if (typeof value.sessionId !== 'string') throw new Error('Invalid application conversation')
|
|
174
180
|
// switchSession enforces the same visibility/engine restrictions as /chats.
|
|
@@ -181,8 +187,7 @@ export class ApplicationChannel {
|
|
|
181
187
|
await controls.select(preset, expected, guard)
|
|
182
188
|
}
|
|
183
189
|
if (value.action === 'model') {
|
|
184
|
-
const model = (await controls.catalog())
|
|
185
|
-
if (!model || (value.effort !== undefined && (typeof value.effort !== 'string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
|
|
190
|
+
const model = catalogSelection(value, await controls.catalog())
|
|
186
191
|
await this.sharedBinding(bindingId)
|
|
187
192
|
const preset = await controls.saveSelection(model, value.effort as string | undefined, guard)
|
|
188
193
|
await this.sharedBinding(bindingId)
|
|
@@ -231,8 +236,7 @@ export class ApplicationChannel {
|
|
|
231
236
|
await controls.validate(preset)
|
|
232
237
|
}
|
|
233
238
|
if (value.action==='model') {
|
|
234
|
-
const model = (await controls.catalog())
|
|
235
|
-
if (!model || (value.effort!==undefined && (typeof value.effort!=='string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
|
|
239
|
+
const model = catalogSelection(value, await controls.catalog())
|
|
236
240
|
preset = await controls.saveSelection(model,value.effort as string|undefined,guard)
|
|
237
241
|
}
|
|
238
242
|
await control.changeApplicationSession(hashedScope,guard,preset)
|
package/src/control-state.ts
CHANGED
|
@@ -763,10 +763,16 @@ export class ControlStore {
|
|
|
763
763
|
await requireControlGuard(state, guard)
|
|
764
764
|
if (!state.ai) throw new Error('AI settings not initialized')
|
|
765
765
|
// Auto-detected client defaults come and go with installed clients; only
|
|
766
|
-
// owner-saved choices count toward the cap.
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
766
|
+
// owner-saved choices count toward the cap. Conversations keep their own
|
|
767
|
+
// preset copy, so at the cap the oldest unreferenced choice makes room.
|
|
768
|
+
const ai = state.ai
|
|
769
|
+
const saved = ai.presets.filter((p) => !p.id.startsWith('detected_'))
|
|
770
|
+
if (saved.length >= 12 && !ai.presets.some((p) => p.id === preset.id)) {
|
|
771
|
+
const kept = new Set([ai.selectedId, ai.defaultId, ...(ai.recentIds ?? [])])
|
|
772
|
+
const oldest = saved.find((p) => !kept.has(p.id))!
|
|
773
|
+
ai.presets = ai.presets.filter((p) => p.id !== oldest.id)
|
|
774
|
+
}
|
|
775
|
+
ai.presets = [...ai.presets.filter((p) => p.id !== preset.id), persistedPreset(preset)]
|
|
770
776
|
await this.writeState(state)
|
|
771
777
|
})
|
|
772
778
|
}
|
|
@@ -78,7 +78,7 @@ const readEndpoint = async controlDir => {
|
|
|
78
78
|
} catch { return null }
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
const callOnce = async (socketPath, op, timeoutMs) => {
|
|
82
82
|
const id = `${Date.now().toString(36)}_${Math.floor(Math.random() * 0xffffff).toString(16)}`
|
|
83
83
|
try {
|
|
84
84
|
return await exchange(() => createConnection(socketPath), { id, ...op }, timeoutMs, socketPath)
|
|
@@ -104,3 +104,17 @@ export const deliverySocketAlive = async (socketPath, timeoutMs = 2000) => {
|
|
|
104
104
|
return false
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
|
+
|
|
108
|
+
// Only retry connection failures during a declared runtime swap. No frame was
|
|
109
|
+
// accepted in that case. Timeouts, provider failures and uncertain writes fail.
|
|
110
|
+
export const callDeliverySocket = async (socketPath, op, timeoutMs = 130_000) => {
|
|
111
|
+
const until=Date.now()+120000;
|
|
112
|
+
for(;;){
|
|
113
|
+
try{return await callOnce(socketPath,op,timeoutMs);}
|
|
114
|
+
catch(error){
|
|
115
|
+
if(!error?.connectFailure||op.op!=='enqueue'||Date.now()>=until)throw error;
|
|
116
|
+
try{await readFile(path.join(path.dirname(socketPath),'upgrade-pause.json'),'utf8');}catch{throw error;}
|
|
117
|
+
await new Promise(resolve=>setTimeout(resolve,250));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
};
|
package/src/delivery-socket.ts
CHANGED
|
@@ -83,7 +83,7 @@ export const authorizeRun = async (controlDir: string, runId: string): Promise<R
|
|
|
83
83
|
const encode = (value: unknown): string => `${JSON.stringify(value)}\n`
|
|
84
84
|
|
|
85
85
|
export type DeliveryEndpoint = { host: '127.0.0.1'; port: number; token: string }
|
|
86
|
-
export type DeliveryServeOptions = { tcpPort?: number }
|
|
86
|
+
export type DeliveryServeOptions = { tcpPort?: number; beforeClose?: () => Promise<void> }
|
|
87
87
|
const endpointFile = 'delivery-endpoint.json'
|
|
88
88
|
const tokensMatch = (candidate: unknown, token: string): boolean =>
|
|
89
89
|
typeof candidate === 'string' && candidate.length === token.length && timingSafeEqual(Buffer.from(candidate), Buffer.from(token))
|
|
@@ -94,6 +94,8 @@ export const serveDeliverySocket = async (
|
|
|
94
94
|
options: DeliveryServeOptions = {},
|
|
95
95
|
): Promise<{ socketPath: string; endpoint: DeliveryEndpoint | null; stop: () => Promise<void> }> => {
|
|
96
96
|
const socketPath = deliverySocketPath(controlDir)
|
|
97
|
+
let closing=false
|
|
98
|
+
const admissions=new Set<Promise<void>>()
|
|
97
99
|
await rm(socketPath, { force: true }).catch(() => {})
|
|
98
100
|
const attach = (socket: Socket, token: string | null): void => {
|
|
99
101
|
let buffer = ''
|
|
@@ -105,7 +107,8 @@ export const serveDeliverySocket = async (
|
|
|
105
107
|
const line = buffer.slice(0, newline)
|
|
106
108
|
buffer = buffer.slice(newline + 1)
|
|
107
109
|
if (!line.trim()) continue
|
|
108
|
-
|
|
110
|
+
let admission=false
|
|
111
|
+
const work=(async () => {
|
|
109
112
|
let id: unknown = null
|
|
110
113
|
try {
|
|
111
114
|
const envelope = JSON.parse(line) as { id?: unknown; token?: unknown; op?: unknown; payload?: unknown }
|
|
@@ -116,12 +119,19 @@ export const serveDeliverySocket = async (
|
|
|
116
119
|
// re-verifies owner authority server-side.
|
|
117
120
|
if (token !== null && !tokensMatch(envelope.token, token)) { socket.destroy(); return }
|
|
118
121
|
const { token: _token, ...request } = envelope
|
|
122
|
+
if(request.op==='enqueue'){
|
|
123
|
+
if(closing)throw Error('Delivery relay is shutting down; message was not accepted')
|
|
124
|
+
admission=true
|
|
125
|
+
}
|
|
119
126
|
const result = await handle(request as DeliverySocketOp)
|
|
120
127
|
socket.write(encode({ id, ok: true, result: result ?? null }))
|
|
121
128
|
} catch (error) {
|
|
122
129
|
socket.write(encode({ id, ok: false, error: error instanceof Error ? error.message : String(error ?? 'Unknown error') }))
|
|
123
130
|
}
|
|
124
131
|
})()
|
|
132
|
+
// Only finite admission handlers are a drain barrier; a receipt wait
|
|
133
|
+
// completes after the relay's delivery pump flushes the accepted items.
|
|
134
|
+
if(admission){admissions.add(work);void work.finally(()=>admissions.delete(work))}
|
|
125
135
|
}
|
|
126
136
|
})
|
|
127
137
|
}
|
|
@@ -176,9 +186,13 @@ export const serveDeliverySocket = async (
|
|
|
176
186
|
socketPath,
|
|
177
187
|
endpoint,
|
|
178
188
|
stop: async () => {
|
|
179
|
-
|
|
189
|
+
closing=true
|
|
190
|
+
const closed=Promise.all([new Promise<void>((resolve) => server.close(() => resolve())),
|
|
191
|
+
...(tcpServer ? [new Promise<void>((resolve) => tcpServer!.close(() => resolve()))] : [])])
|
|
192
|
+
await Promise.all(admissions)
|
|
193
|
+
await options.beforeClose?.()
|
|
194
|
+
await closed
|
|
180
195
|
await rm(socketPath, { force: true }).catch(() => {})
|
|
181
|
-
if (tcpServer) await new Promise<void>((resolve) => tcpServer.close(() => resolve()))
|
|
182
196
|
if (tcpEndpoint) await rm(path.join(controlDir, endpointFile), { force: true }).catch(() => {})
|
|
183
197
|
},
|
|
184
198
|
}
|
package/src/executor.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { executionDefaults } from './model-policy.js'
|
|
2
2
|
import { startTaskExecutor } from './task-executor.js'
|
|
3
3
|
import { deliverySocketPath } from './delivery-socket.js'
|
|
4
|
-
import { mkdtemp, rm, writeFile, mkdir, symlink, readFile } from 'node:fs/promises'
|
|
4
|
+
import { mkdtemp, rm, writeFile, mkdir, symlink, readFile, rename } from 'node:fs/promises'
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
5
6
|
import { accessSync, openSync, closeSync, constants as fsConstants } from 'node:fs'
|
|
6
7
|
import { tmpdir, homedir } from 'node:os'
|
|
7
8
|
import path from 'node:path'
|
|
@@ -13,9 +14,17 @@ import { DESKTOP_UNAVAILABLE } from './desktop-bridge.js'
|
|
|
13
14
|
import { macosWorkspaceProfile, workspaceSiblingDenies } from './workspace-confine.js'
|
|
14
15
|
import { Scripts, type ScriptRunRef } from './scripts.js'
|
|
15
16
|
import { claudeAuthEnvironment, cliHome, provisionedHome } from './auth-profile.js'
|
|
17
|
+
import { isHostRunId } from './host-executor-protocol.js'
|
|
18
|
+
|
|
19
|
+
const hostCancelFiles = new WeakMap<ChildProcess, string>()
|
|
20
|
+
const hostCancelFile = (controlDir: string, runId: string): string => {
|
|
21
|
+
if (!isHostRunId(runId)) throw new Error('Invalid host executor run ID')
|
|
22
|
+
return path.join(controlDir, 'host-executor', runId + '.cancel')
|
|
23
|
+
}
|
|
16
24
|
|
|
17
25
|
export type ExecutorOptions = {
|
|
18
26
|
repairEnabled?: boolean
|
|
27
|
+
catalogTransport?: 'host' | 'local'
|
|
19
28
|
workspace: string
|
|
20
29
|
timeoutMs: number
|
|
21
30
|
runId: string
|
|
@@ -79,7 +88,7 @@ export const executorEnvironment = (environment: NodeJS.ProcessEnv = process.env
|
|
|
79
88
|
}
|
|
80
89
|
|
|
81
90
|
export const executorJobEnv = (
|
|
82
|
-
options: Pick<ExecutorOptions, 'runId' | 'controlDir' | 'binDir' | 'toolsHome' | 'repairEnabled'> & { workspace?: string },
|
|
91
|
+
options: Pick<ExecutorOptions, 'runId' | 'controlDir' | 'binDir' | 'toolsHome' | 'repairEnabled' | 'catalogTransport'> & { workspace?: string },
|
|
83
92
|
environment: NodeJS.ProcessEnv = process.env,
|
|
84
93
|
): NodeJS.ProcessEnv => {
|
|
85
94
|
const base = executorEnvironment(environment)
|
|
@@ -88,6 +97,7 @@ export const executorJobEnv = (
|
|
|
88
97
|
...base,
|
|
89
98
|
PATH: pathValue,
|
|
90
99
|
EZ_RUN_ID: options.runId,
|
|
100
|
+
EZ_AI_CATALOG: options.catalogTransport ?? (environment.EZ_EXECUTOR_TRANSPORT==='host' ? 'host' : 'local'),
|
|
91
101
|
EZ_CONTROL_DIR: options.controlDir,
|
|
92
102
|
...(options.workspace ? { EZ_AGENT_WORKSPACE: options.workspace } : {}),
|
|
93
103
|
EZ_DELIVERY_SOCKET: deliverySocketPath(options.controlDir),
|
|
@@ -340,22 +350,33 @@ export const executorInvocation = (command: string, args: string[]) => {
|
|
|
340
350
|
: { command, args }
|
|
341
351
|
}
|
|
342
352
|
|
|
353
|
+
// The isolated worker uses the same file transport as the host worker, but
|
|
354
|
+
// stays in its own container. A published worker binding must never fall back
|
|
355
|
+
// to local execution when its heartbeat expires.
|
|
356
|
+
export const usesFileTransport = async (controlDir: string, env: NodeJS.ProcessEnv = process.env): Promise<boolean> => {
|
|
357
|
+
if (env.EZ_CONTAINER_EXECUTOR === '1') return false
|
|
358
|
+
if (env.EZ_EXECUTOR_TRANSPORT === 'host') return true
|
|
359
|
+
try { return JSON.parse(await readFile(path.join(controlDir,'host-executor/heartbeat.json'),'utf8')).containerExecutor === true }
|
|
360
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; return false }
|
|
361
|
+
}
|
|
362
|
+
|
|
343
363
|
export const startExecutorJob = async (
|
|
344
364
|
texts: string[],
|
|
345
365
|
options: ExecutorOptions,
|
|
346
366
|
): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; stdout: string }> => {
|
|
347
|
-
|
|
367
|
+
const host = await usesFileTransport(options.controlDir)
|
|
368
|
+
const cancelFile = host ? hostCancelFile(options.controlDir, options.runId) : undefined
|
|
369
|
+
if (options.script && !host) return startScriptJob(options as ExecutorOptions & { script: ScriptLaunch })
|
|
348
370
|
options = executionDefaults(executorKey(options.cli), options)
|
|
349
371
|
// Named credential homes are owner-provisioned; fail closed before any launch state.
|
|
350
372
|
if (options.authProfile !== undefined) await provisionedHome(options.controlDir, executorKey(options.cli), options.authProfile)
|
|
351
373
|
// Routing is caller-owned: a restricted task run goes to the task runner
|
|
352
374
|
// unless the host transport must ship it across the boundary first.
|
|
353
|
-
if (options.taskRun &&
|
|
375
|
+
if (options.taskRun && !host) return startTaskExecutor(options)
|
|
354
376
|
if (options.nativeSession && !/^[a-zA-Z0-9_-]+$/.test(options.runId)) throw new Error('Invalid native task run ID')
|
|
355
377
|
if (options.codexSandbox !== undefined && (options.codexSandbox !== 'external' || process.env.EZ_EXECUTOR_TRANSPORT !== 'local' || options.taskRun || executorKey(options.cli) !== 'codex')) throw new Error('External Codex sandbox requires an owner-authorized native local run')
|
|
356
378
|
const outputDirectory = await mkdtemp(path.join(tmpdir(), 'ezenciel-agents-'))
|
|
357
379
|
const key = executorKey(options.cli)
|
|
358
|
-
const host = process.env.EZ_EXECUTOR_TRANSPORT === 'host'
|
|
359
380
|
const gui = !host && key === 'codex-gui'
|
|
360
381
|
const nativeSession = !host && key === 'codex' && options.nativeSession === true
|
|
361
382
|
// Literal input plus the caller-provided trigger line. No coaching,
|
|
@@ -458,6 +479,7 @@ export const startExecutorJob = async (
|
|
|
458
479
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
459
480
|
detached: process.platform !== 'win32',
|
|
460
481
|
})
|
|
482
|
+
if (cancelFile) hostCancelFiles.set(child, cancelFile)
|
|
461
483
|
await new Promise<void>((resolve, reject) => {
|
|
462
484
|
child.once('spawn', resolve)
|
|
463
485
|
child.once('error', reject)
|
|
@@ -500,6 +522,17 @@ export const startExecutorJob = async (
|
|
|
500
522
|
|
|
501
523
|
export const startGrokJob = startExecutorJob
|
|
502
524
|
|
|
525
|
+
// Watch a host-transport run a previous relay submitted; it is never resubmitted.
|
|
526
|
+
export const attachHostJob = async (controlDir: string, runId: string): Promise<ChildProcess> => {
|
|
527
|
+
const cancelFile = hostCancelFile(controlDir, runId)
|
|
528
|
+
const invocation = executorInvocation(process.execPath, ['--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)),
|
|
529
|
+
fileURLToPath(new URL('./host-executor-client.ts', import.meta.url)), controlDir, runId, '--attach'])
|
|
530
|
+
const child = spawn(invocation.command, invocation.args, { env: executorEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32' })
|
|
531
|
+
hostCancelFiles.set(child, cancelFile)
|
|
532
|
+
await new Promise<void>((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject) })
|
|
533
|
+
return child
|
|
534
|
+
}
|
|
535
|
+
|
|
503
536
|
// Spawn a verified registered script with the executor identity, environment
|
|
504
537
|
// allowlist and workspace confinement. No shell, model, prompt or native session.
|
|
505
538
|
export const startScriptJob = async (
|
|
@@ -543,11 +576,27 @@ export const nativeSessionId = (cli: string, line: string): string | undefined =
|
|
|
543
576
|
} catch {}
|
|
544
577
|
}
|
|
545
578
|
|
|
546
|
-
const terminating = new
|
|
547
|
-
export const terminateJob = (child: ChildProcess, inspect = processSnapshot):
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
579
|
+
const terminating = new WeakMap<ChildProcess, Promise<boolean>>()
|
|
580
|
+
export const terminateJob = (child: ChildProcess, inspect = processSnapshot): Promise<boolean> => {
|
|
581
|
+
const pending = terminating.get(child)
|
|
582
|
+
if (pending) return pending
|
|
583
|
+
if (!child.pid || child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true)
|
|
584
|
+
const work = (async () => {
|
|
585
|
+
const cancelFile = hostCancelFiles.get(child)
|
|
586
|
+
if (cancelFile) {
|
|
587
|
+
// The observer may still be loading, or unable to handle a signal. Commit
|
|
588
|
+
// the native worker's stop request before terminating that observer.
|
|
589
|
+
const temporary = cancelFile + '.' + process.pid + '.' + randomUUID() + '.tmp'
|
|
590
|
+
try {
|
|
591
|
+
await mkdir(path.dirname(cancelFile), { recursive: true, mode: 0o700 })
|
|
592
|
+
await writeFile(temporary, '', { mode: 0o600, flag: 'wx' })
|
|
593
|
+
await rename(temporary, cancelFile)
|
|
594
|
+
} catch (error) {
|
|
595
|
+
await rm(temporary, { force: true }).catch(() => {})
|
|
596
|
+
console.error('Cannot request host executor cancellation; observer remains active', error)
|
|
597
|
+
return false
|
|
598
|
+
}
|
|
599
|
+
}
|
|
551
600
|
const targets = new Set([child.pid!])
|
|
552
601
|
// Native tool terminals can start separate process groups. Capture ancestry
|
|
553
602
|
// before stopping the CLI, while those children still have their parent.
|
|
@@ -575,5 +624,9 @@ export const terminateJob = (child: ChildProcess, inspect = processSnapshot): vo
|
|
|
575
624
|
void processSnapshot().then(current => signal(matchingProcessIds(identities, current), 'SIGKILL'))
|
|
576
625
|
.catch(error => console.error('Cannot inspect executor descendants for escalation', error))
|
|
577
626
|
}, 3000)
|
|
627
|
+
return true
|
|
578
628
|
})()
|
|
629
|
+
terminating.set(child, work)
|
|
630
|
+
void work.then(stopped => { if (!stopped) terminating.delete(child) })
|
|
631
|
+
return work
|
|
579
632
|
}
|
|
@@ -1,17 +1,29 @@
|
|
|
1
1
|
// File transport across the Docker/host boundary. The host chooses the CLI,
|
|
2
2
|
// workspace and environment; a request cannot choose a command or credentials.
|
|
3
|
-
import { mkdir, readFile, writeFile, rename, rm } from 'node:fs/promises'
|
|
3
|
+
import { mkdir, readFile, writeFile, rename, rm, stat } from 'node:fs/promises'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { isHostRunId } from './host-executor-protocol.js'
|
|
6
|
-
const [control, id] = process.argv.slice(2)
|
|
7
|
-
if (!control || !isHostRunId(id || '')) throw new Error('Invalid executor binding')
|
|
6
|
+
const [control, id, mode] = process.argv.slice(2)
|
|
7
|
+
if (!control || !isHostRunId(id || '') || (mode !== undefined && mode !== '--attach')) throw new Error('Invalid executor binding')
|
|
8
8
|
const directory = path.join(control, 'host-executor')
|
|
9
9
|
await mkdir(directory, {recursive:true, mode:0o700})
|
|
10
|
-
let input = ''
|
|
11
|
-
for await (const chunk of process.stdin) input += chunk
|
|
12
10
|
const base = path.join(directory, id)
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
if (mode === '--attach') {
|
|
12
|
+
// A previous relay submitted this run: watch it, never resubmit. Without a
|
|
13
|
+
// request, claim or event record its outcome is unknown.
|
|
14
|
+
// Check in lifecycle order: claim renames the request, and the host writes
|
|
15
|
+
// the exit event before removing the claim, so a live run is always seen.
|
|
16
|
+
const exists = (suffix: string) => stat(base+suffix).then(() => true, error => { if (error.code === 'ENOENT') return false; throw error })
|
|
17
|
+
if (!await exists('.request.json') && !await exists('.running.json') && !await exists('.events')) {
|
|
18
|
+
console.error('Handed-off host run has no request, claim or result; its outcome is unknown')
|
|
19
|
+
process.exit(1)
|
|
20
|
+
}
|
|
21
|
+
} else {
|
|
22
|
+
let input = ''
|
|
23
|
+
for await (const chunk of process.stdin) input += chunk
|
|
24
|
+
await writeFile(base+'.tmp', input, {mode:0o600, flag:'wx'})
|
|
25
|
+
await rename(base+'.tmp', base+'.request.json')
|
|
26
|
+
}
|
|
15
27
|
let interrupted = false
|
|
16
28
|
for (const signal of ['SIGTERM','SIGINT'] as const) process.once(signal, () => {
|
|
17
29
|
if (interrupted) return
|
|
@@ -38,7 +50,10 @@ try {
|
|
|
38
50
|
// Drain completion first. A brief missing file at the shared-filesystem
|
|
39
51
|
// boundary must not cancel a healthy job or hide its terminal result.
|
|
40
52
|
try {
|
|
41
|
-
const
|
|
53
|
+
const claimed = await stat(base+'.running.json').then(()=>true,error=>{if(error.code==='ENOENT')return false;throw error})
|
|
54
|
+
const heartbeat = claimed
|
|
55
|
+
? {at:(await stat(base+'.events')).mtimeMs}
|
|
56
|
+
: JSON.parse(await readFile(path.join(directory,'heartbeat.json'),'utf8'))
|
|
42
57
|
if (!Number.isFinite(heartbeat.at)) throw new Error('Invalid host CLI heartbeat')
|
|
43
58
|
lastHeartbeat = heartbeat.at
|
|
44
59
|
} catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|