@jini-ai/daemon 0.6.19 → 0.6.23

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 CHANGED
@@ -1,3 +1,11 @@
1
+ ## 0.6.23
2
+
3
+ - Security hardening.
4
+
5
+ ## 0.6.22
6
+
7
+ - Child stdout can be framed and sanitized line by line before parsing. A timed-out or cancelled delegated tool can report host-loaded recovery facts through `recoverInterruptedTool`, with no retry.
8
+
1
9
  ## 0.6.19
2
10
 
3
11
  - `DelegatedToolsHttpDeps.toolRegistry` takes a list-only registry view (`Pick<ToolRegistry, 'list'>`): the route only reads descriptors, and hosts pass descriptor-only facades.
@@ -1,4 +1,4 @@
1
- import type { McpHttpEndpoint, McpJsonServerEntry } from '../contracts.js';
1
+ import type { McpHttpEndpoint, McpJsonServerEntry, McpJsonInjectionOptions } from '../contracts.js';
2
2
  /** A TOML key: bare when TOML allows it, quoted otherwise — host-supplied bridge env names are not guaranteed bare-safe. */
3
3
  export declare function tomlKey(key: string): string;
4
4
  /**
@@ -16,8 +16,9 @@ export declare function tomlKey(key: string): string;
16
16
  * @complexity O(n) in the number of argv/env entries.
17
17
  * @overallScore 100/100
18
18
  */
