@jini-ai/daemon 0.3.8 → 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 +375 -176
- package/dist/agent-executor.js +388 -335
- package/dist/artifacts/index.d.ts +14 -0
- package/dist/artifacts/index.js +14 -0
- package/dist/artifacts/manifest.d.ts +90 -0
- package/dist/artifacts/manifest.js +229 -0
- package/dist/artifacts/publication-guard.d.ts +28 -0
- package/dist/artifacts/publication-guard.js +77 -0
- package/dist/artifacts/runtime-compat.d.ts +37 -0
- package/dist/artifacts/runtime-compat.js +33 -0
- package/dist/artifacts/store.d.ts +85 -0
- package/dist/artifacts/store.js +92 -0
- package/dist/artifacts/stub-guard.d.ts +73 -0
- package/dist/artifacts/stub-guard.js +198 -0
- package/dist/artifacts/text-suppression.d.ts +68 -0
- package/dist/artifacts/text-suppression.js +184 -0
- 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 +81 -74
- 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,835 @@
|
|
|
1
|
+
import { promises as fsPromises, } from 'node:fs';
|
|
2
|
+
import { homedir, tmpdir, } from 'node:os';
|
|
3
|
+
import { join, } from 'node:path';
|
|
4
|
+
import {} from '@jini-ai/agent-runtime';
|
|
5
|
+
import { isRecord, errorMessage, } from './values.js';
|
|
6
|
+
import {} from './contracts.js';
|
|
7
|
+
const JINI_MCP_SERVER_KEY = 'jini';
|
|
8
|
+
/** The run-error code a run gets when its CLI reports the injected `jini` bridge as anything but connected. */
|
|
9
|
+
export const MCP_BRIDGE_UNAVAILABLE = 'MCP_BRIDGE_UNAVAILABLE';
|
|
10
|
+
/**
|
|
11
|
+
* Judges a CLI's startup report of the injected bridge. Returns the bridge's status (`'missing'`
|
|
12
|
+
* when the list omits it) when it is not `'connected'`, and `undefined` when it is connected or
|
|
13
|
+
* when `rawEvent` is not an init status carrying an MCP server list (nothing to judge).
|
|
14
|
+
*
|
|
15
|
+
* `'pending'` counts as a failure on purpose: the CLI decides which MCP tools a session has at
|
|
16
|
+
* startup, so a bridge that is not connected by then never joins this run.
|
|
17
|
+
* @param rawEvent - One parsed stream event, as `@jini-ai/agent-runtime`'s parser emits it.
|
|
18
|
+
* @complexity O(n) in the number of reported servers.
|
|
19
|
+
*/
|
|
20
|
+
export function unavailableJiniBridgeStatus({ rawEvent }) {
|
|
21
|
+
if (!isRecord(rawEvent) || rawEvent.type !== 'status' || rawEvent.label !== 'initializing')
|
|
22
|
+
return undefined;
|
|
23
|
+
if (!Array.isArray(rawEvent.mcpServers))
|
|
24
|
+
return undefined;
|
|
25
|
+
const bridge = rawEvent.mcpServers.find((server) => isRecord(server) && server.name === JINI_MCP_SERVER_KEY);
|
|
26
|
+
const status = isRecord(bridge) ? String(bridge.status) : 'missing';
|
|
27
|
+
return status === 'connected' ? undefined : status;
|
|
28
|
+
}
|
|
29
|
+
/** The user-readable reason a run stopped because its bridge did not connect. */
|
|
30
|
+
export function bridgeUnavailableMessage(status) {
|
|
31
|
+
return `The assistant's tools did not load (MCP bridge "${JINI_MCP_SERVER_KEY}" status: ${status}). The run was stopped instead of continuing without them.`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Builds this run's `mcpServers.jini` entry — pure and synchronous, so every field mapping is
|
|
35
|
+
* directly assertable without touching the filesystem. The credential arrives already resolved:
|
|
36
|
+
* `McpJsonInjectionOptions.credential` is a possibly-async per-run resolver, and awaiting it is
|
|
37
|
+
* `writeMcpJsonForRun`'s job, which keeps the effect out of this function.
|
|
38
|
+
*
|
|
39
|
+
* @param runId - The run this entry scopes its child to.
|
|
40
|
+
* @param options - `command`/`args`/`daemonUrl`/`env` from the host's injection options.
|
|
41
|
+
* @param credential - The already-resolved bearer token, or `undefined` to omit `JINI_DAEMON_TOKEN`
|
|
42
|
+
* entirely. Omitting produces byte-identical output to before this parameter existed.
|
|
43
|
+
* @complexity O(1).
|
|
44
|
+
* @overallScore 100/100
|
|
45
|
+
*/
|
|
46
|
+
export function buildMcpJsonServerEntry({ runId, options }, { credential } = {}) {
|
|
47
|
+
const hostEnv = Object.entries(options.env ?? {}).filter(([key]) => !key.startsWith('JINI_'));
|
|
48
|
+
return {
|
|
49
|
+
command: options.command,
|
|
50
|
+
args: options.args !== undefined ? [...options.args] : [],
|
|
51
|
+
env: {
|
|
52
|
+
...Object.fromEntries(hostEnv),
|
|
53
|
+
JINI_RUN_ID: runId,
|
|
54
|
+
JINI_DAEMON_URL: options.daemonUrl,
|
|
55
|
+
...(credential !== undefined ? { JINI_DAEMON_TOKEN: credential } : {}),
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Merges {@link JINI_MCP_SERVER_KEY} into an existing `.mcp.json`'s `mcpServers` map, preserving
|
|
61
|
+
* every other key and every other registered server untouched. A missing (`existingRaw ===
|
|
62
|
+
* undefined`), empty, or unparseable-as-a-JSON-object existing file all degrade to "start from an
|
|
63
|
+
* empty document" rather than throwing — an unparseable project `.mcp.json` is a pre-existing
|
|
64
|
+
* problem this driver did not create and cannot safely repair, so it is deliberately overwritten
|
|
65
|
+
* with a fresh, valid file containing just this run's bridge entry rather than left broken or
|
|
66
|
+
* left blocking the run. Pure — no I/O — so every branch is directly assertable.
|
|
67
|
+
* @complexity O(1) plus `JSON.parse`/`JSON.stringify`'s own cost on a small config file.
|
|
68
|
+
* @overallScore 100/100
|
|
69
|
+
*/
|
|
70
|
+
export function mergeMcpJsonContent({ existingRaw, serverEntry }) {
|
|
71
|
+
let doc = {};
|
|
72
|
+
if (existingRaw !== undefined) {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(existingRaw);
|
|
75
|
+
if (isRecord(parsed))
|
|
76
|
+
doc = parsed;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
doc = {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const existingServers = isRecord(doc.mcpServers) ? doc.mcpServers : {};
|
|
83
|
+
const mcpServers = { ...existingServers, [JINI_MCP_SERVER_KEY]: serverEntry };
|
|
84
|
+
return `${JSON.stringify({ ...doc, mcpServers }, null, 2)}\n`;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Mechanism 2 of 5 — `'acp-merge'`. Re-shapes the same bridge entry into the `mcpServers` element
|
|
88
|
+
* an ACP `session/new` call carries, for the 9 ACP-native defs declaring this strategy (amr, devin,
|
|
89
|
+
* hermes, kilo, kimi, kiro, reasonix, trae-cli, vibe). Pure.
|
|
90
|
+
*
|
|
91
|
+
* `env` is emitted as a plain object on purpose: `@jini-ai/agent-runtime`'s
|
|
92
|
+
* `buildAcpSessionNewParams` already normalises a plain-object `env` into either the
|
|
93
|
+
* `[{name, value}]` array form or the `{"KEY": "val"}` map form according to each def's own
|
|
94
|
+
* `acpMcpEnvFormat`, so the per-vendor wire-shape difference stays in the one place that already
|
|
95
|
+
* owns it rather than being re-decided here.
|
|
96
|
+
*
|
|
97
|
+
* **The credential travels in `env`, never in `args`.** An ACP agent spawns this server itself and
|
|
98
|
+
* applies `env` to that child's environment; a token in `args` would land in the child's process
|
|
99
|
+
* arguments, readable by any other local user via `ps`. Same rule as the `.mcp.json` path.
|
|
100
|
+
*
|
|
101
|
+
* @param entry - The shared bridge entry from {@link buildMcpJsonServerEntry}.
|
|
102
|
+
* @returns A single-element list — this driver contributes exactly its own bridge server and never
|
|
103
|
+
* removes or rewrites servers a def or host added by other means.
|
|
104
|
+
* @complexity O(1).
|
|
105
|
+
* @overallScore 100/100
|
|
106
|
+
*/
|
|
107
|
+
export function buildAcpMcpBridgeServers({ entry }) {
|
|
108
|
+
return [
|
|
109
|
+
{
|
|
110
|
+
type: 'stdio',
|
|
111
|
+
name: JINI_MCP_SERVER_KEY,
|
|
112
|
+
command: entry.command,
|
|
113
|
+
args: [...entry.args],
|
|
114
|
+
env: { ...entry.env },
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Mechanism 3+4 of 5 — the spawn-env-content strategies. One map, not two code paths: OpenCode and
|
|
120
|
+
* MiMo consume byte-identical JSON (MiMo's def doc: "the same JSON schema as OpenCode's `mcp`
|
|
121
|
+
* config ... following the same structure as `OPENCODE_CONFIG_CONTENT`"), and differ only in which
|
|
122
|
+
* env var carries it. Adding a third such CLI is a row here, not a new serializer.
|
|
123
|
+
*/
|
|
124
|
+
const ENV_CONTENT_VAR_BY_STRATEGY = {
|
|
125
|
+
'opencode-env-content': 'OPENCODE_CONFIG_CONTENT',
|
|
126
|
+
'mimo-env-content': 'MIMOCODE_CONFIG_CONTENT',
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* Serialises the bridge entry into the OpenCode-schema config JSON that `OPENCODE_CONFIG_CONTENT`
|
|
130
|
+
* / `MIMOCODE_CONFIG_CONTENT` carries, merging into whatever the host already put in that variable
|
|
131
|
+
* rather than replacing it — the same "merge, never clobber" discipline
|
|
132
|
+
* {@link mergeMcpJsonContent} applies to `.mcp.json`, and for the same reason: a host may already
|
|
133
|
+
* be handing the CLI the *user's* configured MCP servers through this exact variable, and
|
|
134
|
+
* overwriting it would silently delete them.
|
|
135
|
+
*
|
|
136
|
+
* A missing, empty, or unparseable-as-a-JSON-object existing value degrades to "start from an empty
|
|
137
|
+
* document". Overwriting an unparseable value is deliberate and matches `mergeMcpJsonContent`: this
|
|
138
|
+
* driver did not create it, cannot safely repair it, and must not block the run on it.
|
|
139
|
+
*
|
|
140
|
+
* Emitted per server: `{type: 'local', command: [<command>, ...<args>], environment: {...},
|
|
141
|
+
* enabled: true}` — the shape `@jini-ai/mcp`'s own `buildOpenCodeMcpConfigContent` emits for a
|
|
142
|
+
* stdio server, so both producers stay schema-compatible.
|
|
143
|
+
*
|
|
144
|
+
* **The credential lands in `environment`, i.e. the MCP child's env — never in `command`.** OpenCode
|
|
145
|
+
* spawns the bridge from `command`, so a token placed there would be visible in `ps` output to
|
|
146
|
+
* every other local user. This is the same constraint that keeps `JINI_DAEMON_TOKEN` out of argv on
|
|
147
|
+
* the `.mcp.json` and ACP paths.
|
|
148
|
+
*
|
|
149
|
+
* @param existingRaw - Whatever the spawn env already held for this variable, or `undefined`.
|
|
150
|
+
* @param entry - The shared bridge entry from {@link buildMcpJsonServerEntry}.
|
|
151
|
+
* @returns The full JSON string to set as the env var's value.
|
|
152
|
+
* @complexity O(1) plus `JSON.parse`/`JSON.stringify` over a small config document.
|
|
153
|
+
* @overallScore 100/100
|
|
154
|
+
*/
|
|
155
|
+
export function mergeEnvContentMcpConfig({ existingRaw, entry }) {
|
|
156
|
+
let doc = {};
|
|
157
|
+
if (existingRaw !== undefined && existingRaw.length > 0) {
|
|
158
|
+
try {
|
|
159
|
+
const parsed = JSON.parse(existingRaw);
|
|
160
|
+
if (isRecord(parsed))
|
|
161
|
+
doc = parsed;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
doc = {};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const existingMcp = isRecord(doc.mcp) ? doc.mcp : {};
|
|
168
|
+
const mcp = {
|
|
169
|
+
...existingMcp,
|
|
170
|
+
[JINI_MCP_SERVER_KEY]: {
|
|
171
|
+
type: 'local',
|
|
172
|
+
command: [entry.command, ...entry.args],
|
|
173
|
+
environment: { ...entry.env },
|
|
174
|
+
enabled: true,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
return JSON.stringify({ ...doc, mcp });
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Merges a staged system-prompt overlay file's path into the `instructions` array of the same
|
|
181
|
+
* OpenCode-schema config document {@link mergeEnvContentMcpConfig} writes `mcp` into — for a
|
|
182
|
+
* `systemPromptDelivery: { strategy: 'config-instructions-file' }` def (`opencode` today).
|
|
183
|
+
*
|
|
184
|
+
* Confirmed live (2026-09-01, opencode-cli 1.17.10), not inferred from docs alone:
|
|
185
|
+
* 1. `instructions` is honored — a run configured with it visibly followed the file's directive
|
|
186
|
+
* (a required exact-token prefix), while an identical run without it did not.
|
|
187
|
+
* 2. It appends, never replaces: the same run that followed the custom instruction ALSO still
|
|
188
|
+
* answered correctly using opencode's own baked-in environment-context system prompt (asked
|
|
189
|
+
* for its cwd, with nothing about cwd anywhere in the custom instructions file) — proof
|
|
190
|
+
* opencode's own defaults survive alongside a custom `instructions` entry, not just proof the
|
|
191
|
+
* file was read at all.
|
|
192
|
+
* 3. Adding this key alongside `mcp` in the same `OPENCODE_CONFIG_CONTENT` document disturbs
|
|
193
|
+
* neither: in one combined run, the MCP bridge still got its connection attempt (logged
|
|
194
|
+
* `key=jini type=local`) AND the custom instruction was still followed — same as running each
|
|
195
|
+
* key alone.
|
|
196
|
+
* 4. `instructions` is re-read fresh from the env on every spawn, including a `-s <id>`-resumed
|
|
197
|
+
* turn (proved by swapping in a second instructions file between two turns of one resumed
|
|
198
|
+
* session and seeing the second turn immediately reflect it while still recalling
|
|
199
|
+
* conversation memory from turn one) — so this mechanism is safe to redeliver every turn like
|
|
200
|
+
* `'append-flag'`/`'env-var'`, exempt from the prompt-prefix fallback's create-only gating
|
|
201
|
+
* (see {@link resolveSystemPromptOverlayDelivery}'s doc): nothing here is ever baked into
|
|
202
|
+
* opencode's own persisted session state the way re-injecting fallback prompt text would be.
|
|
203
|
+
*
|
|
204
|
+
* @param existingRaw - Whatever the spawn env already held for this variable (already possibly
|
|
205
|
+
* carrying `mcp`, if `mergeEnvContentMcpConfig` ran first on the same value — order between the two
|
|
206
|
+
* doesn't matter, each only touches its own top-level key), or `undefined`.
|
|
207
|
+
* @param instructionsFilePath - The staged overlay file's absolute path (see
|
|
208
|
+
* {@link prepareSystemPromptOverlayFileIfNeeded}).
|
|
209
|
+
* @returns The full JSON string to set as the env var's value. Appends to, never clobbers, any
|
|
210
|
+
* `instructions` entries already present — the same "merge, never clobber" discipline
|
|
211
|
+
* {@link mergeEnvContentMcpConfig} applies to `mcp`, in case a host is already using this same
|
|
212
|
+
* config-content variable to carry the operator's own instruction files.
|
|
213
|
+
* @complexity O(1) plus `JSON.parse`/`JSON.stringify` over a small config document.
|
|
214
|
+
* @overallScore 100/100
|
|
215
|
+
*/
|
|
216
|
+
export function mergeEnvContentInstructions({ existingRaw, instructionsFilePath }) {
|
|
217
|
+
let doc = {};
|
|
218
|
+
if (existingRaw !== undefined && existingRaw.length > 0) {
|
|
219
|
+
try {
|
|
220
|
+
const parsed = JSON.parse(existingRaw);
|
|
221
|
+
if (isRecord(parsed))
|
|
222
|
+
doc = parsed;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
doc = {};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const existingInstructions = Array.isArray(doc.instructions)
|
|
229
|
+
? doc.instructions.filter((entry) => typeof entry === 'string')
|
|
230
|
+
: [];
|
|
231
|
+
return JSON.stringify({ ...doc, instructions: [...existingInstructions, instructionsFilePath] });
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* TOML basic-string escaping for the narrow value shapes {@link buildCodexMcpServerToml} emits (a
|
|
235
|
+
* command name, an argv token, an env var value — never multi-line or control-character-heavy
|
|
236
|
+
* text). Escapes exactly what TOML's basic-string grammar requires: backslash first (so it is not
|
|
237
|
+
* re-escaped by a later replacement), then the quote delimiter, then the three whitespace control
|
|
238
|
+
* characters a real command/argv/env value could plausibly contain.
|
|
239
|
+
*
|
|
240
|
+
* A hand-rolled minimal escaper rather than a TOML dependency — this mechanism never needs to
|
|
241
|
+
* *parse* TOML (the real install's existing `config.toml` is appended after, never rewritten — see
|
|
242
|
+
* {@link buildCodexHomeConfigToml}), so pulling in a full TOML library for one serialization shape
|
|
243
|
+
* would be substantially more surface than the problem needs. Checked against the repo's existing
|
|
244
|
+
* dependency graph first — no package here already depends on a TOML library.
|
|
245
|
+
* @param value - The raw string to embed inside TOML `"..."` delimiters.
|
|
246
|
+
* @returns The escaped text, WITHOUT the surrounding quotes — {@link tomlString} adds those.
|
|
247
|
+
* @complexity O(n) in the string's length.
|
|
248
|
+
*/
|
|
249
|
+
function escapeTomlBasicString(value) {
|
|
250
|
+
return value
|
|
251
|
+
.replace(/\\/g, '\\\\')
|
|
252
|
+
.replace(/"/g, '\\"')
|
|
253
|
+
.replace(/\n/g, '\\n')
|
|
254
|
+
.replace(/\r/g, '\\r')
|
|
255
|
+
.replace(/\t/g, '\\t');
|
|
256
|
+
}
|
|
257
|
+
/** Wraps {@link escapeTomlBasicString}'s output in the TOML basic-string delimiters. */
|
|
258
|
+
function tomlString(value) {
|
|
259
|
+
return `"${escapeTomlBasicString(value)}"`;
|
|
260
|
+
}
|
|
261
|
+
/** A TOML key: bare when TOML allows it, quoted otherwise — host-supplied bridge env names are not guaranteed bare-safe. */
|
|
262
|
+
function tomlKey(key) {
|
|
263
|
+
return /^[A-Za-z0-9_-]+$/.test(key) ? key : tomlString(key);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Codex's per-tool deadline for the Jini bridge, in seconds: `@jini-ai/mcp`'s
|
|
267
|
+
* `DEFAULT_DELEGATED_TOOL_TIMEOUT_MS` (6 min, mirrored because this package does not depend on
|
|
268
|
+
* that one) plus 40 s. Codex's own default (60 s) is shorter than a delegated call parked on a
|
|
269
|
+
* human dialog, and when Codex abandons a call the dialog stayed answerable, so a late click ran
|
|
270
|
+
* an action the model had been told failed. Outlasting the bridge's deadline means the bridge
|
|
271
|
+
* times out first, drops its daemon request, and the daemon expires the dialog. Codex does not
|
|
272
|
+
* pass the parent env through to MCP servers, so the bridge under Codex always runs at its default.
|
|
273
|
+
*/
|
|
274
|
+
const CODEX_TOOL_TIMEOUT_SEC = 6 * 60 + 40;
|
|
275
|
+
/**
|
|
276
|
+
* Mechanism 5 of 5 — `'codex-toml'`'s serialization step. Builds the `[mcp_servers.jini]` TOML
|
|
277
|
+
* table (plus, when the entry carries any env vars, a separate `[mcp_servers.jini.env]` table)
|
|
278
|
+
* Codex's own config schema expects.
|
|
279
|
+
*
|
|
280
|
+
* Confirmed against a real installed Codex CLI (0.151.0), not assumed from docs: round-tripping
|
|
281
|
+
* `codex mcp add <name> --env K=V -- <cmd> <args>` against a scratch `CODEX_HOME` and reading back
|
|
282
|
+
* `config.toml` produced exactly this shape (`command`/`args` as TOML strings/array in the main
|
|
283
|
+
* table, env vars in a nested `.env` table) — see `archived provenance ledger` for the transcript.
|
|
284
|
+
* @param entry - The shared bridge entry from {@link buildMcpJsonServerEntry}.
|
|
285
|
+
* @returns A TOML fragment with no leading/trailing blank-line padding — {@link buildCodexHomeConfigToml} owns spacing when combining it with existing content.
|
|
286
|
+
* @complexity O(n) in the number of argv/env entries.
|
|
287
|
+
* @overallScore 100/100
|
|
288
|
+
*/
|
|
289
|
+
export function buildCodexMcpServerToml({ entry }) {
|
|
290
|
+
const argsLiteral = entry.args.map(tomlString).join(', ');
|
|
291
|
+
const serverTable = `[mcp_servers.${JINI_MCP_SERVER_KEY}]\ncommand = ${tomlString(entry.command)}\nargs = [${argsLiteral}]\n` +
|
|
292
|
+
`tool_timeout_sec = ${CODEX_TOOL_TIMEOUT_SEC}\n`;
|
|
293
|
+
const envLines = Object.entries(entry.env)
|
|
294
|
+
.filter((pair) => typeof pair[1] === 'string')
|
|
295
|
+
.map(([key, value]) => `${tomlKey(key)} = ${tomlString(value)}`);
|
|
296
|
+
if (envLines.length === 0)
|
|
297
|
+
return serverTable;
|
|
298
|
+
return `${serverTable}\n[mcp_servers.${JINI_MCP_SERVER_KEY}.env]\n${envLines.join('\n')}\n`;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Splits a TOML table header's dotted key into its individual segments, honoring quoted parts
|
|
302
|
+
* (`"basic"` or `'literal'`) that may themselves contain a literal `.` — a bare `.` only
|
|
303
|
+
* separates segments outside of quotes. Each segment is trimmed and, if quoted, unwrapped.
|
|
304
|
+
*
|
|
305
|
+
* Not a full TOML parser: it does not resolve escape sequences (`\"`, `\u...`) inside basic
|
|
306
|
+
* strings. That is deliberately out of scope — see {@link stripExistingJiniMcpServerTable}'s doc
|
|
307
|
+
* for why it is safe to skip for this specific comparison.
|
|
308
|
+
* @param rawKey - The raw text between a table header's `[` and `]`.
|
|
309
|
+
* @returns The dotted key's segments, dequoted and trimmed.
|
|
310
|
+
* @complexity O(n) in the length of `rawKey`.
|
|
311
|
+
*/
|
|
312
|
+
function splitTomlDottedKey(rawKey) {
|
|
313
|
+
const segments = [];
|
|
314
|
+
let current = '';
|
|
315
|
+
let quote = null;
|
|
316
|
+
for (const ch of rawKey) {
|
|
317
|
+
if (quote) {
|
|
318
|
+
if (ch === quote)
|
|
319
|
+
quote = null;
|
|
320
|
+
else
|
|
321
|
+
current += ch;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (ch === '"' || ch === "'") {
|
|
325
|
+
quote = ch;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (ch === '.') {
|
|
329
|
+
segments.push(current.trim());
|
|
330
|
+
current = '';
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
current += ch;
|
|
334
|
+
}
|
|
335
|
+
segments.push(current.trim());
|
|
336
|
+
return segments;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Removes any pre-existing `[mcp_servers.{@link JINI_MCP_SERVER_KEY}]` table — and its
|
|
340
|
+
* `[mcp_servers.{@link JINI_MCP_SERVER_KEY}.*]` subtables (e.g. `.env`) — from a real Codex
|
|
341
|
+
* `config.toml`'s raw text, so {@link buildCodexHomeConfigToml} can append this run's own table
|
|
342
|
+
* without producing the duplicate TOML key Codex's parser rejects at startup.
|
|
343
|
+
*
|
|
344
|
+
* Line-oriented, not a real TOML parser (matching {@link buildCodexMcpServerToml}'s own
|
|
345
|
+
* no-TOML-dependency constraint), but wide enough to survive a hand-edited config, which is the
|
|
346
|
+
* scenario this whole function exists for. A table header line is recognized when, after
|
|
347
|
+
* trimming, it is `[<key>]` optionally followed only by a `# comment` (TOML's own grammar allows
|
|
348
|
+
* nothing else there) — so it tolerates leading indentation, whitespace inside the brackets, a
|
|
349
|
+
* trailing line comment, and a dotted key written with quoted segments (`["mcp_servers"."jini"]`),
|
|
350
|
+
* via {@link splitTomlDottedKey}. Once such a header's key matches the jini table or one of its
|
|
351
|
+
* subtables, every following line is dropped until the next table header (of any name) or EOF.
|
|
352
|
+
* Every other table, key, comment-only, and blank line is passed through untouched.
|
|
353
|
+
*
|
|
354
|
+
* Deliberately unhandled, and why it is safe to leave that way:
|
|
355
|
+
* - **Escape sequences inside quoted key segments** (e.g. a segment containing `\"`) — neither
|
|
356
|
+
* `mcp_servers` nor {@link JINI_MCP_SERVER_KEY} ever needs escaping, and this driver's own
|
|
357
|
+
* writer ({@link buildCodexMcpServerToml}) never emits a quoted form at all, so no real
|
|
358
|
+
* `config.toml` this driver produced can exercise this gap; a hand-edit that goes out of its
|
|
359
|
+
* way to escape a character inside a key that is supposed to spell "jini" would fail to match
|
|
360
|
+
* and fall back to today's pre-fix behavior (append a duplicate) rather than silently doing
|
|
361
|
+
* something worse.
|
|
362
|
+
* - **`[[array-of-tables]]` syntax** — Codex's schema has no array-of-tables shape for
|
|
363
|
+
* `mcp_servers`, and the header regex's `[^[\]]+` capture cannot match a line starting with a
|
|
364
|
+
* second `[`, so `[[mcp_servers.jini]]` is not recognized as a header before or after this fix
|
|
365
|
+
* — unchanged, not a new gap.
|
|
366
|
+
* - **A single quoted segment that happens to spell the same characters with the dot included**
|
|
367
|
+
* (e.g. `["mcp_servers.jini"]`, which in real TOML names one key literally containing a `.`,
|
|
368
|
+
* not two nested tables) — this collapses to the same joined string as the real nested-table
|
|
369
|
+
* spelling and is therefore treated as a match. This is a false positive in the conservative
|
|
370
|
+
* direction the reported bug calls for: a missed detection duplicates a key and crashes Codex,
|
|
371
|
+
* so on this axis over-matching a spelling nobody would plausibly hand-write for an MCP server
|
|
372
|
+
* table is preferable to under-matching the real one.
|
|
373
|
+
* @param existingRaw - The real Codex home's `config.toml` content, already known to be defined
|
|
374
|
+
* (callers pass `''` for a missing file).
|
|
375
|
+
* @returns `existingRaw` with any jini table/subtable removed.
|
|
376
|
+
* @complexity O(n) in the number of lines.
|
|
377
|
+
*/
|
|
378
|
+
function stripExistingJiniMcpServerTable(existingRaw) {
|
|
379
|
+
const jiniTableKey = `mcp_servers.${JINI_MCP_SERVER_KEY}`;
|
|
380
|
+
const kept = [];
|
|
381
|
+
let skipping = false;
|
|
382
|
+
for (const line of existingRaw.split('\n')) {
|
|
383
|
+
const header = /^\s*\[([^[\]]+)\]\s*(#.*)?$/.exec(line);
|
|
384
|
+
if (header) {
|
|
385
|
+
const key = splitTomlDottedKey(header[1] ?? '').join('.');
|
|
386
|
+
skipping = key === jiniTableKey || key.startsWith(`${jiniTableKey}.`);
|
|
387
|
+
if (skipping)
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (!skipping)
|
|
391
|
+
kept.push(line);
|
|
392
|
+
}
|
|
393
|
+
return kept.join('\n');
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Builds the full `config.toml` a run's scratch `CODEX_HOME` gets: the real Codex home's own
|
|
397
|
+
* config, with any pre-existing `[mcp_servers.jini]` table removed (see
|
|
398
|
+
* {@link stripExistingJiniMcpServerTable}), then this run's own table appended.
|
|
399
|
+
*
|
|
400
|
+
* **Append-only by design, not a parse-and-merge, for everything but the jini table itself.**
|
|
401
|
+
* `mergeMcpJsonContent`/`mergeEnvContentMcpConfig` above can safely parse-merge-reserialize because
|
|
402
|
+
* their formats have a JS-native parser (`JSON.parse`); this driver has no general TOML parser in
|
|
403
|
+
* its dependency graph (see `buildCodexMcpServerToml`'s doc), and every other setting a real Codex
|
|
404
|
+
* install carries — model choice, sandbox policy, the trusted-project list, the operator's own
|
|
405
|
+
* other MCP servers — must survive a spawn byte-for-byte. Only the one table this driver itself
|
|
406
|
+
* owns (`jini` is this integration's own reserved server name — see {@link JINI_MCP_SERVER_KEY}) is
|
|
407
|
+
* ever removed, via the narrow line-oriented scan above, never a full TOML parse.
|
|
408
|
+
* @param existingRaw - The real Codex home's `config.toml` content, or `undefined` when it does not
|
|
409
|
+
* exist (a fresh Codex install — degrades to "start from just this run's block", matching
|
|
410
|
+
* {@link mergeMcpJsonContent}'s own "missing file" handling).
|
|
411
|
+
* @param entry - The shared bridge entry.
|
|
412
|
+
* @returns The full text to write to the scratch `CODEX_HOME`'s `config.toml`.
|
|
413
|
+
* @complexity O(n) in the existing config's length.
|
|
414
|
+
*/
|
|
415
|
+
export function buildCodexHomeConfigToml({ existingRaw, entry }) {
|
|
416
|
+
const base = stripExistingJiniMcpServerTable(existingRaw ?? '');
|
|
417
|
+
const separator = base.length === 0 ? '' : base.endsWith('\n') ? '\n' : '\n\n';
|
|
418
|
+
return `${base}${separator}${buildCodexMcpServerToml({ entry: entry })}`;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Where `'codex-toml'` reads the operator's REAL Codex config from, to seed a run's scratch copy —
|
|
422
|
+
* never where it writes. Resolved against the daemon HOST process's own environment (`hostEnv`,
|
|
423
|
+
* `process.env` at the real call site), not a run's sandboxed spawn env: `CODEX_HOME` is not in
|
|
424
|
+
* `BASELINE_AGENT_ENV_KEYS`, so a spawned child never inherits it anyway, and the whole point here
|
|
425
|
+
* is finding wherever the *operator's actual* Codex install lives, which is a host-machine fact.
|
|
426
|
+
* @param hostEnv - The daemon process's own environment.
|
|
427
|
+
* @returns `hostEnv.CODEX_HOME` when set to a non-blank value (matching Codex's own resolution
|
|
428
|
+
* order), else the CLI's documented default, `~/.codex`.
|
|
429
|
+
* @complexity O(1).
|
|
430
|
+
* @overallScore 100/100
|
|
431
|
+
*/
|
|
432
|
+
export function resolveSourceCodexHomeDir({ hostEnv }) {
|
|
433
|
+
const override = hostEnv.CODEX_HOME;
|
|
434
|
+
return override !== undefined && override.trim().length > 0 ? override : join(homedir(), '.codex');
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* **The single dispatch point from an `externalMcpInjection` strategy to its delivery mechanism.**
|
|
438
|
+
* Pure and synchronous — the one effectful input (the per-run bearer credential) arrives already
|
|
439
|
+
* resolved, so every strategy's mapping is directly assertable without touching the filesystem,
|
|
440
|
+
* the environment, or a keystore.
|
|
441
|
+
*
|
|
442
|
+
* Keyed off the declared *strategy*, never off `def.id`: a def gets a working bridge by declaring a
|
|
443
|
+
* mechanism, not by being named in this file. That is what makes the 9 `'acp-merge'` defs work
|
|
444
|
+
* without any of their own files being touched.
|
|
445
|
+
*
|
|
446
|
+
* @param input.cwd - The run's working directory; only `'claude-mcp-json'` uses it, to place this
|
|
447
|
+
* run's own config file (see {@link mcpJsonPathForRun}) — never `cwd/.mcp.json` itself.
|
|
448
|
+
* @param input.runId - Scopes the bridge child to this run.
|
|
449
|
+
* @param input.strategy - The def's declared `externalMcpInjection`, or `undefined` for a def with no native MCP transport.
|
|
450
|
+
* @param input.options - The host's bridge options, or `undefined` when the host never configured injection.
|
|
451
|
+
* @param input.credential - Already-resolved bearer token, or `undefined` to omit `JINI_DAEMON_TOKEN` entirely.
|
|
452
|
+
* @returns `null` when this run delivers nothing — an unconfigured host, or a def declaring no
|
|
453
|
+
* strategy — which is byte-identical to this feature not existing.
|
|
454
|
+
* @complexity O(1).
|
|
455
|
+
* @overallScore 100/100
|
|
456
|
+
*/
|
|
457
|
+
export function buildMcpBridgeDelivery(input) {
|
|
458
|
+
const { cwd, runId, strategy, options, credential } = input;
|
|
459
|
+
if (options === undefined || strategy === undefined)
|
|
460
|
+
return null;
|
|
461
|
+
const serverEntry = buildMcpJsonServerEntry({ runId: runId, options: options }, credential === undefined ? {} : { credential });
|
|
462
|
+
switch (strategy) {
|
|
463
|
+
case 'claude-mcp-json':
|
|
464
|
+
return { kind: 'claude-mcp-json', mcpJsonPath: mcpJsonPathForRun(cwd, runId), serverEntry };
|
|
465
|
+
case 'acp-merge':
|
|
466
|
+
return { kind: 'acp-merge', mcpServers: buildAcpMcpBridgeServers({ entry: serverEntry }) };
|
|
467
|
+
case 'opencode-env-content':
|
|
468
|
+
case 'mimo-env-content':
|
|
469
|
+
return { kind: 'env-content', envVarName: ENV_CONTENT_VAR_BY_STRATEGY[strategy], serverEntry };
|
|
470
|
+
case 'codex-toml':
|
|
471
|
+
return { kind: 'codex-toml', serverEntry };
|
|
472
|
+
case 'env-passthrough':
|
|
473
|
+
return { kind: 'env-passthrough', serverEntry };
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function defaultReadMcpJsonFile({ path }) {
|
|
477
|
+
return fsPromises.readFile(path, 'utf8');
|
|
478
|
+
}
|
|
479
|
+
function defaultWriteMcpJsonFile({ path, content }) {
|
|
480
|
+
return fsPromises.writeFile(path, content, 'utf8');
|
|
481
|
+
}
|
|
482
|
+
export function defaultRemoveMcpJsonFile({ path }) {
|
|
483
|
+
return fsPromises.rm(path, { force: true });
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* This run's own MCP config path, inside `cwd` but deliberately **not** `cwd/.mcp.json`.
|
|
487
|
+
*
|
|
488
|
+
* A shared filename cannot carry two runs' identities at once, and that is exactly what the file
|
|
489
|
+
* carries: `mcpServers.jini.env` holds this run's `JINI_RUN_ID` and its bearer `JINI_DAEMON_TOKEN`.
|
|
490
|
+
* A spawned CLI reads its MCP config when it starts its client, not synchronously at spawn — so with
|
|
491
|
+
* one shared file, a second run in the same directory overwrote the entry the first run's child had
|
|
492
|
+
* not read yet, and that child's `jini-mcp` subprocess then called back carrying the *other* run's id
|
|
493
|
+
* and token: run A's tool calls executing inside run B's authority context. Concurrent runs in one
|
|
494
|
+
* working directory are supported by design (see `McpJsonInjectionOptions.credential`'s doc on why the
|
|
495
|
+
* credential is a per-run resolver at all), so the resolution is one file per run, not a lock that
|
|
496
|
+
* refuses the second run.
|
|
497
|
+
*
|
|
498
|
+
* Naming it after the run also means the project's own `.mcp.json` is never written at all — it stays
|
|
499
|
+
* purely a merge source, so there is no original content to restore afterwards either.
|
|
500
|
+
*
|
|
501
|
+
* The run id is host-supplied and lands in a filename, so everything outside `[A-Za-z0-9_-]` is
|
|
502
|
+
* replaced (dots included — a `..` segment must not survive) and the result is length-capped. Real run
|
|
503
|
+
* ids are UUIDs, which pass through untouched; the cap could in principle collide two ids sharing a
|
|
504
|
+
* 128-character prefix, which no id shape this daemon mints can produce.
|
|
505
|
+
* @complexity O(n) in the run id's length.
|
|
506
|
+
*/
|
|
507
|
+
function mcpJsonPathForRun(cwd, runId) {
|
|
508
|
+
const safeRunId = runId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 128);
|
|
509
|
+
return join(cwd, `.mcp.jini-${safeRunId}.json`);
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* The `'claude-mcp-json'` mechanism's one effect: writes (merging, never clobbering — see
|
|
513
|
+
* {@link mergeMcpJsonContent}) this run's own config — the project's own servers merged with this
|
|
514
|
+
* run's `jini` bridge entry — to the run-scoped path ({@link mcpJsonPathForRun}) the def was already
|
|
515
|
+
* handed via `RuntimeContext.mcpJsonPath`, so the def's own `--strict-mcp-config --mcp-config <path>`
|
|
516
|
+
* argv has a real file to point at by spawn time, instead of auto-discovering `cwd/.mcp.json` (which
|
|
517
|
+
* needs an interactive trust prompt a headless spawn can never answer — confirmed live 2026-07-30,
|
|
518
|
+
* see `@jini-ai/agent-runtime`'s `defs/claude.ts`).
|
|
519
|
+
*
|
|
520
|
+
* Reads `cwd/.mcp.json` and writes `delivery.mcpJsonPath`: the project's file is a merge source only,
|
|
521
|
+
* never a write target. See {@link mcpJsonPathForRun} for why one file per run is load-bearing rather
|
|
522
|
+
* than cosmetic, and why the read and write paths must differ.
|
|
523
|
+
*
|
|
524
|
+
* A no-op for every other delivery mechanism, which is expressed by the caller simply not having a
|
|
525
|
+
* `'claude-mcp-json'` delivery to hand it rather than by a strategy re-check in here.
|
|
526
|
+
* @param cwd - The run's working directory, so the project's own `.mcp.json` can be read as the
|
|
527
|
+
* merge base — not carried on `delivery` itself, since that only describes the write target.
|
|
528
|
+
* @param delivery - The already-built `'claude-mcp-json'` delivery (path + entry). Both fields come
|
|
529
|
+
* from {@link buildMcpBridgeDelivery}, so the credential was resolved exactly once, for this run.
|
|
530
|
+
* @param options - Supplies the injectable `readFile`/`writeFile` seams.
|
|
531
|
+
* @throws Whatever `writeFile` rejects with — the caller (`run()`) turns that into a pre-spawn
|
|
532
|
+
* `AGENT_SPAWN_FAILED` failure, matching the pre-spawn filesystem guards in `launch.ts`
|
|
533
|
+
* (`stagePromptFile`'s own try/catch).
|
|
534
|
+
* @complexity O(1) plus one `readFile`/`writeFile` round trip.
|
|
535
|
+
* @overallScore 100/100
|
|
536
|
+
*/
|
|
537
|
+
async function writeMcpJsonForRun(cwd, delivery, options) {
|
|
538
|
+
const readFileFn = options.readFile ?? defaultReadMcpJsonFile;
|
|
539
|
+
const writeFileFn = options.writeFile ?? defaultWriteMcpJsonFile;
|
|
540
|
+
let existingRaw;
|
|
541
|
+
try {
|
|
542
|
+
existingRaw = await readFileFn({ path: join(cwd, '.mcp.json') });
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
// No existing file (ENOENT — the common case) or unreadable for any other reason: both
|
|
546
|
+
// degrade to "start fresh", matching mergeMcpJsonContent's own doc.
|
|
547
|
+
existingRaw = undefined;
|
|
548
|
+
}
|
|
549
|
+
await writeFileFn({ path: delivery.mcpJsonPath, content: mergeMcpJsonContent({ existingRaw: existingRaw, serverEntry: delivery.serverEntry }) });
|
|
550
|
+
}
|
|
551
|
+
function defaultMkdtempCodexHome({ prefix }) {
|
|
552
|
+
return fsPromises.mkdtemp(join(tmpdir(), prefix));
|
|
553
|
+
}
|
|
554
|
+
function defaultRemoveCodexHomeDir({ path }) {
|
|
555
|
+
return fsPromises.rm(path, { recursive: true, force: true });
|
|
556
|
+
}
|
|
557
|
+
function resolveCodexHomeSeams(options) {
|
|
558
|
+
return {
|
|
559
|
+
mkdtemp: options.mkdtemp ?? defaultMkdtempCodexHome,
|
|
560
|
+
readFile: options.readFile ?? defaultReadMcpJsonFile,
|
|
561
|
+
writeFile: options.writeFile ?? defaultWriteMcpJsonFile,
|
|
562
|
+
removeDir: options.removeDir ?? defaultRemoveCodexHomeDir,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Mechanism 5 of 5 — `'codex-toml'`'s one effect. Stages a fresh, randomly-named `CODEX_HOME`
|
|
567
|
+
* directory (see {@link McpJsonInjectionOptions.mkdtemp}'s doc for why non-deterministic naming is
|
|
568
|
+
* load-bearing here, not cosmetic) carrying:
|
|
569
|
+
* - `config.toml`: the real Codex home's own config (read best-effort — see
|
|
570
|
+
* {@link buildCodexHomeConfigToml}'s "missing file" handling) with this run's
|
|
571
|
+
* `[mcp_servers.jini]` table appended.
|
|
572
|
+
* - `auth.json`: a best-effort copy of the real Codex home's stored login, so the spawned CLI is
|
|
573
|
+
* still authenticated. Best-effort is safe here, not merely convenient: a real headless spawn
|
|
574
|
+
* against a `CODEX_HOME` with no `auth.json` at all was confirmed (against installed Codex CLI
|
|
575
|
+
* 0.151.0) to fail fast with a structured `401 Unauthorized` stream event, never an interactive
|
|
576
|
+
* login prompt or a hang — see `defs/codex.ts`'s module doc for the full transcript summary.
|
|
577
|
+
*
|
|
578
|
+
* **Never touches the real `CODEX_HOME`.** `sourceCodexHomeDir` is read-only throughout; nothing is
|
|
579
|
+
* ever written back to it.
|
|
580
|
+
*
|
|
581
|
+
* A failure after the directory is created (a rejecting `writeFile`, most plausibly) does not leak
|
|
582
|
+
* it: the directory may already hold a partial `config.toml` or a copied credential, so the
|
|
583
|
+
* `catch` below best-effort-removes it before rethrowing, exactly the "partial-failure state leak"
|
|
584
|
+
* class of bug this package's own adversarial-test-design guidance calls out.
|
|
585
|
+
* @param runId - Embedded in the temp-dir prefix for traceability, sanitized the same way
|
|
586
|
+
* `@jini-ai/agent-runtime`'s `prepareAgentLogFile`'s `label` is.
|
|
587
|
+
* @param entry - The shared bridge entry.
|
|
588
|
+
* @param sourceCodexHomeDir - Where to read the real install's `config.toml`/`auth.json` from — see {@link resolveSourceCodexHomeDir}.
|
|
589
|
+
* @param seams - Injectable mkdtemp/readFile/writeFile/removeDir, real filesystem by default.
|
|
590
|
+
* @throws Whatever `mkdtemp`/`writeFile` rejects with — the caller ({@link prepareCodexHomeIfNeeded}) turns that into a pre-spawn `AGENT_SPAWN_FAILED` failure, matching {@link writeMcpJsonForRun}'s own contract.
|
|
591
|
+
* @complexity O(1) plus one directory creation and up to two best-effort file read/write round trips.
|
|
592
|
+
* @overallScore 100/100
|
|
593
|
+
*/
|
|
594
|
+
async function prepareCodexHomeForRun(runId, entry, sourceCodexHomeDir, seams) {
|
|
595
|
+
// Stricter than `@jini-ai/agent-runtime`'s `prepareAgentLogFile`/`preparePromptFileForAgent`
|
|
596
|
+
// labels (which keep dots): this prefix stages a directory that ends up holding a copied Codex
|
|
597
|
+
// login credential, so it gets `mcpJsonPathForRun`'s tighter discipline instead — dots stripped
|
|
598
|
+
// too, not just path separators, so a run id like `../../etc/evil` cannot leave even a cosmetic
|
|
599
|
+
// `..` substring in the mkdtemp prefix.
|
|
600
|
+
const safeRunId = runId.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 80) || 'run';
|
|
601
|
+
const dir = await seams.mkdtemp({ prefix: `jini-codex-home-${safeRunId}-` });
|
|
602
|
+
try {
|
|
603
|
+
let existingConfigRaw;
|
|
604
|
+
try {
|
|
605
|
+
existingConfigRaw = await seams.readFile({ path: join(sourceCodexHomeDir, 'config.toml') });
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
// No config yet (fresh Codex install) or unreadable — start from just this run's block,
|
|
609
|
+
// matching writeMcpJsonForRun's identical "missing file" handling.
|
|
610
|
+
existingConfigRaw = undefined;
|
|
611
|
+
}
|
|
612
|
+
await seams.writeFile({ path: join(dir, 'config.toml'), content: buildCodexHomeConfigToml({ existingRaw: existingConfigRaw, entry: entry }) });
|
|
613
|
+
try {
|
|
614
|
+
const authRaw = await seams.readFile({ path: join(sourceCodexHomeDir, 'auth.json') });
|
|
615
|
+
await seams.writeFile({ path: join(dir, 'auth.json'), content: authRaw });
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
// No stored login (or unreadable) — the spawned CLI runs unauthenticated. Confirmed above:
|
|
619
|
+
// this fails the run fast and observably, never as a hang.
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
catch (err) {
|
|
623
|
+
await seams.removeDir({ path: dir }).catch(() => {
|
|
624
|
+
// Best-effort only — the original error below is what the caller must see either way.
|
|
625
|
+
});
|
|
626
|
+
throw err;
|
|
627
|
+
}
|
|
628
|
+
return {
|
|
629
|
+
path: dir,
|
|
630
|
+
cleanup: async () => {
|
|
631
|
+
await seams.removeDir({ path: dir });
|
|
632
|
+
},
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
function defaultMkdtempClaudeConfigDir({ prefix }) {
|
|
636
|
+
return fsPromises.mkdtemp(join(tmpdir(), prefix));
|
|
637
|
+
}
|
|
638
|
+
function defaultRemoveClaudeConfigDir({ path }) {
|
|
639
|
+
return fsPromises.rm(path, { recursive: true, force: true });
|
|
640
|
+
}
|
|
641
|
+
function resolveClaudeConfigDirSeams(options) {
|
|
642
|
+
return {
|
|
643
|
+
mkdtemp: options?.mkdtemp ?? defaultMkdtempClaudeConfigDir,
|
|
644
|
+
readFile: options?.readFile ?? defaultReadMcpJsonFile,
|
|
645
|
+
writeFile: options?.writeFile ?? defaultWriteMcpJsonFile,
|
|
646
|
+
removeDir: options?.removeDir ?? defaultRemoveClaudeConfigDir,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Where `claude`'s own config resolution reads the operator's REAL config from, to seed a run's
|
|
651
|
+
* scratch copy — never where it writes. Mirrors {@link resolveSourceCodexHomeDir}'s exact reasoning
|
|
652
|
+
* and resolution order: `CLAUDE_CONFIG_DIR` is not in `BASELINE_AGENT_ENV_KEYS`, so a spawned child
|
|
653
|
+
* never inherits it anyway — the whole point is finding wherever the *operator's actual* Claude Code
|
|
654
|
+
* install lives, a host-machine fact resolved against the daemon HOST process's own environment.
|
|
655
|
+
* @param hostEnv - The daemon process's own environment.
|
|
656
|
+
* @returns `hostEnv.CLAUDE_CONFIG_DIR` when set to a non-blank value (matching Claude Code's own
|
|
657
|
+
* resolution order, confirmed against installed Claude Code 2.1.263), else the CLI's documented
|
|
658
|
+
* default, `~/.claude`.
|
|
659
|
+
* @complexity O(1).
|
|
660
|
+
*/
|
|
661
|
+
export function resolveSourceClaudeConfigDir({ hostEnv }) {
|
|
662
|
+
const override = hostEnv.CLAUDE_CONFIG_DIR;
|
|
663
|
+
return override !== undefined && override.trim().length > 0 ? override : join(homedir(), '.claude');
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Stages a fresh, randomly-named `CLAUDE_CONFIG_DIR` directory (same non-determinism requirement as
|
|
667
|
+
* {@link McpJsonInjectionOptions.mkdtemp}'s own doc — `os.tmpdir()` is a shared location on a
|
|
668
|
+
* multi-user host, so a guessable name is a real pre-plant/symlink target).
|
|
669
|
+
*
|
|
670
|
+
* **Deliberately empty by default** — unlike {@link prepareCodexHomeForRun}, which copies the real
|
|
671
|
+
* `config.toml` wholesale (Codex has no personal-data problem in that file), this directory gets
|
|
672
|
+
* NOTHING written into it beyond a best-effort copy of `.credentials.json` (see below). That is the
|
|
673
|
+
* fix: the operator's real `skills`/`plugins`/`agents`/`memory-path index`/`settings.json` must NOT
|
|
674
|
+
* carry over implicitly. `claude` runs correctly against a config directory holding nothing at all —
|
|
675
|
+
* it falls back to its own built-in defaults, not an error.
|
|
676
|
+
*
|
|
677
|
+
* **Login preservation, verified rather than assumed** (per this task's own instruction — "prove
|
|
678
|
+
* login still resolves; do not assume"): confirmed live (2026-09-07, installed Claude Code 2.1.263,
|
|
679
|
+
* macOS) that `claude auth status` reports `loggedIn: false` against ANY `CLAUDE_CONFIG_DIR` other
|
|
680
|
+
* than the operator's real one — including the real `HOME` with only `CLAUDE_CONFIG_DIR` swapped —
|
|
681
|
+
* and confirmed against Claude Code's own docs (code.claude.com/docs/en/authentication) why: "If
|
|
682
|
+
* you've set the CLAUDE_CONFIG_DIR environment variable, Claude Code keeps the .credentials.json
|
|
683
|
+
* file under that directory instead, including the file the macOS fallback writes, and keys the
|
|
684
|
+
* macOS Keychain entry to that directory too, so a session with a different CLAUDE_CONFIG_DIR reads
|
|
685
|
+
* a different entry." So a scratch directory is never logged in by default on ANY platform, not just
|
|
686
|
+
* the ones with no Keychain at all. This function only closes the *portable* case: when the source
|
|
687
|
+
* directory holds a file-based `.credentials.json` (Linux, Windows, or a Keychain-locked macOS
|
|
688
|
+
* fallback — none of which this function can distinguish, and does not need to), it is copied
|
|
689
|
+
* best-effort into the scratch directory, exactly `prepareCodexHomeForRun`'s `auth.json` copy. When
|
|
690
|
+
* it does not (a normal macOS Keychain-only install, confirmed the common case on this codebase's
|
|
691
|
+
* own dev machine), this function does NOT attempt to read the macOS Keychain itself — that would
|
|
692
|
+
* mean this daemon process extracting a live OAuth secret out of an OS-managed credential store into
|
|
693
|
+
* a plaintext file, a materially different and larger security surface than forwarding an
|
|
694
|
+
* already-resolved credential the host handed it (which `credentialEnv`/`ANTHROPIC_API_KEY` already
|
|
695
|
+
* does, safely, today — see `AgentExecutorRunInput.credentialEnv`'s own doc). In that case this
|
|
696
|
+
* mirrors `prepareCodexHomeForRun`'s own accepted outcome for a missing credential file verbatim:
|
|
697
|
+
* "the spawned CLI runs unauthenticated" is documented, existing, precedented behavior in this file,
|
|
698
|
+
* not a new failure mode invented here. A host that needs the isolated child to stay logged in on
|
|
699
|
+
* such an install must supply a credential explicitly via `AgentExecutorRunInput.credentialEnv`
|
|
700
|
+
* (`ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` — both outrank Keychain-based subscription login
|
|
701
|
+
* in Claude Code's own auth precedence, so the isolated child authenticates without ever needing the
|
|
702
|
+
* operator's personal Keychain entry at all) — see this fix's own handoff report for the operational
|
|
703
|
+
* consequence on a host with no such credential configured yet.
|
|
704
|
+
*
|
|
705
|
+
* **Never touches the real config directory.** `sourceConfigDir` is read-only throughout.
|
|
706
|
+
* @param runId - Embedded in the temp-dir prefix for traceability, same sanitization discipline as {@link prepareCodexHomeForRun}'s `safeRunId`.
|
|
707
|
+
* @param sourceConfigDir - Where to read a possible real `.credentials.json` from — see {@link resolveSourceClaudeConfigDir}.
|
|
708
|
+
* @param seams - Injectable mkdtemp/readFile/writeFile/removeDir, real filesystem by default.
|
|
709
|
+
* @throws Whatever `mkdtemp` rejects with — the caller ({@link prepareClaudeConfigDirIfNeeded}) turns that into a pre-spawn `AGENT_SPAWN_FAILED` failure, matching {@link prepareCodexHomeForRun}'s own contract.
|
|
710
|
+
* @complexity O(1) plus one directory creation and up to one best-effort file read/write round trip.
|
|
711
|
+
*/
|
|
712
|
+
async function prepareClaudeConfigDirForRun(runId, sourceConfigDir, seams) {
|
|
713
|
+
const safeRunId = runId.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 80) || 'run';
|
|
714
|
+
const dir = await seams.mkdtemp({ prefix: `jini-claude-config-${safeRunId}-` });
|
|
715
|
+
try {
|
|
716
|
+
const credentialsPath = join(sourceConfigDir, '.credentials.json');
|
|
717
|
+
let credentialsRaw;
|
|
718
|
+
try {
|
|
719
|
+
credentialsRaw = await seams.readFile({ path: credentialsPath });
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
// No file-based credential to copy (the common macOS-Keychain-only case) — see this
|
|
723
|
+
// function's own doc for why that is an accepted, documented outcome, not a failure here.
|
|
724
|
+
credentialsRaw = undefined;
|
|
725
|
+
}
|
|
726
|
+
if (credentialsRaw !== undefined) {
|
|
727
|
+
await seams.writeFile({ path: join(dir, '.credentials.json'), content: credentialsRaw });
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
catch (err) {
|
|
731
|
+
await seams.removeDir({ path: dir }).catch(() => {
|
|
732
|
+
// Best-effort only — the original error below is what the caller must see either way.
|
|
733
|
+
});
|
|
734
|
+
throw err;
|
|
735
|
+
}
|
|
736
|
+
return {
|
|
737
|
+
path: dir,
|
|
738
|
+
cleanup: async () => {
|
|
739
|
+
await seams.removeDir({ path: dir });
|
|
740
|
+
},
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
/** Phase 5: resolves this run's MCP bridge delivery (credential resolution + {@link buildMcpBridgeDelivery}). */
|
|
744
|
+
export async function resolveMcpBridgeForRun({ input, deps }) {
|
|
745
|
+
try {
|
|
746
|
+
// Awaited here rather than inside `buildMcpBridgeDelivery` so that function stays pure and
|
|
747
|
+
// synchronous. `undefined` when the host supplied no resolver, which omits the token entirely.
|
|
748
|
+
const credential = deps.mcpJsonInjection !== undefined ? await deps.mcpJsonInjection.credential?.({ runId: input.runId }) : undefined;
|
|
749
|
+
return buildMcpBridgeDelivery({
|
|
750
|
+
cwd: input.cwd,
|
|
751
|
+
runId: input.runId,
|
|
752
|
+
strategy: input.def.externalMcpInjection,
|
|
753
|
+
options: deps.mcpJsonInjection,
|
|
754
|
+
credential,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
catch (err) {
|
|
758
|
+
// Spawning a child that cannot authenticate would produce a run whose every bridged tool call
|
|
759
|
+
// 401s, so a rejecting credential resolver fails the run before spawn instead.
|
|
760
|
+
await deps.cleanupStagedFiles();
|
|
761
|
+
return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not resolve the MCP bridge credential for agent "${input.def.id}": ${errorMessage(err)}` });
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
/** Phase 10: mechanism 1 of 5's one effect — stages this run's own `.mcp.json`, returning the path `cleanupStagedFiles` should later remove (`undefined` for every other mechanism / unconfigured host). */
|
|
765
|
+
export async function writeMcpJsonIfNeeded({ input, deps }) {
|
|
766
|
+
if (input.mcpBridge?.kind !== 'claude-mcp-json' || deps.mcpJsonInjection === undefined) {
|
|
767
|
+
return undefined;
|
|
768
|
+
}
|
|
769
|
+
try {
|
|
770
|
+
await writeMcpJsonForRun(input.cwd, input.mcpBridge, deps.mcpJsonInjection);
|
|
771
|
+
return input.mcpBridge.mcpJsonPath;
|
|
772
|
+
}
|
|
773
|
+
catch (err) {
|
|
774
|
+
await deps.releaseStagedResources();
|
|
775
|
+
return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not write .mcp.json for agent "${input.def.id}": ${errorMessage(err)}` });
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Phase 10b: mechanism 5 of 5's one effect — stages this run's scratch `CODEX_HOME` directory,
|
|
780
|
+
* returning the prepared handle `cleanupStagedFiles` should later release (`null` for every other
|
|
781
|
+
* mechanism, or for an unconfigured host — matching {@link writeMcpJsonIfNeeded}'s identical gate).
|
|
782
|
+
* @param input.def - Only used for its `id`, in the failure message.
|
|
783
|
+
* @param input.mcpBridge - This run's resolved bridge delivery — a no-op unless its `kind` is `'codex-toml'`.
|
|
784
|
+
* @param deps.hostEnv - The daemon's own environment, threaded through to {@link resolveSourceCodexHomeDir} rather than read from a module-level `process.env` so this phase stays testable with an injected env.
|
|
785
|
+
* @complexity O(1) plus {@link prepareCodexHomeForRun}'s own cost.
|
|
786
|
+
* @overallScore 100/100
|
|
787
|
+
*/
|
|
788
|
+
export async function prepareCodexHomeIfNeeded({ input, deps }) {
|
|
789
|
+
if (input.mcpBridge?.kind !== 'codex-toml' || deps.mcpJsonInjection === undefined) {
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
try {
|
|
793
|
+
return await prepareCodexHomeForRun(input.runId, input.mcpBridge.serverEntry, resolveSourceCodexHomeDir({ hostEnv: deps.hostEnv }), resolveCodexHomeSeams(deps.mcpJsonInjection));
|
|
794
|
+
}
|
|
795
|
+
catch (err) {
|
|
796
|
+
await deps.releaseStagedResources();
|
|
797
|
+
return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not stage a CODEX_HOME for agent "${input.def.id}": ${errorMessage(err)}` });
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Finding 1 of SEC-assistant-env-isolation-2026-09-07's one effect — stages this run's scratch
|
|
802
|
+
* `CLAUDE_CONFIG_DIR` directory, returning the prepared handle `cleanupStagedFiles` should later
|
|
803
|
+
* release (`null` for every def other than `claude` — see {@link prepareClaudeConfigDirForRun}'s
|
|
804
|
+
* own doc for why this is unconditional for `claude` runs, unlike {@link prepareCodexHomeIfNeeded}'s
|
|
805
|
+
* gate on a host-configured MCP bridge strategy).
|
|
806
|
+
*
|
|
807
|
+
* Gated on `def.id === 'claude'` directly rather than on `externalMcpInjection === 'claude-mcp-json'`
|
|
808
|
+
* (which `codebuddy` also declares): isolating the operator's personal `~/.claude` is specific to
|
|
809
|
+
* the real `claude` CLI's own config resolution, not to every def that happens to share its `.mcp.
|
|
810
|
+
* json` delivery shape. Matches the existing `USER`-for-claude-login special case already singled
|
|
811
|
+
* out by id in `BASELINE_AGENT_ENV_KEYS`'s own doc in `launch.ts`.
|
|
812
|
+
*
|
|
813
|
+
* ALSO gated on `deps.enabled` (`CreateAgentExecutorOptions.claudeConfigDirIsolationEnabled`, default
|
|
814
|
+
* `false`) since this task's own fix — see that field's doc for why it defaults off (Keychain login
|
|
815
|
+
* is `CLAUDE_CONFIG_DIR`-keyed and no caller was supplying a credential when this was unconditional).
|
|
816
|
+
* `def.id === 'claude'` is still checked first and independently: a host that flips this flag on
|
|
817
|
+
* should not suddenly stage a directory for `codebuddy` or any other def.
|
|
818
|
+
* @param input.def - Used for both the `id` gate and the failure message.
|
|
819
|
+
* @param deps.enabled - The isolation on/off switch — see this function's own doc above.
|
|
820
|
+
* @param deps.hostEnv - The daemon's own environment, threaded through to {@link resolveSourceClaudeConfigDir} rather than read from a module-level `process.env`, matching {@link prepareCodexHomeIfNeeded}'s identical testability reasoning.
|
|
821
|
+
* @complexity O(1) plus {@link prepareClaudeConfigDirForRun}'s own cost.
|
|
822
|
+
*/
|
|
823
|
+
export async function prepareClaudeConfigDirIfNeeded({ input, deps }) {
|
|
824
|
+
if (input.def.id !== 'claude' || !deps.enabled) {
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
try {
|
|
828
|
+
return await prepareClaudeConfigDirForRun(input.runId, resolveSourceClaudeConfigDir({ hostEnv: deps.hostEnv }), resolveClaudeConfigDirSeams(deps.claudeConfigDirIsolation));
|
|
829
|
+
}
|
|
830
|
+
catch (err) {
|
|
831
|
+
await deps.releaseStagedResources();
|
|
832
|
+
return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not stage a CLAUDE_CONFIG_DIR for agent "${input.def.id}": ${errorMessage(err)}` });
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
//# sourceMappingURL=mcp-bridge.js.map
|