@jini-ai/daemon 0.6.11 → 0.6.15-rc.1
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 +41 -0
- package/dist/agent-executor/codex/home-config.d.ts +50 -0
- package/dist/agent-executor/codex/home-config.js +209 -0
- package/dist/agent-executor/codex/home-staging.d.ts +60 -0
- package/dist/agent-executor/codex/home-staging.js +195 -0
- package/dist/agent-executor/codex/isolation.d.ts +27 -0
- package/dist/agent-executor/codex/isolation.js +67 -0
- package/dist/agent-executor/contracts.d.ts +4 -1
- package/dist/agent-executor/event-translation.d.ts +7 -7
- package/dist/agent-executor/event-translation.js +17 -17
- package/dist/agent-executor/events.js +1 -1
- package/dist/agent-executor/index.d.ts +3 -1
- package/dist/agent-executor/index.js +3 -1
- package/dist/agent-executor/launch.d.ts +3 -2
- package/dist/agent-executor/launch.js +68 -17
- package/dist/agent-executor/mcp-bridge-probe.d.ts +2 -1
- package/dist/agent-executor/mcp-bridge-probe.js +1 -1
- package/dist/agent-executor/mcp-bridge.d.ts +9 -86
- package/dist/agent-executor/mcp-bridge.js +4 -355
- package/dist/ai-trace-lifecycle.d.ts +48 -0
- package/dist/ai-trace-lifecycle.js +116 -0
- package/dist/core/read-only-tools.js +3 -1
- package/dist/delegated-tool-bridge.js +19 -1
- package/dist/http/attachment-lookup-routes.d.ts +44 -0
- package/dist/http/attachment-lookup-routes.js +71 -0
- package/dist/http/attachments.d.ts +112 -7
- package/dist/http/attachments.js +58 -17
- package/dist/http/connectors.js +23 -18
- package/dist/http/index.d.ts +6 -0
- package/dist/http/index.js +3 -0
- package/dist/http/tool-execution-routes.d.ts +69 -0
- package/dist/http/tool-execution-routes.js +181 -0
- package/dist/http/xai.js +19 -9
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/remote-attachment-lookup.d.ts +29 -0
- package/dist/remote-attachment-lookup.js +71 -0
- package/dist/remote-tool-executor.d.ts +85 -0
- package/dist/remote-tool-executor.js +250 -0
- package/dist/routing-tool-executor.d.ts +29 -0
- package/dist/routing-tool-executor.js +16 -0
- package/dist/run-lifecycle.d.ts +4 -1
- package/dist/run-lifecycle.js +35 -5
- package/dist/store/event-log/sqlite-kernel.d.ts +18 -0
- package/dist/store/event-log/sqlite-kernel.js +135 -0
- package/dist/store/event-log/sqlite-policy.d.ts +93 -0
- package/dist/store/event-log/sqlite-policy.js +74 -0
- package/dist/store/event-log/sqlite.d.ts +7 -31
- package/dist/store/event-log/sqlite.js +32 -95
- package/dist/tool-recovery/analytics.d.ts +27 -0
- package/dist/tool-recovery/analytics.js +38 -0
- package/dist/tool-recovery/index.d.ts +2 -0
- package/dist/tool-recovery/index.js +2 -0
- package/dist/tool-recovery/observability.d.ts +82 -0
- package/dist/tool-recovery/observability.js +88 -0
- package/package.json +15 -14
|
@@ -3,9 +3,9 @@ import { randomBytes } from 'node:crypto';
|
|
|
3
3
|
import { supportsMessageRelay } from './message-relay.js';
|
|
4
4
|
import { ensureAntigravityMcpPermission } from './antigravity-permissions.js';
|
|
5
5
|
import { homedir, tmpdir, } from 'node:os';
|
|
6
|
-
import { join,
|
|
6
|
+
import { join, } from 'node:path';
|
|
7
7
|
import { isRecord, errorMessage, } from './values.js';
|
|
8
|
-
const JINI_MCP_SERVER_KEY = 'jini';
|
|
8
|
+
export const JINI_MCP_SERVER_KEY = 'jini';
|
|
9
9
|
/** The run-error code a run gets when its CLI reports the injected `jini` bridge as anything but connected. */
|
|
10
10
|
export const MCP_BRIDGE_UNAVAILABLE = 'MCP_BRIDGE_UNAVAILABLE';
|
|
11
11
|
/**
|
|
@@ -238,225 +238,6 @@ export function mergeEnvContentInstructions({ existingRaw, instructionsFilePath
|
|
|
238
238
|
: [];
|
|
239
239
|
return JSON.stringify({ ...doc, instructions: [...existingInstructions, instructionsFilePath] });
|
|
240
240
|
}
|
|
241
|
-
/**
|
|
242
|
-
* TOML basic-string escaping for the narrow value shapes {@link buildCodexMcpServerToml} emits (a
|
|
243
|
-
* command name, an argv token, an env var value — never multi-line or control-character-heavy
|
|
244
|
-
* text). Escapes exactly what TOML's basic-string grammar requires: backslash first (so it is not
|
|
245
|
-
* re-escaped by a later replacement), then the quote delimiter, then the three whitespace control
|
|
246
|
-
* characters a real command/argv/env value could plausibly contain.
|
|
247
|
-
*
|
|
248
|
-
* A hand-rolled minimal escaper rather than a TOML dependency — this mechanism never needs to
|
|
249
|
-
* *parse* TOML (the real install's existing `config.toml` is appended after, never rewritten — see
|
|
250
|
-
* {@link buildCodexHomeConfigToml}), so pulling in a full TOML library for one serialization shape
|
|
251
|
-
* would be substantially more surface than the problem needs. Checked against the repo's existing
|
|
252
|
-
* dependency graph first — no package here already depends on a TOML library.
|
|
253
|
-
* @param value - The raw string to embed inside TOML `"..."` delimiters.
|
|
254
|
-
* @returns The escaped text, WITHOUT the surrounding quotes — {@link tomlString} adds those.
|
|
255
|
-
* @complexity O(n) in the string's length.
|
|
256
|
-
*/
|
|
257
|
-
function escapeTomlBasicString(value) {
|
|
258
|
-
return value
|
|
259
|
-
.replace(/\\/g, '\\\\')
|
|
260
|
-
.replace(/"/g, '\\"')
|
|
261
|
-
.replace(/\n/g, '\\n')
|
|
262
|
-
.replace(/\r/g, '\\r')
|
|
263
|
-
.replace(/\t/g, '\\t');
|
|
264
|
-
}
|
|
265
|
-
/** Wraps {@link escapeTomlBasicString}'s output in the TOML basic-string delimiters. */
|
|
266
|
-
function tomlString(value) {
|
|
267
|
-
return `"${escapeTomlBasicString(value)}"`;
|
|
268
|
-
}
|
|
269
|
-
/** A TOML key: bare when TOML allows it, quoted otherwise — host-supplied bridge env names are not guaranteed bare-safe. */
|
|
270
|
-
function tomlKey(key) {
|
|
271
|
-
return /^[A-Za-z0-9_-]+$/.test(key) ? key : tomlString(key);
|
|
272
|
-
}
|
|
273
|
-
/**
|
|
274
|
-
* Codex's per-tool deadline for the Jini bridge, in seconds: `@jini-ai/mcp`'s
|
|
275
|
-
* `DEFAULT_DELEGATED_TOOL_TIMEOUT_MS` (6 min, mirrored because this package does not depend on
|
|
276
|
-
* that one) plus 40 s. Codex's own default (60 s) is shorter than a delegated call parked on a
|
|
277
|
-
* human dialog, and when Codex abandons a call the dialog stayed answerable, so a late click ran
|
|
278
|
-
* an action the model had been told failed. Outlasting the bridge's deadline means the bridge
|
|
279
|
-
* times out first, drops its daemon request, and the daemon expires the dialog. Codex does not
|
|
280
|
-
* pass the parent env through to MCP servers, so the bridge under Codex always runs at its default.
|
|
281
|
-
*/
|
|
282
|
-
const CODEX_TOOL_TIMEOUT_SEC = 6 * 60 + 40;
|
|
283
|
-
/**
|
|
284
|
-
* Mechanism 5 of 5 — `'codex-toml'`'s serialization step. Builds the `[mcp_servers.jini]` TOML
|
|
285
|
-
* table (plus, when the entry carries any env vars, a separate `[mcp_servers.jini.env]` table)
|
|
286
|
-
* Codex's own config schema expects.
|
|
287
|
-
*
|
|
288
|
-
* Confirmed against a real installed Codex CLI (0.151.0), not assumed from docs: round-tripping
|
|
289
|
-
* `codex mcp add <name> --env K=V -- <cmd> <args>` against a scratch `CODEX_HOME` and reading back
|
|
290
|
-
* `config.toml` produced exactly this shape (`command`/`args` as TOML strings/array in the main
|
|
291
|
-
* table, env vars in a nested `.env` table) — see `archived provenance ledger` for the transcript.
|
|
292
|
-
* @param entry - The shared bridge entry from {@link buildMcpJsonServerEntry}.
|
|
293
|
-
* @returns A TOML fragment with no leading/trailing blank-line padding — {@link buildCodexHomeConfigToml} owns spacing when combining it with existing content.
|
|
294
|
-
* @complexity O(n) in the number of argv/env entries.
|
|
295
|
-
* @overallScore 100/100
|
|
296
|
-
*/
|
|
297
|
-
export function buildCodexMcpServerToml({ entry }) {
|
|
298
|
-
const argsLiteral = entry.args.map(tomlString).join(', ');
|
|
299
|
-
const serverTable = `[mcp_servers.${JINI_MCP_SERVER_KEY}]\ncommand = ${tomlString(entry.command)}\nargs = [${argsLiteral}]\n` +
|
|
300
|
-
`tool_timeout_sec = ${CODEX_TOOL_TIMEOUT_SEC}\n`;
|
|
301
|
-
const envLines = Object.entries(entry.env)
|
|
302
|
-
.filter((pair) => typeof pair[1] === 'string')
|
|
303
|
-
.map(([key, value]) => `${tomlKey(key)} = ${tomlString(value)}`);
|
|
304
|
-
if (envLines.length === 0)
|
|
305
|
-
return serverTable;
|
|
306
|
-
return `${serverTable}\n[mcp_servers.${JINI_MCP_SERVER_KEY}.env]\n${envLines.join('\n')}\n`;
|
|
307
|
-
}
|
|
308
|
-
/**
|
|
309
|
-
* Splits a TOML table header's dotted key into its individual segments, honoring quoted parts
|
|
310
|
-
* (`"basic"` or `'literal'`) that may themselves contain a literal `.` — a bare `.` only
|
|
311
|
-
* separates segments outside of quotes. Each segment is trimmed and, if quoted, unwrapped.
|
|
312
|
-
*
|
|
313
|
-
* Not a full TOML parser: it does not resolve escape sequences (`\"`, `\u...`) inside basic
|
|
314
|
-
* strings. That is deliberately out of scope — see {@link stripExistingJiniMcpServerTable}'s doc
|
|
315
|
-
* for why it is safe to skip for this specific comparison.
|
|
316
|
-
* @param rawKey - The raw text between a table header's `[` and `]`.
|
|
317
|
-
* @returns The dotted key's segments, dequoted and trimmed.
|
|
318
|
-
* @complexity O(n) in the length of `rawKey`.
|
|
319
|
-
*/
|
|
320
|
-
function splitTomlDottedKey(rawKey) {
|
|
321
|
-
const scan = { segments: [], current: '', quote: null };
|
|
322
|
-
for (const ch of rawKey) {
|
|
323
|
-
if (scan.quote === null)
|
|
324
|
-
scanBareTomlKeyChar(scan, ch);
|
|
325
|
-
else if (ch === scan.quote)
|
|
326
|
-
scan.quote = null;
|
|
327
|
-
else
|
|
328
|
-
scan.current += ch;
|
|
329
|
-
}
|
|
330
|
-
scan.segments.push(scan.current.trim());
|
|
331
|
-
return scan.segments;
|
|
332
|
-
}
|
|
333
|
-
/** One key character outside quotes: opens a quote, ends the open segment at a `.`, or extends it. */
|
|
334
|
-
function scanBareTomlKeyChar(scan, ch) {
|
|
335
|
-
if (ch === '"' || ch === "'")
|
|
336
|
-
scan.quote = ch;
|
|
337
|
-
else if (ch === '.') {
|
|
338
|
-
scan.segments.push(scan.current.trim());
|
|
339
|
-
scan.current = '';
|
|
340
|
-
}
|
|
341
|
-
else
|
|
342
|
-
scan.current += ch;
|
|
343
|
-
}
|
|
344
|
-
// An inline or dotted root `mcp_servers` declaration, which the table scanner below cannot rewrite.
|
|
345
|
-
const INLINE_MCP_SERVERS = /^\s*(?:mcp_servers|"mcp_servers"|'mcp_servers')\s*(?:\.|=)/;
|
|
346
|
-
// `[<key>]` optionally followed only by a `# comment`; `[[...]]` never matches (see below).
|
|
347
|
-
const TOML_TABLE_HEADER = /^\s*\[([^[\]]+)\]\s*(#.*)?$/;
|
|
348
|
-
/**
|
|
349
|
-
* Whether {@link stripExistingJiniMcpServerTable} drops the table named `key`: always the jini
|
|
350
|
-
* table and its subtables; with `allServers` every MCP table; with `mcpOnly` every non-MCP table.
|
|
351
|
-
*/
|
|
352
|
-
function dropsTomlTable(key, optional) {
|
|
353
|
-
const jiniTableKey = `mcp_servers.${JINI_MCP_SERVER_KEY}`;
|
|
354
|
-
if (key === jiniTableKey || key.startsWith(`${jiniTableKey}.`))
|
|
355
|
-
return true;
|
|
356
|
-
const isMcp = key === 'mcp_servers' || key.startsWith('mcp_servers.');
|
|
357
|
-
return (optional.allServers === true && isMcp) || (optional.mcpOnly === true && !isMcp);
|
|
358
|
-
}
|
|
359
|
-
/**
|
|
360
|
-
* Removes any pre-existing `[mcp_servers.{@link JINI_MCP_SERVER_KEY}]` table — and its
|
|
361
|
-
* `[mcp_servers.{@link JINI_MCP_SERVER_KEY}.*]` subtables (e.g. `.env`) — from a real Codex
|
|
362
|
-
* `config.toml`'s raw text, so {@link buildCodexHomeConfigToml} can append this run's own table
|
|
363
|
-
* without producing the duplicate TOML key Codex's parser rejects at startup.
|
|
364
|
-
*
|
|
365
|
-
* Line-oriented, not a real TOML parser (matching {@link buildCodexMcpServerToml}'s own
|
|
366
|
-
* no-TOML-dependency constraint), but wide enough to survive a hand-edited config, which is the
|
|
367
|
-
* scenario this whole function exists for. A table header line is recognized when, after
|
|
368
|
-
* trimming, it is `[<key>]` optionally followed only by a `# comment` (TOML's own grammar allows
|
|
369
|
-
* nothing else there) — so it tolerates leading indentation, whitespace inside the brackets, a
|
|
370
|
-
* trailing line comment, and a dotted key written with quoted segments (`["mcp_servers"."jini"]`),
|
|
371
|
-
* via {@link splitTomlDottedKey}. Once such a header's key matches the jini table or one of its
|
|
372
|
-
* subtables, every following line is dropped until the next table header (of any name) or EOF.
|
|
373
|
-
* Every other table, key, comment-only, and blank line is passed through untouched.
|
|
374
|
-
*
|
|
375
|
-
* Deliberately unhandled, and why it is safe to leave that way:
|
|
376
|
-
* - **Escape sequences inside quoted key segments** (e.g. a segment containing `\"`) — neither
|
|
377
|
-
* `mcp_servers` nor {@link JINI_MCP_SERVER_KEY} ever needs escaping, and this driver's own
|
|
378
|
-
* writer ({@link buildCodexMcpServerToml}) never emits a quoted form at all, so no real
|
|
379
|
-
* `config.toml` this driver produced can exercise this gap; a hand-edit that goes out of its
|
|
380
|
-
* way to escape a character inside a key that is supposed to spell "jini" would fail to match
|
|
381
|
-
* and fall back to today's pre-fix behavior (append a duplicate) rather than silently doing
|
|
382
|
-
* something worse.
|
|
383
|
-
* - **`[[array-of-tables]]` syntax** — Codex's schema has no array-of-tables shape for
|
|
384
|
-
* `mcp_servers`, and the header regex's `[^[\]]+` capture cannot match a line starting with a
|
|
385
|
-
* second `[`, so `[[mcp_servers.jini]]` is not recognized as a header before or after this fix
|
|
386
|
-
* — unchanged, not a new gap.
|
|
387
|
-
* - **A single quoted segment that happens to spell the same characters with the dot included**
|
|
388
|
-
* (e.g. `["mcp_servers.jini"]`, which in real TOML names one key literally containing a `.`,
|
|
389
|
-
* not two nested tables) — this collapses to the same joined string as the real nested-table
|
|
390
|
-
* spelling and is therefore treated as a match. This is a false positive in the conservative
|
|
391
|
-
* direction the reported bug calls for: a missed detection duplicates a key and crashes Codex,
|
|
392
|
-
* so on this axis over-matching a spelling nobody would plausibly hand-write for an MCP server
|
|
393
|
-
* table is preferable to under-matching the real one.
|
|
394
|
-
* @param existingRaw - The real Codex home's `config.toml` content, already known to be defined
|
|
395
|
-
* (callers pass `''` for a missing file).
|
|
396
|
-
* @returns `existingRaw` with any jini table/subtable removed.
|
|
397
|
-
* @complexity O(n) in the number of lines.
|
|
398
|
-
*/
|
|
399
|
-
function stripExistingJiniMcpServerTable(existingRaw, optional = {}) {
|
|
400
|
-
const kept = [];
|
|
401
|
-
let skipping = optional.mcpOnly === true;
|
|
402
|
-
for (const line of existingRaw.split('\n')) {
|
|
403
|
-
// The existing table scanner cannot safely rewrite inline/dotted root server values. Refuse
|
|
404
|
-
// that uncommon shape when isolating servers rather than accidentally preserving a grant.
|
|
405
|
-
if (optional.allServers && INLINE_MCP_SERVERS.test(line)) {
|
|
406
|
-
throw new Error('Cannot turn off personal MCP servers: the Codex config.toml declares mcp_servers inline (a dotted key or `mcp_servers = {...}`), which cannot be removed safely; move each server into its own [mcp_servers.<name>] table.');
|
|
407
|
-
}
|
|
408
|
-
const header = TOML_TABLE_HEADER.exec(line);
|
|
409
|
-
// The header's key capture group is not optional, so `header[1]` is always set on a match.
|
|
410
|
-
if (header)
|
|
411
|
-
skipping = dropsTomlTable(splitTomlDottedKey(header[1]).join('.'), optional);
|
|
412
|
-
if (!skipping)
|
|
413
|
-
kept.push(line);
|
|
414
|
-
}
|
|
415
|
-
return kept.join('\n');
|
|
416
|
-
}
|
|
417
|
-
/**
|
|
418
|
-
* Builds the full `config.toml` a run's scratch `CODEX_HOME` gets: the real Codex home's own
|
|
419
|
-
* config, with any pre-existing `[mcp_servers.jini]` table removed (see
|
|
420
|
-
* {@link stripExistingJiniMcpServerTable}), then this run's own table appended.
|
|
421
|
-
*
|
|
422
|
-
* **Append-only by design, not a parse-and-merge, for everything but the jini table itself.**
|
|
423
|
-
* `mergeMcpJsonContent`/`mergeEnvContentMcpConfig` above can safely parse-merge-reserialize because
|
|
424
|
-
* their formats have a JS-native parser (`JSON.parse`); this driver has no general TOML parser in
|
|
425
|
-
* its dependency graph (see `buildCodexMcpServerToml`'s doc), and every other setting a real Codex
|
|
426
|
-
* install carries — model choice, sandbox policy, the trusted-project list, the operator's own
|
|
427
|
-
* other MCP servers — must survive a spawn byte-for-byte. Only the one table this driver itself
|
|
428
|
-
* owns (`jini` is this integration's own reserved server name — see {@link JINI_MCP_SERVER_KEY}) is
|
|
429
|
-
* removed by default, via the narrow line-oriented scan above, never a full TOML parse. Explicit
|
|
430
|
-
* per-run OFF choices additionally omit personal MCP tables or retain only those tables when
|
|
431
|
-
* user settings are off. Inline root MCP declarations fail closed when isolation is requested.
|
|
432
|
-
* @param existingRaw - The real Codex home's `config.toml` content, or `undefined` when it does not
|
|
433
|
-
* exist (a fresh Codex install — degrades to "start from just this run's block", matching
|
|
434
|
-
* {@link mergeMcpJsonContent}'s own "missing file" handling).
|
|
435
|
-
* @param entry - The shared bridge entry.
|
|
436
|
-
* @returns The full text to write to the scratch `CODEX_HOME`'s `config.toml`.
|
|
437
|
-
* @complexity O(n) in the existing config's length.
|
|
438
|
-
*/
|
|
439
|
-
export function buildCodexHomeConfigToml({ existingRaw, entry, inheritUserMcp = true, inheritUserSettings = true }, _optional = {}) {
|
|
440
|
-
const base = stripExistingJiniMcpServerTable(existingRaw ?? '', { allServers: !inheritUserMcp, mcpOnly: !inheritUserSettings });
|
|
441
|
-
const separator = base.length === 0 ? '' : base.endsWith('\n') ? '\n' : '\n\n';
|
|
442
|
-
return `${base}${separator}${buildCodexMcpServerToml({ entry: entry })}`;
|
|
443
|
-
}
|
|
444
|
-
/**
|
|
445
|
-
* Where `'codex-toml'` reads the operator's REAL Codex config from, to seed a run's scratch copy —
|
|
446
|
-
* never where it writes. Resolved against the daemon HOST process's own environment (`hostEnv`,
|
|
447
|
-
* `process.env` at the real call site), not a run's sandboxed spawn env: `CODEX_HOME` is not in
|
|
448
|
-
* `BASELINE_AGENT_ENV_KEYS`, so a spawned child never inherits it anyway, and the whole point here
|
|
449
|
-
* is finding wherever the *operator's actual* Codex install lives, which is a host-machine fact.
|
|
450
|
-
* @param hostEnv - The daemon process's own environment.
|
|
451
|
-
* @returns `hostEnv.CODEX_HOME` when set to a non-blank value (matching Codex's own resolution
|
|
452
|
-
* order), else the CLI's documented default, `~/.codex`.
|
|
453
|
-
* @complexity O(1).
|
|
454
|
-
* @overallScore 100/100
|
|
455
|
-
*/
|
|
456
|
-
export function resolveSourceCodexHomeDir({ hostEnv }) {
|
|
457
|
-
const override = hostEnv.CODEX_HOME;
|
|
458
|
-
return override !== undefined && override.trim().length > 0 ? override : join(homedir(), '.codex');
|
|
459
|
-
}
|
|
460
241
|
/**
|
|
461
242
|
* **The single dispatch point from an `externalMcpInjection` strategy to its delivery mechanism.**
|
|
462
243
|
* Pure and synchronous — the one effectful input (the per-run bearer credential) arrives already
|
|
@@ -500,10 +281,10 @@ export function buildMcpBridgeDelivery(input) {
|
|
|
500
281
|
return { kind: 'env-passthrough', serverEntry };
|
|
501
282
|
}
|
|
502
283
|
}
|
|
503
|
-
function defaultReadMcpJsonFile({ path }) {
|
|
284
|
+
export function defaultReadMcpJsonFile({ path }) {
|
|
504
285
|
return fsPromises.readFile(path, 'utf8');
|
|
505
286
|
}
|
|
506
|
-
function defaultWriteMcpJsonFile({ path, content }) {
|
|
287
|
+
export function defaultWriteMcpJsonFile({ path, content }) {
|
|
507
288
|
return fsPromises.writeFile(path, content, 'utf8');
|
|
508
289
|
}
|
|
509
290
|
export function defaultRemoveMcpJsonFile({ path }) {
|
|
@@ -575,105 +356,6 @@ async function writeMcpJsonForRun(cwd, delivery, options, inheritUserConfig = tr
|
|
|
575
356
|
}
|
|
576
357
|
await writeFileFn({ path: delivery.mcpJsonPath, content: mergeMcpJsonContent({ existingRaw: existingRaw, serverEntry: delivery.serverEntry }) });
|
|
577
358
|
}
|
|
578
|
-
function defaultMkdtempCodexHome({ prefix }) {
|
|
579
|
-
return fsPromises.mkdtemp(join(tmpdir(), prefix));
|
|
580
|
-
}
|
|
581
|
-
function defaultRemoveCodexHomeDir({ path }) {
|
|
582
|
-
return fsPromises.rm(path, { recursive: true, force: true });
|
|
583
|
-
}
|
|
584
|
-
/** Config credentials stay run-scoped, but rollouts must survive both completion and cancellation.
|
|
585
|
-
* Native links keep Codex as the sole writer of sessions and refreshed authentication.
|
|
586
|
-
* Parity also links skills/rules/plugins and other home entries; config.toml alone stays staged
|
|
587
|
-
* so run-scoped bearer tokens never reach the real config. Cleanup removes links, not targets.
|
|
588
|
-
* O(h) I/O for h top-level home entries; locked runs link only sessions and authentication. */
|
|
589
|
-
async function defaultLinkCodexSessionStore({ runHome, sourceHome }, optional = {}) {
|
|
590
|
-
for (const name of ['sessions', 'archived_sessions']) {
|
|
591
|
-
const target = resolve(sourceHome, name);
|
|
592
|
-
await fsPromises.mkdir(target, { recursive: true, mode: 0o700 });
|
|
593
|
-
await fsPromises.symlink(target, join(runHome, name), 'junction');
|
|
594
|
-
}
|
|
595
|
-
await fsPromises.symlink(join(sourceHome, 'auth.json'), join(runHome, 'auth.json'), 'file');
|
|
596
|
-
if (optional.inheritUserConfig !== false) {
|
|
597
|
-
for (const entry of await fsPromises.readdir(sourceHome, { withFileTypes: true })) {
|
|
598
|
-
if (['config.toml', 'auth.json', 'sessions', 'archived_sessions'].includes(entry.name))
|
|
599
|
-
continue;
|
|
600
|
-
await fsPromises.symlink(join(sourceHome, entry.name), join(runHome, entry.name), entry.isDirectory() ? 'junction' : 'file');
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
function resolveCodexHomeSeams(options) {
|
|
605
|
-
return {
|
|
606
|
-
mkdtemp: options.mkdtemp ?? defaultMkdtempCodexHome,
|
|
607
|
-
readFile: options.readFile ?? defaultReadMcpJsonFile,
|
|
608
|
-
writeFile: options.writeFile ?? defaultWriteMcpJsonFile,
|
|
609
|
-
removeDir: options.removeDir ?? defaultRemoveCodexHomeDir,
|
|
610
|
-
linkSessionStore: options.linkSessionStore ?? defaultLinkCodexSessionStore,
|
|
611
|
-
};
|
|
612
|
-
}
|
|
613
|
-
/**
|
|
614
|
-
* Mechanism 5 of 5 — `'codex-toml'`'s one effect. Stages a fresh, randomly-named `CODEX_HOME`
|
|
615
|
-
* directory (see {@link McpJsonInjectionOptions.mkdtemp}'s doc for why non-deterministic naming is
|
|
616
|
-
* load-bearing here, not cosmetic) carrying:
|
|
617
|
-
* - `config.toml`: the real Codex home's own config (read best-effort — see
|
|
618
|
-
* {@link buildCodexHomeConfigToml}'s "missing file" handling) with this run's
|
|
619
|
-
* `[mcp_servers.jini]` table appended.
|
|
620
|
-
* - `auth.json`: a link to the real Codex home's stored login, so the spawned CLI is
|
|
621
|
-
* still authenticated. Best-effort is safe here, not merely convenient: a real headless spawn
|
|
622
|
-
* against a `CODEX_HOME` with no `auth.json` at all was confirmed (against installed Codex CLI
|
|
623
|
-
* 0.151.0) to fail fast with a structured `401 Unauthorized` stream event, never an interactive
|
|
624
|
-
* login prompt or a hang — see `defs/codex.ts`'s module doc for the full transcript summary.
|
|
625
|
-
*
|
|
626
|
-
* Never changes the real config. Authentication is linked so a CLI token refresh survives cleanup;
|
|
627
|
-
* copying rotating credentials lost refreshes and could log the operator out. Deleting scratch
|
|
628
|
-
* state must not erase linked native state. The run-bound MCP credential stays in scratch config.
|
|
629
|
-
*
|
|
630
|
-
* A failure after the directory is created (a rejecting `writeFile`, most plausibly) does not leak
|
|
631
|
-
* it: the directory may already hold a partial `config.toml` or a copied credential, so the
|
|
632
|
-
* `catch` below best-effort-removes it before rethrowing, exactly the "partial-failure state leak"
|
|
633
|
-
* class of bug this package's own adversarial-test-design guidance calls out.
|
|
634
|
-
* @param runId - Embedded in the temp-dir prefix for traceability, sanitized the same way
|
|
635
|
-
* `@jini-ai/agent-runtime`'s `prepareAgentLogFile`'s `label` is.
|
|
636
|
-
* @param entry - The shared bridge entry.
|
|
637
|
-
* @param sourceCodexHomeDir - Where to read the real install's `config.toml`/`auth.json` from — see {@link resolveSourceCodexHomeDir}.
|
|
638
|
-
* @param seams - Injectable mkdtemp/readFile/writeFile/removeDir/linkSessionStore, real filesystem by default.
|
|
639
|
-
* @throws Whatever staging rejects with — the caller ({@link prepareCodexHomeIfNeeded}) turns that into a pre-spawn `AGENT_SPAWN_FAILED` failure, matching {@link writeMcpJsonForRun}'s own contract.
|
|
640
|
-
* @complexity O(h) filesystem operations for h top-level user-home entries; one staged config write.
|
|
641
|
-
* @overallScore 100/100
|
|
642
|
-
*/
|
|
643
|
-
async function prepareCodexHomeForRun(runId, entry, sourceCodexHomeDir, seams, inheritUserConfig = true, inheritUserMcp = true) {
|
|
644
|
-
// Stricter than `@jini-ai/agent-runtime`'s `prepareAgentLogFile`/`preparePromptFileForAgent`
|
|
645
|
-
// labels (which keep dots): this prefix stages a directory that ends up holding a copied Codex
|
|
646
|
-
// login credential, so it gets `mcpJsonPathForRun`'s tighter discipline instead — dots stripped
|
|
647
|
-
// too, not just path separators, so a run id like `../../etc/evil` cannot leave even a cosmetic
|
|
648
|
-
// `..` substring in the mkdtemp prefix.
|
|
649
|
-
const safeRunId = runId.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 80) || 'run';
|
|
650
|
-
const dir = await seams.mkdtemp({ prefix: `jini-codex-home-${safeRunId}-` });
|
|
651
|
-
try {
|
|
652
|
-
let existingConfigRaw;
|
|
653
|
-
try {
|
|
654
|
-
existingConfigRaw = inheritUserConfig || inheritUserMcp ? await seams.readFile({ path: join(sourceCodexHomeDir, 'config.toml') }) : undefined;
|
|
655
|
-
}
|
|
656
|
-
catch {
|
|
657
|
-
// No config yet (fresh Codex install) or unreadable — start from just this run's block,
|
|
658
|
-
// matching writeMcpJsonForRun's identical "missing file" handling.
|
|
659
|
-
existingConfigRaw = undefined;
|
|
660
|
-
}
|
|
661
|
-
await seams.writeFile({ path: join(dir, 'config.toml'), content: buildCodexHomeConfigToml({ existingRaw: existingConfigRaw, entry: entry, inheritUserMcp, inheritUserSettings: inheritUserConfig }) });
|
|
662
|
-
await seams.linkSessionStore({ runHome: dir, sourceHome: sourceCodexHomeDir }, { inheritUserConfig });
|
|
663
|
-
}
|
|
664
|
-
catch (err) {
|
|
665
|
-
await seams.removeDir({ path: dir }).catch(() => {
|
|
666
|
-
// Best-effort only — the original error below is what the caller must see either way.
|
|
667
|
-
});
|
|
668
|
-
throw err;
|
|
669
|
-
}
|
|
670
|
-
return {
|
|
671
|
-
path: dir,
|
|
672
|
-
cleanup: async () => {
|
|
673
|
-
await seams.removeDir({ path: dir });
|
|
674
|
-
},
|
|
675
|
-
};
|
|
676
|
-
}
|
|
677
359
|
function defaultMkdtempClaudeConfigDir({ prefix }) {
|
|
678
360
|
return fsPromises.mkdtemp(join(tmpdir(), prefix));
|
|
679
361
|
}
|
|
@@ -827,39 +509,6 @@ export async function writeMcpJsonIfNeeded({ input, deps }) {
|
|
|
827
509
|
return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not write .mcp.json for agent "${input.def.id}": ${errorMessage(err)}` });
|
|
828
510
|
}
|
|
829
511
|
}
|
|
830
|
-
/** True when a Codex run turns off the operator's personal MCP servers or settings, which only a staged home can do. */
|
|
831
|
-
function codexTurnsOffPersonalConfig(input) {
|
|
832
|
-
const capabilities = input.nativeCapabilities;
|
|
833
|
-
return input.def.id === 'codex' && (capabilities?.userMcp === false || capabilities?.userSettings === false);
|
|
834
|
-
}
|
|
835
|
-
/**
|
|
836
|
-
* Phase 10b: mechanism 5 of 5's one effect — stages this run's scratch `CODEX_HOME` directory,
|
|
837
|
-
* returning the prepared handle `cleanupStagedFiles` should later release (`null` for every other
|
|
838
|
-
* mechanism, or for an unconfigured host — matching {@link writeMcpJsonIfNeeded}'s identical gate).
|
|
839
|
-
* @param input.def - Only used for its `id`, in the failure message.
|
|
840
|
-
* @param input.mcpBridge - This run's resolved bridge delivery — a no-op unless its `kind` is `'codex-toml'`.
|
|
841
|
-
* @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.
|
|
842
|
-
* @complexity O(1) plus {@link prepareCodexHomeForRun}'s own cost.
|
|
843
|
-
* @overallScore 100/100
|
|
844
|
-
*/
|
|
845
|
-
export async function prepareCodexHomeIfNeeded({ input, deps }) {
|
|
846
|
-
if (input.mcpBridge?.kind !== 'codex-toml' || deps.mcpJsonInjection === undefined) {
|
|
847
|
-
if (codexTurnsOffPersonalConfig(input)) {
|
|
848
|
-
await deps.releaseStagedResources();
|
|
849
|
-
return deps.failBeforeSpawn({ runId: input.runId, code: 'NATIVE_TOOL_POLICY_UNSUPPORTED',
|
|
850
|
-
message: 'AgentExecutor: disabling personal Codex configuration requires run-scoped home staging.' });
|
|
851
|
-
}
|
|
852
|
-
return null;
|
|
853
|
-
}
|
|
854
|
-
const nativeAllowed = input.nativeToolAccess !== false;
|
|
855
|
-
try {
|
|
856
|
-
return await prepareCodexHomeForRun(input.runId, input.mcpBridge.serverEntry, resolveSourceCodexHomeDir({ hostEnv: deps.hostEnv }), resolveCodexHomeSeams(deps.mcpJsonInjection), nativeAllowed && input.nativeCapabilities?.userSettings !== false, nativeAllowed && input.nativeCapabilities?.userMcp !== false);
|
|
857
|
-
}
|
|
858
|
-
catch (err) {
|
|
859
|
-
await deps.releaseStagedResources();
|
|
860
|
-
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)}` });
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
512
|
/**
|
|
864
513
|
* Finding 1 of SEC-assistant-env-isolation-2026-09-07's one effect — stages this run's scratch
|
|
865
514
|
* `CLAUDE_CONFIG_DIR` directory, returning the prepared handle `cleanupStagedFiles` should later
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module ai-trace-lifecycle
|
|
3
|
+
*
|
|
4
|
+
* Wraps a {@link RunLifecycle} so every run it drives is also recorded as an AI trace. This is
|
|
5
|
+
* the single tap for every provider path: child stream parsers (Claude Code, Copilot, Qoder,
|
|
6
|
+
* Codex/OpenCode/Cursor/Kimi JSON streams), ACP sessions and pi-rpc all publish their normalized
|
|
7
|
+
* events through `lifecycle.emit({ event: 'agent' })`. Observing the committed stream also sees
|
|
8
|
+
* watchdog-driven terminal transitions, which call the base lifecycle's closed-over `finish()`.
|
|
9
|
+
* Hosts that pass no sink never call this, so tracing off costs nothing.
|
|
10
|
+
*
|
|
11
|
+
* What it cannot see: the user prompt (the kernel only holds an opaque `contextRef`) — hosts that
|
|
12
|
+
* want prompt capture call `noteRunInput` with the text they sent. Resumed runs keep their
|
|
13
|
+
* original trace closed; a resume is not re-traced in this prototype.
|
|
14
|
+
*/
|
|
15
|
+
import { type AiTraceRecorderOptions, type AiTraceSink } from '@jini-ai/agent-runtime';
|
|
16
|
+
import type { RunLifecycle } from './run-lifecycle.js';
|
|
17
|
+
export interface AiTracingOptions extends Pick<AiTraceRecorderOptions, 'capture' | 'exactSecrets' | 'maxCapturedChars' | 'priceUsage' | 'onSinkError' | 'now'> {
|
|
18
|
+
/** Maps an agent id (`claude`, `codex`, `gemini`, …) to the provider label a backend groups by. Default: the agent id. */
|
|
19
|
+
readonly providerOf?: (args: {
|
|
20
|
+
agentId: string;
|
|
21
|
+
}) => string | null;
|
|
22
|
+
/** Host session/chat id for a run's opaque `contextRef`. Default: the contextRef itself. */
|
|
23
|
+
readonly sessionOf?: (args: {
|
|
24
|
+
contextRef: string;
|
|
25
|
+
}) => string | null;
|
|
26
|
+
}
|
|
27
|
+
export interface TracedRunLifecycle extends RunLifecycle {
|
|
28
|
+
/** Records the prompt text for a run's trace (subject to the capture mode). No-op for unknown runs. */
|
|
29
|
+
noteRunInput(args: {
|
|
30
|
+
runId: string;
|
|
31
|
+
input: string;
|
|
32
|
+
}): void;
|
|
33
|
+
/** Host teardown: releases every open trace WITHOUT finishing it (no terminal outcome is invented)
|
|
34
|
+
* and cancels background terminal waits, so a retained lifecycle keeps no recorder or waiter. Never throws. */
|
|
35
|
+
dispose(): void;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Records the lifecycle's committed stream, including internal terminal transitions.
|
|
39
|
+
* @param required - Existing lifecycle owner and vendor-neutral sink.
|
|
40
|
+
* @param options - Capture policy, mappings, clock, pricing, and guarded error reporting.
|
|
41
|
+
* @returns The lifecycle with traced starts and prompt capture; run results and errors are preserved.
|
|
42
|
+
* @complexity O(1) per live event; initial replay uses the lifecycle's O(e) stream for e committed events.
|
|
43
|
+
*/
|
|
44
|
+
export declare function withAiTracing({ lifecycle, sink }: {
|
|
45
|
+
lifecycle: RunLifecycle;
|
|
46
|
+
sink: AiTraceSink;
|
|
47
|
+
}, options?: AiTracingOptions): TracedRunLifecycle;
|
|
48
|
+
//# sourceMappingURL=ai-trace-lifecycle.d.ts.map
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module ai-trace-lifecycle
|
|
3
|
+
*
|
|
4
|
+
* Wraps a {@link RunLifecycle} so every run it drives is also recorded as an AI trace. This is
|
|
5
|
+
* the single tap for every provider path: child stream parsers (Claude Code, Copilot, Qoder,
|
|
6
|
+
* Codex/OpenCode/Cursor/Kimi JSON streams), ACP sessions and pi-rpc all publish their normalized
|
|
7
|
+
* events through `lifecycle.emit({ event: 'agent' })`. Observing the committed stream also sees
|
|
8
|
+
* watchdog-driven terminal transitions, which call the base lifecycle's closed-over `finish()`.
|
|
9
|
+
* Hosts that pass no sink never call this, so tracing off costs nothing.
|
|
10
|
+
*
|
|
11
|
+
* What it cannot see: the user prompt (the kernel only holds an opaque `contextRef`) — hosts that
|
|
12
|
+
* want prompt capture call `noteRunInput` with the text they sent. Resumed runs keep their
|
|
13
|
+
* original trace closed; a resume is not re-traced in this prototype.
|
|
14
|
+
*/
|
|
15
|
+
import { createAiTraceRecorder } from '@jini-ai/agent-runtime';
|
|
16
|
+
/**
|
|
17
|
+
* Records the lifecycle's committed stream, including internal terminal transitions.
|
|
18
|
+
* @param required - Existing lifecycle owner and vendor-neutral sink.
|
|
19
|
+
* @param options - Capture policy, mappings, clock, pricing, and guarded error reporting.
|
|
20
|
+
* @returns The lifecycle with traced starts and prompt capture; run results and errors are preserved.
|
|
21
|
+
* @complexity O(1) per live event; initial replay uses the lifecycle's O(e) stream for e committed events.
|
|
22
|
+
*/
|
|
23
|
+
export function withAiTracing({ lifecycle, sink }, options = {}) {
|
|
24
|
+
const recorders = new Map();
|
|
25
|
+
const { providerOf = ({ agentId }) => agentId, sessionOf = ({ contextRef }) => contextRef, ...recorderOptions } = options;
|
|
26
|
+
const teardown = new AbortController();
|
|
27
|
+
/** Only tracing effects are contained; the underlying lifecycle's errors still propagate. */
|
|
28
|
+
function safely({ action }) {
|
|
29
|
+
try {
|
|
30
|
+
action();
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
try {
|
|
34
|
+
options.onSinkError?.(error);
|
|
35
|
+
}
|
|
36
|
+
catch { /* Reporting must not change committed run behavior. */ }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function close({ runId, outcome }) {
|
|
40
|
+
const entry = recorders.get(runId);
|
|
41
|
+
if (!entry)
|
|
42
|
+
return;
|
|
43
|
+
try {
|
|
44
|
+
safely({ action: () => entry.recorder.finish({ outcome }) });
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
recorders.delete(runId);
|
|
48
|
+
safely({ action: () => entry.unsubscribe?.() });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function observe({ event }) {
|
|
52
|
+
if (event.kind === 'end') {
|
|
53
|
+
const { status } = event.payload;
|
|
54
|
+
return close({ runId: event.runId, outcome: status === 'canceled' ? 'cancelled' : status ?? 'failed' });
|
|
55
|
+
}
|
|
56
|
+
const recorder = recorders.get(event.runId)?.recorder;
|
|
57
|
+
if (event.kind === 'agent')
|
|
58
|
+
recorder?.observe(event.payload);
|
|
59
|
+
if (event.kind === 'error')
|
|
60
|
+
recorder?.observe({ type: 'error', message: event.payload.message, code: event.payload.error?.code });
|
|
61
|
+
}
|
|
62
|
+
async function open({ runId, contextRef, agentId }) {
|
|
63
|
+
const recorder = createAiTraceRecorder({ sink, runId, agentId }, { ...recorderOptions, provider: providerOf({ agentId }), sessionId: sessionOf({ contextRef }) });
|
|
64
|
+
const entry = { recorder };
|
|
65
|
+
recorders.set(runId, entry);
|
|
66
|
+
recorder.start({});
|
|
67
|
+
const subscription = await lifecycle.stream({ runId, onEvent: (event) => safely({ action: () => observe({ event }) }) });
|
|
68
|
+
if (subscription.kind !== 'ok')
|
|
69
|
+
throw new Error(`AI trace subscription refused: ${subscription.kind}`);
|
|
70
|
+
// A terminal event can arrive during replay, before stream returns its unsubscribe handle.
|
|
71
|
+
if (!recorders.has(runId))
|
|
72
|
+
subscription.unsubscribe();
|
|
73
|
+
else
|
|
74
|
+
entry.unsubscribe = subscription.unsubscribe;
|
|
75
|
+
}
|
|
76
|
+
/** A failed subscription has already recorded run_started: finish the trace from the run's real
|
|
77
|
+
* terminal state instead of dropping it. Background only, so tracing never affects the run; when
|
|
78
|
+
* the outcome cannot be read (or `dispose` cancels the wait) it is not invented and the recorder
|
|
79
|
+
* is just released. */
|
|
80
|
+
function finishWhenTerminal({ runId }) {
|
|
81
|
+
// waitForTerminal resolves only terminal states, whose labels are exactly AiRunOutcome's.
|
|
82
|
+
void Promise.resolve().then(() => lifecycle.waitForTerminal({ runId }, { signal: teardown.signal }))
|
|
83
|
+
.then((status) => close({ runId, outcome: status.state }))
|
|
84
|
+
.catch(() => { recorders.delete(runId); });
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
...lifecycle,
|
|
88
|
+
async start(required, optional = {}) {
|
|
89
|
+
const result = await lifecycle.start(required, optional);
|
|
90
|
+
// dispose() may run while the underlying start is pending; opening afterwards would leave a
|
|
91
|
+
// recorder and subscription behind that no teardown will ever release.
|
|
92
|
+
if (result.started && !teardown.signal.aborted) {
|
|
93
|
+
// Not awaited: a slow or hung event-log replay must not delay the committed start. The
|
|
94
|
+
// recorder is registered synchronously; replay delivers anything emitted before subscribing.
|
|
95
|
+
void open({ runId: result.run.id, contextRef: required.contextRef, agentId: optional.agentId ?? 'unknown' }).catch((error) => {
|
|
96
|
+
safely({ action: () => { throw error; } });
|
|
97
|
+
finishWhenTerminal({ runId: result.run.id });
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
},
|
|
102
|
+
// start() has already opened the trace by the time a host knows the run id, so the prompt
|
|
103
|
+
// rides the first model turn (where backends show a generation's input) rather than run_started.
|
|
104
|
+
noteRunInput({ runId, input }) {
|
|
105
|
+
safely({ action: () => recorders.get(runId)?.recorder.noteInput({ input }) });
|
|
106
|
+
},
|
|
107
|
+
dispose() {
|
|
108
|
+
teardown.abort();
|
|
109
|
+
for (const [runId, entry] of recorders) {
|
|
110
|
+
recorders.delete(runId);
|
|
111
|
+
safely({ action: () => entry.unsubscribe?.() });
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=ai-trace-lifecycle.js.map
|
|
@@ -9,7 +9,9 @@ export const defaultDaemonMessages = {
|
|
|
9
9
|
readOnly: {
|
|
10
10
|
unverifiableMessage: 'this host cannot verify read-only tools — POST /api/delegated-tool-calls was mounted without DelegatedToolsHttpDeps.toolRegistry, so a requireReadOnly call cannot be checked and is refused',
|
|
11
11
|
toolRefusalMessage: ({ toolId }) => `tool "${toolId}" is not registered as read-only — this gateway executes only tools whose registration declares readOnly; call it through execute_delegated_tool instead`,
|
|
12
|
-
unknownToolMessage: ({ toolId, candidates }) =>
|
|
12
|
+
unknownToolMessage: ({ toolId, candidates }) => toolId === 'check_messages'
|
|
13
|
+
? 'check_messages is an MCP-local tool; call check_messages directly, never as toolId in a delegated gateway'
|
|
14
|
+
: `no tool "${toolId}" is registered; use search_tools${candidates.length > 0 ? ` — did you mean ${candidates.join(', ')}?` : ''}`,
|
|
13
15
|
},
|
|
14
16
|
};
|
|
15
17
|
/** @returns A copied, attenuated principal; its roles are unchanged. */
|
|
@@ -38,6 +38,24 @@ function correlationFor(channel, toolUseId) {
|
|
|
38
38
|
function errorMessage(error) {
|
|
39
39
|
return error instanceof Error ? error.message : String(error);
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* `@jini-ai/chat`'s `UNKNOWN_MUTATION_ERROR`, copied because chat is only a dev dependency here.
|
|
43
|
+
* A test pins the two equal.
|
|
44
|
+
*/
|
|
45
|
+
const UNKNOWN_MUTATION_ERROR = "This operation may already be done. Its earlier outcome is unknown; verify the current state before repeating it.";
|
|
46
|
+
/**
|
|
47
|
+
* Model-facing text for a remote tool-call failure, keyed by the `ToolRpcError` `code` string the
|
|
48
|
+
* remote executor throws. Matched by name and code, not by import, so this file shares nothing with
|
|
49
|
+
* the executor. An unknown outcome is marked so the durable run blocks a blind repeat.
|
|
50
|
+
*/
|
|
51
|
+
const TOOL_RPC_RESULTS = {
|
|
52
|
+
TOOL_NOT_DISPATCHED: { content: 'The site did not receive this call; nothing ran. You may retry it.' },
|
|
53
|
+
TOOL_OUTCOME_UNKNOWN: { content: UNKNOWN_MUTATION_ERROR, outcome: 'unknown' },
|
|
54
|
+
};
|
|
55
|
+
function failedResult({ error }, _optional = {}) {
|
|
56
|
+
const code = error instanceof Error && error.name === 'ToolRpcError' ? error.code : undefined;
|
|
57
|
+
return (typeof code === 'string' && Object.hasOwn(TOOL_RPC_RESULTS, code) ? TOOL_RPC_RESULTS[code] : undefined) ?? { content: errorMessage(error) };
|
|
58
|
+
}
|
|
41
59
|
export { serializeDelegatedToolOutput, resultContent } from './tool-result-media.js';
|
|
42
60
|
/**
|
|
43
61
|
* Builds the abort controller handed to `ToolExecutor`, aborted by either a run cancellation or the
|
|
@@ -206,7 +224,7 @@ export function createDelegatedToolBridge(requiredArgs, optionalArgs = {}) {
|
|
|
206
224
|
seam.settle();
|
|
207
225
|
await lifecycle.emit({ runId: runId, input: {
|
|
208
226
|
event: 'agent',
|
|
209
|
-
data: { type: 'tool_result', toolUseId,
|
|
227
|
+
data: { type: 'tool_result', toolUseId, ...failedResult({ error }, {}), isError: true },
|
|
210
228
|
} });
|
|
211
229
|
throw error;
|
|
212
230
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module attachment-lookup-routes
|
|
3
|
+
*
|
|
4
|
+
* `POST /internal/attachment-lookup` — lets another local process (a site server running the
|
|
5
|
+
* attachment tools) read this daemon's `AttachmentStore` without owning a second copy of it. The
|
|
6
|
+
* store stays the sole owner of upload records; the caller gets exactly what the store's own two
|
|
7
|
+
* read methods return, including the store's non-disclosing rejection for a ref another run owns.
|
|
8
|
+
*
|
|
9
|
+
* Body: `{ op: "resolveForRun", ref, runId }` or `{ op: "listPendingForOwner", ownerId, runId? }`.
|
|
10
|
+
* Answer: `200 { value }` (`value: null` for an unknown ref), `400 ATTACHMENT_REJECTED` with
|
|
11
|
+
* `details.reason` for a store rejection, `400 BAD_REQUEST` for a malformed body, and a redacted
|
|
12
|
+
* `500` for anything else.
|
|
13
|
+
*
|
|
14
|
+
* Authentication is the host's: mount this behind the daemon's own bearer middleware. Run and owner
|
|
15
|
+
* ids come from the authenticated caller's execution context, never from model input — the store
|
|
16
|
+
* scopes by them (see `AttachmentStore.listPendingForOwner`).
|
|
17
|
+
*/
|
|
18
|
+
import { type RequestHandler } from 'express';
|
|
19
|
+
import { type AttachmentStore } from './attachments.js';
|
|
20
|
+
export declare const ATTACHMENT_LOOKUP_ROUTE_PATH = "/internal/attachment-lookup";
|
|
21
|
+
/** The two read operations the lookup proxies. */
|
|
22
|
+
export type AttachmentLookupRequest = {
|
|
23
|
+
readonly op: 'resolveForRun';
|
|
24
|
+
readonly ref: string;
|
|
25
|
+
readonly runId: string;
|
|
26
|
+
} | {
|
|
27
|
+
readonly op: 'listPendingForOwner';
|
|
28
|
+
readonly ownerId: string;
|
|
29
|
+
readonly runId?: string;
|
|
30
|
+
};
|
|
31
|
+
/** The read half of `AttachmentStore` a remote caller gets. */
|
|
32
|
+
export type AttachmentLookup = Pick<AttachmentStore, 'resolveForRun' | 'listPendingForOwner'>;
|
|
33
|
+
export interface AttachmentLookupRoutesRequired {
|
|
34
|
+
readonly app: {
|
|
35
|
+
post(path: string, ...handlers: RequestHandler[]): unknown;
|
|
36
|
+
};
|
|
37
|
+
readonly store: AttachmentLookup;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Mounts the lookup route. It brings a small JSON parser of its own, which is a no-op when the host
|
|
41
|
+
* already parsed the body upstream.
|
|
42
|
+
*/
|
|
43
|
+
export declare function registerAttachmentLookupRoutes({ app, store }: AttachmentLookupRoutesRequired, _optional?: Record<string, never>): void;
|
|
44
|
+
//# sourceMappingURL=attachment-lookup-routes.d.ts.map
|