@jc_stack/ez-agents 0.1.0-beta.48 → 0.1.0-beta.49
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/CHANGELOG.md +9 -0
- package/docker/broker-update-smoke.mjs +16 -2
- package/docker/upgrade-smoke.mjs +1 -1
- package/docs/architecture/ai-selection.md +5 -1
- package/docs/docker-runtime.md +28 -0
- package/docs/scheduling.md +41 -0
- package/docs/upgrades.md +4 -10
- package/package.json +1 -1
- package/src/ai-cli.ts +28 -15
- package/src/ai.ts +76 -12
- package/src/application-channel.ts +9 -9
- package/src/auth-profile.ts +53 -0
- package/src/codex-session.ts +14 -3
- package/src/control-state.ts +28 -21
- package/src/delivery-socket.ts +2 -0
- package/src/executor.ts +29 -26
- package/src/host-executor.ts +35 -10
- package/src/index.ts +206 -112
- package/src/menu.ts +5 -5
- package/src/plugin-broker.mjs +4 -11
- package/src/plugins/manager.mjs +27 -8
- package/src/runs.ts +13 -2
- package/src/schedule-cli.ts +33 -7
- package/src/scheduler.ts +9 -4
- package/src/setup-fallback.ts +122 -0
- package/src/task-executor.ts +3 -2
- package/src/updates/runtime.mjs +79 -41
- package/src/updates/supervisor.mjs +27 -6
- package/test/auth-profiles.test.ts +187 -0
- package/test/plugin-broker.test.mjs +3 -0
- package/test/plugin-manager.test.mjs +24 -0
- package/test/setup-fallback.test.ts +245 -0
- package/test/updates.test.mjs +58 -5
package/src/control-state.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { assertEffort } from './model-policy.js'
|
|
|
2
2
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
3
3
|
import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
4
4
|
import path from 'node:path'
|
|
5
|
-
import { isPreset, persistedPreset, presetProvider, type AiPreset, type ExecutionChoice, type ModelChoice } from './ai.js'
|
|
5
|
+
import { isPreset, persistedPreset, presetProvider, sameEngine, type AiPreset, type ExecutionChoice, type ModelChoice } from './ai.js'
|
|
6
|
+
import { isAuthProfile } from './auth-profile.js'
|
|
6
7
|
|
|
7
8
|
export type Owner = {
|
|
8
9
|
id?: string
|
|
@@ -58,6 +59,8 @@ export type SessionState = {
|
|
|
58
59
|
sessionId: string
|
|
59
60
|
hasStarted: boolean
|
|
60
61
|
cli?: string
|
|
62
|
+
// Bound with cli at creation: a native session never resumes under another home.
|
|
63
|
+
authProfile?: string
|
|
61
64
|
nativeSessionId?: string
|
|
62
65
|
title?: string
|
|
63
66
|
archived?: boolean
|
|
@@ -129,7 +132,8 @@ const isState = (value: unknown): value is ControlState => {
|
|
|
129
132
|
(s.archived === undefined || typeof s.archived === 'boolean') &&
|
|
130
133
|
(s.applicationScope === undefined || /^[a-f0-9]{64}$/.test(s.applicationScope)) &&
|
|
131
134
|
(s.telegramShared === undefined || typeof s.telegramShared === 'boolean') &&
|
|
132
|
-
(s.
|
|
135
|
+
(s.authProfile === undefined || (isAuthProfile(s.authProfile) && s.cli !== undefined)) &&
|
|
136
|
+
(s.preset === undefined || (isPreset(s.preset) && sameEngine(s.preset, s)))
|
|
133
137
|
return (
|
|
134
138
|
candidate.version === 1 &&
|
|
135
139
|
Array.isArray(candidate.pending) &&
|
|
@@ -150,9 +154,11 @@ const isState = (value: unknown): value is ControlState => {
|
|
|
150
154
|
export const sessionTitle = (session: SessionState): string =>
|
|
151
155
|
session.title || `Conversation ${session.sessionId.slice(0, 8)}`
|
|
152
156
|
|
|
157
|
+
const engineOf = (preset: AiPreset) => ({ cli: preset.cli, ...(preset.authProfile ? { authProfile: preset.authProfile } : {}) })
|
|
158
|
+
|
|
153
159
|
const rememberPreset = (state: ControlState) => {
|
|
154
160
|
const preset = state.ai?.presets.find(p => p.id === state.ai!.selectedId)
|
|
155
|
-
if (state.activeSession && preset && state.activeSession
|
|
161
|
+
if (state.activeSession && preset && sameEngine(state.activeSession, preset))
|
|
156
162
|
state.activeSession.preset = preset
|
|
157
163
|
}
|
|
158
164
|
|
|
@@ -486,8 +492,8 @@ export class ControlStore {
|
|
|
486
492
|
const ai = state.ai
|
|
487
493
|
// Older sessions did not record their model. Reuse a known preset for the
|
|
488
494
|
// same CLI; never resume an engine ID through a different client.
|
|
489
|
-
const preset = session.preset ?? ai?.presets.find(p => p
|
|
490
|
-
if (!ai || !preset || preset
|
|
495
|
+
const preset = session.preset ?? ai?.presets.find(p => sameEngine(p, session))
|
|
496
|
+
if (!ai || !preset || !sameEngine(preset, session))
|
|
491
497
|
throw new Error('This older conversation has no saved AI binding. Start a new conversation.')
|
|
492
498
|
if (session.hasStarted && ['codex', 'codex-gui', 'opencode'].includes(session.cli!) && !session.nativeSessionId)
|
|
493
499
|
throw new Error('This conversation has no native session ID. Start a new conversation.')
|
|
@@ -537,10 +543,11 @@ export class ControlStore {
|
|
|
537
543
|
const state = await this.readState()
|
|
538
544
|
await requireControlGuard(state, guard)
|
|
539
545
|
if (expectedSession !== undefined && (state.activeSession?.sessionId ?? null) !== expectedSession) throw new Error('Conversation changed. Refresh controls before trying again.')
|
|
546
|
+
const fallback = state.ai?.presets.find((p) => p.id === state.ai!.defaultId)
|
|
540
547
|
const next: SessionState = {
|
|
541
548
|
sessionId: crypto.randomUUID(),
|
|
542
549
|
hasStarted: false,
|
|
543
|
-
|
|
550
|
+
...(fallback ? engineOf(fallback) : {}),
|
|
544
551
|
}
|
|
545
552
|
rememberPreset(state)
|
|
546
553
|
if (state.activeSession) (state.sessions ??= []).push(state.activeSession)
|
|
@@ -624,25 +631,25 @@ export class ControlStore {
|
|
|
624
631
|
const settledPreset = settledAi?.presets.find((p) => p.id === settledAi.selectedId)
|
|
625
632
|
const session = settled.activeSession
|
|
626
633
|
if (settledPreset && session && (session.cli || session.hasStarted) &&
|
|
627
|
-
!(session
|
|
634
|
+
!(sameEngine(session, settledPreset) && session.hasStarted && session.preset &&
|
|
628
635
|
presetProvider(session.preset) !== presetProvider(settledPreset)) &&
|
|
629
|
-
(session
|
|
636
|
+
(!sameEngine(session, settledPreset) || JSON.stringify(session.preset) === JSON.stringify(settledPreset)) &&
|
|
630
637
|
Boolean(session.title || session.hasStarted || !title?.trim()))
|
|
631
638
|
return { sessionId: session.sessionId, preset: settledPreset }
|
|
632
639
|
return this.withLock(async () => {
|
|
633
640
|
const state = await this.readState()
|
|
634
641
|
state.ai ??= { presets: [persistedPreset(initial)], defaultId: initial.id, selectedId: initial.id, recentIds: [] }
|
|
635
642
|
const preset = state.ai.presets.find((p) => p.id === state.ai!.selectedId)!
|
|
636
|
-
state.activeSession ??= { sessionId: crypto.randomUUID(), hasStarted: false,
|
|
637
|
-
if (!state.activeSession.cli && !state.activeSession.hasStarted) state.activeSession
|
|
638
|
-
if (state.activeSession
|
|
643
|
+
state.activeSession ??= { sessionId: crypto.randomUUID(), hasStarted: false, ...engineOf(preset) }
|
|
644
|
+
if (!state.activeSession.cli && !state.activeSession.hasStarted) Object.assign(state.activeSession, engineOf(preset))
|
|
645
|
+
if (sameEngine(state.activeSession, preset)) {
|
|
639
646
|
const previous = state.activeSession.preset
|
|
640
647
|
// A provider change starts a separate native context. Model and effort
|
|
641
648
|
// changes are native turn settings within the existing conversation.
|
|
642
649
|
if (state.activeSession.hasStarted && previous &&
|
|
643
650
|
presetProvider(previous) !== presetProvider(preset)) {
|
|
644
651
|
;(state.sessions ??= []).push(state.activeSession)
|
|
645
|
-
state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false,
|
|
652
|
+
state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, ...engineOf(preset), preset }
|
|
646
653
|
} else {
|
|
647
654
|
state.activeSession.preset = preset
|
|
648
655
|
}
|
|
@@ -675,14 +682,14 @@ export class ControlStore {
|
|
|
675
682
|
state.ai!.selectedId = session.preset!.id
|
|
676
683
|
}
|
|
677
684
|
if (previous?.preset) {
|
|
678
|
-
if (requested && requested
|
|
685
|
+
if (requested && !sameEngine(requested, previous)) throw new Error('Application request conflicts with existing session engine')
|
|
679
686
|
if (requested) previous.preset = requested
|
|
680
687
|
activate(previous)
|
|
681
688
|
if (shareTelegram || requested) await this.writeState(state)
|
|
682
689
|
return { sessionId: previous.sessionId, preset: previous.preset }
|
|
683
690
|
}
|
|
684
691
|
const preset = requested ?? state.ai.presets.find(item => item.id === state.ai!.selectedId)!
|
|
685
|
-
const session: SessionState = { sessionId: crypto.randomUUID(), hasStarted: false,
|
|
692
|
+
const session: SessionState = { sessionId: crypto.randomUUID(), hasStarted: false, ...engineOf(preset), preset, applicationScope: scope }
|
|
686
693
|
state.sessions.push(session)
|
|
687
694
|
activate(session)
|
|
688
695
|
await this.writeState(state)
|
|
@@ -705,7 +712,7 @@ export class ControlStore {
|
|
|
705
712
|
throw new Error('Application scope is shared; use shared controls')
|
|
706
713
|
const nextPreset = preset ?? state.ai?.presets.find(item => item.id === state.ai!.defaultId)
|
|
707
714
|
if (!nextPreset || !isPreset(nextPreset)) throw new Error('Invalid application AI selection')
|
|
708
|
-
if (preset && previous
|
|
715
|
+
if (preset && previous && sameEngine(previous, preset)) {
|
|
709
716
|
previous.preset = persistedPreset(preset)
|
|
710
717
|
await this.writeState(state)
|
|
711
718
|
return previous
|
|
@@ -714,7 +721,7 @@ export class ControlStore {
|
|
|
714
721
|
// the old immutable session ID; private history stays absent from /chats.
|
|
715
722
|
if (previous) previous.archived = true
|
|
716
723
|
const next: SessionState = {sessionId:crypto.randomUUID(), hasStarted:false,
|
|
717
|
-
|
|
724
|
+
...engineOf(nextPreset), preset:persistedPreset(nextPreset), applicationScope:scope}
|
|
718
725
|
;(state.sessions ??= []).push(next)
|
|
719
726
|
await this.writeState(state)
|
|
720
727
|
return next
|
|
@@ -725,8 +732,8 @@ export class ControlStore {
|
|
|
725
732
|
const state = await this.status()
|
|
726
733
|
const session = state.activeSession?.sessionId === choice.sessionId ? state.activeSession
|
|
727
734
|
: state.sessions?.find((s) => s.sessionId === choice.sessionId)
|
|
728
|
-
if (!session || session
|
|
729
|
-
throw new Error('Conversation has no matching CLI binding. Use New conversation explicitly; files are preserved.')
|
|
735
|
+
if (!session?.cli || !sameEngine(session, choice.preset))
|
|
736
|
+
throw new Error('Conversation has no matching CLI binding for this client and auth profile. Use New conversation explicitly; files are preserved.')
|
|
730
737
|
if (session.hasStarted && ['codex', 'codex-gui', 'opencode'].includes(session.cli) && !session.nativeSessionId)
|
|
731
738
|
throw new Error('Native session ID missing. Use New conversation explicitly; no automatic reset.')
|
|
732
739
|
return session
|
|
@@ -774,16 +781,16 @@ export class ControlStore {
|
|
|
774
781
|
assertEffort(preset.effort, preset.model, preset.cli)
|
|
775
782
|
if ((state.activeSession?.sessionId ?? null) !== expectedSession) throw new Error('Menu expired. Open Choose AI again.')
|
|
776
783
|
const current = ai.presets.find((p) => p.id === ai.selectedId)!
|
|
777
|
-
if (state.activeSession && (current
|
|
784
|
+
if (state.activeSession && (!sameEngine(current, preset) || presetProvider(current) !== presetProvider(preset) || !state.activeSession.cli) && !fresh) return false
|
|
778
785
|
if (fresh || !state.activeSession) {
|
|
779
786
|
rememberPreset(state)
|
|
780
787
|
if (state.activeSession) (state.sessions ??= []).push(state.activeSession)
|
|
781
|
-
state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false,
|
|
788
|
+
state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, ...engineOf(preset), preset: persistedPreset(preset) }
|
|
782
789
|
}
|
|
783
790
|
ai.selectedId = id
|
|
784
791
|
// Persist the conversation's choice now, even if the owner switches chats
|
|
785
792
|
// or restarts before sending another message. Queued choices are snapshots.
|
|
786
|
-
if (state.activeSession
|
|
793
|
+
if (sameEngine(state.activeSession, preset) && (!state.activeSession.preset || presetProvider(state.activeSession.preset) === presetProvider(preset)))
|
|
787
794
|
state.activeSession.preset = persistedPreset(preset)
|
|
788
795
|
ai.recentIds = [id, ...(ai.recentIds ?? []).filter((recentId) => recentId !== id)].slice(0, 3)
|
|
789
796
|
await this.writeState(state)
|
package/src/delivery-socket.ts
CHANGED
|
@@ -318,6 +318,8 @@ export const createLedgerHandler = (controlDir: string, hooks: LedgerHooks): Del
|
|
|
318
318
|
const payload = (op.payload ?? {}) as { runId?: unknown; change?: unknown }
|
|
319
319
|
if (typeof payload.runId !== 'string' || !payload.runId) throw new Error('Run ID is required')
|
|
320
320
|
if (!payload.change || typeof payload.change !== 'object') throw new Error('Patch change is required')
|
|
321
|
+
// Setup attempts select what the host admits; only the relay records them.
|
|
322
|
+
if ('attempts' in payload.change) throw new Error('Setup attempts are relay-owned')
|
|
321
323
|
return runs.patch(payload.runId, payload.change as never)
|
|
322
324
|
}
|
|
323
325
|
case 'taskApplication': {
|
package/src/executor.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
12
12
|
import { DESKTOP_UNAVAILABLE } from './desktop-bridge.js'
|
|
13
13
|
import { macosWorkspaceProfile, workspaceSiblingDenies } from './workspace-confine.js'
|
|
14
14
|
import { Scripts, type ScriptRunRef } from './scripts.js'
|
|
15
|
+
import { claudeAuthEnvironment, cliHome, provisionedHome } from './auth-profile.js'
|
|
15
16
|
|
|
16
17
|
export type ExecutorOptions = {
|
|
17
18
|
repairEnabled?: boolean
|
|
@@ -35,6 +36,8 @@ export type ExecutorOptions = {
|
|
|
35
36
|
model?: string
|
|
36
37
|
effort?: string
|
|
37
38
|
provider?: string
|
|
39
|
+
// Named owner-provisioned credential home; unset keeps the agent default.
|
|
40
|
+
authProfile?: string
|
|
38
41
|
codexSandbox?: 'external'
|
|
39
42
|
codexAutoCompactTokens?: number
|
|
40
43
|
codexProvider?: CodexProviderBinding
|
|
@@ -95,6 +98,10 @@ export const executorJobEnv = (
|
|
|
95
98
|
|
|
96
99
|
export const grokJobEnv = executorJobEnv
|
|
97
100
|
|
|
101
|
+
// The selected native CLI is not installed or not executable. Nothing ran, so a
|
|
102
|
+
// scheduled occurrence may advance to its next setup.
|
|
103
|
+
export class CliUnavailableError extends Error {}
|
|
104
|
+
|
|
98
105
|
// Package binDir is Ez tools (ezenciel-agents-message). Native CLIs come from the
|
|
99
106
|
// host PATH so one agent's wrapper cannot retarget another agent's CODEX_HOME.
|
|
100
107
|
export const resolveHostCommand = (command: string, pathValue = process.env.PATH): string => {
|
|
@@ -107,14 +114,14 @@ export const resolveHostCommand = (command: string, pathValue = process.env.PATH
|
|
|
107
114
|
return candidate
|
|
108
115
|
} catch { /* try the next PATH entry */ }
|
|
109
116
|
}
|
|
110
|
-
throw new
|
|
117
|
+
throw new CliUnavailableError(`Native CLI ${command} is not executable on the host PATH`)
|
|
111
118
|
}
|
|
112
119
|
export type CliAdapter = {
|
|
113
120
|
name: string
|
|
114
121
|
command: string
|
|
115
122
|
description: string
|
|
116
123
|
buildArgs: (
|
|
117
|
-
options: Pick<ExecutorOptions, 'workspace' | 'sessionId' | 'isResume' | 'model' | 'effort' | 'toolsHome' | 'sharedWorkspace' | 'additionalWorkspaces' | 'codexAutoCompactTokens' | 'codexSandbox' | 'codexProvider'> & { controlDir?: string },
|
|
124
|
+
options: Pick<ExecutorOptions, 'workspace' | 'sessionId' | 'isResume' | 'model' | 'effort' | 'toolsHome' | 'sharedWorkspace' | 'additionalWorkspaces' | 'codexAutoCompactTokens' | 'codexSandbox' | 'codexProvider' | 'nativeSession'> & { controlDir?: string },
|
|
118
125
|
promptFile: string,
|
|
119
126
|
promptText: string,
|
|
120
127
|
) => string[]
|
|
@@ -161,6 +168,9 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
|
|
|
161
168
|
args.push('--session-id', opts.sessionId)
|
|
162
169
|
}
|
|
163
170
|
args.push('--print', '--dangerously-skip-permissions')
|
|
171
|
+
// Scheduled runs stream structured events so core can tell a provider
|
|
172
|
+
// rejection before work from a failure after work began.
|
|
173
|
+
if (opts.nativeSession) args.push('--output-format', 'stream-json', '--verbose')
|
|
164
174
|
for (const directory of [opts.controlDir, opts.sharedWorkspace, ...(opts.additionalWorkspaces ?? []), opts.toolsHome])
|
|
165
175
|
if (directory) args.push('--add-dir', directory)
|
|
166
176
|
if (opts.model) args.push('--model', opts.model)
|
|
@@ -294,7 +304,7 @@ export const opencodeDataHome = (controlDir: string): string | undefined => {
|
|
|
294
304
|
}
|
|
295
305
|
|
|
296
306
|
// Agent-bound Claude Code configuration directory (CLAUDE_CONFIG_DIR).
|
|
297
|
-
export const claudeConfigHome = (controlDir: string): string =>
|
|
307
|
+
export const claudeConfigHome = (controlDir: string, authProfile?: string): string => cliHome(controlDir, 'claude', authProfile)
|
|
298
308
|
|
|
299
309
|
// Pi resolves its provider/model catalog from an agent dir (settings.json,
|
|
300
310
|
// auth.json). Only a path is ever returned, never a secret.
|
|
@@ -336,6 +346,8 @@ export const startExecutorJob = async (
|
|
|
336
346
|
): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; stdout: string }> => {
|
|
337
347
|
if (options.script && process.env.EZ_EXECUTOR_TRANSPORT !== 'host') return startScriptJob(options as ExecutorOptions & { script: ScriptLaunch })
|
|
338
348
|
options = executionDefaults(executorKey(options.cli), options)
|
|
349
|
+
// Named credential homes are owner-provisioned; fail closed before any launch state.
|
|
350
|
+
if (options.authProfile !== undefined) await provisionedHome(options.controlDir, executorKey(options.cli), options.authProfile)
|
|
339
351
|
// Routing is caller-owned: a restricted task run goes to the task runner
|
|
340
352
|
// unless the host transport must ship it across the boundary first.
|
|
341
353
|
if (options.taskRun && process.env.EZ_EXECUTOR_TRANSPORT !== 'host') return startTaskExecutor(options)
|
|
@@ -377,7 +389,7 @@ export const startExecutorJob = async (
|
|
|
377
389
|
}
|
|
378
390
|
if (!host && !gui && command === 'codex') {
|
|
379
391
|
// Share the existing authentication, never the user's memory/config/sessions.
|
|
380
|
-
const base =
|
|
392
|
+
const base = await provisionedHome(options.controlDir, 'codex', options.authProfile)
|
|
381
393
|
const home = nativeSession ? path.join(base,'tasks',options.runId) : base
|
|
382
394
|
await mkdir(home, {recursive:true,mode:0o700})
|
|
383
395
|
if(nativeSession){
|
|
@@ -387,9 +399,10 @@ export const startExecutorJob = async (
|
|
|
387
399
|
try{await writeFile(path.join(home,'config.toml'),await readFile(path.join(base,'config.toml')),{flag:'wx',mode:0o600})}
|
|
388
400
|
catch(error){if(!['ENOENT','EEXIST'].includes((error as NodeJS.ErrnoException).code || ''))throw error}
|
|
389
401
|
}
|
|
390
|
-
// Isolated agents keep auth in this CODEX_HOME.
|
|
391
|
-
// installer login. Never replace an
|
|
392
|
-
|
|
402
|
+
// Isolated agents and named profiles keep auth in this CODEX_HOME. The
|
|
403
|
+
// host-capable default may link the installer login. Never replace an
|
|
404
|
+
// existing binding.
|
|
405
|
+
const authLinks = process.env.EZ_ISOLATION === 'isolated' || options.authProfile !== undefined
|
|
393
406
|
? nativeSession ? [[path.join(home, 'auth.json'), path.join(base, 'auth.json')]] : []
|
|
394
407
|
: [[path.join(base, 'auth.json'), path.join(homedir(), '.codex', 'auth.json')],
|
|
395
408
|
...(nativeSession ? [[path.join(home, 'auth.json'), path.join(base, 'auth.json')]] : [])]
|
|
@@ -402,28 +415,18 @@ export const startExecutorJob = async (
|
|
|
402
415
|
if (!host && !gui && key === 'claude') {
|
|
403
416
|
// Mirror the Codex binding: agent-bound settings, memory, plugins and
|
|
404
417
|
// sessions; never the user's personal ~/.claude configuration.
|
|
405
|
-
const home =
|
|
418
|
+
const home = await provisionedHome(options.controlDir, 'claude', options.authProfile)
|
|
406
419
|
await mkdir(home, {recursive:true,mode:0o700})
|
|
407
|
-
|
|
408
|
-
//
|
|
409
|
-
// storage dir makes Claude use its default credential store (macOS
|
|
420
|
+
// Host-capable default homes share the installer login only. An empty
|
|
421
|
+
// secure storage dir makes Claude use its default credential store (macOS
|
|
410
422
|
// Keychain entry or ~/.claude/.credentials.json) in place, so token
|
|
411
423
|
// refreshes stay shared rather than diverging through a copy. Isolated
|
|
412
|
-
// agents keep their own login inside CLAUDE_CONFIG_DIR.
|
|
413
|
-
// runs unlabelled, so this matches the Codex auth-link
|
|
414
|
-
// resolveIsolation().
|
|
415
|
-
|
|
416
|
-
//
|
|
417
|
-
|
|
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
|
-
}
|
|
424
|
+
// agents and named profiles keep their own login inside CLAUDE_CONFIG_DIR.
|
|
425
|
+
// The host executor runs unlabelled, so this matches the Codex auth-link
|
|
426
|
+
// check, not resolveIsolation(). An optional owner-provisioned long-lived
|
|
427
|
+
// token (`claude setup-token`) never refreshes, so one file can be copied
|
|
428
|
+
// to every agent without them invalidating each other's OAuth session.
|
|
429
|
+
Object.assign(environment, await claudeAuthEnvironment(home, options.authProfile))
|
|
427
430
|
}
|
|
428
431
|
if (!host && !gui && key === 'opencode') {
|
|
429
432
|
// Point OpenCode at the agent-bound data home (cli/opencode/auth.json)
|
package/src/host-executor.ts
CHANGED
|
@@ -7,9 +7,11 @@ 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, validateCodexProvider, type CodexProviderBinding, type ExecutorOptions } from './executor.js'
|
|
10
|
+
import { CliUnavailableError, startExecutorJob, terminateJob, resolveExecutor, validateCodexProvider, type CodexProviderBinding, type ExecutorOptions } from './executor.js'
|
|
11
|
+
import { activeSetup } from './runs.js'
|
|
12
|
+
import { startupLine, startupObserver } from './setup-fallback.js'
|
|
11
13
|
import { parseIsolationClass, type IsolationClass } from './isolation.js'
|
|
12
|
-
import { readModels, validateSelection, validateOpencodeProviders, type ModelChoice } from './ai.js'
|
|
14
|
+
import { readModels, sameEngine, validateSelection, validateOpencodeProviders, type ModelChoice } from './ai.js'
|
|
13
15
|
import type { ChildProcess } from 'node:child_process'
|
|
14
16
|
import { packageVersion } from './version.js'
|
|
15
17
|
import { installedPluginVersions } from './software-status.js'
|
|
@@ -48,6 +50,8 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
48
50
|
new Set(installation.agents.map(a=>a.controlDir)).size !== installation.agents.length)
|
|
49
51
|
throw new Error('Each agent requires a separate workspace and control directory')
|
|
50
52
|
const active = new Map<string, ChildProcess>()
|
|
53
|
+
// A fallback attempt reuses its run ID; claim it only after the prior attempt's cleanup.
|
|
54
|
+
const claimed = new Set<string>()
|
|
51
55
|
const tasks = new Set<Promise<void>>()
|
|
52
56
|
let catalogRefresh: Promise<void> | undefined
|
|
53
57
|
const locks: string[] = []
|
|
@@ -148,6 +152,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
148
152
|
for (const file of await readdir(directory)) {
|
|
149
153
|
if (!file.endsWith('.request.json') || !isHostRunId(file.slice(0,-13))) continue
|
|
150
154
|
const id=file.slice(0,-13)
|
|
155
|
+
if (claimed.has(path.join(directory,id))) continue
|
|
151
156
|
let run
|
|
152
157
|
try {
|
|
153
158
|
run = await readRun(agent.controlDir, id)
|
|
@@ -170,6 +175,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
170
175
|
activeWorkspaces.set(agent, workspace)
|
|
171
176
|
}
|
|
172
177
|
workspace.lanes.set(id, scheduled)
|
|
178
|
+
claimed.add(base)
|
|
173
179
|
// Keep plugin workspace invocations excluded until all native jobs finish.
|
|
174
180
|
const releaseWorkspace = async () => {
|
|
175
181
|
workspace.lanes.delete(id)
|
|
@@ -179,11 +185,12 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
179
185
|
}
|
|
180
186
|
}
|
|
181
187
|
try { await rename(base+'.request.json',base+'.running.json') }
|
|
182
|
-
catch (error) { await releaseWorkspace(); throw error }
|
|
188
|
+
catch (error) { claimed.delete(base); await releaseWorkspace(); throw error }
|
|
183
189
|
const task=(async()=>{
|
|
184
190
|
let job: Awaited<ReturnType<typeof startExecutorJob>> | undefined
|
|
185
191
|
let cancellation: ReturnType<typeof setInterval> | undefined
|
|
186
192
|
let writes=Promise.resolve()
|
|
193
|
+
let observeClaude=false
|
|
187
194
|
const emit=(event:unknown)=>{writes=writes.then(()=>appendFile(base+'.events',JSON.stringify(event)+'\n',{mode:0o600}))}
|
|
188
195
|
try {
|
|
189
196
|
try { await readFile(base+'.cancel'); throw new Error('Cancelled') } catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
@@ -203,12 +210,20 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
203
210
|
script:{...run.script,scheduleId:run.scheduled.id,scheduleRevision:run.scheduled.revision,dueAt:run.scheduled.dueAt}}
|
|
204
211
|
job=await launch(request.texts,options)
|
|
205
212
|
} else {
|
|
213
|
+
const cli = opts.cli || installation.cli
|
|
206
214
|
if (run?.scheduled) {
|
|
207
|
-
|
|
208
|
-
|
|
215
|
+
const setup = activeSetup(run)
|
|
216
|
+
assertScheduledModel(setup?.model)
|
|
217
|
+
if (opts.model !== setup!.model || cli !== setup!.cli) throw new Error('Scheduled run model does not match its saved task model')
|
|
218
|
+
observeClaude = cli === 'claude'
|
|
209
219
|
}
|
|
210
|
-
const cli = opts.cli || installation.cli
|
|
211
220
|
resolveExecutor(cli)
|
|
221
|
+
// The credential home comes from the authorized run's captured
|
|
222
|
+
// choice; a request can never move it to another account.
|
|
223
|
+
const captured = run?.scheduled ? activeSetup(run) : run?.execution?.preset
|
|
224
|
+
if ((opts.authProfile !== undefined || (captured?.cli === cli && captured.authProfile !== undefined)) &&
|
|
225
|
+
!(captured && sameEngine(captured, {cli, authProfile: opts.authProfile})))
|
|
226
|
+
throw new Error('Run auth profile does not match its captured AI selection')
|
|
212
227
|
const provider = cli === 'codex' && opts.provider
|
|
213
228
|
? (agent.codexProviders ?? []).map(validateCodexProvider).find(candidate=>candidate.id===opts.provider)
|
|
214
229
|
: undefined
|
|
@@ -221,17 +236,24 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
221
236
|
// Admission and execution must use the same catalog snapshot. A
|
|
222
237
|
// transient native-client probe failure must not reject a model
|
|
223
238
|
// 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')))
|
|
239
|
+
if (cli !== installation.cli || opts.authProfile !== undefined) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,authProfile:opts.authProfile,model,effort:opts.effort},JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')))
|
|
225
240
|
const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
|
|
226
241
|
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,
|
|
242
|
+
sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,authProfile:opts.authProfile,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
|
|
228
243
|
promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
|
|
229
244
|
job=await launch(request.texts,options)
|
|
230
245
|
}
|
|
231
246
|
active.set(base,job.child)
|
|
232
247
|
await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid}),{mode:0o600})
|
|
233
248
|
if(signal.aborted)terminateJob(job.child)
|
|
234
|
-
|
|
249
|
+
// Scheduled Claude streams every event; forward only the startup summary.
|
|
250
|
+
const startup = observeClaude ? startupObserver() : undefined
|
|
251
|
+
let summary = ''
|
|
252
|
+
job.child.stdout?.setEncoding('utf8').on('data',(chunk:string)=>{
|
|
253
|
+
if (!startup) return emit({stream:'stdout',text:chunk})
|
|
254
|
+
startup.write(chunk)
|
|
255
|
+
if (startup.summary() !== summary) { summary = startup.summary(); emit({stream:'stdout',text:summary+'\n'}) }
|
|
256
|
+
})
|
|
235
257
|
job.child.stderr?.on('data',chunk=>emit({stream:'stderr',text:chunk.toString()}))
|
|
236
258
|
cancellation=setInterval(()=>{void readFile(base+'.cancel').then(()=>terminateJob(job!.child)).catch(()=>{})},250)
|
|
237
259
|
const code=await new Promise<number>(resolve=>job!.child.once('close',code=>resolve(code??1)))
|
|
@@ -239,7 +261,9 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
239
261
|
await job.cleanup()
|
|
240
262
|
job = undefined
|
|
241
263
|
emit({stream:'exit',code})
|
|
242
|
-
} catch (error) {
|
|
264
|
+
} catch (error) {
|
|
265
|
+
if (error instanceof CliUnavailableError) emit({stream:'stdout',text:startupLine({workBegan:false,rejection:{category:'cli-unavailable'}})+'\n'})
|
|
266
|
+
emit({stream:'stderr',text:'Host CLI execution failed: '+redactFailure(error instanceof Error ? error.message : 'Unknown error')+'\n'}); emit({stream:'exit',code:1}) }
|
|
243
267
|
finally {
|
|
244
268
|
if(cancellation)clearInterval(cancellation)
|
|
245
269
|
await job?.cleanup().catch(() => {})
|
|
@@ -249,6 +273,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
249
273
|
await rm(base+'.cancel',{force:true})
|
|
250
274
|
active.delete(base)
|
|
251
275
|
await releaseWorkspace()
|
|
276
|
+
claimed.delete(base)
|
|
252
277
|
}
|
|
253
278
|
})()
|
|
254
279
|
tasks.add(task); void task.finally(()=>tasks.delete(task))
|