@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 +8 -0
- package/dist/agent-executor/codex/home-config.d.ts +5 -3
- package/dist/agent-executor/codex/home-config.js +8 -7
- package/dist/agent-executor/codex/home-staging.js +5 -5
- package/dist/agent-executor/contracts.d.ts +4 -0
- package/dist/agent-executor/events.d.ts +6 -4
- package/dist/agent-executor/events.js +27 -6
- package/dist/agent-executor/sanitized-line-stream.d.ts +24 -0
- package/dist/agent-executor/sanitized-line-stream.js +45 -0
- package/dist/http/delegated-tools.d.ts +12 -0
- package/dist/http/delegated-tools.js +18 -3
- package/dist/http/frontend-control.d.ts +4 -0
- package/dist/http/frontend-control.js +2 -1
- package/dist/http/frontend-request-route.d.ts +27 -0
- package/dist/http/frontend-request-route.js +69 -0
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +1 -0
- package/dist/http/tool-execution-routes.d.ts +14 -2
- package/dist/http/tool-execution-routes.js +9 -2
- package/dist/session-coordination.d.ts +9 -2
- package/dist/session-coordination.js +8 -3
- package/dist/tool-recovery/redaction.d.ts +1 -0
- package/dist/tool-recovery/redaction.js +4 -1
- package/package.json +12 -12
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 },
|
|
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
|
|
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
|
|
24
|
-
*
|
|
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
|
|
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
|
|
199
|
-
*
|
|
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
|
|
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`
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
package/dist/http/index.d.ts
CHANGED
|
@@ -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
|
package/dist/http/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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/
|
|
157
|
-
"@jini-ai/
|
|
158
|
-
"@jini-ai/
|
|
159
|
-
"@jini-ai/
|
|
160
|
-
"@jini-ai/
|
|
161
|
-
"@jini-ai/
|
|
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.
|
|
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/
|
|
171
|
-
"@jini-ai/
|
|
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",
|