@jini-ai/daemon 0.3.7 → 0.5.0
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 +119 -0
- package/README.md +50 -8
- package/dist/agent-executor/contracts.d.ts +736 -0
- package/dist/agent-executor/contracts.js +28 -0
- package/dist/agent-executor/event-translation.d.ts +87 -0
- package/dist/agent-executor/event-translation.js +230 -0
- package/dist/agent-executor/events.d.ts +190 -0
- package/dist/agent-executor/events.js +848 -0
- package/dist/agent-executor/handoff.d.ts +18 -0
- package/dist/agent-executor/handoff.js +16 -0
- package/dist/agent-executor/index.d.ts +98 -0
- package/dist/agent-executor/index.js +98 -0
- package/dist/agent-executor/launch.d.ts +375 -0
- package/dist/agent-executor/launch.js +1001 -0
- package/dist/agent-executor/mcp-bridge.d.ts +338 -0
- package/dist/agent-executor/mcp-bridge.js +835 -0
- package/dist/agent-executor/values.d.ts +5 -0
- package/dist/agent-executor/values.js +13 -0
- package/dist/agent-executor.d.ts +382 -177
- package/dist/agent-executor.js +408 -344
- package/dist/close-status.d.ts +11 -8
- package/dist/close-status.js +31 -19
- package/dist/continuation/continuation-transport.d.ts +3 -1
- package/dist/continuation/continuation-transport.js +1 -1
- package/dist/continuation/journal.d.ts +12 -5
- package/dist/continuation/journal.js +4 -4
- package/dist/continuation/run-scoped-context-store.d.ts +13 -4
- package/dist/continuation/run-scoped-context-store.js +6 -6
- package/dist/continuation/run-start-handler.d.ts +1 -1
- package/dist/continuation/run-start-handler.js +3 -16
- package/dist/delegated-tool-bridge.d.ts +7 -3
- package/dist/delegated-tool-bridge.js +32 -31
- package/dist/event-log.d.ts +7 -5
- package/dist/event-log.js +12 -11
- package/dist/frontend-capability-tools.d.ts +1 -1
- package/dist/frontend-capability-tools.js +3 -2
- package/dist/frontend-session-registry.d.ts +32 -9
- package/dist/frontend-session-registry.js +12 -11
- package/dist/http/active-context.d.ts +105 -0
- package/dist/http/active-context.js +67 -0
- package/dist/http/agents.d.ts +68 -0
- package/dist/http/agents.js +12 -0
- package/dist/http/attachments.d.ts +618 -0
- package/dist/http/attachments.js +1034 -0
- package/dist/http/cancel-owned-runs.d.ts +49 -0
- package/dist/http/cancel-owned-runs.js +35 -0
- package/dist/http/component-catalog.d.ts +84 -0
- package/dist/http/component-catalog.js +64 -0
- package/dist/http/connectors.d.ts +209 -0
- package/dist/http/connectors.js +409 -0
- package/dist/http/daemon-status.d.ts +76 -0
- package/dist/http/daemon-status.js +55 -0
- package/dist/http/db-ops.d.ts +123 -0
- package/dist/http/db-ops.js +189 -0
- package/dist/http/delegated-tools.d.ts +113 -0
- package/dist/http/delegated-tools.js +230 -0
- package/dist/http/frontend-control.d.ts +136 -0
- package/dist/http/frontend-control.js +79 -0
- package/dist/http/frontend-sessions.d.ts +110 -0
- package/dist/http/frontend-sessions.js +180 -0
- package/dist/http/health.d.ts +95 -0
- package/dist/http/health.js +40 -0
- package/dist/http/host-tools.d.ts +214 -0
- package/dist/http/host-tools.js +329 -0
- package/dist/http/index.d.ts +52 -0
- package/dist/http/index.js +27 -0
- package/dist/http/media.d.ts +146 -0
- package/dist/http/media.js +346 -0
- package/dist/http/memory.d.ts +265 -0
- package/dist/http/memory.js +328 -0
- package/dist/http/model-proxy.d.ts +49 -0
- package/dist/http/model-proxy.js +642 -0
- package/dist/http/remote-run-events.d.ts +70 -0
- package/dist/http/remote-run-events.js +224 -0
- package/dist/http/research.d.ts +48 -0
- package/dist/http/research.js +315 -0
- package/dist/http/route-manifest.d.ts +35 -0
- package/dist/http/route-manifest.js +103 -0
- package/dist/http/routines.d.ts +120 -0
- package/dist/http/routines.js +298 -0
- package/dist/http/run-credentials.d.ts +73 -0
- package/dist/http/run-credentials.js +71 -0
- package/dist/http/runs.d.ts +91 -0
- package/dist/http/runs.js +221 -0
- package/dist/http/terminals.d.ts +138 -0
- package/dist/http/terminals.js +325 -0
- package/dist/http/tool-catalog.d.ts +78 -0
- package/dist/http/tool-catalog.js +66 -0
- package/dist/http/workspace-root.d.ts +77 -0
- package/dist/http/workspace-root.js +63 -0
- package/dist/http/xai.d.ts +90 -0
- package/dist/http/xai.js +655 -0
- package/dist/image-prompt-delivery.d.ts +13 -3
- package/dist/image-prompt-delivery.js +5 -5
- package/dist/index.d.ts +556 -4
- package/dist/index.js +556 -4
- package/dist/legacy-data-migration.d.ts +59 -8
- package/dist/legacy-data-migration.js +38 -33
- package/dist/read-only-tools.d.ts +130 -0
- package/dist/read-only-tools.js +115 -0
- package/dist/remote-tool-bridge.d.ts +11 -5
- package/dist/remote-tool-bridge.js +15 -15
- package/dist/routines/index.d.ts +1 -1
- package/dist/routines/index.js +1 -1
- package/dist/routines/routine-store.d.ts +34 -12
- package/dist/routines/routine-store.js +21 -20
- package/dist/routines/schedule.d.ts +48 -10
- package/dist/routines/schedule.js +21 -21
- package/dist/routines/scheduler.d.ts +32 -11
- package/dist/routines/scheduler.js +61 -57
- package/dist/routines/types.d.ts +10 -3
- package/dist/run/core/result.d.ts +6 -2
- package/dist/run/core/result.js +3 -3
- package/dist/run/core/retry.d.ts +17 -4
- package/dist/run/core/retry.js +7 -11
- package/dist/run/diagnostics/diagnostics.d.ts +14 -4
- package/dist/run/diagnostics/diagnostics.js +7 -7
- package/dist/run/index.d.ts +1 -1
- package/dist/run/index.js +1 -1
- package/dist/run-credentials.d.ts +138 -0
- package/dist/run-credentials.js +152 -0
- package/dist/run-lifecycle.d.ts +92 -33
- package/dist/run-lifecycle.js +86 -73
- package/dist/scheduler.d.ts +8 -0
- package/dist/scheduler.js +2 -0
- package/dist/session-coordination.d.ts +115 -0
- package/dist/session-coordination.js +111 -0
- package/dist/store/agent-sessions/errors.d.ts +8 -0
- package/dist/store/agent-sessions/errors.js +10 -0
- package/dist/store/agent-sessions/index.d.ts +12 -0
- package/dist/store/agent-sessions/index.js +19 -0
- package/dist/store/agent-sessions/pglite.d.ts +12 -0
- package/dist/store/agent-sessions/pglite.js +7 -0
- package/dist/store/agent-sessions/ports.d.ts +24 -0
- package/dist/store/agent-sessions/ports.js +2 -0
- package/dist/store/agent-sessions/postgres.d.ts +12 -0
- package/dist/store/agent-sessions/postgres.js +7 -0
- package/dist/store/agent-sessions/schema.d.ts +3 -0
- package/dist/store/agent-sessions/schema.js +23 -0
- package/dist/store/agent-sessions/sql.d.ts +35 -0
- package/dist/store/agent-sessions/sql.js +59 -0
- package/dist/store/agent-sessions/sqlite.d.ts +117 -0
- package/dist/store/agent-sessions/sqlite.js +128 -0
- package/dist/store/event-log/sqlite.d.ts +75 -0
- package/dist/store/event-log/sqlite.js +230 -0
- package/dist/surface-exchanges.d.ts +97 -0
- package/dist/surface-exchanges.js +153 -0
- package/dist/terminal-session.d.ts +43 -11
- package/dist/terminal-session.js +22 -20
- package/dist/tokens.js +4 -4
- package/dist/tool-audit.d.ts +114 -0
- package/dist/tool-audit.js +95 -0
- package/dist/tool-executor.d.ts +22 -7
- package/dist/tool-executor.js +16 -16
- package/dist/tool-result-media.d.ts +3 -1
- package/dist/tool-result-media.js +1 -1
- package/dist/tool-result-surfaces.d.ts +3 -1
- package/dist/tool-result-surfaces.js +1 -1
- package/package.json +127 -9
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
import { spawn as nodeSpawn, } from 'node:child_process';
|
|
2
|
+
import { promises as fsPromises, } from 'node:fs';
|
|
3
|
+
import { tmpdir, } from 'node:os';
|
|
4
|
+
import { join, } from 'node:path';
|
|
5
|
+
import { agentCapabilities, applyAgentLaunchEnv, ensureAgentCapabilities, getAgentDef, resolveAgentLaunch, attachAcpSession, attachPiRpcSession, checkPromptArgvBudget, checkWindowsCmdShimCommandLineBudget, checkWindowsDirectExeCommandLineBudget, prepareAgentLogFile, preparePromptFileForAgent, } from '@jini-ai/agent-runtime';
|
|
6
|
+
import { collectProcessTreePids, createCommandInvocation, listProcessSnapshots, stopProcesses, } from '@jini-ai/platform';
|
|
7
|
+
import { applyImagePromptDelivery, } from '../image-prompt-delivery.js';
|
|
8
|
+
import { SUPPORTED_STREAM_FORMATS, AgentExecutorError, } from './contracts.js';
|
|
9
|
+
import { errorMessage, } from './values.js';
|
|
10
|
+
import { defaultCleanupFailureSink, DEFAULT_BUFFERED_STDOUT_MAX_BYTES, wireChildLifecycle, writePromptToStdin, runAcpDispatch, runPiRpcDispatch, } from './events.js';
|
|
11
|
+
import { mergeEnvContentMcpConfig, mergeEnvContentInstructions, defaultRemoveMcpJsonFile, resolveMcpBridgeForRun, writeMcpJsonIfNeeded, prepareCodexHomeIfNeeded, prepareClaudeConfigDirIfNeeded, } from './mcp-bridge.js';
|
|
12
|
+
import { acquireRuntimeLockIfConfigured, armHandoffWatcher, } from './handoff.js';
|
|
13
|
+
/**
|
|
14
|
+
* Narrows a `RuntimeAgentDef.streamFormat` string to the supported
|
|
15
|
+
* families.
|
|
16
|
+
* @param value - The def's raw `streamFormat` string.
|
|
17
|
+
* @returns `true` when `value` is one of the JSON-stream-parser, ACP, pi-rpc, or plain formats this driver wires.
|
|
18
|
+
* @complexity O(1) — fixed membership check.
|
|
19
|
+
* @overallScore 100/100
|
|
20
|
+
*/
|
|
21
|
+
export function isSupportedStreamFormat(args) {
|
|
22
|
+
const { value } = args;
|
|
23
|
+
return SUPPORTED_STREAM_FORMATS.includes(value);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The single source of truth for whether this executor can drive a def.
|
|
27
|
+
*
|
|
28
|
+
* It exists because that knowledge was previously reachable only by *calling* `run()` and inspecting
|
|
29
|
+
* the failure. Anything that lists agents for a user to pick from — a discovery route, an agent
|
|
30
|
+
* picker, a CLI healthcheck — needs the same answer *before* a run exists, and had no way to ask it.
|
|
31
|
+
* The observable symptom was a consumer advertising an agent that its own executor then rejected the
|
|
32
|
+
* instant it was selected.
|
|
33
|
+
*
|
|
34
|
+
* `run()` consumes this rather than re-checking the conditions itself, so the discovery-time answer
|
|
35
|
+
* and the run-time guards cannot disagree. A predicate that merely duplicated the guards would be
|
|
36
|
+
* the same bug in a second location.
|
|
37
|
+
*
|
|
38
|
+
* @param def - The def to assess. Must be the **full** `RuntimeAgentDef`, not a projected
|
|
39
|
+
* `DetectedAgent`: that type omits `maxPromptArgBytes`, one of the three prompt-delivery signals
|
|
40
|
+
* checked here, so the argv-bound defs (`aider`, `deepseek`) would be misjudged as unsupported.
|
|
41
|
+
* @returns A discriminated result — see {@link AgentExecutorCompatibility}. The `reason` text is
|
|
42
|
+
* operator-facing and is what `run()` reports as its `AGENT_RUNTIME_UNSUPPORTED` message.
|
|
43
|
+
* @complexity O(1) — fixed field checks.
|
|
44
|
+
* @overallScore 100/100
|
|
45
|
+
*/
|
|
46
|
+
export function assessAgentExecutorCompatibility({ def }) {
|
|
47
|
+
const format = { value: def.streamFormat };
|
|
48
|
+
if (!isSupportedStreamFormat(format)) {
|
|
49
|
+
return {
|
|
50
|
+
supported: false,
|
|
51
|
+
reason: `AgentExecutor: agent "${def.id}" has streamFormat "${format.value}", which is not implemented in v1 — only ${SUPPORTED_STREAM_FORMATS.join(', ')} are supported`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const streamFormat = format.value;
|
|
55
|
+
if (streamFormat !== 'acp-json-rpc' &&
|
|
56
|
+
def.promptViaStdin !== true &&
|
|
57
|
+
def.promptViaFile !== true &&
|
|
58
|
+
typeof def.maxPromptArgBytes !== 'number') {
|
|
59
|
+
return {
|
|
60
|
+
supported: false,
|
|
61
|
+
reason: `AgentExecutor: agent "${def.id}" does not deliver its prompt via stdin, a staged prompt file, or a byte-budgeted argv — v1 has no other prompt delivery path`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return { supported: true, streamFormat };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Whether `run()` can actually drive this def — the discovery-time counterpart to the guards inside
|
|
68
|
+
* `run()`, so a consumer never offers a user an agent that fails the moment it is selected.
|
|
69
|
+
*
|
|
70
|
+
* @param def - The full `RuntimeAgentDef`; see {@link assessAgentExecutorCompatibility} for why a
|
|
71
|
+
* projected `DetectedAgent` is not sufficient.
|
|
72
|
+
* @returns `true` when this executor would attempt the run.
|
|
73
|
+
* @complexity O(1).
|
|
74
|
+
* @overallScore 100/100
|
|
75
|
+
*/
|
|
76
|
+
export function isAgentExecutorSupported({ def }) {
|
|
77
|
+
return assessAgentExecutorCompatibility({ def: def }).supported;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Drops `undefined` values so `NodeJS.ProcessEnv` (whose values are
|
|
81
|
+
* `string | undefined`) can feed `resolveAgentLaunch`'s
|
|
82
|
+
* `Record<string, string>` parameter.
|
|
83
|
+
* @param env - The source environment (the caller-supplied `input.env` escape hatch — the
|
|
84
|
+
* default path builds its env via `buildAgentEnv` instead, never this function on `process.env`).
|
|
85
|
+
* @returns A new object containing only the string-valued entries.
|
|
86
|
+
* @complexity O(n) in the number of env entries.
|
|
87
|
+
* @overallScore 100/100
|
|
88
|
+
*/
|
|
89
|
+
function toStringEnvRecord(env) {
|
|
90
|
+
const result = {};
|
|
91
|
+
for (const [key, value] of Object.entries(env)) {
|
|
92
|
+
if (typeof value === 'string')
|
|
93
|
+
result[key] = value;
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Fixed baseline of host environment variables every spawned agent subprocess may see by
|
|
99
|
+
* default — resolved from the host's env one name at a time, never derived programmatically
|
|
100
|
+
* from `process.env` as a bag, so this can't silently widen. SEC-001's deny-by-default fix; see
|
|
101
|
+
* `AgentExecutorRunInput.env`'s doc for the full threat model.
|
|
102
|
+
*/
|
|
103
|
+
const BASELINE_AGENT_ENV_KEYS = [
|
|
104
|
+
'PATH', 'HOME', 'USERPROFILE', 'TMPDIR', 'TEMP', 'TMP', 'SHELL',
|
|
105
|
+
'LANG', 'LC_ALL', 'LC_CTYPE',
|
|
106
|
+
// `USER` is required for a spawned `claude` CLI to find its own login/credential state — with
|
|
107
|
+
// it omitted (even though `HOME` is present), `claude` fails fast with "Not logged in · Please
|
|
108
|
+
// run /login" despite real credentials existing on disk/keychain. Confirmed by bisection against
|
|
109
|
+
// a real authenticated `claude` install: `BASELINE_AGENT_ENV_KEYS` alone fails, adding back every
|
|
110
|
+
// `CLAUDE_CODE_*`/`CLAUDECODE` var still fails, `LOGNAME`/`SSH_AUTH_SOCK` alone still fail, but
|
|
111
|
+
// `USER` alone flips it to success. Login lookup requires the user identity
|
|
112
|
+
// as well as the home directory.
|
|
113
|
+
'USER',
|
|
114
|
+
'SystemRoot', 'windir', 'ComSpec', 'PATHEXT', // Windows-only; harmless no-ops elsewhere
|
|
115
|
+
];
|
|
116
|
+
/**
|
|
117
|
+
* Deny-by-default agent subprocess environment: `BASELINE_AGENT_ENV_KEYS` resolved from
|
|
118
|
+
* `hostEnv`, plus this run's explicitly-delegated credential(s) — never a passthrough of
|
|
119
|
+
* `hostEnv` itself. Only reached when the caller omits `AgentExecutorRunInput.env`; supplying
|
|
120
|
+
* `env` bypasses this function entirely (see its call site).
|
|
121
|
+
*/
|
|
122
|
+
function buildAgentEnv(hostEnv, credentialEnv) {
|
|
123
|
+
const result = {};
|
|
124
|
+
for (const key of BASELINE_AGENT_ENV_KEYS) {
|
|
125
|
+
const value = hostEnv[key];
|
|
126
|
+
if (typeof value === 'string')
|
|
127
|
+
result[key] = value;
|
|
128
|
+
}
|
|
129
|
+
for (const [key, value] of Object.entries(credentialEnv ?? {})) {
|
|
130
|
+
result[key] = value;
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Resolves once `child` emits `'spawn'`, or rejects on `'error'`. Replicates
|
|
136
|
+
* `@jini-ai/platform`'s own internal (non-exported) `waitForChildSpawn` race
|
|
137
|
+
* idiom inline — see that module's `spawnLoggedProcess`/`spawnBackgroundProcess`.
|
|
138
|
+
* @param child - The just-spawned `ChildProcess` to race.
|
|
139
|
+
* @returns A promise settling on the first of `'spawn'`/`'error'` to fire.
|
|
140
|
+
* @complexity O(1) — two one-time listener registrations.
|
|
141
|
+
* @overallScore 100/100
|
|
142
|
+
*/
|
|
143
|
+
function waitForSpawnOrError(child) {
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
child.once('error', reject);
|
|
146
|
+
child.once('spawn', resolve);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Resolves `CreateAgentExecutorOptions`' agent-runtime collaborator seams (registry lookup, launch
|
|
151
|
+
* resolution, ACP/pi-rpc session attachment, prompt/log file staging) to their real
|
|
152
|
+
* `@jini-ai/agent-runtime` defaults. Split out of `createAgentExecutor` together with
|
|
153
|
+
* {@link resolveProcessDeps}/{@link resolveMiscExecutorDeps}: a flat 14-line `options.x ?? default`
|
|
154
|
+
* sequence was that function's entire cyclomatic-complexity excess (one branch point per default) —
|
|
155
|
+
* grouping the same defaults by concern keeps each resulting function's own complexity low without
|
|
156
|
+
* hiding which options belong together. Pure.
|
|
157
|
+
*/
|
|
158
|
+
function resolveAgentRuntimeDeps(options) {
|
|
159
|
+
return {
|
|
160
|
+
getAgentDef: options.getAgentDef ?? getAgentDef,
|
|
161
|
+
resolveAgentLaunch: options.resolveAgentLaunch ?? resolveAgentLaunch,
|
|
162
|
+
ensureAgentCapabilities: options.ensureAgentCapabilities ?? ensureAgentCapabilities,
|
|
163
|
+
applyAgentLaunchEnv: options.applyAgentLaunchEnv ?? applyAgentLaunchEnv,
|
|
164
|
+
attachAcpSession: options.attachAcpSession ?? attachAcpSession,
|
|
165
|
+
attachPiRpcSession: options.attachPiRpcSession ?? attachPiRpcSession,
|
|
166
|
+
preparePromptFileForAgent: options.preparePromptFileForAgent ?? preparePromptFileForAgent,
|
|
167
|
+
prepareAgentLogFile: options.prepareAgentLogFile ?? prepareAgentLogFile,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/** Resolves the OS-process-facing collaborator seams — see {@link resolveAgentRuntimeDeps}'s doc. Pure. */
|
|
171
|
+
function resolveProcessDeps(options) {
|
|
172
|
+
return {
|
|
173
|
+
createCommandInvocation: options.createCommandInvocation ?? createCommandInvocation,
|
|
174
|
+
spawn: options.spawn ?? nodeSpawn,
|
|
175
|
+
listProcessSnapshots: options.listProcessSnapshots ?? listProcessSnapshots,
|
|
176
|
+
collectProcessTreePids: options.collectProcessTreePids ?? (({ processes, rootPids }) => collectProcessTreePids(processes, rootPids)),
|
|
177
|
+
stopProcesses: options.stopProcesses ?? (({ pids }) => stopProcesses(pids)),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/** Resolves the two remaining defaultable options — see {@link resolveAgentRuntimeDeps}'s doc. Pure. */
|
|
181
|
+
function resolveMiscExecutorDeps(options) {
|
|
182
|
+
return {
|
|
183
|
+
onCleanupFailure: options.onCleanupFailure ?? defaultCleanupFailureSink,
|
|
184
|
+
bufferedStdoutMaxBytes: options.bufferedStdoutMaxBytes ?? DEFAULT_BUFFERED_STDOUT_MAX_BYTES,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/** Phase 1: registry lookup + `assessAgentExecutorCompatibility` guard. */
|
|
188
|
+
export async function resolveDefAndStreamFormat({ input, deps }) {
|
|
189
|
+
const def = deps.getAgentDef({ id: input.agentId });
|
|
190
|
+
if (!def) {
|
|
191
|
+
return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_NOT_FOUND', message: `AgentExecutor: unknown agentId "${input.agentId}"` });
|
|
192
|
+
}
|
|
193
|
+
const compatibility = assessAgentExecutorCompatibility({ def: def });
|
|
194
|
+
if (!compatibility.supported) {
|
|
195
|
+
return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_RUNTIME_UNSUPPORTED', message: compatibility.reason });
|
|
196
|
+
}
|
|
197
|
+
return { def, streamFormat: compatibility.streamFormat };
|
|
198
|
+
}
|
|
199
|
+
/** Phase 2: image-prompt-delivery augmentation + argv-budget guard for argv-bound defs. */
|
|
200
|
+
export async function resolveImageDeliveryAndArgvBudget({ input, deps }) {
|
|
201
|
+
const imageDelivery = applyImagePromptDelivery({ imageDelivery: input.def.imageDelivery, prompt: input.prompt, imagePaths: input.imagePaths, extraAllowedDirs: input.extraAllowedDirs });
|
|
202
|
+
const argvBudgetError = checkPromptArgvBudget({ def: input.def, composed: imageDelivery.prompt });
|
|
203
|
+
if (argvBudgetError) {
|
|
204
|
+
return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_PROMPT_TOO_LARGE', message: argvBudgetError.message });
|
|
205
|
+
}
|
|
206
|
+
return imageDelivery;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Phase 3a: the subprocess environment this run's launch resolution and spawn should use — the
|
|
210
|
+
* caller-supplied escape hatch verbatim, or the deny-by-default `BASELINE_AGENT_ENV_KEYS` allowlist.
|
|
211
|
+
* Pure.
|
|
212
|
+
*/
|
|
213
|
+
export function resolveRunEnv({ input, hostEnv }) {
|
|
214
|
+
return input.env !== undefined ? toStringEnvRecord(input.env) : buildAgentEnv(hostEnv, input.credentialEnv);
|
|
215
|
+
}
|
|
216
|
+
/** Phase 3b: launch-path resolution + binary-not-resolved guard. */
|
|
217
|
+
export async function resolveLaunch({ input, deps }) {
|
|
218
|
+
const launch = deps.resolveAgentLaunch({ def: input.def }, { configuredEnv: input.resolvedEnv });
|
|
219
|
+
if (!launch.launchPath) {
|
|
220
|
+
return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_BINARY_NOT_RESOLVED', message: `AgentExecutor: could not resolve an executable for agent "${input.def.id}" (bin "${input.def.bin}")` });
|
|
221
|
+
}
|
|
222
|
+
// Narrowed by the guard above; `resolveAgentLaunch`'s own return type still declares
|
|
223
|
+
// `launchPath: string | null` since it can't know this call site already checked.
|
|
224
|
+
return launch;
|
|
225
|
+
}
|
|
226
|
+
/** Phase 4a: stage a `promptViaFile` def's prompt to a temp file (a no-op for every other def). */
|
|
227
|
+
export async function stagePromptFile({ input, deps }) {
|
|
228
|
+
try {
|
|
229
|
+
return await deps.preparePromptFileForAgent({ def: input.def, prompt: input.prompt, label: input.runId });
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not stage a prompt file for agent "${input.def.id}": ${errorMessage(err)}` });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** Phase 4b: stage a `needsAgentLogFile` def's diagnostic-log path (a no-op for every other def). */
|
|
236
|
+
export async function stageLogFile({ input, deps }) {
|
|
237
|
+
try {
|
|
238
|
+
return await deps.prepareAgentLogFile({ def: input.def, label: input.runId });
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
await (input.preparedPromptFile ? input.preparedPromptFile.cleanup() : Promise.resolve());
|
|
242
|
+
return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not stage a log file for agent "${input.def.id}": ${errorMessage(err)}` });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Phase 6a/10c: the subprocess environment every env-riding mechanism uses — mechanism 3+4
|
|
247
|
+
* (`'opencode-env-content'`/`'mimo-env-content'`, merged into whatever the host already set there,
|
|
248
|
+
* never a CLI argument: the config embeds `JINI_DAEMON_TOKEN`, and process arguments are readable
|
|
249
|
+
* by any other local user through `ps`), mechanism 5 (`'codex-toml'`, `CODEX_HOME` relocation),
|
|
250
|
+
* mechanism 6 (`'env-passthrough'`, the bridge entry's flat env vars set directly with no carrier
|
|
251
|
+
* document — see {@link McpBridgeDelivery}'s own doc), a
|
|
252
|
+
* `systemPromptDelivery: 'env-var'` def's overlay (`reasonix`'s `REASONIX_ACP_SYSTEM_APPEND` today
|
|
253
|
+
* — see `resolveSystemPromptOverlayDelivery`'s own doc), and a `'config-instructions-file'` def's
|
|
254
|
+
* staged overlay file (`opencode` today — see {@link mergeEnvContentInstructions}'s own doc). Pure
|
|
255
|
+
* — `codexHomeDir` and `stagedInstructionsFile` arrive already staged by
|
|
256
|
+
* {@link prepareCodexHomeIfNeeded} and {@link prepareSystemPromptOverlayFileIfNeeded} respectively,
|
|
257
|
+
* the only parts of this mechanism that are NOT pure (real `mkdtemp`/`writeFile` calls).
|
|
258
|
+
* @param spawnEnv - The env every other spawn-time step (launch-path resolution, `applyAgentLaunchEnv`) already computed.
|
|
259
|
+
* @param mcpBridge - This run's resolved bridge delivery, or `null` for an unconfigured host / no-strategy def.
|
|
260
|
+
* @param codexHomeDir - The staged scratch `CODEX_HOME` path for a `'codex-toml'` def, or `undefined` for every other run (including a `'codex-toml'` def when `mcpJsonInjection` was never configured — see `prepareCodexHomeIfNeeded`'s own gate).
|
|
261
|
+
* @param systemPromptEnvOverrides - `resolveSystemPromptOverlayDelivery`'s `envOverrides` — `{}` (default) for every def but an `'env-var'`-strategy one with an overlay present, in which case it carries that one var. Applied after `codexHomeDir`, so it can never be shadowed by it — the two never share a key (`CODEX_HOME` vs. e.g. `REASONIX_ACP_SYSTEM_APPEND`), so the ordering is a documentation choice, not a correctness one.
|
|
262
|
+
* @param stagedInstructionsFile - `varName` (from the def's own `systemPromptDelivery` declaration) and the staged overlay file's `path`, or `undefined` for every def but a `'config-instructions-file'` one with an overlay present. Merged into `varName`'s value AFTER the `mcp` merge above (reading `envContentApplied`, not the original `spawnEnv`, for that same key) so both a `mcp` entry and an `instructions` entry from the two mechanisms survive together in one document — confirmed live this coexistence is safe (see {@link mergeEnvContentInstructions}'s doc).
|
|
263
|
+
* @complexity O(1) plus `mergeEnvContentMcpConfig`'s and `mergeEnvContentInstructions`'s own `JSON.parse`/`JSON.stringify` cost.
|
|
264
|
+
* @overallScore 100/100
|
|
265
|
+
*/
|
|
266
|
+
export function computeChildEnv({ spawnEnv, mcpBridge }, { codexHomeDir, systemPromptEnvOverrides, stagedInstructionsFile, claudeConfigDir } = {}) {
|
|
267
|
+
const envContentApplied = mcpBridge?.kind === 'env-content'
|
|
268
|
+
? {
|
|
269
|
+
...spawnEnv,
|
|
270
|
+
[mcpBridge.envVarName]: mergeEnvContentMcpConfig({ existingRaw: spawnEnv[mcpBridge.envVarName], entry: mcpBridge.serverEntry }),
|
|
271
|
+
}
|
|
272
|
+
: spawnEnv;
|
|
273
|
+
// `'env-passthrough'` (antigravity): no document, no named carrier variable — the bridge
|
|
274
|
+
// entry's own `env` keys (`JINI_RUN_ID`/`JINI_DAEMON_URL`/`JINI_DAEMON_TOKEN`) are set directly
|
|
275
|
+
// on the child's environment, for the spawned CLI to inherit down to its own globally
|
|
276
|
+
// pre-registered MCP child in turn. See `McpBridgeDelivery`'s own doc for why this def has no
|
|
277
|
+
// config document to merge into at all.
|
|
278
|
+
const envPassthroughApplied = mcpBridge?.kind === 'env-passthrough' ? { ...envContentApplied, ...mcpBridge.serverEntry.env } : envContentApplied;
|
|
279
|
+
const instructionsApplied = stagedInstructionsFile === undefined
|
|
280
|
+
? envPassthroughApplied
|
|
281
|
+
: {
|
|
282
|
+
...envPassthroughApplied,
|
|
283
|
+
[stagedInstructionsFile.varName]: mergeEnvContentInstructions({ existingRaw: envPassthroughApplied[stagedInstructionsFile.varName], instructionsFilePath: stagedInstructionsFile.path }),
|
|
284
|
+
};
|
|
285
|
+
const codexHomeApplied = codexHomeDir === undefined ? instructionsApplied : { ...instructionsApplied, CODEX_HOME: codexHomeDir };
|
|
286
|
+
// Finding 1 of SEC-assistant-env-isolation-2026-09-07: same "only set when staged" shape as
|
|
287
|
+
// codexHomeApplied above — mutually exclusive with it in practice (codexHomeDir is only ever set
|
|
288
|
+
// for a 'codex-toml' bridge, claudeConfigDir only ever for a `claude`-id run), so both existing
|
|
289
|
+
// side by side here is a documentation convenience, not a real collision risk.
|
|
290
|
+
const claudeConfigDirApplied = claudeConfigDir === undefined ? codexHomeApplied : { ...codexHomeApplied, CLAUDE_CONFIG_DIR: claudeConfigDir };
|
|
291
|
+
return systemPromptEnvOverrides === undefined ? claudeConfigDirApplied : { ...claudeConfigDirApplied, ...systemPromptEnvOverrides };
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Phase 6b: the `RuntimeContext` `buildArgs` receives — `undefined` unless a file, bridge path, or
|
|
295
|
+
* session id was staged. Pure.
|
|
296
|
+
*
|
|
297
|
+
* `resumeSessionId`/`newSessionId` round-trip a prior run's `RunEndPayload.sessionRef` (see
|
|
298
|
+
* `@jini-ai/protocol`'s doc on that field) back into this run's `RuntimeContext`, letting a
|
|
299
|
+
* `resumesSessionViaCli` def (e.g. claude) continue its own CLI session across turns instead of
|
|
300
|
+
* spawning cold every time. Either one alone must still produce a context — a run supplying ONLY a
|
|
301
|
+
* session id, with no prompt/log file staged and no claude-mcp-json bridge, is exactly the common
|
|
302
|
+
* case for a resumed turn.
|
|
303
|
+
*/
|
|
304
|
+
export function computeRuntimeContext({ preparedPromptFile, preparedLogFile, mcpBridge }, { resumeSessionId, newSessionId } = {}) {
|
|
305
|
+
// Matches claude.ts buildArgs' own `typeof x === 'string' && x` truthiness check, so an empty
|
|
306
|
+
// string or explicit `null` (no resume target yet) is treated as absent here too, rather than
|
|
307
|
+
// manufacturing a context that carries a session field the def would ignore anyway.
|
|
308
|
+
const hasResumeSessionId = typeof resumeSessionId === 'string' && resumeSessionId.length > 0;
|
|
309
|
+
const hasNewSessionId = typeof newSessionId === 'string' && newSessionId.length > 0;
|
|
310
|
+
if (!preparedPromptFile
|
|
311
|
+
&& !preparedLogFile
|
|
312
|
+
&& mcpBridge?.kind !== 'claude-mcp-json'
|
|
313
|
+
&& !hasResumeSessionId
|
|
314
|
+
&& !hasNewSessionId) {
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
...(preparedPromptFile ? { promptFilePath: preparedPromptFile.path } : {}),
|
|
319
|
+
...(preparedLogFile ? { agentLogFilePath: preparedLogFile.path } : {}),
|
|
320
|
+
// Safe to pass before the file exists: `writeMcpJsonForRun` runs after buildArgs but still
|
|
321
|
+
// before spawn, so the path is real by the time the child process starts.
|
|
322
|
+
...(mcpBridge?.kind === 'claude-mcp-json' ? { mcpJsonPath: mcpBridge.mcpJsonPath } : {}),
|
|
323
|
+
...(hasResumeSessionId ? { resumeSessionId } : {}),
|
|
324
|
+
...(hasNewSessionId ? { newSessionId } : {}),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Phase 8: the host's `PromptAugmenter.systemOverlay()` result, if configured — see
|
|
329
|
+
* `CreateAgentExecutorOptions.promptAugmenter`'s doc for `turnIndex`'s coarse 0/1 proxy.
|
|
330
|
+
*/
|
|
331
|
+
function computeSystemPromptOverlay(promptAugmenter, agentId, runtimeContext) {
|
|
332
|
+
return promptAugmenter?.systemOverlay?.({
|
|
333
|
+
agentId,
|
|
334
|
+
turnIndex: runtimeContext?.hasPriorAssistantTurn ? 1 : 0,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
/** Phase 9a: the def's `buildArgs` 4th argument. Historically omitted when every override was absent;
|
|
338
|
+
* that let defs silently enable auto-approval. Always supply the validated restricted default now. Pure. */
|
|
339
|
+
export function buildAgentBuildArgsOptions({ input, systemPromptOverlay }) {
|
|
340
|
+
const permissionMode = input.permissionMode ?? 'restricted';
|
|
341
|
+
if (permissionMode !== 'restricted' && permissionMode !== 'bypass') {
|
|
342
|
+
throw new AgentExecutorError({ code: 'AGENT_PERMISSION_MODE_INVALID', message: 'AgentExecutor: permissionMode must be restricted or bypass' });
|
|
343
|
+
}
|
|
344
|
+
const hasOverlay = systemPromptOverlay !== undefined && systemPromptOverlay !== null;
|
|
345
|
+
const options = {
|
|
346
|
+
...(input.model !== undefined ? { model: input.model } : {}),
|
|
347
|
+
...(input.reasoning !== undefined ? { reasoning: input.reasoning } : {}),
|
|
348
|
+
permissionMode,
|
|
349
|
+
...(input.disallowedTools !== undefined ? { disallowedTools: input.disallowedTools } : {}),
|
|
350
|
+
...(input.allowedTools !== undefined ? { allowedTools: input.allowedTools } : {}),
|
|
351
|
+
...(input.settingSources !== undefined ? { settingSources: input.settingSources } : {}),
|
|
352
|
+
...(input.settings !== undefined ? { settings: input.settings } : {}),
|
|
353
|
+
...(hasOverlay ? { systemPromptOverlay } : {}),
|
|
354
|
+
};
|
|
355
|
+
return Object.keys(options).length === 0 ? undefined : options;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* **The single dispatch point from a computed system-prompt overlay to its delivery mechanism** —
|
|
359
|
+
* see `RuntimeAgentDef.systemPromptDelivery`'s own doc for the declared shape. Pure and
|
|
360
|
+
* synchronous, mirroring {@link buildMcpBridgeDelivery}'s "keyed off the declared strategy, never
|
|
361
|
+
* off the def's id" contract: a def earns overlay delivery by declaring a strategy, not by being
|
|
362
|
+
* named in this file. That is what makes every def with no declaration work via the fallback
|
|
363
|
+
* without any of their own files being touched.
|
|
364
|
+
*
|
|
365
|
+
* The fallback (no declared strategy — every def but `claude` today) prefixes the overlay directly
|
|
366
|
+
* onto the composed prompt text, clearly delimited from the user's own request. It is gated on
|
|
367
|
+
* session state, not merely on whether an overlay exists: a def that carries its own conversation
|
|
368
|
+
* memory across spawns (`resumesSessionViaCli` / `resumesSessionViaAcpLoad`) persists whatever its
|
|
369
|
+
* session-creating turn sends it — see `RuntimeContext.resumeSessionId`'s own doc: its presence on
|
|
370
|
+
* a run means "continue a prior session", not "start one". Prefixing on every later turn of that
|
|
371
|
+
* same session would therefore bake the overlay into the CLI's own stored history again and again,
|
|
372
|
+
* compounding without bound turn over turn. So the fallback prefixes only when there is no resume
|
|
373
|
+
* target yet (the session's own first turn, or a def with no session memory at all, which never
|
|
374
|
+
* replays anything back at the CLI and so gets it on every turn).
|
|
375
|
+
*
|
|
376
|
+
* `'append-flag'` and `'env-var'` defs are the opposite case: the flag/env var is a fresh,
|
|
377
|
+
* un-stored per-spawn directive — never part of what a resumed session replays — so it is set on
|
|
378
|
+
* every turn unconditionally, exactly `claude`'s pre-existing (now-centralized) behavior before
|
|
379
|
+
* this function existed.
|
|
380
|
+
*
|
|
381
|
+
* @param input.defId - Looks up this def's probed capabilities for an `'append-flag'` strategy's
|
|
382
|
+
* `capabilityKey`. Otherwise unused — the dispatch itself is keyed off `systemPromptDelivery`, per
|
|
383
|
+
* this function's own doc above, never off the id.
|
|
384
|
+
* @param input.systemPromptDelivery - The def's declared strategy, or `undefined` for the fallback.
|
|
385
|
+
* @param input.resumesSessionViaCli - The def's own flag (see `RuntimeAgentDef`'s doc).
|
|
386
|
+
* @param input.resumesSessionViaAcpLoad - The def's own flag (see `RuntimeAgentDef`'s doc).
|
|
387
|
+
* @param input.overlay - The computed `PromptAugmenter.systemOverlay()` result. `null`/`undefined`/
|
|
388
|
+
* empty short-circuits to "no delivery" — byte-identical to no `PromptAugmenter` configured at all.
|
|
389
|
+
* @param input.prompt - The composed prompt `buildArgs` would otherwise receive verbatim.
|
|
390
|
+
* @param input.resumeSessionId - This run's `RuntimeContext.resumeSessionId`; presence means an
|
|
391
|
+
* existing session is being continued, not created.
|
|
392
|
+
* @returns A {@link SystemPromptOverlayDelivery}: the prefix this run's prompt text must carry
|
|
393
|
+
* (`''` unless the fallback applies), that prefix already applied to `input.prompt`, any extra argv
|
|
394
|
+
* to append to whatever `buildArgs` itself returns, and any env var overrides to merge into the
|
|
395
|
+
* spawn env (`{}` for every strategy but `'env-var'`). Every one of `run()`'s four prompt
|
|
396
|
+
* transports consumes this same result — see the interface's own no-double-delivery note.
|
|
397
|
+
* @complexity O(n) in the overlay/prompt lengths — string concatenation only, no I/O.
|
|
398
|
+
* @overallScore 100/100
|
|
399
|
+
*/
|
|
400
|
+
export function resolveSystemPromptOverlayDelivery(input) {
|
|
401
|
+
const { defId, systemPromptDelivery, resumesSessionViaCli, resumesSessionViaAcpLoad, overlay, prompt, resumeSessionId } = input;
|
|
402
|
+
if (typeof overlay !== 'string' || overlay.length === 0) {
|
|
403
|
+
return { promptPrefix: '', prompt, extraArgs: [], envOverrides: {} };
|
|
404
|
+
}
|
|
405
|
+
if (systemPromptDelivery?.strategy === 'append-flag') {
|
|
406
|
+
const capabilityKey = systemPromptDelivery.capabilityKey;
|
|
407
|
+
// `!== false`, not a truthiness check: mirrors `claude.ts`'s own pre-existing
|
|
408
|
+
// `agentCapabilities.get('claude') || {}` gate exactly (moved here, not changed) — an
|
|
409
|
+
// undetected/never-probed capability defaults to allowed, and only an EXPLICIT `false` (the
|
|
410
|
+
// `--help` probe ran and did not find the flag) withholds it. `capabilityKey === undefined`
|
|
411
|
+
// (e.g. `pi`'s existing `--append-system-prompt`, trusted unconditionally) always passes, same
|
|
412
|
+
// as an absent key.
|
|
413
|
+
const capabilityOk = capabilityKey === undefined || agentCapabilities.get(defId)?.[capabilityKey] !== false;
|
|
414
|
+
return { promptPrefix: '', prompt, extraArgs: capabilityOk ? [systemPromptDelivery.flag, overlay] : [], envOverrides: {} };
|
|
415
|
+
}
|
|
416
|
+
if (systemPromptDelivery?.strategy === 'env-var') {
|
|
417
|
+
// No capability gate, unlike `'append-flag'`: an unrecognized env var is inert to a CLI (it
|
|
418
|
+
// simply never reads it), never a fatal "unknown option" exit — there is no equivalent hazard
|
|
419
|
+
// to probe-gate against here. Set verbatim, not merged with any existing value — a dedicated
|
|
420
|
+
// single-purpose var, not a shared config channel (see this field's own `types.ts` doc).
|
|
421
|
+
return { promptPrefix: '', prompt, extraArgs: [], envOverrides: { [systemPromptDelivery.varName]: overlay } };
|
|
422
|
+
}
|
|
423
|
+
if (systemPromptDelivery?.strategy === 'config-instructions-file') {
|
|
424
|
+
// Delivered elsewhere, not here: unlike `'append-flag'`/`'env-var'`, this mechanism needs real
|
|
425
|
+
// filesystem I/O (staging the overlay to a temp file — `opencode`'s `instructions` array only
|
|
426
|
+
// accepts a file path or URL, confirmed live, never inline text), which this function's "pure
|
|
427
|
+
// and synchronous" contract cannot perform. `prepareSystemPromptOverlayFileIfNeeded` (a separate
|
|
428
|
+
// async phase in `run()`, gated on this same strategy check) stages the file, and
|
|
429
|
+
// `computeChildEnv` merges its path into the config document via `mergeEnvContentInstructions`.
|
|
430
|
+
// This branch's only job is to make sure the universal prefix fallback below does NOT ALSO run
|
|
431
|
+
// for a def that already has this strategy declared — the same "no double delivery" concern
|
|
432
|
+
// `imageDelivery`'s doc calls out for its own native-vs-fallback split.
|
|
433
|
+
return { promptPrefix: '', prompt, extraArgs: [], envOverrides: {} };
|
|
434
|
+
}
|
|
435
|
+
const isContinuingExistingSession = (resumesSessionViaCli === true || resumesSessionViaAcpLoad === true) &&
|
|
436
|
+
typeof resumeSessionId === 'string' &&
|
|
437
|
+
resumeSessionId.length > 0;
|
|
438
|
+
if (isContinuingExistingSession) {
|
|
439
|
+
return { promptPrefix: '', prompt, extraArgs: [], envOverrides: {} };
|
|
440
|
+
}
|
|
441
|
+
// KNOWN TRADE-OFF, deliberate: for a resume-capable def with no `'append-flag'`/`'env-var'`
|
|
442
|
+
// mechanism yet (`codex`, `codebuddy`, `opencode`, `amr` — all four presently on this fallback),
|
|
443
|
+
// the overlay is therefore only injected on the SESSION-CREATING turn, not every turn. A host
|
|
444
|
+
// whose `PromptAugmenter.systemOverlay()` result can change mid-conversation (e.g. a host that
|
|
445
|
+
// lets an operator edit its own stored instructions and re-reads them before every run — see
|
|
446
|
+
// `prompt-augmenter.ts`'s own doc for the seam) will see NO effect from such an edit until a NEW
|
|
447
|
+
// session starts for one of these four defs specifically — a real, silent limitation, not a
|
|
448
|
+
// theoretical one. This is the correct
|
|
449
|
+
// trade against the alternative (re-injecting every turn would bake the overlay into that def's
|
|
450
|
+
// own CLI-persisted session history again and again, compounding without bound) — do not change
|
|
451
|
+
// this gating to "fix" the staleness. The actual fix is giving each of the four its own
|
|
452
|
+
// `'append-flag'`-equivalent `systemPromptDelivery` (an argv flag or an env var, neither of which
|
|
453
|
+
// is part of what a resumed session replays), which removes this limitation entirely for that
|
|
454
|
+
// def. See `reasonix.ts`'s and `opencode.ts`'s module docs for the two already-identified,
|
|
455
|
+
// not-yet-wired native mechanisms.
|
|
456
|
+
const promptPrefix = `${overlay}\n\n---\n\n`;
|
|
457
|
+
return { promptPrefix, prompt: `${promptPrefix}${prompt}`, extraArgs: [], envOverrides: {} };
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Phase 9b: calls the def's `buildArgs`, releasing staged resources and failing the run on a throw.
|
|
461
|
+
*
|
|
462
|
+
* @param input.overlayDelivery - This run's already-resolved overlay decision, computed once in
|
|
463
|
+
* `run()` rather than here. It is resolved upstream because `buildArgs` is only ONE of the four
|
|
464
|
+
* channels that can carry the prompt: the staged prompt file is written *before* this function
|
|
465
|
+
* runs, and stdin/ACP/pi-rpc send theirs *after* spawn. A decision made inside this function could
|
|
466
|
+
* therefore only ever reach the 7 defs whose `buildArgs` reads its first argument at all — the
|
|
467
|
+
* other 17 declare it `_prompt` and discard it, which is exactly how the overlay used to go missing.
|
|
468
|
+
*/
|
|
469
|
+
export async function buildRunArgs({ input, deps }) {
|
|
470
|
+
try {
|
|
471
|
+
const delivery = input.overlayDelivery;
|
|
472
|
+
const options = buildAgentBuildArgsOptions({ input: input.runInput, systemPromptOverlay: input.systemPromptOverlay });
|
|
473
|
+
const args = input.def.buildArgs({ prompt: delivery.prompt, imagePaths: [...(input.imagePaths ?? [])] }, {
|
|
474
|
+
...(input.imageDelivery.extraAllowedDirs === undefined ? {} : { extraAllowedDirs: [...input.imageDelivery.extraAllowedDirs] }),
|
|
475
|
+
...(options === undefined ? {} : { options }),
|
|
476
|
+
...(input.runtimeContext === undefined ? {} : { runtimeContext: input.runtimeContext }),
|
|
477
|
+
});
|
|
478
|
+
// `'append-flag'` delivery's extra argv (empty for every other def/strategy) is appended after
|
|
479
|
+
// whatever the def's own `buildArgs` returned — safe because it is only ever non-empty for a
|
|
480
|
+
// `promptViaStdin` def with no trailing positional argv (`claude`/`pi` today; see
|
|
481
|
+
// `resolveSystemPromptOverlayDelivery`'s doc for why a future 'append-flag' def must keep that
|
|
482
|
+
// property too). `envOverrides` (non-empty only for `'env-var'` — `reasonix` today) is handed
|
|
483
|
+
// back rather than applied here, since the spawn env isn't finalized until `computeChildEnv`
|
|
484
|
+
// runs, later in `run()`.
|
|
485
|
+
return { args: [...args, ...delivery.extraArgs], envOverrides: delivery.envOverrides };
|
|
486
|
+
}
|
|
487
|
+
catch (err) {
|
|
488
|
+
await deps.releaseStagedResources();
|
|
489
|
+
return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not build launch arguments for agent "${input.def.id}": ${errorMessage(err)}` });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Phase 10b1: `systemPromptDelivery: { strategy: 'config-instructions-file' }`'s one effect —
|
|
494
|
+
* stages the computed overlay to a fresh, run-scoped temp file, so `computeChildEnv` has a real
|
|
495
|
+
* path to merge into that def's `instructions` config array (see
|
|
496
|
+
* {@link mergeEnvContentInstructions}'s own doc for the live verification this mechanism rests on).
|
|
497
|
+
* `null` for every other strategy, an unset `systemPromptDelivery`, or no overlay present at all —
|
|
498
|
+
* byte-identical to before this mechanism existed, matching {@link writeMcpJsonIfNeeded}'s and
|
|
499
|
+
* {@link prepareCodexHomeIfNeeded}'s identical no-op-when-inapplicable gate.
|
|
500
|
+
*
|
|
501
|
+
* `opencode`'s `instructions` field only accepts a file path or a remote URL — confirmed live
|
|
502
|
+
* (2026-09-01): a literal instruction string in the array is silently ignored (no error, just never
|
|
503
|
+
* honored), so an inline-text shortcut is not available and this staging step is load-bearing, not
|
|
504
|
+
* a defensive extra.
|
|
505
|
+
* @param input.def - Only used for its `id`, in the failure message, and its `systemPromptDelivery` declaration.
|
|
506
|
+
* @param input.overlay - The computed `PromptAugmenter.systemOverlay()` result for this run.
|
|
507
|
+
* @complexity O(1) plus one directory creation and one file write.
|
|
508
|
+
* @overallScore 100/100
|
|
509
|
+
*/
|
|
510
|
+
export async function prepareSystemPromptOverlayFileIfNeeded({ input, deps }) {
|
|
511
|
+
if (input.def.systemPromptDelivery?.strategy !== 'config-instructions-file' ||
|
|
512
|
+
typeof input.overlay !== 'string' ||
|
|
513
|
+
input.overlay.length === 0) {
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
const safeRunId = input.runId.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 80) || 'run';
|
|
518
|
+
const dir = await fsPromises.mkdtemp(join(tmpdir(), `jini-system-prompt-overlay-${safeRunId}-`));
|
|
519
|
+
const filePath = join(dir, 'overlay.md');
|
|
520
|
+
await fsPromises.writeFile(filePath, input.overlay, { encoding: 'utf8', mode: 0o600 });
|
|
521
|
+
return {
|
|
522
|
+
path: filePath,
|
|
523
|
+
cleanup: async () => {
|
|
524
|
+
await fsPromises.rm(dir, { recursive: true, force: true });
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
catch (err) {
|
|
529
|
+
await deps.releaseStagedResources();
|
|
530
|
+
return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not stage a system-prompt overlay file for agent "${input.def.id}": ${errorMessage(err)}` });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/** Phase 11: post-`buildArgs` guard for argv-bound defs whose resolved binary is a Windows shim/.exe — a no-op off-Windows and for non-argv-bound defs. */
|
|
534
|
+
export async function guardWindowsCommandLineBudget({ input, deps }) {
|
|
535
|
+
const windowsBudgetError = checkWindowsCmdShimCommandLineBudget({ def: input.def, resolvedBin: input.launchPath, args: input.args }) ??
|
|
536
|
+
checkWindowsDirectExeCommandLineBudget({ def: input.def, resolvedBin: input.launchPath, args: input.args });
|
|
537
|
+
if (windowsBudgetError) {
|
|
538
|
+
await deps.releaseStagedResources();
|
|
539
|
+
await deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_PROMPT_TOO_LARGE', message: windowsBudgetError.message });
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Phase 12: the real `node:child_process.spawn` call.
|
|
544
|
+
*
|
|
545
|
+
* **Deliberately synchronous, unlike every other phase in this file.** A spawned child can emit
|
|
546
|
+
* `'error'` on the very next microtask tick (Node schedules it eagerly on some failure modes, and a
|
|
547
|
+
* test harness simulating "the child emits 'error' before 'spawn'" does so explicitly via
|
|
548
|
+
* `queueMicrotask`). `run()` must register its `'error'` listeners (`wireChildLifecycle`'s safety net,
|
|
549
|
+
* then `waitForSpawnOrError`'s `child.once('error', reject)`) in the *same synchronous turn* as this
|
|
550
|
+
* spawn call — Node's `EventEmitter` throws synchronously when `'error'` fires with zero listeners
|
|
551
|
+
* attached. Wrapping this call in an `async function` and `await`ing it (as every other phase here
|
|
552
|
+
* does) would insert a microtask tick between spawn and listener registration, occasionally losing
|
|
553
|
+
* that race — confirmed by a real test failure during this refactor (an uncaught `EventEmitter`
|
|
554
|
+
* `'error'` exception) before this function was changed back to a plain, unawaited call returning a
|
|
555
|
+
* result object instead of throwing/rejecting.
|
|
556
|
+
* @returns `{kind:'ok', child}` on success, `{kind:'error', error}` on a synchronous throw from `spawn`
|
|
557
|
+
* — `run()` itself is responsible for cleanup and `failBeforeSpawn` on the error variant, both of
|
|
558
|
+
* which are safe to make asynchronous since no child (and hence no listener race) exists yet.
|
|
559
|
+
* @complexity O(1) plus `spawn`'s own cost.
|
|
560
|
+
*/
|
|
561
|
+
export function spawnAgentChildProcess({ input, deps }) {
|
|
562
|
+
try {
|
|
563
|
+
return {
|
|
564
|
+
kind: 'ok',
|
|
565
|
+
child: deps.spawn(input.invocation.command, input.invocation.args, {
|
|
566
|
+
cwd: input.cwd,
|
|
567
|
+
env: input.childEnv,
|
|
568
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
569
|
+
windowsVerbatimArguments: input.invocation.windowsVerbatimArguments,
|
|
570
|
+
}),
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
catch (error) {
|
|
574
|
+
return { kind: 'error', error };
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
/** Named predicate replacing an inline `streamFormat === 'acp-json-rpc' || streamFormat === 'pi-rpc'` check — the two formats that own their own prompt/event protocol and skip `wireChildLifecycle`. */
|
|
578
|
+
export function isStdinDrivenFormat(args) {
|
|
579
|
+
const { streamFormat } = args;
|
|
580
|
+
return streamFormat !== 'acp-json-rpc' && streamFormat !== 'pi-rpc';
|
|
581
|
+
}
|
|
582
|
+
/** Phase 13: awaits spawn confirmation, routing a failure through the same `failBeforeSpawn` shape every earlier guard uses. */
|
|
583
|
+
export async function confirmChildSpawned({ input, deps }) {
|
|
584
|
+
try {
|
|
585
|
+
await waitForSpawnOrError(input.child);
|
|
586
|
+
}
|
|
587
|
+
catch (err) {
|
|
588
|
+
await deps.releaseStagedResources();
|
|
589
|
+
await deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: failed to spawn agent "${input.def.id}": ${errorMessage(err)}` });
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Creates the `AgentExecutor` reference implementation: an in-process
|
|
594
|
+
* `RunLifecycle` driver over real (by default) `@jini-ai/agent-runtime`
|
|
595
|
+
* registry lookup, launch resolution, and stream parsing, plus a real
|
|
596
|
+
* `node:child_process.spawn`. Every collaborator is an injectable seam
|
|
597
|
+
* (matching this package's established convention — see
|
|
598
|
+
* `tool-executor.ts`/`run-lifecycle.ts`) so tests can drive a fake child
|
|
599
|
+
* process and a fake registry without touching the filesystem or spawning
|
|
600
|
+
* a real subprocess.
|
|
601
|
+
*
|
|
602
|
+
* @param options.lifecycle - The `RunLifecycle` this executor drives — its `start()` must already have been called for any `runId` passed to `run()`.
|
|
603
|
+
* @returns An `AgentExecutor` whose `run()` never bare-throws (see index.ts module doc's Invariant).
|
|
604
|
+
* @complexity `run()`'s own setup is O(1); steady-state cost is the chosen stream parser's.
|
|
605
|
+
* @overallScore 100/100
|
|
606
|
+
*/
|
|
607
|
+
export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
|
|
608
|
+
const options = { ...requiredArgs, ...optionalArgs };
|
|
609
|
+
const lifecycle = options.lifecycle;
|
|
610
|
+
const { getAgentDef: getAgentDefFn, resolveAgentLaunch: resolveAgentLaunchFn, ensureAgentCapabilities: ensureAgentCapabilitiesFn, applyAgentLaunchEnv: applyAgentLaunchEnvFn, attachAcpSession: attachAcpSessionFn, attachPiRpcSession: attachPiRpcSessionFn, preparePromptFileForAgent: preparePromptFileForAgentFn, prepareAgentLogFile: prepareAgentLogFileFn, } = resolveAgentRuntimeDeps(options);
|
|
611
|
+
const { createCommandInvocation: createCommandInvocationFn, spawn: spawnFn, listProcessSnapshots: listProcessSnapshotsFn, collectProcessTreePids: collectProcessTreePidsFn, stopProcesses: stopProcessesFn, } = resolveProcessDeps(options);
|
|
612
|
+
const { onCleanupFailure: onCleanupFailureFn, bufferedStdoutMaxBytes } = resolveMiscExecutorDeps(options);
|
|
613
|
+
const journal = options.journal;
|
|
614
|
+
const continuation = options.continuation;
|
|
615
|
+
const classifyFailure = options.classifyFailure;
|
|
616
|
+
const mcpJsonInjection = options.mcpJsonInjection;
|
|
617
|
+
const claudeConfigDirIsolation = options.claudeConfigDirIsolation;
|
|
618
|
+
const claudeConfigDirIsolationEnabled = options.claudeConfigDirIsolationEnabled ?? false;
|
|
619
|
+
const promptAugmenter = options.promptAugmenter;
|
|
620
|
+
/**
|
|
621
|
+
* Transitions `runId` to `'failed'` (idempotent, never resumable — no
|
|
622
|
+
* classifier exists, see index.ts module doc) then rejects with a typed
|
|
623
|
+
* {@link AgentExecutorError}. Every pre-spawn guard in `run()` returns
|
|
624
|
+
* this call directly.
|
|
625
|
+
* @param runId - The run to transition.
|
|
626
|
+
* @param code - The machine-readable failure reason.
|
|
627
|
+
* @param message - The human-readable rejection message.
|
|
628
|
+
* @throws Always — this function never returns normally.
|
|
629
|
+
* @complexity O(1) plus `lifecycle.finish()`'s own cost.
|
|
630
|
+
* @overallScore 100/100
|
|
631
|
+
*/
|
|
632
|
+
async function failBeforeSpawn({ runId, code, message }) {
|
|
633
|
+
// The reason goes on the run's own stream before `end`, not only into the thrown error: the
|
|
634
|
+
// thrown error reaches the server log, while a chat client sees only this stream. Without it
|
|
635
|
+
// the user got a bare "Run failed". Best-effort: a stream that will not take the event must not
|
|
636
|
+
// stop the run from finishing.
|
|
637
|
+
await lifecycle
|
|
638
|
+
.emit({ runId: runId, input: { event: 'error', data: { message: `The assistant could not start: ${message.replace(/^AgentExecutor:\s*/, '')}` } } })
|
|
639
|
+
.catch(() => undefined);
|
|
640
|
+
await lifecycle.finish({ runId, status: 'failed', code: null, signal: null, resumable: false });
|
|
641
|
+
throw new AgentExecutorError({ code: code, message: message });
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* `AgentExecutor.run()` — see that interface method's own doc for the
|
|
645
|
+
* public contract. Implementation note on shape: every guard below
|
|
646
|
+
* returns `failBeforeSpawn(...)` directly (a `Promise<never>`, valid
|
|
647
|
+
* wherever `Promise<void>` is expected) rather than `await`-then-`return`,
|
|
648
|
+
* so each failure path reads as a single, obviously-terminal statement.
|
|
649
|
+
* @param input - `{runId, agentId, prompt, cwd, model?, reasoning?, imagePaths?, extraAllowedDirs?, uploadRoot?, env?}` — `runId` must already be `lifecycle.start()`-ed.
|
|
650
|
+
* @throws {@link AgentExecutorError} — see index.ts module doc's Invariant; never a bare `Error`.
|
|
651
|
+
* @complexity O(1) setup (registry lookup, launch resolution, one spawn call); steady-state cost thereafter belongs to {@link wireChildLifecycle}.
|
|
652
|
+
* @overallScore 100/100
|
|
653
|
+
*/
|
|
654
|
+
async function run(requiredArgs, optionalArgs = {}) {
|
|
655
|
+
const input = { ...requiredArgs, ...optionalArgs, permissionMode: optionalArgs.permissionMode ?? 'restricted' };
|
|
656
|
+
if (input.permissionMode !== 'restricted' && input.permissionMode !== 'bypass') {
|
|
657
|
+
return failBeforeSpawn({ runId: input.runId, code: 'AGENT_PERMISSION_MODE_INVALID', message: 'AgentExecutor: permissionMode must be restricted or bypass' });
|
|
658
|
+
}
|
|
659
|
+
const { def, streamFormat } = await resolveDefAndStreamFormat({ input: { runId: input.runId, agentId: input.agentId }, deps: { getAgentDef: getAgentDefFn, failBeforeSpawn } });
|
|
660
|
+
// Computed once, before anything downstream ever looks at "the prompt" or "the allowed
|
|
661
|
+
// dirs" — a no-op (`{prompt: input.prompt, extraAllowedDirs: input.extraAllowedDirs}`,
|
|
662
|
+
// literally unchanged) unless `def.imageDelivery === 'prompt-path'` AND `input.imagePaths`
|
|
663
|
+
// is non-empty, so this can never affect a 'native'-delivery def (ACP, pi-rpc, qoder) or a
|
|
664
|
+
// run with no attachments. See `image-prompt-delivery.ts`'s own doc for the full mechanism;
|
|
665
|
+
// every use of `input.prompt`/`input.extraAllowedDirs` below that reflects what the CLI
|
|
666
|
+
// actually receives reads `imageDelivery.*` instead — the two ACP/pi-rpc `wire*Lifecycle`
|
|
667
|
+
// calls further down deliberately keep reading `input.prompt` verbatim, since those two
|
|
668
|
+
// defs' own native protocol already delivers the image and must never also get this
|
|
669
|
+
// treatment (the double-delivery hazard this mechanism exists to avoid).
|
|
670
|
+
const imageDelivery = await resolveImageDeliveryAndArgvBudget({ input: { runId: input.runId, def, prompt: input.prompt, imagePaths: input.imagePaths, extraAllowedDirs: input.extraAllowedDirs }, deps: { failBeforeSpawn } });
|
|
671
|
+
// Phase 8, hoisted deliberately above EVERY step that writes or sends the prompt. Four
|
|
672
|
+
// different channels carry a prompt in this driver, and they do not all run at the same point:
|
|
673
|
+
// the `promptViaFile` staging below writes its file BEFORE `buildArgs`, `buildArgs` itself runs
|
|
674
|
+
// mid-`run()`, and stdin/ACP/pi-rpc all send theirs AFTER spawn. Resolving the overlay once,
|
|
675
|
+
// here, is what lets all four consume the same decision; resolving it later (as this used to,
|
|
676
|
+
// inside `buildRunArgs`) could only ever reach `buildArgs`, so the 17 defs that discard that
|
|
677
|
+
// argument, plus grok-build's staged file, silently received no overlay at all.
|
|
678
|
+
//
|
|
679
|
+
// `computeSystemPromptOverlay`'s third argument is the pre-staging `RuntimeContext`: it reads
|
|
680
|
+
// only `hasPriorAssistantTurn`, which `computeRuntimeContext` never populates from any of the
|
|
681
|
+
// staged-file/MCP inputs added to the fuller context built further down, so nothing between
|
|
682
|
+
// here and there can change the overlay this returns. Passing the narrower context makes that
|
|
683
|
+
// independence explicit rather than relying on the ordering staying lucky.
|
|
684
|
+
//
|
|
685
|
+
// `turnIndex` is a coarse 0/1 proxy (no exact turn counter exists on this driver) — sufficient
|
|
686
|
+
// because every `PromptAugmenter.systemOverlay()` implementation this seam has today wants the
|
|
687
|
+
// same overlay on every turn, not a first-turn-only one; a caller that needs finer-grained turn
|
|
688
|
+
// numbering can track it itself and ignore this arg.
|
|
689
|
+
const preStagingRuntimeContext = computeRuntimeContext({ preparedPromptFile: null, preparedLogFile: null, mcpBridge: null }, {
|
|
690
|
+
...(input.resumeSessionId === undefined ? {} : { resumeSessionId: input.resumeSessionId }),
|
|
691
|
+
...(input.newSessionId === undefined ? {} : { newSessionId: input.newSessionId }),
|
|
692
|
+
});
|
|
693
|
+
const systemPromptOverlay = computeSystemPromptOverlay(promptAugmenter, def.id, preStagingRuntimeContext);
|
|
694
|
+
const overlayDelivery = resolveSystemPromptOverlayDelivery({
|
|
695
|
+
defId: def.id,
|
|
696
|
+
systemPromptDelivery: def.systemPromptDelivery,
|
|
697
|
+
resumesSessionViaCli: def.resumesSessionViaCli,
|
|
698
|
+
resumesSessionViaAcpLoad: def.resumesSessionViaAcpLoad,
|
|
699
|
+
overlay: systemPromptOverlay,
|
|
700
|
+
prompt: imageDelivery.prompt,
|
|
701
|
+
resumeSessionId: preStagingRuntimeContext?.resumeSessionId,
|
|
702
|
+
});
|
|
703
|
+
// The def's own declared env (e.g. claude's `ENABLE_TOOL_SEARCH=false`) is a default the run's
|
|
704
|
+
// env can override. Detection already spawns with it (`detection.ts`); without this the run
|
|
705
|
+
// spawn silently dropped it. Declared by the def itself, so it never widens SEC-001's allowlist.
|
|
706
|
+
const resolvedEnv = { ...(def.env ?? {}), ...resolveRunEnv({ input: input, hostEnv: process.env }) };
|
|
707
|
+
const launch = await resolveLaunch({ input: { runId: input.runId, def, resolvedEnv }, deps: { resolveAgentLaunch: resolveAgentLaunchFn, failBeforeSpawn } });
|
|
708
|
+
const spawnEnv = applyAgentLaunchEnvFn({ env: { ...resolvedEnv }, launch });
|
|
709
|
+
// Fill the def's `--help` capability gate before `buildArgs` reads it. Without this, only a
|
|
710
|
+
// host that happened to call `detectAgents` in THIS process ever had the gate filled, so e.g.
|
|
711
|
+
// `claude` never got `--include-partial-messages` (no streamed text). Probes once per binary;
|
|
712
|
+
// never rejects (a failed probe keeps the safe no-optional-flags baseline).
|
|
713
|
+
await ensureAgentCapabilitiesFn({ def, launchPath: launch.launchPath, env: spawnEnv });
|
|
714
|
+
// Stage a promptViaFile def's (grok-build) prompt to a temp file before buildArgs runs — its
|
|
715
|
+
// buildArgs throws without runtimeContext.promptFilePath. A no-op (returns null) for every
|
|
716
|
+
// def without promptViaFile: true (preparePromptFileForAgent's own guard).
|
|
717
|
+
//
|
|
718
|
+
// `overlayDelivery.prompt`, not `imageDelivery.prompt`: for a `promptViaFile` def this file IS
|
|
719
|
+
// the prompt transport — its `buildArgs` declares `_prompt` and passes only the path — so the
|
|
720
|
+
// overlay has to be in the bytes written here or the CLI never sees it at all.
|
|
721
|
+
const preparedPromptFile = await stagePromptFile({ input: { runId: input.runId, def, prompt: overlayDelivery.prompt }, deps: { preparePromptFileForAgent: preparePromptFileForAgentFn, failBeforeSpawn } });
|
|
722
|
+
// Stage a needsAgentLogFile def's (antigravity) diagnostic-log path, on the same terms and at
|
|
723
|
+
// the same point as the prompt file above: before buildArgs, since buildArgs is what turns the
|
|
724
|
+
// path into a `--log-file <path>` argument. A no-op (returns null) for every def without
|
|
725
|
+
// `needsAgentLogFile: true` (prepareAgentLogFile's own guard). Sequenced after the prompt file
|
|
726
|
+
// rather than concurrently so the failure path above has exactly one thing to clean up.
|
|
727
|
+
const preparedLogFile = await stageLogFile({ input: { runId: input.runId, def, preparedPromptFile }, deps: { prepareAgentLogFile: prepareAgentLogFileFn, failBeforeSpawn } });
|
|
728
|
+
// Cleaned up after the child exits (wireChildLifecycle/wireAcpLifecycle/wirePiRpcLifecycle's
|
|
729
|
+
// close handlers) and on every pre-spawn/spawn-failure path below — a leaked temp file
|
|
730
|
+
// containing the full prompt, or whatever the CLI chose to write into its log, is a
|
|
731
|
+
// confidentiality gap, not just a disk leak. One composed closure covering both staged files;
|
|
732
|
+
// see `WireChildLifecycleContext.cleanupStagedFiles`'s doc for why they are not two fields.
|
|
733
|
+
/**
|
|
734
|
+
* Set once `writeMcpJsonForRun` has actually written this run's MCP config, so `cleanupStagedFiles`
|
|
735
|
+
* knows there is a file holding a live bearer token to remove. Cleared as it is consumed, so the
|
|
736
|
+
* removal happens exactly once across the several paths that may call the cleanup. Only the
|
|
737
|
+
* `'claude-mcp-json'` mechanism stages a file at all — `'acp-merge'` and `'env-content'` leave
|
|
738
|
+
* nothing on disk, so this stays `undefined` for those.
|
|
739
|
+
*/
|
|
740
|
+
let writtenMcpJsonPath;
|
|
741
|
+
const removeMcpJsonFileFn = mcpJsonInjection?.removeFile ?? defaultRemoveMcpJsonFile;
|
|
742
|
+
/**
|
|
743
|
+
* Set once `prepareCodexHomeIfNeeded` has actually staged this run's scratch `CODEX_HOME`, so
|
|
744
|
+
* `cleanupStagedFiles` knows there is a directory holding a copied login credential to remove.
|
|
745
|
+
* Cleared as it is consumed, matching `writtenMcpJsonPath`'s identical single-removal discipline.
|
|
746
|
+
* Only the `'codex-toml'` mechanism stages a directory at all.
|
|
747
|
+
*/
|
|
748
|
+
let preparedCodexHome = null;
|
|
749
|
+
/**
|
|
750
|
+
* Set once `prepareClaudeConfigDirIfNeeded` has actually staged this run's scratch
|
|
751
|
+
* `CLAUDE_CONFIG_DIR`, so `cleanupStagedFiles` knows there is a directory (possibly holding a
|
|
752
|
+
* copied login credential) to remove. Cleared as it is consumed, matching `preparedCodexHome`'s
|
|
753
|
+
* identical single-removal discipline. Only a `claude`-id run stages a directory this way.
|
|
754
|
+
*/
|
|
755
|
+
let preparedClaudeConfigDir = null;
|
|
756
|
+
/**
|
|
757
|
+
* Set once `prepareSystemPromptOverlayFileIfNeeded` has actually staged this run's overlay file
|
|
758
|
+
* for a `'config-instructions-file'` def, so `cleanupStagedFiles` knows there is a temp
|
|
759
|
+
* directory to remove. Cleared as it is consumed, matching `preparedCodexHome`'s identical
|
|
760
|
+
* single-removal discipline. Only that one strategy stages a file this way — `null` for every
|
|
761
|
+
* other def/strategy/no-overlay run.
|
|
762
|
+
*/
|
|
763
|
+
let preparedSystemPromptOverlayFile = null;
|
|
764
|
+
const cleanupStagedFiles = async () => {
|
|
765
|
+
if (preparedPromptFile)
|
|
766
|
+
await preparedPromptFile.cleanup();
|
|
767
|
+
if (preparedLogFile)
|
|
768
|
+
await preparedLogFile.cleanup();
|
|
769
|
+
if (writtenMcpJsonPath !== undefined) {
|
|
770
|
+
const mcpJsonFileToRemove = writtenMcpJsonPath;
|
|
771
|
+
writtenMcpJsonPath = undefined;
|
|
772
|
+
await removeMcpJsonFileFn({ path: mcpJsonFileToRemove });
|
|
773
|
+
}
|
|
774
|
+
if (preparedCodexHome) {
|
|
775
|
+
const codexHomeToRemove = preparedCodexHome;
|
|
776
|
+
preparedCodexHome = null;
|
|
777
|
+
await codexHomeToRemove.cleanup();
|
|
778
|
+
}
|
|
779
|
+
if (preparedClaudeConfigDir) {
|
|
780
|
+
const claudeConfigDirToRemove = preparedClaudeConfigDir;
|
|
781
|
+
preparedClaudeConfigDir = null;
|
|
782
|
+
await claudeConfigDirToRemove.cleanup();
|
|
783
|
+
}
|
|
784
|
+
if (preparedSystemPromptOverlayFile) {
|
|
785
|
+
const overlayFileToRemove = preparedSystemPromptOverlayFile;
|
|
786
|
+
preparedSystemPromptOverlayFile = null;
|
|
787
|
+
await overlayFileToRemove.cleanup();
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
// Resolve this run's MCP bridge delivery once, before buildArgs — the `'claude-mcp-json'`
|
|
791
|
+
// variant's path has to be in `runtimeContext` for that def's own `--mcp-config` argv, and
|
|
792
|
+
// resolving here means the per-run bearer credential is minted exactly once no matter which of
|
|
793
|
+
// the five mechanisms ends up carrying it. `null` for an unconfigured host or a def declaring
|
|
794
|
+
// no strategy — see `buildMcpBridgeDelivery`'s doc.
|
|
795
|
+
const mcpBridge = await resolveMcpBridgeForRun({ input: { runId: input.runId, cwd: input.cwd, def }, deps: { mcpJsonInjection, cleanupStagedFiles, failBeforeSpawn } });
|
|
796
|
+
const runtimeContext = computeRuntimeContext({ preparedPromptFile: preparedPromptFile, preparedLogFile: preparedLogFile, mcpBridge: mcpBridge }, {
|
|
797
|
+
...(input.resumeSessionId === undefined ? {} : { resumeSessionId: input.resumeSessionId }),
|
|
798
|
+
...(input.newSessionId === undefined ? {} : { newSessionId: input.newSessionId }),
|
|
799
|
+
});
|
|
800
|
+
// A `runtimeLock` def's buildArgs mutates process-global state its own CLI reads back at
|
|
801
|
+
// startup, so the mutex must be held from before buildArgs until the spawned child has
|
|
802
|
+
// demonstrably consumed it — see `RuntimeLock`'s own doc for the concrete race. Undefined for
|
|
803
|
+
// 23 of 24 defs, in which case nothing below waits on anything.
|
|
804
|
+
const selectedModel = input.model;
|
|
805
|
+
const runtimeLockHold = await acquireRuntimeLockIfConfigured(def, selectedModel);
|
|
806
|
+
// Aborts once the spawned process is gone — or immediately, on a path where no process ever
|
|
807
|
+
// ran — so a def's own handoff watcher can never outlive the run it was polling for.
|
|
808
|
+
const processExitedController = new AbortController();
|
|
809
|
+
/**
|
|
810
|
+
* Releases the runtime lock and cancels any handoff watcher. Safe to call from any number of
|
|
811
|
+
* paths: `AbortController.abort()` after the first is a no-op, and `RuntimeLockHold.release`
|
|
812
|
+
* is idempotent by contract.
|
|
813
|
+
*/
|
|
814
|
+
const releaseRuntimeLock = () => {
|
|
815
|
+
processExitedController.abort();
|
|
816
|
+
runtimeLockHold?.release();
|
|
817
|
+
};
|
|
818
|
+
/** Both staged-file and lock release, for the pre-spawn/spawn-failure paths that own neither a child nor a close handler. */
|
|
819
|
+
const releaseStagedResources = async () => {
|
|
820
|
+
releaseRuntimeLock();
|
|
821
|
+
await cleanupStagedFiles();
|
|
822
|
+
};
|
|
823
|
+
// Guarded, like every other step between staging and spawn: a `runtimeLock` def's `buildArgs` is
|
|
824
|
+
// guarded precisely *because* it performs real filesystem writes (antigravity writes its model
|
|
825
|
+
// choice into a shared settings file), so EACCES on a read-only home, ENOSPC, or a malformed
|
|
826
|
+
// existing settings file all reach here as a throw. Unguarded, that escaped `run()` as a bare
|
|
827
|
+
// `Error` — breaking this driver's "never a bare throw, always an `AgentExecutorError`" contract
|
|
828
|
+
// — and left the run `'running'` forever while still holding the process-global mutex and both
|
|
829
|
+
// staged files, so no later run of that def could ever acquire the lock either.
|
|
830
|
+
const { args, envOverrides: systemPromptEnvOverrides } = await buildRunArgs({ input: { runId: input.runId, def, imageDelivery, imagePaths: input.imagePaths, runInput: input, systemPromptOverlay, overlayDelivery, runtimeContext }, deps: { releaseStagedResources, failBeforeSpawn } });
|
|
831
|
+
// Mechanism 1 of 5's one effect — stage this run's own MCP config file (run-scoped, see
|
|
832
|
+
// `mcpJsonPathForRun`) before spawn so the `--mcp-config <path>` argv buildArgs just produced
|
|
833
|
+
// points at a real file. Skipped entirely for the other four mechanisms and whenever no bridge
|
|
834
|
+
// was resolved at all. `writtenMcpJsonPath` is set only once the write actually happens, so
|
|
835
|
+
// `cleanupStagedFiles` knows there is a live-token file to remove afterward.
|
|
836
|
+
writtenMcpJsonPath = await writeMcpJsonIfNeeded({ input: { runId: input.runId, cwd: input.cwd, def, mcpBridge }, deps: { mcpJsonInjection, releaseStagedResources, failBeforeSpawn } });
|
|
837
|
+
// Mechanism 5 of 5's one effect — stage this run's scratch `CODEX_HOME` directory. Skipped
|
|
838
|
+
// entirely for the other four mechanisms and whenever no bridge was resolved at all.
|
|
839
|
+
// `codex.ts`'s `buildArgs` needs no argv change for this (CODEX_HOME is an env var, not a flag),
|
|
840
|
+
// so — unlike the `.mcp.json` staging above — this can run after `buildArgs` with no ordering
|
|
841
|
+
// constraint of its own; it is placed here only to keep the two staging steps adjacent.
|
|
842
|
+
preparedCodexHome = await prepareCodexHomeIfNeeded({ input: { runId: input.runId, def, mcpBridge }, deps: { mcpJsonInjection, hostEnv: process.env, releaseStagedResources, failBeforeSpawn } });
|
|
843
|
+
// Finding 1 of SEC-assistant-env-isolation-2026-09-07's one effect — stage this run's scratch
|
|
844
|
+
// `CLAUDE_CONFIG_DIR` directory. Unconditional for a `claude`-id run (unlike CODEX_HOME above,
|
|
845
|
+
// this does not depend on `mcpJsonInjection` being configured at all — see
|
|
846
|
+
// `prepareClaudeConfigDirIfNeeded`'s own doc for why). Placed here only to stay adjacent to the
|
|
847
|
+
// other pre-`computeChildEnv` staging steps; `claude.ts`'s `buildArgs` needs no argv change for
|
|
848
|
+
// this (CLAUDE_CONFIG_DIR is an env var, not a flag), same as CODEX_HOME.
|
|
849
|
+
preparedClaudeConfigDir = await prepareClaudeConfigDirIfNeeded({ input: { runId: input.runId, def }, deps: {
|
|
850
|
+
enabled: claudeConfigDirIsolationEnabled,
|
|
851
|
+
claudeConfigDirIsolation,
|
|
852
|
+
hostEnv: process.env,
|
|
853
|
+
releaseStagedResources,
|
|
854
|
+
failBeforeSpawn,
|
|
855
|
+
} });
|
|
856
|
+
// `'config-instructions-file'`'s one effect — stage the overlay to a temp file so
|
|
857
|
+
// `computeChildEnv` below has a real path to merge into that def's `instructions` array. A
|
|
858
|
+
// no-op (`null`) for every other def/strategy or a run with no overlay at all. Independent of
|
|
859
|
+
// `mcpBridge`/`preparedCodexHome` above (a different strategy field entirely), so placed here
|
|
860
|
+
// only to stay adjacent to the other pre-`computeChildEnv` staging steps, not for any ordering
|
|
861
|
+
// requirement between them.
|
|
862
|
+
preparedSystemPromptOverlayFile = await prepareSystemPromptOverlayFileIfNeeded({ input: { runId: input.runId, def, overlay: systemPromptOverlay }, deps: { releaseStagedResources, failBeforeSpawn } });
|
|
863
|
+
// Computed only now, not right after `mcpBridge` resolution: mechanism 5's directory path is
|
|
864
|
+
// not known until the staging step directly above actually runs `mkdtemp` (see
|
|
865
|
+
// `McpBridgeDelivery`'s `'codex-toml'` variant doc for why it cannot be pre-computed the way
|
|
866
|
+
// `'claude-mcp-json'`'s deterministic path is). Nothing between the old, earlier call site and
|
|
867
|
+
// here ever read `childEnv`, so moving the call cost nothing.
|
|
868
|
+
const childEnv = computeChildEnv({ spawnEnv: spawnEnv, mcpBridge: mcpBridge }, {
|
|
869
|
+
...(preparedCodexHome === null ? {} : { codexHomeDir: preparedCodexHome.path }),
|
|
870
|
+
systemPromptEnvOverrides,
|
|
871
|
+
...(preparedSystemPromptOverlayFile && def.systemPromptDelivery?.strategy === 'config-instructions-file'
|
|
872
|
+
? { stagedInstructionsFile: { varName: def.systemPromptDelivery.varName, path: preparedSystemPromptOverlayFile.path } }
|
|
873
|
+
: {}),
|
|
874
|
+
...(preparedClaudeConfigDir === null ? {} : { claudeConfigDir: preparedClaudeConfigDir.path }),
|
|
875
|
+
});
|
|
876
|
+
// Post-buildArgs guard for argv-bound defs whose resolved binary is a
|
|
877
|
+
// Windows .cmd/.bat shim or a direct .exe: a prompt under the raw byte
|
|
878
|
+
// budget can still expand past CreateProcess's command-line cap once
|
|
879
|
+
// quote-escaped. Both are no-ops off-Windows / for non-argv-bound defs.
|
|
880
|
+
await guardWindowsCommandLineBudget({ input: { runId: input.runId, def, launchPath: launch.launchPath, args }, deps: { releaseStagedResources, failBeforeSpawn } });
|
|
881
|
+
const invocation = createCommandInvocationFn({ command: launch.launchPath, args, env: childEnv });
|
|
882
|
+
// Kept a synchronous call (no `await`) on purpose — see `spawnAgentChildProcess`'s own doc for
|
|
883
|
+
// the microtask-timing race this avoids. The error branch's own cleanup/failBeforeSpawn calls are
|
|
884
|
+
// async, which is fine: no child exists yet on that path, so nothing is racing a listener.
|
|
885
|
+
const spawnResult = spawnAgentChildProcess({ input: { cwd: input.cwd, childEnv, invocation }, deps: { spawn: spawnFn } });
|
|
886
|
+
if (spawnResult.kind === 'error') {
|
|
887
|
+
await releaseStagedResources();
|
|
888
|
+
return failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: spawn threw synchronously for agent "${def.id}": ${errorMessage(spawnResult.error)}` });
|
|
889
|
+
}
|
|
890
|
+
const child = spawnResult.child;
|
|
891
|
+
// Registered before the spawn-confirmation await below, for the same reason
|
|
892
|
+
// `wireChildLifecycle` is: a child that exits immediately must not slip past the listener.
|
|
893
|
+
// `'exit'` rather than `'close'` on purpose — a `runtimeLock` guards state the *process* reads,
|
|
894
|
+
// so the process being gone is the release condition, not its stdio pipes draining (which a
|
|
895
|
+
// grandchild inheriting them can delay arbitrarily). A spawn that never produced a process at
|
|
896
|
+
// all emits no `'exit'`, and is covered instead by `releaseStagedResources` on the reject path.
|
|
897
|
+
child.once('exit', releaseRuntimeLock);
|
|
898
|
+
const formatForStdin = { streamFormat };
|
|
899
|
+
const stdinHandle = isStdinDrivenFormat(formatForStdin)
|
|
900
|
+
? wireChildLifecycle({
|
|
901
|
+
runId: input.runId,
|
|
902
|
+
def,
|
|
903
|
+
streamFormat: formatForStdin.streamFormat,
|
|
904
|
+
child,
|
|
905
|
+
lifecycle,
|
|
906
|
+
listProcessSnapshots: listProcessSnapshotsFn,
|
|
907
|
+
collectProcessTreePids: collectProcessTreePidsFn,
|
|
908
|
+
stopProcesses: stopProcessesFn,
|
|
909
|
+
onCleanupFailure: onCleanupFailureFn,
|
|
910
|
+
cleanupStagedFiles,
|
|
911
|
+
journal,
|
|
912
|
+
continuation,
|
|
913
|
+
classifyFailure,
|
|
914
|
+
bufferedStdoutMaxBytes,
|
|
915
|
+
expectsJiniBridge: writtenMcpJsonPath !== undefined,
|
|
916
|
+
})
|
|
917
|
+
: null;
|
|
918
|
+
await confirmChildSpawned({ input: { runId: input.runId, def, child }, deps: { releaseStagedResources, failBeforeSpawn } });
|
|
919
|
+
// Now — and only now — is there a live process that could consume the locked side effect, so
|
|
920
|
+
// this is where a def's handoff watcher starts. Deliberately not awaited: the whole point is to
|
|
921
|
+
// release the lock as soon as the child confirms the handoff, in parallel with this run
|
|
922
|
+
// continuing. Rejection releases too — a lock stuck open because a watcher threw is strictly
|
|
923
|
+
// worse than releasing early (see `RuntimeLockHold.waitForHandoff`'s own doc).
|
|
924
|
+
armHandoffWatcher({ runtimeLockHold: runtimeLockHold, handoffInput: { logFilePath: preparedLogFile?.path, model: selectedModel, processExited: processExitedController.signal }, release: releaseRuntimeLock });
|
|
925
|
+
if (streamFormat === 'acp-json-rpc') {
|
|
926
|
+
await runAcpDispatch({ input: {
|
|
927
|
+
runId: input.runId,
|
|
928
|
+
agentId: def.id,
|
|
929
|
+
child,
|
|
930
|
+
// `overlayDelivery.promptPrefix` applied to `input.prompt`, NOT `overlayDelivery.prompt`:
|
|
931
|
+
// this call site deliberately sends the raw input prompt rather than the image-delivery
|
|
932
|
+
// rewrite (see `resolveImageDeliveryAndArgvBudget`'s call site above — an ACP def delivers
|
|
933
|
+
// images natively and must never also get `'prompt-path'`'s appended paths), and the prefix
|
|
934
|
+
// is the part of the overlay decision that applies to whatever prompt text a transport was
|
|
935
|
+
// already sending. `''` for `reasonix` (env-var) and any resumed ACP session, so those keep
|
|
936
|
+
// sending byte-identical text and never receive the overlay twice.
|
|
937
|
+
prompt: `${overlayDelivery.promptPrefix}${input.prompt}`,
|
|
938
|
+
cwd: input.cwd,
|
|
939
|
+
model: input.model,
|
|
940
|
+
imagePaths: input.imagePaths ?? [],
|
|
941
|
+
envFormat: def.acpMcpEnvFormat,
|
|
942
|
+
mcpBridge,
|
|
943
|
+
}, deps: {
|
|
944
|
+
lifecycle,
|
|
945
|
+
attachAcpSession: attachAcpSessionFn,
|
|
946
|
+
onPermissionRequest: options.acpPermissionHandler,
|
|
947
|
+
listProcessSnapshots: listProcessSnapshotsFn,
|
|
948
|
+
collectProcessTreePids: collectProcessTreePidsFn,
|
|
949
|
+
stopProcesses: stopProcessesFn,
|
|
950
|
+
onCleanupFailure: onCleanupFailureFn,
|
|
951
|
+
cleanupStagedFiles,
|
|
952
|
+
journal,
|
|
953
|
+
classifyFailure,
|
|
954
|
+
releaseStagedResources,
|
|
955
|
+
failBeforeSpawn,
|
|
956
|
+
} });
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (streamFormat === 'pi-rpc') {
|
|
960
|
+
await runPiRpcDispatch({ input: {
|
|
961
|
+
runId: input.runId,
|
|
962
|
+
agentId: def.id,
|
|
963
|
+
child,
|
|
964
|
+
// Same reasoning as the ACP call site above. `pi` declares an `'append-flag'` strategy,
|
|
965
|
+
// so its prefix is `''` and this stays byte-identical to the raw prompt — the overlay
|
|
966
|
+
// rides `--append-system-prompt` instead, exactly once.
|
|
967
|
+
prompt: `${overlayDelivery.promptPrefix}${input.prompt}`,
|
|
968
|
+
cwd: input.cwd,
|
|
969
|
+
model: input.model,
|
|
970
|
+
imagePaths: input.imagePaths ?? [],
|
|
971
|
+
uploadRoot: input.uploadRoot,
|
|
972
|
+
}, deps: {
|
|
973
|
+
lifecycle,
|
|
974
|
+
attachPiRpcSession: attachPiRpcSessionFn,
|
|
975
|
+
listProcessSnapshots: listProcessSnapshotsFn,
|
|
976
|
+
collectProcessTreePids: collectProcessTreePidsFn,
|
|
977
|
+
stopProcesses: stopProcessesFn,
|
|
978
|
+
onCleanupFailure: onCleanupFailureFn,
|
|
979
|
+
cleanupStagedFiles,
|
|
980
|
+
journal,
|
|
981
|
+
classifyFailure,
|
|
982
|
+
releaseStagedResources,
|
|
983
|
+
failBeforeSpawn,
|
|
984
|
+
} });
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
// The overlay reaches stdin only for a def that declares stdin as its prompt transport. The
|
|
988
|
+
// four `plain`-format defs that do not (`aider`/`antigravity`/`deepseek` put the prompt in
|
|
989
|
+
// argv, `grok-build` in a staged file) are still written to and closed here exactly as before,
|
|
990
|
+
// because this driver always spawns with `stdio: ['pipe','pipe','pipe']` and their CLIs need
|
|
991
|
+
// the EOF — but their prompt already carried the overlay through argv or the staged file, so
|
|
992
|
+
// sending the overlaid text here too would deliver it twice. This is the one place the
|
|
993
|
+
// resolver's own `''`-prefix rule is not sufficient on its own: those four defs are on the
|
|
994
|
+
// fallback strategy, so their prefix is genuinely non-empty; what makes stdin the wrong
|
|
995
|
+
// channel for them is the def's declared transport, not the strategy.
|
|
996
|
+
const stdinPrompt = def.promptViaStdin === true ? overlayDelivery.prompt : imageDelivery.prompt;
|
|
997
|
+
writePromptToStdin(def, child, stdinPrompt, stdinHandle);
|
|
998
|
+
}
|
|
999
|
+
return { run };
|
|
1000
|
+
}
|
|
1001
|
+
//# sourceMappingURL=launch.js.map
|