19
- export declare function buildCodexMcpServerToml({ entry }: {
19
+ export declare function buildCodexMcpServerToml({ entry, toolApprovalOwner }: {
20
20
  readonly entry: McpJsonServerEntry | Pick<McpHttpEndpoint, 'url' | 'token'>;
21
+ readonly toolApprovalOwner?: McpJsonInjectionOptions['toolApprovalOwner'];
21
22
  }, _options?: {}): string;
22
23
  /**
23
24
  * Builds the full `config.toml` a run's scratch `CODEX_HOME` gets: the real Codex home's own
@@ -42,10 +43,11 @@ export declare function buildCodexMcpServerToml({ entry }: {
42
43
  * @returns The full text to write to the scratch `CODEX_HOME`'s `config.toml`.
43
44
  * @complexity O(n) in the existing config's length.
44
45
  */
45
- export declare function buildCodexHomeConfigToml({ existingRaw, entry, inheritUserMcp, inheritUserSettings }: {
46
+ export declare function buildCodexHomeConfigToml({ existingRaw, entry, inheritUserMcp, inheritUserSettings, toolApprovalOwner }: {
46
47
  readonly existingRaw: string | undefined;
47
48
  readonly entry: McpJsonServerEntry | Pick<McpHttpEndpoint, 'url' | 'token'>;
48
49
  readonly inheritUserMcp?: boolean;
49
50
  readonly inheritUserSettings?: boolean;
51
+ readonly toolApprovalOwner?: McpJsonInjectionOptions['toolApprovalOwner'];
50
52
  }, _optional?: Record<string, never>): string;
51
53
  //# sourceMappingURL=home-config.d.ts.map
@@ -56,12 +56,13 @@ const CODEX_TOOL_TIMEOUT_SEC = 6 * 60 + 40;
56
56
  * @complexity O(n) in the number of argv/env entries.
57
57
  * @overallScore 100/100
58
58
  */
59
- export function buildCodexMcpServerToml({ entry }, _options = {}) {
59
+ export function buildCodexMcpServerToml({ entry, toolApprovalOwner }, _options = {}) {
60
+ const approval = toolApprovalOwner === 'host' ? 'default_tools_approval_mode = "approve"\n' : '';
60
61
  if ('url' in entry)
61
- return buildCodexHttpMcpServerToml({ endpoint: entry });
62
+ return buildCodexHttpMcpServerToml({ endpoint: entry }, { approval });
62
63
  const argsLiteral = entry.args.map(tomlString).join(', ');
63
64
  const serverTable = `[mcp_servers.${JINI_MCP_SERVER_KEY}]\ncommand = ${tomlString(entry.command)}\nargs = [${argsLiteral}]\n` +
64
- `tool_timeout_sec = ${CODEX_TOOL_TIMEOUT_SEC}\n`;
65
+ `tool_timeout_sec = ${CODEX_TOOL_TIMEOUT_SEC}\n${approval}`;
65
66
  const envLines = Object.entries(entry.env)
66
67
  .filter((pair) => typeof pair[1] === 'string')
67
68
  .map(([key, value]) => `${tomlKey(key)} = ${tomlString(value)}`);
@@ -73,9 +74,9 @@ export function buildCodexMcpServerToml({ entry }, _options = {}) {
73
74
  * @returns One HTTP table using the existing timeout and TOML escaping owner.
74
75
  * @complexity O(endpoint bytes), without I/O.
75
76
  */
76
- function buildCodexHttpMcpServerToml({ endpoint }, _options = {}) {
77
+ function buildCodexHttpMcpServerToml({ endpoint }, { approval = '' } = {}) {
77
78
  return `[mcp_servers.${JINI_MCP_SERVER_KEY}]\nurl = ${tomlString(endpoint.url)}\n` +
78
- `tool_timeout_sec = ${CODEX_TOOL_TIMEOUT_SEC}\n\n[mcp_servers.${JINI_MCP_SERVER_KEY}.http_headers]\n` +
79
+ `tool_timeout_sec = ${CODEX_TOOL_TIMEOUT_SEC}\n${approval}\n[mcp_servers.${JINI_MCP_SERVER_KEY}.http_headers]\n` +
79
80
  `Authorization = ${tomlString(`Bearer ${endpoint.token}`)}\n`;
80
81
  }
81
82
  /**
@@ -213,10 +214,10 @@ function stripExistingJiniMcpServerTable(existingRaw, optional = {}) {
213
214
  * @returns The full text to write to the scratch `CODEX_HOME`'s `config.toml`.
214
215
  * @complexity O(n) in the existing config's length.
215
216
  */
216
- export function buildCodexHomeConfigToml({ existingRaw, entry, inheritUserMcp = true, inheritUserSettings = true }, _optional = {}) {
217
+ export function buildCodexHomeConfigToml({ existingRaw, entry, inheritUserMcp = true, inheritUserSettings = true, toolApprovalOwner }, _optional = {}) {
217
218
  const base = stripExistingJiniMcpServerTable(existingRaw ?? '', { allServers: !inheritUserMcp, mcpOnly: !inheritUserSettings });
218
219
  const blockBreak = base.endsWith('\n') ? '\n' : '\n\n';
219
220
  const separator = base.length === 0 ? '' : blockBreak;
220
- return `${base}${separator}${buildCodexMcpServerToml({ entry: entry })}`;
221
+ return `${base}${separator}${buildCodexMcpServerToml({ entry, toolApprovalOwner })}`;
221
222
  }
222
223
  //# sourceMappingURL=home-config.js.map
@@ -75,8 +75,8 @@ function resolveCodexHomeSeams(options) {
75
75
  * {@link prepareCodexHomeIfNeeded} stays the backstop for every blocked case.
76
76
  * @throws {@link CodexMcpIsolationError} when the run can't be isolated.
77
77
  */
78
- function buildStagedCodexConfig({ existingRaw, entry, inheritUserConfig, inheritUserMcp, sourceHome }) {
79
- const build = () => buildCodexHomeConfigToml({ existingRaw, entry, inheritUserMcp, inheritUserSettings: inheritUserConfig });
78
+ function buildStagedCodexConfig({ existingRaw, entry, inheritUserConfig, inheritUserMcp, sourceHome, toolApprovalOwner }) {
79
+ const build = () => buildCodexHomeConfigToml({ existingRaw, entry, inheritUserMcp, inheritUserSettings: inheritUserConfig, toolApprovalOwner });
80
80
  if (inheritUserMcp)
81
81
  return build();
82
82
  const path = join(sourceHome, 'config.toml');
@@ -115,7 +115,7 @@ function buildStagedCodexConfig({ existingRaw, entry, inheritUserConfig, inherit
115
115
  * @complexity O(h) filesystem operations for h top-level user-home entries; one staged config write.
116
116
  * @overallScore 100/100
117
117
  */
118
- async function prepareCodexHomeForRun(runId, entry, sourceCodexHomeDir, seams, inheritUserConfig = true, inheritUserMcp = true) {
118
+ async function prepareCodexHomeForRun(runId, entry, sourceCodexHomeDir, seams, inheritUserConfig = true, inheritUserMcp = true, toolApprovalOwner) {
119
119
  // Stricter than `@jini-ai/agent-runtime`'s `prepareAgentLogFile`/`preparePromptFileForAgent`
120
120
  // labels (which keep dots): this prefix stages a directory that ends up holding a copied Codex
121
121
  // login credential, so it gets `mcpJsonPathForRun`'s tighter discipline instead — dots stripped
@@ -133,7 +133,7 @@ async function prepareCodexHomeForRun(runId, entry, sourceCodexHomeDir, seams, i
133
133
  // matching writeMcpJsonForRun's identical "missing file" handling.
134
134
  existingConfigRaw = undefined;
135
135
  }
136
- const content = buildStagedCodexConfig({ existingRaw: existingConfigRaw, entry, inheritUserConfig, inheritUserMcp, sourceHome: sourceCodexHomeDir });
136
+ const content = buildStagedCodexConfig({ existingRaw: existingConfigRaw, entry, inheritUserConfig, inheritUserMcp, sourceHome: sourceCodexHomeDir, toolApprovalOwner });
137
137
  await seams.writeFile({ path: join(dir, 'config.toml'), content });
138
138
  await seams.linkSessionStore({ runHome: dir, sourceHome: sourceCodexHomeDir }, { inheritUserConfig });
139
139
  }
@@ -183,7 +183,7 @@ export async function prepareCodexHomeIfNeeded({ input, deps }) {
183
183
  }
184
184
  const inherit = codexInheritance(input);
185
185
  try {
186
- return await prepareCodexHomeForRun(input.runId, entry, resolveSourceCodexHomeDir({ hostEnv: deps.hostEnv }), resolveCodexHomeSeams(deps.mcpJsonInjection), inherit.settings, inherit.mcp);
186
+ return await prepareCodexHomeForRun(input.runId, entry, resolveSourceCodexHomeDir({ hostEnv: deps.hostEnv }), resolveCodexHomeSeams(deps.mcpJsonInjection), inherit.settings, inherit.mcp, deps.mcpJsonInjection.toolApprovalOwner);
187
187
  }
188
188
  catch (err) {
189
189
  await deps.releaseStagedResources();
@@ -351,6 +351,10 @@ export interface McpHttpEndpoint {
351
351
  close(required: Record<string, never>, optional?: Record<string, never>): Promise<void>;
352
352
  }
353
353
  export interface McpJsonInjectionOptions {
354
+ /** The trusted host owns authorization and human confirmation for bridge calls. Codex may
355
+ * approve the jini MCP transport without its own prompt; this grants no target-tool permission.
356
+ * Omission preserves the CLI's default approval behavior. Applies only to the jini bridge. */
357
+ readonly toolApprovalOwner?: 'host';
354
358
  /** Optional transport port. Reject before CLI launch when HTTP cannot be prepared.
355
359
  * The host keeps callback credentials in memory; only this endpoint capability reaches the CLI. */
356
360
  readonly prepareHttp?: (required: {
@@ -11,7 +11,7 @@ import type { ChildDrivenStreamFormat, TerminateChildTreeDeps, AgentCleanupFailu
11
11
  /** Default sink when a host does not supply `onCleanupFailure`: still observable, never silent. Redacted per SEC-007 (a spawn/permission error can embed paths/host detail). */
12
12
  export declare function defaultCleanupFailureSink(context: AgentCleanupFailureContext): void;
13
13
  /**
14
- * Default ceiling on the `'until-close'` stdout accumulator (see `RuntimeStdoutPolicy` in
14
+ * Default ceiling on the `'until-close'` or per-line stdout accumulator (see `RuntimeStdoutPolicy` in
15
15
  * `@jini-ai/agent-runtime`), in bytes of received UTF-8.
16
16
  *
17
17
  * A buffered def holds its child's entire stdout in one in-memory string until the process closes,
@@ -20,8 +20,8 @@ export declare function defaultCleanupFailureSink(context: AgentCleanupFailureCo
20
20
  * nothing obliges it to ever close or to stop emitting. Without a ceiling one run could exhaust the
21
21
  * daemon's heap and take every unrelated run in the process down with it.
22
22
  *
23
- * 8 MiB is chosen to sit far above any real buffered-agent transcript (antigravity's print-mode
24
- * output — the only `'until-close'` def — is a few KiB of auth prompt and result text) while staying
23
+ * 8 MiB was chosen to sit far above the original buffered print-mode auth prompt and result text
24
+ * (a few KiB) while staying
25
25
  * small enough that a hostile child cannot meaningfully pressure the heap. A host that genuinely
26
26
  * needs more passes `CreateAgentExecutorOptions.bufferedStdoutMaxBytes`.
27
27
  */
@@ -101,13 +101,15 @@ interface WireChildLifecycleContext extends TerminateChildTreeDeps {
101
101
  *
102
102
  * *When* those chunks leave is the def's call, via `def.stdoutPolicy`:
103
103
  *
104
- * - `'live'` (the default, and every def but antigravity) — emit per
104
+ * - `'live'` (the default) — emit per
105
105
  * chunk, as it arrives.
106
106
  * - `'until-close'` — accumulate, and emit the whole thing exactly once
107
107
  * from the `close` handler, after `def.stdoutPolicy.sanitize`. For an
108
108
  * adapter that can print a secret to stdout and still exit 0, no
109
109
  * per-chunk decision is safe: the pattern to redact can straddle two
110
110
  * `'data'` events.
111
+ * - `'line'` — sanitize each bounded complete wire line before both raw delivery and parsing;
112
+ * retain only its partial tail. Secrets split across chunks stay private without waiting for exit.
111
113
  *
112
114
  * The buffered path holds back the raw `'stdout'` echo too, not just the
113
115
  * `'agent'`/`text_delta`, and sanitizes both. Emitting an unsanitized raw
@@ -1,6 +1,7 @@
1
1
  import { createToolResultDiagnosticProjection, projectDiagnosticFrame } from '../tool-result-diagnostics.js';
2
2
  import { appendSerialTask } from "@jini-ai/core/primitives";
3
3
  import { createLiveSessionState } from './live-session-state.js';
4
+ import { createSanitizedLineStream } from './sanitized-line-stream.js';
4
5
  import { acceptsMidRunUserMessages, createMessageRelay } from './message-relay.js';
5
6
  import { randomUUID } from 'node:crypto';
6
7
  import { createModelReceiptTracker } from './model-receipts.js';
@@ -186,7 +187,7 @@ function receivedJournalEntry(channel, content) {
186
187
  return { content, provenance: { source: 'agent', channel }, trust: 'untrusted' };
187
188
  }
188
189
  /**
189
- * Default ceiling on the `'until-close'` stdout accumulator (see `RuntimeStdoutPolicy` in
190
+ * Default ceiling on the `'until-close'` or per-line stdout accumulator (see `RuntimeStdoutPolicy` in
190
191
  * `@jini-ai/agent-runtime`), in bytes of received UTF-8.
191
192
  *
192
193
  * A buffered def holds its child's entire stdout in one in-memory string until the process closes,
@@ -195,8 +196,8 @@ function receivedJournalEntry(channel, content) {
195
196
  * nothing obliges it to ever close or to stop emitting. Without a ceiling one run could exhaust the
196
197
  * daemon's heap and take every unrelated run in the process down with it.
197
198
  *
198
- * 8 MiB is chosen to sit far above any real buffered-agent transcript (antigravity's print-mode
199
- * output — the only `'until-close'` def — is a few KiB of auth prompt and result text) while staying
199
+ * 8 MiB was chosen to sit far above the original buffered print-mode auth prompt and result text
200
+ * (a few KiB) while staying
200
201
  * small enough that a hostile child cannot meaningfully pressure the heap. A host that genuinely
201
202
  * needs more passes `CreateAgentExecutorOptions.bufferedStdoutMaxBytes`.
202
203
  */
@@ -283,13 +284,15 @@ function createStdoutDiagnostics({ ctx, enqueueEmit }, _options = {}) {
283
284
  *
284
285
  * *When* those chunks leave is the def's call, via `def.stdoutPolicy`:
285
286
  *
286
- * - `'live'` (the default, and every def but antigravity) — emit per
287
+ * - `'live'` (the default) — emit per
287
288
  * chunk, as it arrives.
288
289
  * - `'until-close'` — accumulate, and emit the whole thing exactly once
289
290
  * from the `close` handler, after `def.stdoutPolicy.sanitize`. For an
290
291
  * adapter that can print a secret to stdout and still exit 0, no
291
292
  * per-chunk decision is safe: the pattern to redact can straddle two
292
293
  * `'data'` events.
294
+ * - `'line'` — sanitize each bounded complete wire line before both raw delivery and parsing;
295
+ * retain only its partial tail. Secrets split across chunks stay private without waiting for exit.
293
296
  *
294
297
  * The buffered path holds back the raw `'stdout'` echo too, not just the
295
298
  * `'agent'`/`text_delta`, and sanitizes both. Emitting an unsanitized raw
@@ -364,12 +367,25 @@ export function wireChildLifecycle(ctx) {
364
367
  let bridgeChecked = !ctx.expectsJiniBridge;
365
368
  let bridgeUnavailable = false;
366
369
  // `def.stdoutPolicy` read once, up front, so the per-chunk handler below is a single boolean
367
- // test rather than a repeated union narrowing. `undefined` (every def but antigravity) means
370
+ // test rather than a repeated union narrowing. `undefined` means
368
371
  // live — see this function's own doc.
369
372
  const stdoutPolicy = def.stdoutPolicy;
370
373
  const bufferStdoutUntilClose = stdoutPolicy?.buffering === 'until-close';
371
374
  const sanitizeBufferedStdout = stdoutPolicy?.buffering === 'until-close' ? stdoutPolicy.sanitize : undefined;
372
- // Accumulator for the `'until-close'` path. Stays `''` for every live def, and the flush below
375
+ const lineStdout = stdoutPolicy?.buffering === 'line' ? createSanitizedLineStream({
376
+ maxBytes: ctx.bufferedStdoutMaxBytes, sanitize: stdoutPolicy.sanitize,
377
+ emit: ({ text }) => {
378
+ stdoutDiagnostics.feed({ text });
379
+ if (streamFormat === 'plain')
380
+ enqueueEmit(() => lifecycle.emit({ runId, input: { event: 'agent', data: { type: 'text_delta', delta: text } } }));
381
+ else
382
+ streamHandler?.feed({ chunk: text });
383
+ },
384
+ onOverflow: ({ bytes }) => enqueueEmit(() => lifecycle.emit({ runId, input: { event: 'error', data: {
385
+ message: bufferedStdoutTruncationNotice(bytes, ctx.bufferedStdoutMaxBytes),
386
+ } } })),
387
+ }) : undefined;
388
+ // Accumulator for the `'until-close'` path. Stays `''` for live and line policies, and the flush below
373
389
  // is then a no-op that emits nothing. Bounded by `ctx.bufferedStdoutMaxBytes` — see
374
390
  // {@link DEFAULT_BUFFERED_STDOUT_MAX_BYTES} for why an unbounded accumulator was a
375
391
  // denial-of-service surface rather than merely untidy.
@@ -647,6 +663,10 @@ export function wireChildLifecycle(ctx) {
647
663
  child.stdout?.on('data', (chunk) => {
648
664
  const text = chunk.toString('utf8');
649
665
  stdoutDiagnostics.record({ chunk, text });
666
+ if (lineStdout) {
667
+ lineStdout.feed({ chunk: text });
668
+ return;
669
+ }
650
670
  if (streamFormat === 'plain' && text.length > 0)
651
671
  userVisibleOutputSeen = true;
652
672
  if (bufferStdoutUntilClose) {
@@ -716,6 +736,7 @@ export function wireChildLifecycle(ctx) {
716
736
  controlScanBuffer = '';
717
737
  }
718
738
  flushBufferedStdout();
739
+ lineStdout?.flush({});
719
740
  streamHandler?.flush();
720
741
  session.endSession();
721
742
  stdoutDiagnostics.flush();
@@ -0,0 +1,24 @@
1
+ /** Frame and sanitize stdout before either parser or raw-event delivery.
2
+ * @param required - Host byte cap, pure sanitizer and delivery/error sinks.
3
+ * @returns A per-process stream; flush delivers a final unterminated line once.
4
+ * @complexity O(n) time for n wire characters, O(maxBytes) retained text plus incoming chunk slices.
5
+ * @example const stream = createSanitizedLineStream({ maxBytes: 4096, sanitize, emit, onOverflow });
6
+ */
7
+ export declare function createSanitizedLineStream({ maxBytes, sanitize, emit, onOverflow }: {
8
+ maxBytes: number;
9
+ sanitize: (required: {
10
+ fullText: string;
11
+ }) => string;
12
+ emit: (required: {
13
+ text: string;
14
+ }, optional?: Record<string, never>) => void;
15
+ onOverflow: (required: {
16
+ bytes: number;
17
+ }, optional?: Record<string, never>) => void;
18
+ }, _options?: Record<string, never>): {
19
+ feed(required: {
20
+ chunk: string;
21
+ }, optional?: Record<string, never>): void;
22
+ flush(required: Record<string, never>, optional?: Record<string, never>): void;
23
+ };
24
+ //# sourceMappingURL=sanitized-line-stream.d.ts.map
@@ -0,0 +1,45 @@
1
+ /** Frame and sanitize stdout before either parser or raw-event delivery.
2
+ * @param required - Host byte cap, pure sanitizer and delivery/error sinks.
3
+ * @returns A per-process stream; flush delivers a final unterminated line once.
4
+ * @complexity O(n) time for n wire characters, O(maxBytes) retained text plus incoming chunk slices.
5
+ * @example const stream = createSanitizedLineStream({ maxBytes: 4096, sanitize, emit, onOverflow });
6
+ */
7
+ export function createSanitizedLineStream({ maxBytes, sanitize, emit, onOverflow }, _options = {}) {
8
+ let parts = [];
9
+ let bytes = 0;
10
+ let overflow = false;
11
+ /** One line's fragments retain at most maxBytes; an oversized line is discarded through its end. */
12
+ function append(text) {
13
+ if (!text)
14
+ return;
15
+ bytes += Buffer.byteLength(text, 'utf8');
16
+ if (bytes > maxBytes) {
17
+ parts = [];
18
+ overflow = true;
19
+ }
20
+ if (!overflow)
21
+ parts.push(text);
22
+ }
23
+ /** Delivery happens once per completed line, or once for the final unterminated tail. */
24
+ function flush(newline) {
25
+ if (overflow)
26
+ onOverflow({ bytes });
27
+ else if (bytes > 0 || newline)
28
+ emit({ text: sanitize({ fullText: parts.join('') + newline }) });
29
+ parts = [];
30
+ bytes = 0;
31
+ overflow = false;
32
+ }
33
+ return {
34
+ feed({ chunk }, _optional = {}) {
35
+ const lines = chunk.split('\n');
36
+ lines.forEach((line, index) => {
37
+ append(line);
38
+ if (index < lines.length - 1)
39
+ flush('\n');
40
+ });
41
+ },
42
+ flush(_required, _optional = {}) { flush(''); },
43
+ };
44
+ }
45
+ //# sourceMappingURL=sanitized-line-stream.js.map
@@ -37,6 +37,8 @@ export interface DelegatedToolExecuteResponse {
37
37
  */
38
38
  export interface DelegatedToolsInternalErrorContext {
39
39
  readonly source: 'delegated-tool-execute' | 'resolve-principal';
40
+ /** Optional host-loaded recovery facts; the host describer must redact these as usual. */
41
+ readonly recoveryMessage?: string;
40
42
  readonly runId: string;
41
43
  readonly toolId: string;
42
44
  readonly correlationId: string;
@@ -50,6 +52,16 @@ export interface DelegatedToolsInternalErrorContext {
50
52
  readonly status?: 'timed-out' | 'cancelled' | 'failed';
51
53
  }
52
54
  export interface DelegatedToolsHttpDeps {
55
+ /** One bounded read after an interrupted call, for hosts whose jobs live in another process.
56
+ * Must authorize with the supplied principal, never execute a write/retry, and impose its own
57
+ * deadline. A throw is contained; absent facts remain unknown. No calls on the success path.
58
+ */
59
+ readonly recoverInterruptedTool?: (required: {
60
+ principal: Principal;
61
+ runId: string;
62
+ toolId: string;
63
+ result: ToolExecutionResult;
64
+ }) => Promise<string | undefined>;
53
65
  /** The same negotiated digest store supplied to the run lifecycle and agent executor. */
54
66
  readonly delegatedResultCorrelation?: DelegatedResultCorrelation;
55
67
  readonly lifecycle: RunLifecycle;
@@ -130,7 +130,7 @@ function parseDelegatedToolExecute(input) {
130
130
  * vouches for this exact result via `deps.isModelSafeToolFailure` (it has already redacted the text
131
131
  * itself), in which case it is a `422 TOOL_EXECUTION_FAILED` carrying the host's text verbatim.
132
132
  */
133
- function toolExecutionResultToApiResult(deps, { runId, toolId, result }) {
133
+ function toolExecutionResultToApiResult(deps, { runId, toolId, result, recoveryMessage }) {
134
134
  switch (result.status) {
135
135
  case 'completed': {
136
136
  const outcome = batchOutcomeFor({ output: result.output });
@@ -143,7 +143,7 @@ function toolExecutionResultToApiResult(deps, { runId, toolId, result }) {
143
143
  return err({ error: createApiError({ code: 'TOOL_OPERATION_DENIED', message: 'this operation was denied during confirmation' }) });
144
144
  case 'timed-out':
145
145
  case 'cancelled':
146
- return err({ error: reportInternalError(deps, { source: 'delegated-tool-execute', error: result.status, runId, toolId, status: result.status }) });
146
+ return err({ error: reportInternalError(deps, { source: 'delegated-tool-execute', error: result.status, runId, toolId, ...(recoveryMessage ? { recoveryMessage } : {}), status: result.status }) });
147
147
  case 'failed':
148
148
  return err({ error: failedResultToApiError(deps, { runId, toolId, result }) });
149
149
  default: {
@@ -163,6 +163,21 @@ function failedResultToApiError(deps, { runId, toolId, result }) {
163
163
  }
164
164
  return reportInternalError(deps, { source: 'delegated-tool-execute', error: result.error ?? result.status, runId, toolId, status: 'failed' });
165
165
  }
166
+ /** Read host-owned recovery facts once after interruption. The host supplies the deadline and
167
+ * permission-checked read; a failed read never replaces or retries the original operation.
168
+ * @complexity One optional bounded read; no extra work on successful executions.
169
+ */
170
+ async function toolExecutionResultWithRecovery(deps, required) {
171
+ const { principal, runId, toolId, result } = required;
172
+ let recoveryMessage;
173
+ if (result.status === 'timed-out' || result.status === 'cancelled') {
174
+ try {
175
+ recoveryMessage = await deps.recoverInterruptedTool?.({ principal, runId, toolId, result });
176
+ }
177
+ catch { /* Recovery cannot change the interrupted write or expose its own failure. */ }
178
+ }
179
+ return toolExecutionResultToApiResult(deps, { runId, toolId, result, ...(recoveryMessage ? { recoveryMessage } : {}) });
180
+ }
166
181
  /**
167
182
  * `POST /api/delegated-tool-calls` — executes one delegated tool call against an already-started
168
183
  * run. Maps `ToolExecutionResult.status` to the HTTP response via `toolExecutionResultToApiResult`
@@ -224,7 +239,7 @@ export const delegatedToolExecuteRoute = defineJsonRoute({
224
239
  input: input.input,
225
240
  ...(signal !== undefined ? { signal } : {}),
226
241
  });
227
- return toolExecutionResultToApiResult(deps, { runId: input.runId, toolId: input.toolId, result });
242
+ return toolExecutionResultWithRecovery(deps, { principal, runId: input.runId, toolId: input.toolId, result });
228
243
  }
229
244
  catch (error) {
230
245
  if (error instanceof ToolInputError) {
@@ -52,6 +52,10 @@ export interface CreateFrontendControlOptions {
52
52
  readonly resolveBindToken: (requiredArgs: {
53
53
  readonly request: RunCreateRequest;
54
54
  }) => string | undefined;
55
+ /** Authenticated owner of a run's originating surface when session ownership is enabled. */
56
+ readonly resolveBindOwner?: (required: {
57
+ readonly request: RunCreateRequest;
58
+ }) => FrontendSessionOwner | undefined;
55
59
  /** @default `@jini-ai/daemon`'s `denyAllFrontendCapabilityPolicy` — a host grants access explicitly. */
56
60
  readonly policy?: ToolPolicy;
57
61
  /** @default `@jini-ai/daemon`'s `DEFAULT_FRONTEND_CAPABILITY_TIMEOUT_MS`. */
@@ -100,7 +100,8 @@ export function createFrontendControl(requiredArgs, optionalArgs = {}) {
100
100
  return;
101
101
  let release;
102
102
  try {
103
- release = registry.bindRunByToken({ runId: run.id, bindToken });
103
+ const owner = options.resolveBindOwner?.({ request });
104
+ release = registry.bindRunByToken({ runId: run.id, bindToken, ...(owner ? { owner } : {}) });
104
105
  }
105
106
  catch (error) {
106
107
  reportBindError({ runId: run.id, error });
@@ -0,0 +1,27 @@
1
+ import type { Express, Request, Response } from 'express';
2
+ import type { FrontendControl } from './frontend-control.js';
3
+ import type { FrontendSessionOwner } from '../frontend-session-registry.js';
4
+ import type { ToolExecutor } from '../tool-executor.js';
5
+ export interface FrontendRequestRouteRequired {
6
+ readonly app: Express;
7
+ readonly path: string;
8
+ readonly control: Pick<FrontendControl, 'bindRequest'>;
9
+ readonly executor: Pick<ToolExecutor, 'execute'>;
10
+ /** Host must authenticate session, origin and the tab before returning an owner. */
11
+ readonly resolveOwner: (required: {
12
+ req: Request;
13
+ res: Response;
14
+ }) => Promise<FrontendSessionOwner | null>;
15
+ readonly capabilityIds: readonly string[];
16
+ }
17
+ /** Bind one authenticated request to its originating surface, then use the host's existing gates.
18
+ * @param required Host-authenticated owner, frontend control, gated executor and allowed ids.
19
+ * @param optional Injectable fresh request id. A caller never chooses a run id or principal.
20
+ * @returns Nothing; mounts one JSON-body route behind the host's authentication/body-limit middleware.
21
+ * @complexity O(allowed ids) setup; one binding and one execution per request, no storage or polling.
22
+ * @overallScore 100
23
+ */
24
+ export declare function registerFrontendRequestRoute({ app, path, control, executor, resolveOwner, capabilityIds }: FrontendRequestRouteRequired, { newRequestId }?: {
25
+ newRequestId?: () => string;
26
+ }): void;
27
+ //# sourceMappingURL=frontend-request-route.d.ts.map
@@ -0,0 +1,69 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { streamToolExecution } from './tool-execution-routes.js';
3
+ function isRecord(value) {
4
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
5
+ }
6
+ /** Exact browser envelope: an agent cannot borrow a run or supply execution authority.
7
+ * @complexity O(input bytes) time and space for the frozen request; envelope has three keys.
8
+ * @overallScore 100
9
+ */
10
+ function readFrontendRequest(body, allowed) {
11
+ if (!isRecord(body))
12
+ return null;
13
+ const value = body;
14
+ if (Object.keys(value).length !== 3)
15
+ return null;
16
+ if (typeof value.capabilityId !== 'string' || !allowed.has(value.capabilityId))
17
+ return null;
18
+ if (typeof value.bindToken !== 'string' || value.bindToken.length === 0)
19
+ return null;
20
+ if (!isRecord(value.input))
21
+ return null;
22
+ return { capabilityId: value.capabilityId, bindToken: value.bindToken, input: structuredClone(value.input) };
23
+ }
24
+ /** Bind one authenticated request to its originating surface, then use the host's existing gates.
25
+ * @param required Host-authenticated owner, frontend control, gated executor and allowed ids.
26
+ * @param optional Injectable fresh request id. A caller never chooses a run id or principal.
27
+ * @returns Nothing; mounts one JSON-body route behind the host's authentication/body-limit middleware.
28
+ * @complexity O(allowed ids) setup; one binding and one execution per request, no storage or polling.
29
+ * @overallScore 100
30
+ */
31
+ export function registerFrontendRequestRoute({ app, path, control, executor, resolveOwner, capabilityIds }, { newRequestId = randomUUID } = {}) {
32
+ const allowed = new Set(capabilityIds);
33
+ app.post(path, async (req, res) => {
34
+ const request = readFrontendRequest(req.body, allowed);
35
+ if (!request) {
36
+ res.status(400).json({ error: 'Expected an allowed capabilityId, object input and frontend bindToken.' });
37
+ return;
38
+ }
39
+ let owner;
40
+ try {
41
+ owner = await resolveOwner({ req, res });
42
+ }
43
+ catch {
44
+ res.status(403).json({ error: 'The browser session could not be verified for this page action.' });
45
+ return;
46
+ }
47
+ if (!owner) {
48
+ res.status(403).json({ error: 'This page request has no authenticated browser owner.' });
49
+ return;
50
+ }
51
+ const runId = newRequestId();
52
+ const boundExecutor = {
53
+ execute: async (call, options = {}) => {
54
+ const signal = options.signal;
55
+ const release = control.bindRequest({ runId, bindToken: request.bindToken, owner, signal });
56
+ try {
57
+ return await executor.execute(call, options);
58
+ }
59
+ finally {
60
+ release();
61
+ }
62
+ },
63
+ };
64
+ await streamToolExecution({ req, res, executor: boundExecutor, call: {
65
+ executionId: runId, run: { id: runId }, principal: { id: owner.principalId }, toolId: request.capabilityId, input: request.input,
66
+ } }, { keepalive: true });
67
+ });
68
+ }
69
+ //# sourceMappingURL=frontend-request-route.js.map
@@ -59,4 +59,6 @@ export { createRemoteAttachmentLookup } from '../remote-attachment-lookup.js';
59
59
  export { createTavilyResearchProvider } from './research-tavily.adapter.js';
60
60
  export { createFrontendGuestScope } from './frontend-guest-scope.js';
61
61
  export { tryRestoreSurface } from './surface-restoration.js';
62
+ export { registerFrontendRequestRoute } from './frontend-request-route.js';
63
+ export type { FrontendRequestRouteRequired } from './frontend-request-route.js';
62
64
  //# sourceMappingURL=index.d.ts.map
@@ -32,4 +32,5 @@ export { createRemoteAttachmentLookup } from '../remote-attachment-lookup.js';
32
32
  export { createTavilyResearchProvider } from './research-tavily.adapter.js';
33
33
  export { createFrontendGuestScope } from './frontend-guest-scope.js';
34
34
  export { tryRestoreSurface } from './surface-restoration.js';
35
+ export { registerFrontendRequestRoute } from './frontend-request-route.js';
35
36
  //# sourceMappingURL=index.js.map
@@ -22,10 +22,10 @@
22
22
  * body that never completed never reaches the executor. Listeners are detached once the call
23
23
  * settles, so a long-lived keep-alive socket never accumulates them.
24
24
  */
25
- import { type ErrorRequestHandler, type RequestHandler } from 'express';
25
+ import { type ErrorRequestHandler, type Request, type RequestHandler, type Response } from 'express';
26
26
  import type { ToolDescriptor } from '@jini-ai/core';
27
27
  import type { ToolExecutor } from '../tool-executor.js';
28
- import { TOOL_EXECUTIONS_ROUTE_PATH } from '../remote-tool-executor.js';
28
+ import { TOOL_EXECUTIONS_ROUTE_PATH, type ToolExecutionWireRequest } from '../remote-tool-executor.js';
29
29
  export { TOOL_EXECUTIONS_ROUTE_PATH };
30
30
  export declare const TOOL_CATALOG_ROUTE_PATH = "/internal/tool-catalog";
31
31
  export declare const DEFAULT_TOOL_EXEC_TOKEN_ENV_VAR = "JINI_TOOL_EXEC_TOKEN";
@@ -62,6 +62,18 @@ export interface ToolExecutionRoutesOptional {
62
62
  }
63
63
  /** The token + loopback gate; mounted before any body parser. Exported for hosts that need it on another path. */
64
64
  export declare function requireToolExecutionCaller(_required: Record<string, never>, { tokenEnvVar, env }?: Pick<ToolExecutionRoutesOptional, 'tokenEnvVar' | 'env'>): RequestHandler;
65
+ /**
66
+ * Streams one execution as NDJSON. Headers go out first; surfaces are written as they are emitted
67
+ * and are dropped once the call settled; exactly one terminal line follows.
68
+ */
69
+ export declare function streamToolExecution({ req, res, call, executor }: {
70
+ readonly req: Request;
71
+ readonly res: Response;
72
+ readonly call: ToolExecutionWireRequest;
73
+ readonly executor: Pick<ToolExecutor, 'execute'>;
74
+ }, { runWithContext, keepalive }: Pick<ToolExecutionRoutesOptional, 'runWithContext'> & {
75
+ keepalive?: boolean;
76
+ }): Promise<void>;
65
77
  /**
66
78
  * Mounts both routes on `app` (an Express app or a `Router`). Mount it BEFORE any app-level
67
79
  * `express.json()` with a smaller limit: this route parses its own body after its gate, and an
@@ -99,7 +99,9 @@ function wireError(error) {
99
99
  * Streams one execution as NDJSON. Headers go out first; surfaces are written as they are emitted
100
100
  * and are dropped once the call settled; exactly one terminal line follows.
101
101
  */
102
- async function streamExecution({ req, res, call, executor }, { runWithContext }) {
102
+ export async function streamToolExecution({ req, res, call, executor }, { runWithContext, keepalive = false }) {
103
+ if (res.destroyed)
104
+ return;
103
105
  const controller = new AbortController();
104
106
  // `res` 'close' (not `req` 'close', which fires as soon as a completed body was read) means the
105
107
  // caller went away; after a normal end `writableFinished` is already true and nothing aborts.
@@ -116,6 +118,9 @@ async function streamExecution({ req, res, call, executor }, { runWithContext })
116
118
  res.write(`${JSON.stringify(line)}\n`); };
117
119
  const emitSurface = async (emission) => { if (!settled)
118
120
  writeLine({ type: 'surface', emission }); };
121
+ // Human confirmation may outlive the proxy's idle-body timeout; never poll for an answer.
122
+ const heartbeat = keepalive ? setInterval(() => writeLine({ type: 'keepalive' }), 15_000) : undefined;
123
+ heartbeat?.unref();
119
124
  const run = () => executor.execute({ principal: call.principal, run: call.run, toolId: call.toolId, input: call.input }, { signal: controller.signal, emitSurface });
120
125
  try {
121
126
  if (!req.complete || controller.signal.aborted)
@@ -128,6 +133,8 @@ async function streamExecution({ req, res, call, executor }, { runWithContext })
128
133
  }
129
134
  finally {
130
135
  settled = true;
136
+ if (heartbeat)
137
+ clearInterval(heartbeat);
131
138
  res.off('close', onClose);
132
139
  req.off('error', onRequestError);
133
140
  res.end();
@@ -141,7 +148,7 @@ function executionHandler({ executor }, optional) {
141
148
  sendApiError({ res, status: 400, error });
142
149
  return;
143
150
  }
144
- void streamExecution({ req, res, call, executor }, optional);
151
+ void streamToolExecution({ req, res, call, executor }, optional);
145
152
  };
146
153
  }
147
154
  /**
@@ -77,11 +77,18 @@ export interface FailingRunLifecycle {
77
77
  }, optional?: Record<string, never>): Promise<unknown>;
78
78
  }
79
79
  /** Emit the refusal reason on the run stream so the chat can show and save it. Event delivery
80
- * is best-effort; finishing remains mandatory even when the stream cannot accept the event. */
81
- export declare function failRunBeforeStart({ lifecycle, runId, message }: {
80
+ * is best-effort; finishing remains mandatory even when the stream cannot accept the event.
81
+ * Startup exceptions carry redacted stage/cause text through that same saved error event.
82
+ * @example failRunBeforeStart({ lifecycle, runId, message: 'Could not start.', startup: { stage: 'session binding', error } }, {});
83
+ */
84
+ export declare function failRunBeforeStart({ lifecycle, runId, message, startup }: {
82
85
  lifecycle: FailingRunLifecycle;
83
86
  runId: string;
84
87
  message: string;
88
+ startup?: {
89
+ stage: string;
90
+ error: unknown;
91
+ };
85
92
  }, _optional?: Record<string, never>): Promise<void>;
86
93
  export interface AgentCapabilities {
87
94
  readonly resumesSessionViaCli?: boolean;
@@ -1,3 +1,4 @@
1
+ import { startupFailureMessage } from '@jini-ai/agent-runtime';
1
2
  /** Fresh private maps keep daemon instances independent. */
2
3
  export function createLiveRunTracker(_args, _optional = {}) {
3
4
  const runs = new Map();
@@ -82,10 +83,14 @@ export async function waitForStoppingRuns({ tracker, lifecycle, scheduler, conve
82
83
  }
83
84
  }
84
85
  /** Emit the refusal reason on the run stream so the chat can show and save it. Event delivery
85
- * is best-effort; finishing remains mandatory even when the stream cannot accept the event. */
86
- export async function failRunBeforeStart({ lifecycle, runId, message }, _optional = {}) {
86
+ * is best-effort; finishing remains mandatory even when the stream cannot accept the event.
87
+ * Startup exceptions carry redacted stage/cause text through that same saved error event.
88
+ * @example failRunBeforeStart({ lifecycle, runId, message: 'Could not start.', startup: { stage: 'session binding', error } }, {});
89
+ */
90
+ export async function failRunBeforeStart({ lifecycle, runId, message, startup }, _optional = {}) {
91
+ const reason = startup ? startupFailureMessage(startup, {}) : message;
87
92
  try {
88
- await lifecycle.emit({ runId: runId, input: { event: 'error', data: { message } } });
93
+ await lifecycle.emit({ runId: runId, input: { event: 'error', data: { message: reason } } });
89
94
  }
90
95
  catch { /* Finishing remains mandatory. */ }
91
96
  await lifecycle.finish({ runId, status: 'failed', code: null, signal: null, resumable: false });
@@ -77,6 +77,7 @@ export declare function isRedactedToolFailure({ result }: {
77
77
  * compiles against a published http-kit that predates `status`.
78
78
  */
79
79
  export interface DelegatedInternalErrorContext {
80
+ readonly recoveryMessage?: string;
80
81
  readonly source: string;
81
82
  readonly runId: string;
82
83
  readonly toolId: string;
@@ -97,7 +97,10 @@ function delegatedInternalErrorReason(context) {
97
97
  * @complexity O(reason length) for the redaction pass.
98
98
  */
99
99
  export function describeDelegatedInternalError({ context, ...deps }, _optional = {}) {
100
- const { text, redactions } = deps.redactSecretShapes({ text: delegatedInternalErrorReason(context) });
100
+ let reason = delegatedInternalErrorReason(context);
101
+ if ((context.status === "timed-out" || context.status === "cancelled") && context.recoveryMessage)
102
+ reason = context.recoveryMessage;
103
+ const { text, redactions } = deps.redactSecretShapes({ text: reason });
101
104
  const errorId = deps.mintErrorId();
102
105
  safeOnFailure(deps.onFailure, {
103
106
  errorId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/daemon",
3
- "version": "0.6.19",
3
+ "version": "0.6.23",
4
4
  "description": "The stateful agent daemon runtime: run lifecycle, durable event-log port, the tool-execution boundary, agent executor, terminal sessions, and routines.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -153,22 +153,22 @@
153
153
  "express": "^4.21.0",
154
154
  "@types/express": "^4.17.21",
155
155
  "@jini-ai/db": "0.6.19",
156
- "@jini-ai/agent-runtime": "^0.6.19",
157
- "@jini-ai/chat": "0.6.19",
158
- "@jini-ai/core": "^0.6.19",
159
- "@jini-ai/platform": "^0.6.19",
160
- "@jini-ai/http-kit": "0.6.19",
161
- "@jini-ai/protocol": "^0.6.19"
156
+ "@jini-ai/chat": "0.6.23",
157
+ "@jini-ai/core": "^0.6.23",
158
+ "@jini-ai/platform": "^0.6.23",
159
+ "@jini-ai/protocol": "^0.6.19",
160
+ "@jini-ai/agent-runtime": "^0.6.23",
161
+ "@jini-ai/http-kit": "0.6.22"
162
162
  },
163
163
  "dependencies": {
164
164
  "smol-toml": "1.9.1",
165
165
  "kysely": "^0.29.6",
166
- "@jini-ai/core": "^0.6.19",
167
- "@jini-ai/protocol": "^0.6.19",
168
- "@jini-ai/agent-runtime": "^0.6.19",
166
+ "@jini-ai/core": "^0.6.23",
169
167
  "@jini-ai/db": "^0.6.19",
170
- "@jini-ai/platform": "^0.6.19",
171
- "@jini-ai/http-kit": "^0.6.19"
168
+ "@jini-ai/protocol": "^0.6.19",
169
+ "@jini-ai/platform": "^0.6.23",
170
+ "@jini-ai/http-kit": "^0.6.22",
171
+ "@jini-ai/agent-runtime": "^0.6.23"
172
172
  },
173
173
  "peerDependencies": {
174
174
  "node-pty": "^1.1.0",