@jini-ai/daemon 0.6.15-rc.2 → 0.6.16
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 +12 -0
- package/README.md +14 -4
- package/dist/agent-executor/codex/home-config.js +3 -2
- package/dist/agent-executor/codex/home-staging.js +2 -2
- package/dist/agent-executor/codex/isolation.js +4 -3
- package/dist/agent-executor/contracts.d.ts +1 -1
- package/dist/agent-executor/contracts.js +0 -1
- package/dist/agent-executor/event-translation.d.ts +3 -3
- package/dist/agent-executor/event-translation.js +6 -6
- package/dist/agent-executor/events.js +65 -51
- package/dist/agent-executor/index.d.ts +5 -5
- package/dist/agent-executor/index.js +5 -5
- package/dist/agent-executor/launch.js +30 -46
- package/dist/agent-executor/live-session-state.js +2 -2
- package/dist/agent-executor/mcp-bridge.js +5 -0
- package/dist/agent-executor/message-relay.js +5 -6
- package/dist/agent-executor/run-output.js +7 -2
- package/dist/ai-trace-lifecycle.d.ts +1 -1
- package/dist/ai-trace-lifecycle.js +1 -1
- package/dist/close-status.js +6 -2
- package/dist/core/omit-keys.d.ts +7 -0
- package/dist/core/omit-keys.js +12 -0
- package/dist/core/read-only-tools.js +5 -1
- package/dist/core/tool-audit.js +11 -7
- package/dist/core/tool-gaps.js +1 -1
- package/dist/delegated-result-correlation.js +2 -2
- package/dist/frontend-capability-tools.d.ts +2 -0
- package/dist/frontend-capability-tools.js +7 -1
- package/dist/frontend-session-registry.d.ts +42 -3
- package/dist/frontend-session-registry.js +61 -9
- package/dist/http/agents.d.ts +4 -0
- package/dist/http/attachments.js +13 -12
- package/dist/http/component-catalog.js +1 -1
- package/dist/http/connectors.js +7 -5
- package/dist/http/db-ops.js +5 -0
- package/dist/http/delegated-tools.js +5 -0
- package/dist/http/frontend-control.d.ts +13 -0
- package/dist/http/frontend-control.js +14 -3
- package/dist/http/frontend-guest-scope.d.ts +32 -0
- package/dist/http/frontend-guest-scope.js +63 -0
- package/dist/http/frontend-sessions.d.ts +29 -6
- package/dist/http/frontend-sessions.js +108 -28
- package/dist/http/host-tools.js +7 -5
- package/dist/http/index.d.ts +1 -0
- package/dist/http/index.js +1 -0
- package/dist/http/memory.js +3 -1
- package/dist/http/model-proxy.js +13 -8
- package/dist/http/research-tavily.adapter.js +2 -1
- package/dist/http/routines.js +7 -5
- package/dist/http/terminals.js +5 -0
- package/dist/http/tool-catalog.js +1 -1
- package/dist/http/xai.js +10 -7
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/legacy-data-migration.d.ts +0 -12
- package/dist/legacy-data-migration.js +21 -13
- package/dist/remote-tool-executor.js +2 -1
- package/dist/routines/schedule.js +2 -1
- package/dist/routines/scheduler.js +6 -7
- package/dist/run-lifecycle.js +2 -1
- package/dist/store/agent-sessions/sql.js +7 -3
- package/dist/store/event-log/sqlite-kernel.js +7 -2
- package/dist/store/event-log/sqlite.js +6 -2
- package/dist/surface-exchanges.js +3 -3
- package/dist/terminal-session.d.ts +51 -20
- package/dist/terminal-session.js +10 -94
- package/dist/terminal.adapter.d.ts +22 -0
- package/dist/terminal.adapter.js +47 -0
- package/dist/tool-executor.d.ts +4 -2
- package/dist/tool-executor.js +95 -15
- package/dist/tool-recovery/recovery.js +5 -3
- package/dist/tool-recovery/redaction.js +1 -2
- package/dist/tool-recovery/stack.js +5 -1
- package/dist/tool-result-media.d.ts +4 -0
- package/dist/tool-result-media.js +10 -3
- package/dist/tool-result-surfaces.js +10 -9
- package/package.json +30 -34
|
@@ -13,6 +13,12 @@ export function validateSessionKernel({ kernel, dialect, transports }) {
|
|
|
13
13
|
function runSessionQuery(kernel, body) {
|
|
14
14
|
return kernel.run(db => body(db));
|
|
15
15
|
}
|
|
16
|
+
/** The one atomic upsert behind `setSessionId`: insert the pair, or replace its session id and time. */
|
|
17
|
+
function upsertSessionQuery({ conversationId, agentId, sessionId, updatedAt }) {
|
|
18
|
+
return (db) => db.insertInto('assistant_agent_sessions')
|
|
19
|
+
.values({ conversation_id: conversationId, agent_id: agentId, session_id: sessionId, updated_at: updatedAt })
|
|
20
|
+
.onConflict(oc => oc.columns(['conversation_id', 'agent_id']).doUpdateSet({ session_id: sessionId, updated_at: updatedAt })).execute();
|
|
21
|
+
}
|
|
16
22
|
/** Translate opaque driver failures without losing their cause or mutating host state. */
|
|
17
23
|
async function atSessionBoundary(operation) {
|
|
18
24
|
try {
|
|
@@ -43,9 +49,7 @@ export function createSqlAgentSessionStore({ kernel }, { clock = createSystemClo
|
|
|
43
49
|
setSessionId({ conversationId, agentId, sessionId }) {
|
|
44
50
|
return atSessionBoundary(async () => {
|
|
45
51
|
const updatedAt = clock.nowMs();
|
|
46
|
-
await runSessionQuery(kernel,
|
|
47
|
-
.values({ conversation_id: conversationId, agent_id: agentId, session_id: sessionId, updated_at: updatedAt })
|
|
48
|
-
.onConflict(oc => oc.columns(['conversation_id', 'agent_id']).doUpdateSet({ session_id: sessionId, updated_at: updatedAt })).execute());
|
|
52
|
+
await runSessionQuery(kernel, upsertSessionQuery({ conversationId, agentId, sessionId, updatedAt }));
|
|
49
53
|
});
|
|
50
54
|
},
|
|
51
55
|
clearSessionId({ conversationId, agentId }) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Async SQLite execution over the host kernel; event-log policy lives in sqlite-policy. */
|
|
2
|
+
import { loadOptionalPeer } from '@jini-ai/core/optional-peer';
|
|
2
3
|
import { createSystemClock } from '@jini-ai/core/primitives';
|
|
3
4
|
import { SQLITE_EVENT_LOG_SCHEMA, resolveMaxEntriesPerRun, assertValidReplayCursor, existingAppendResult, replayResult, rowToEntry, } from './sqlite-policy.js';
|
|
4
5
|
/**
|
|
@@ -57,7 +58,7 @@ export async function createKernelSqliteEventLog(required, optional = {}) {
|
|
|
57
58
|
async function setupSchema({ db }, options = {}) {
|
|
58
59
|
if (options.schemaSetup !== 'host') {
|
|
59
60
|
// Keep the existing synchronous factory free of a new eager peer import.
|
|
60
|
-
const { sql } = await import('kysely');
|
|
61
|
+
const { sql } = await loadOptionalPeer({ feature: 'SQL storage adapters', dependency: 'kysely', load: () => import('kysely') });
|
|
61
62
|
// Kysely executes one statement at a time; the owner DDL contains no embedded semicolons.
|
|
62
63
|
for (const statement of SQLITE_EVENT_LOG_SCHEMA.split(';').filter(part => part.trim())) {
|
|
63
64
|
await sql.raw(statement).execute(db);
|
|
@@ -129,7 +130,11 @@ async function replayInKernel({ db, runId, afterCursor }) {
|
|
|
129
130
|
const result = replayResult({ run: { run_id: runId, next_cursor: first.next_cursor }, oldest, afterCursor });
|
|
130
131
|
if (result.kind !== 'ok')
|
|
131
132
|
return result;
|
|
132
|
-
|
|
133
|
+
// A row missing any event column carries no entry.
|
|
134
|
+
const entries = rows.flatMap(({ cursor, event, data, recorded_at }) => {
|
|
135
|
+
const missing = cursor === null || event === null;
|
|
136
|
+
return missing || data === null || recorded_at === null ? [] : [rowToEntry({ cursor, event, data, recorded_at })];
|
|
137
|
+
});
|
|
133
138
|
return { ...result, entries };
|
|
134
139
|
}
|
|
135
140
|
//# sourceMappingURL=sqlite-kernel.js.map
|
|
@@ -58,8 +58,12 @@ export function openSqliteEventLog(input, options = {}) {
|
|
|
58
58
|
} }, { pragmas: ['journal_mode = WAL'] });
|
|
59
59
|
const log = bindSqliteEventLog(db, options);
|
|
60
60
|
let closed = false;
|
|
61
|
-
return { ...log, async close(_args) {
|
|
62
|
-
|
|
61
|
+
return { ...log, async close(_args) {
|
|
62
|
+
if (closed)
|
|
63
|
+
return;
|
|
64
|
+
closed = true;
|
|
65
|
+
db.close();
|
|
66
|
+
} };
|
|
63
67
|
}
|
|
64
68
|
catch (error) {
|
|
65
69
|
try {
|
|
@@ -143,9 +143,9 @@ export function createSurfaceExchangeStore(deps, { idleTtlMs = DEFAULT_SURFACE_I
|
|
|
143
143
|
// Unknown and closed are the same situation to a human whose dialog is no longer waiting.
|
|
144
144
|
if (!entry)
|
|
145
145
|
return { ok: false, reason: 'unknown-or-closed' };
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
146
|
+
const toolMismatch = spec.toolId !== undefined && entry.binding.toolId !== spec.toolId;
|
|
147
|
+
const channelMismatch = (entry.binding.channel ?? deps.defaultChannel) !== (spec.channel ?? deps.defaultChannel);
|
|
148
|
+
if (toolMismatch || channelMismatch || entry.binding.principalId !== spec.principalId) {
|
|
149
149
|
// A wrong-binding delivery must not consume a conversation the right human is still having.
|
|
150
150
|
return { ok: false, reason: 'binding-mismatch' };
|
|
151
151
|
}
|
|
@@ -1,3 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module terminal-session
|
|
3
|
+
*
|
|
4
|
+
* Interactive-terminal session manager: the `@jini-ai/daemon`-side wiring for
|
|
5
|
+
* OD's `apps/daemon/src/terminals.ts` + `apps/daemon/src/routes/terminal.ts`
|
|
6
|
+
* (see `ADS-memory/reports/proposals/PROP-http-route-packs-terminal-pty-2026-07-21.md`
|
|
7
|
+
* for the full design discussion this module implements a specific decision
|
|
8
|
+
* from). The generic ring-buffer/coalescing/SSE-agnostic session engine
|
|
9
|
+
* already exists as `@jini-ai/platform`'s `terminal.ts` (ported 2026-07-18,
|
|
10
|
+
* before this task) — this module does **not** reimplement that. It adds
|
|
11
|
+
* exactly the three things that engine deliberately left to a caller:
|
|
12
|
+
*
|
|
13
|
+
* 1. **A host-injected `PtySpawn` or `TerminalService`.** The native adapter
|
|
14
|
+
* lives exclusively at `@jini-ai/daemon/terminal`; this default-entry
|
|
15
|
+
* session manager never imports it. The host opts in by supplying
|
|
16
|
+
* `loadSpawnPty: () => loadRealSpawnPty({})` from that subpath.
|
|
17
|
+
*
|
|
18
|
+
* 2. **Session-token gating.** The proposal's blocker was that a
|
|
19
|
+
* *session-scoped* dangerous capability (once a shell is spawned, every
|
|
20
|
+
* subsequent `stdin` write is arbitrary unaudited input for the
|
|
21
|
+
* session's lifetime) does not fit `ToolExecutor`'s call-scoped shape.
|
|
22
|
+
* The resolved design: `ToolExecutor.execute('terminal.create', ...)`
|
|
23
|
+
* (via {@link createTerminalToolRegistrations}) is the one full
|
|
24
|
+
* deny-by-default gated call — it authorizes *creating* a session.
|
|
25
|
+
* Every subsequent `write`/`resize`/`kill`/`attach` call against that
|
|
26
|
+
* session id is validated by a lighter, still-explicit check: this
|
|
27
|
+
* module records which `Principal` created each session at `create()`
|
|
28
|
+
* time, and every later call verifies the calling principal matches,
|
|
29
|
+
* denying (as a `'not-found'` result, never a distinguishable "forbidden"
|
|
30
|
+
* — see {@link checkOwnership}'s doc for why) otherwise. This is not
|
|
31
|
+
* "the session id is unguessable therefore secure" — it is a real,
|
|
32
|
+
* per-call identity check, independent of how hard the id is to guess.
|
|
33
|
+
*
|
|
34
|
+
* 3. **The kill/stdin/resize race fix.** OD's origin (and `@jini-ai/platform`'s
|
|
35
|
+
* faithful port of it) has no lock between a `kill` and a concurrent
|
|
36
|
+
* `write`/`resize` on the same session id: `kill()` only *requests* the
|
|
37
|
+
* real OS process die (sends `SIGTERM`) — the session's `status` does not
|
|
38
|
+
* flip to `'exited'` until the pty's own `onExit` callback fires,
|
|
39
|
+
* asynchronously, once the real process actually dies. A `write`/`resize`
|
|
40
|
+
* processed in that window still observes `status: 'running'` and still
|
|
41
|
+
* reaches the real pty. This module closes that window with its own
|
|
42
|
+
* immediate, lock-protected `killed` flag (see {@link runExclusive} and
|
|
43
|
+
* the `write`/`resize`/`kill` implementations below): once a `kill` call
|
|
44
|
+
* has run its own critical section, any `write`/`resize` queued behind it
|
|
45
|
+
* for the same session id sees `killed: true` and is rejected — it never
|
|
46
|
+
* depends on the real process having actually exited yet.
|
|
47
|
+
*/
|
|
1
48
|
import type { Principal, ToolPolicy, ToolRegistration } from '@jini-ai/core';
|
|
2
49
|
import { type PtySpawn, type TerminalService, type TerminalSseSink } from '@jini-ai/platform';
|
|
3
50
|
export type { TerminalSseSink } from '@jini-ai/platform';
|
|
@@ -13,24 +60,6 @@ export declare const TERMINAL_CREATE_TOOL_ID = "terminal.create";
|
|
|
13
60
|
* merely by registering the tool.
|
|
14
61
|
*/
|
|
15
62
|
export declare const denyAllTerminalCreatePolicy: ToolPolicy;
|
|
16
|
-
/**
|
|
17
|
-
* The real, `node-pty`-backed {@link PtySpawn}. Dynamically imports
|
|
18
|
-
* `node-pty` (mirroring OD's origin `await import('node-pty')`, not a
|
|
19
|
-
* static top-level import) so a host process whose platform/environment
|
|
20
|
-
* lacks a usable `node-pty` addon can still boot — only an actual
|
|
21
|
-
* `terminal.create` call fails, cleanly, rather than crashing at
|
|
22
|
-
* module-eval time. Before the first spawn, repairs `node-pty`'s bundled
|
|
23
|
-
* `spawn-helper` binary's executable bit (pnpm unpacks it non-executable —
|
|
24
|
-
* see `@jini-ai/platform`'s `terminal.ts` module doc for the full story) via
|
|
25
|
-
* that package's own `spawnHelperCandidatePaths`/`ensureSpawnHelperExecutable`,
|
|
26
|
-
* but with the resolver anchored at **this** module
|
|
27
|
-
* (`createRequire(import.meta.url)` here, not `@jini-ai/platform`'s own
|
|
28
|
-
* default): `@jini-ai/platform` deliberately does not depend on `node-pty`, so
|
|
29
|
-
* its default `require.resolve('node-pty')` would not find the copy this
|
|
30
|
-
* package (`@jini-ai/daemon`) actually declares — this is exactly the
|
|
31
|
-
* resolver injection seam `spawnHelperCandidatePaths` was built for.
|
|
32
|
-
*/
|
|
33
|
-
export declare function loadRealSpawnPty(_requiredArgs: Record<string, never>): Promise<PtySpawn>;
|
|
34
63
|
/** The public, principal-neutral session snapshot this module exposes — deliberately its own shape rather than re-exporting `@jini-ai/platform`'s `TerminalSession` verbatim, since that type carries an OD-shaped per-session grouping key (see `@jini-ai/platform`'s `terminal.ts`) this module replaces entirely with the caller-supplied `resourceRef` (see {@link CreateTerminalSessionOptions}), tracked only in this module's own metadata and never forwarded to `@jini-ai/platform` at all — this package's own identifier-neutrality lint (`__tests__/identifier-lint.test.ts`) forbids that origin field's exact name from appearing anywhere in this package's source. */
|
|
35
64
|
export interface TerminalSessionInfo {
|
|
36
65
|
readonly id: string;
|
|
@@ -129,9 +158,9 @@ export interface TerminalSessionManager {
|
|
|
129
158
|
}): Promise<void>;
|
|
130
159
|
}
|
|
131
160
|
export interface CreateTerminalSessionManagerOptions {
|
|
132
|
-
/**
|
|
161
|
+
/** Host-owned session service. Otherwise a fresh platform service uses the injected `loadSpawnPty`. */
|
|
133
162
|
readonly terminalService?: TerminalService;
|
|
134
|
-
/**
|
|
163
|
+
/** Host-owned PTY loader, e.g. `() => loadRealSpawnPty({})` from `@jini-ai/daemon/terminal`. Required for creation unless `terminalService` is supplied. */
|
|
135
164
|
readonly loadSpawnPty?: () => Promise<PtySpawn>;
|
|
136
165
|
readonly maxEvents?: number;
|
|
137
166
|
readonly maxBufferBytes?: number;
|
|
@@ -146,6 +175,8 @@ export interface CreateTerminalSessionManagerOptions {
|
|
|
146
175
|
* around a `@jini-ai/platform` `TerminalService` adding session ownership,
|
|
147
176
|
* the kill/write/resize lock, and (via {@link createTerminalToolRegistrations})
|
|
148
177
|
* `ToolExecutor` gating for creation. See module doc for the full design.
|
|
178
|
+
* Without a host-owned service or loader, boot is inert and `create()` rejects
|
|
179
|
+
* with injection guidance. Wrapper setup is O(1); session work stays in the platform service.
|
|
149
180
|
*/
|
|
150
181
|
export declare function createTerminalSessionManager(_requiredArgs: Record<string, never>, options?: Pick<CreateTerminalSessionManagerOptions, "terminalService" | "loadSpawnPty" | "maxEvents" | "maxBufferBytes" | "exitTailBytes" | "flushIntervalMs" | "flushThresholdBytes" | "ttlMs" | "shutdownGraceMs">): TerminalSessionManager;
|
|
151
182
|
export interface CreateTerminalToolRegistrationsOptions {
|
package/dist/terminal-session.js
CHANGED
|
@@ -1,54 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* @module terminal-session
|
|
3
|
-
*
|
|
4
|
-
* Interactive-terminal session manager: the `@jini-ai/daemon`-side wiring for
|
|
5
|
-
* OD's `apps/daemon/src/terminals.ts` + `apps/daemon/src/routes/terminal.ts`
|
|
6
|
-
* (see `ADS-memory/reports/proposals/PROP-http-route-packs-terminal-pty-2026-07-21.md`
|
|
7
|
-
* for the full design discussion this module implements a specific decision
|
|
8
|
-
* from). The generic ring-buffer/coalescing/SSE-agnostic session engine
|
|
9
|
-
* already exists as `@jini-ai/platform`'s `terminal.ts` (ported 2026-07-18,
|
|
10
|
-
* before this task) — this module does **not** reimplement that. It adds
|
|
11
|
-
* exactly the three things that engine deliberately left to a caller:
|
|
12
|
-
*
|
|
13
|
-
* 1. **A real `node-pty`-backed `PtySpawn`.** `@jini-ai/platform` intentionally
|
|
14
|
-
* does not depend on `node-pty` (a native compiled addon); `@jini-ai/daemon`
|
|
15
|
-
* does (see this package's `package.json` and `archived provenance ledger`'s dated
|
|
16
|
-
* section for why that dependency now lives here, not in `@jini-ai/http-kit` or
|
|
17
|
-
* `@jini-ai/platform`). {@link loadRealSpawnPty} is the real, dynamically
|
|
18
|
-
* imported default the reference session manager below uses.
|
|
19
|
-
*
|
|
20
|
-
* 2. **Session-token gating.** The proposal's blocker was that a
|
|
21
|
-
* *session-scoped* dangerous capability (once a shell is spawned, every
|
|
22
|
-
* subsequent `stdin` write is arbitrary unaudited input for the
|
|
23
|
-
* session's lifetime) does not fit `ToolExecutor`'s call-scoped shape.
|
|
24
|
-
* The resolved design: `ToolExecutor.execute('terminal.create', ...)`
|
|
25
|
-
* (via {@link createTerminalToolRegistrations}) is the one full
|
|
26
|
-
* deny-by-default gated call — it authorizes *creating* a session.
|
|
27
|
-
* Every subsequent `write`/`resize`/`kill`/`attach` call against that
|
|
28
|
-
* session id is validated by a lighter, still-explicit check: this
|
|
29
|
-
* module records which `Principal` created each session at `create()`
|
|
30
|
-
* time, and every later call verifies the calling principal matches,
|
|
31
|
-
* denying (as a `'not-found'` result, never a distinguishable "forbidden"
|
|
32
|
-
* — see {@link checkOwnership}'s doc for why) otherwise. This is not
|
|
33
|
-
* "the session id is unguessable therefore secure" — it is a real,
|
|
34
|
-
* per-call identity check, independent of how hard the id is to guess.
|
|
35
|
-
*
|
|
36
|
-
* 3. **The kill/stdin/resize race fix.** OD's origin (and `@jini-ai/platform`'s
|
|
37
|
-
* faithful port of it) has no lock between a `kill` and a concurrent
|
|
38
|
-
* `write`/`resize` on the same session id: `kill()` only *requests* the
|
|
39
|
-
* real OS process die (sends `SIGTERM`) — the session's `status` does not
|
|
40
|
-
* flip to `'exited'` until the pty's own `onExit` callback fires,
|
|
41
|
-
* asynchronously, once the real process actually dies. A `write`/`resize`
|
|
42
|
-
* processed in that window still observes `status: 'running'` and still
|
|
43
|
-
* reaches the real pty. This module closes that window with its own
|
|
44
|
-
* immediate, lock-protected `killed` flag (see {@link runExclusive} and
|
|
45
|
-
* the `write`/`resize`/`kill` implementations below): once a `kill` call
|
|
46
|
-
* has run its own critical section, any `write`/`resize` queued behind it
|
|
47
|
-
* for the same session id sees `killed: true` and is rejected — it never
|
|
48
|
-
* depends on the real process having actually exited yet.
|
|
49
|
-
*/
|
|
50
|
-
import { createRequire } from 'node:module';
|
|
51
|
-
import { createTerminalService, ensureSpawnHelperExecutable, spawnHelperCandidatePaths, } from '@jini-ai/platform';
|
|
1
|
+
import { createTerminalService, } from '@jini-ai/platform';
|
|
52
2
|
/** The `ToolRegistry` id `terminal.create` executes through — see module doc §2. */
|
|
53
3
|
export const TERMINAL_CREATE_TOOL_ID = 'terminal.create';
|
|
54
4
|
/**
|
|
@@ -63,43 +13,6 @@ export const TERMINAL_CREATE_TOOL_ID = 'terminal.create';
|
|
|
63
13
|
export const denyAllTerminalCreatePolicy = {
|
|
64
14
|
authorize: () => 'deny',
|
|
65
15
|
};
|
|
66
|
-
const daemonRequire = createRequire(import.meta.url);
|
|
67
|
-
/** Adapts a real `node-pty` `IPty` to `@jini-ai/platform`'s narrower `PtyProcess` port — explicit per-member mapping rather than relying on structural assignability, matching this package's established "explicit adapter, no implicit coercion" convention (e.g. `agent-executor.ts`'s `toStringEnvRecord`). */
|
|
68
|
-
function toPtyProcess(pty) {
|
|
69
|
-
return {
|
|
70
|
-
onData: (cb) => {
|
|
71
|
-
pty.onData(cb);
|
|
72
|
-
},
|
|
73
|
-
onExit: (cb) => {
|
|
74
|
-
pty.onExit(cb);
|
|
75
|
-
},
|
|
76
|
-
write: (input) => pty.write(input),
|
|
77
|
-
resize: (cols, rows) => pty.resize(cols, rows),
|
|
78
|
-
kill: (signal) => pty.kill(signal),
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* The real, `node-pty`-backed {@link PtySpawn}. Dynamically imports
|
|
83
|
-
* `node-pty` (mirroring OD's origin `await import('node-pty')`, not a
|
|
84
|
-
* static top-level import) so a host process whose platform/environment
|
|
85
|
-
* lacks a usable `node-pty` addon can still boot — only an actual
|
|
86
|
-
* `terminal.create` call fails, cleanly, rather than crashing at
|
|
87
|
-
* module-eval time. Before the first spawn, repairs `node-pty`'s bundled
|
|
88
|
-
* `spawn-helper` binary's executable bit (pnpm unpacks it non-executable —
|
|
89
|
-
* see `@jini-ai/platform`'s `terminal.ts` module doc for the full story) via
|
|
90
|
-
* that package's own `spawnHelperCandidatePaths`/`ensureSpawnHelperExecutable`,
|
|
91
|
-
* but with the resolver anchored at **this** module
|
|
92
|
-
* (`createRequire(import.meta.url)` here, not `@jini-ai/platform`'s own
|
|
93
|
-
* default): `@jini-ai/platform` deliberately does not depend on `node-pty`, so
|
|
94
|
-
* its default `require.resolve('node-pty')` would not find the copy this
|
|
95
|
-
* package (`@jini-ai/daemon`) actually declares — this is exactly the
|
|
96
|
-
* resolver injection seam `spawnHelperCandidatePaths` was built for.
|
|
97
|
-
*/
|
|
98
|
-
export async function loadRealSpawnPty(_requiredArgs) {
|
|
99
|
-
ensureSpawnHelperExecutable(spawnHelperCandidatePaths({ resolve: (specifier) => daemonRequire.resolve(specifier) }));
|
|
100
|
-
const nodePty = await import('node-pty');
|
|
101
|
-
return (shell, args, options) => toPtyProcess(nodePty.spawn(shell, args, options));
|
|
102
|
-
}
|
|
103
16
|
function toSessionInfo(session, resourceRef) {
|
|
104
17
|
return {
|
|
105
18
|
id: session.id,
|
|
@@ -144,11 +57,15 @@ async function runExclusive(locks, id, fn) {
|
|
|
144
57
|
* around a `@jini-ai/platform` `TerminalService` adding session ownership,
|
|
145
58
|
* the kill/write/resize lock, and (via {@link createTerminalToolRegistrations})
|
|
146
59
|
* `ToolExecutor` gating for creation. See module doc for the full design.
|
|
60
|
+
* Without a host-owned service or loader, boot is inert and `create()` rejects
|
|
61
|
+
* with injection guidance. Wrapper setup is O(1); session work stays in the platform service.
|
|
147
62
|
*/
|
|
148
63
|
export function createTerminalSessionManager(_requiredArgs, options = {}) {
|
|
149
64
|
const terminalService = options.terminalService ??
|
|
150
65
|
createTerminalService({
|
|
151
|
-
loadSpawnPty: options.loadSpawnPty ?? (() =>
|
|
66
|
+
loadSpawnPty: options.loadSpawnPty ?? (() => {
|
|
67
|
+
throw new Error('Interactive terminals require a host-supplied terminalService or loadSpawnPty. Import loadRealSpawnPty from @jini-ai/daemon/terminal to enable node-pty.');
|
|
68
|
+
}),
|
|
152
69
|
...definedOnly({
|
|
153
70
|
maxEvents: options.maxEvents,
|
|
154
71
|
maxBufferBytes: options.maxBufferBytes,
|
|
@@ -200,11 +117,10 @@ export function createTerminalSessionManager(_requiredArgs, options = {}) {
|
|
|
200
117
|
const result = [];
|
|
201
118
|
for (const session of terminalService.list()) {
|
|
202
119
|
const meta = metadata.get(session.id);
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (
|
|
206
|
-
|
|
207
|
-
result.push(toSessionInfo(session, meta.resourceRef));
|
|
120
|
+
const visible = meta !== undefined && meta.principalId === principal.id
|
|
121
|
+
&& (filter.resourceRef === undefined || meta.resourceRef === filter.resourceRef);
|
|
122
|
+
if (visible)
|
|
123
|
+
result.push(toSessionInfo(session, meta.resourceRef));
|
|
208
124
|
}
|
|
209
125
|
return result;
|
|
210
126
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type PtySpawn } from '@jini-ai/platform';
|
|
2
|
+
/**
|
|
3
|
+
* The real, `node-pty`-backed {@link PtySpawn}. Dynamically imports
|
|
4
|
+
* `node-pty` (mirroring OD's origin `await import('node-pty')`, not a
|
|
5
|
+
* static top-level import) so a host process whose platform/environment
|
|
6
|
+
* lacks a usable `node-pty` addon can still boot — only an actual
|
|
7
|
+
* `terminal.create` call fails, cleanly, rather than crashing at
|
|
8
|
+
* module-eval time. Before the first spawn, repairs `node-pty`'s bundled
|
|
9
|
+
* `spawn-helper` binary's executable bit (pnpm unpacks it non-executable —
|
|
10
|
+
* see `@jini-ai/platform`'s `terminal.ts` module doc for the full story) via
|
|
11
|
+
* that package's own `spawnHelperCandidatePaths`/`ensureSpawnHelperExecutable`,
|
|
12
|
+
* but with the resolver anchored at **this** module
|
|
13
|
+
* (`createRequire(import.meta.url)` here, not `@jini-ai/platform`'s own
|
|
14
|
+
* default): `@jini-ai/platform` deliberately does not depend on `node-pty`, so
|
|
15
|
+
* its default `require.resolve('node-pty')` would not find the copy this
|
|
16
|
+
* package (`@jini-ai/daemon`) actually declares — this is exactly the
|
|
17
|
+
* resolver injection seam `spawnHelperCandidatePaths` was built for.
|
|
18
|
+
* @throws A feature-specific load error with the original resolution/ABI error as `cause`.
|
|
19
|
+
* @complexity O(1) adapter work; module loading and helper repair belong to the platform.
|
|
20
|
+
*/
|
|
21
|
+
export declare function loadRealSpawnPty(_requiredArgs: Record<string, never>, _optionalArgs?: Record<string, never>): Promise<PtySpawn>;
|
|
22
|
+
//# sourceMappingURL=terminal.adapter.d.ts.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in native PTY adapter. Import from `@jini-ai/daemon/terminal` and inject
|
|
3
|
+
* `() => loadRealSpawnPty({})` into the session manager's `loadSpawnPty` port.
|
|
4
|
+
*/
|
|
5
|
+
import { loadOptionalPeer } from '@jini-ai/core/optional-peer';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { ensureSpawnHelperExecutable, spawnHelperCandidatePaths, } from '@jini-ai/platform';
|
|
8
|
+
const daemonRequire = createRequire(import.meta.url);
|
|
9
|
+
/** Adapts a real `node-pty` `IPty` to `@jini-ai/platform`'s narrower `PtyProcess` port — explicit per-member mapping rather than relying on structural assignability, matching this package's established "explicit adapter, no implicit coercion" convention (e.g. `agent-executor.ts`'s `toStringEnvRecord`). */
|
|
10
|
+
function toPtyProcess(pty) {
|
|
11
|
+
return {
|
|
12
|
+
onData: (cb) => {
|
|
13
|
+
pty.onData(cb);
|
|
14
|
+
},
|
|
15
|
+
onExit: (cb) => {
|
|
16
|
+
pty.onExit(cb);
|
|
17
|
+
},
|
|
18
|
+
write: (input) => pty.write(input),
|
|
19
|
+
resize: (cols, rows) => pty.resize(cols, rows),
|
|
20
|
+
kill: (signal) => pty.kill(signal),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The real, `node-pty`-backed {@link PtySpawn}. Dynamically imports
|
|
25
|
+
* `node-pty` (mirroring OD's origin `await import('node-pty')`, not a
|
|
26
|
+
* static top-level import) so a host process whose platform/environment
|
|
27
|
+
* lacks a usable `node-pty` addon can still boot — only an actual
|
|
28
|
+
* `terminal.create` call fails, cleanly, rather than crashing at
|
|
29
|
+
* module-eval time. Before the first spawn, repairs `node-pty`'s bundled
|
|
30
|
+
* `spawn-helper` binary's executable bit (pnpm unpacks it non-executable —
|
|
31
|
+
* see `@jini-ai/platform`'s `terminal.ts` module doc for the full story) via
|
|
32
|
+
* that package's own `spawnHelperCandidatePaths`/`ensureSpawnHelperExecutable`,
|
|
33
|
+
* but with the resolver anchored at **this** module
|
|
34
|
+
* (`createRequire(import.meta.url)` here, not `@jini-ai/platform`'s own
|
|
35
|
+
* default): `@jini-ai/platform` deliberately does not depend on `node-pty`, so
|
|
36
|
+
* its default `require.resolve('node-pty')` would not find the copy this
|
|
37
|
+
* package (`@jini-ai/daemon`) actually declares — this is exactly the
|
|
38
|
+
* resolver injection seam `spawnHelperCandidatePaths` was built for.
|
|
39
|
+
* @throws A feature-specific load error with the original resolution/ABI error as `cause`.
|
|
40
|
+
* @complexity O(1) adapter work; module loading and helper repair belong to the platform.
|
|
41
|
+
*/
|
|
42
|
+
export async function loadRealSpawnPty(_requiredArgs, _optionalArgs = {}) {
|
|
43
|
+
ensureSpawnHelperExecutable(spawnHelperCandidatePaths({ resolve: (specifier) => daemonRequire.resolve(specifier) }));
|
|
44
|
+
const nodePty = await loadOptionalPeer({ feature: 'Interactive terminals', dependency: 'node-pty', load: () => import('node-pty') });
|
|
45
|
+
return (shell, args, options) => toPtyProcess(nodePty.spawn(shell, args, options));
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=terminal.adapter.js.map
|
package/dist/tool-executor.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export interface ToolConfirmationRequest {
|
|
|
17
17
|
* The transport-supplied seam for the two touchpoints a headless kernel
|
|
18
18
|
* cannot render itself. Both are optional: a tool whose `ToolPolicy`
|
|
19
19
|
* always resolves definitively needs no `onAuthorize`, and a tool with
|
|
20
|
-
* `requiresConfirmation` unset
|
|
20
|
+
* `requiresConfirmation` unset has no default prompt; a host may strengthen a single call with `requireConfirmation: true`.
|
|
21
21
|
*/
|
|
22
22
|
export interface ExecutionDelegate {
|
|
23
23
|
/**
|
|
@@ -42,7 +42,7 @@ export interface ExecutionDelegate {
|
|
|
42
42
|
*/
|
|
43
43
|
onConfirm?(request: ToolConfirmationRequest): ConfirmationDecision | Promise<ConfirmationDecision> | void;
|
|
44
44
|
}
|
|
45
|
-
export type ToolExecutionPhase = 'requested' | 'authorized' | 'denied' | 'confirmed' | 'confirmation-denied' | 'started' | 'completed' | 'failed' | 'timed-out' | 'cancelled';
|
|
45
|
+
export type ToolExecutionPhase = 'requested' | 'authorized' | 'denied' | 'confirmed' | 'approval-recorded' | 'confirmation-denied' | 'started' | 'completed' | 'failed' | 'timed-out' | 'cancelled';
|
|
46
46
|
export interface ToolExecutionAuditEvent {
|
|
47
47
|
readonly phase: ToolExecutionPhase;
|
|
48
48
|
readonly at: number;
|
|
@@ -85,6 +85,7 @@ export interface ToolExecutor {
|
|
|
85
85
|
* @param emitSurface - Transport-supplied seam letting the handler push a human-only surface into
|
|
86
86
|
* the run's event stream *before* this call settles. Omit for a caller with no run event stream;
|
|
87
87
|
* see `@jini-ai/core`'s `SurfaceEmitter` for why a handler that waits on a human needs it.
|
|
88
|
+
* @param requireConfirmation - Strengthens this call only; cannot bypass a descriptor confirmation.
|
|
88
89
|
* @throws {ToolInputError} If `toolId` isn't registered, with up to eight registered prefix matches.
|
|
89
90
|
*/
|
|
90
91
|
execute(args: {
|
|
@@ -95,6 +96,7 @@ export interface ToolExecutor {
|
|
|
95
96
|
}, optionalArgs?: {
|
|
96
97
|
readonly signal?: AbortSignal;
|
|
97
98
|
readonly emitSurface?: SurfaceEmitter;
|
|
99
|
+
readonly requireConfirmation?: true;
|
|
98
100
|
}): Promise<ToolExecutionResult>;
|
|
99
101
|
/** @throws If `executionId` has no confirmation currently pending (already resumed, never required one, or unknown). */
|
|
100
102
|
resumeConfirmation(args: {
|
package/dist/tool-executor.js
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
* answer gets back in.
|
|
47
47
|
*/
|
|
48
48
|
import { randomUUID } from 'node:crypto';
|
|
49
|
-
import { ToolInputError, withNormalizationNotes, ToolInputNormalizationError, } from '@jini-ai/core';
|
|
49
|
+
import { ToolInputError, APPROVAL_WAIT_TIMEOUT_MS, withNormalizationNotes, ToolInputNormalizationError, isReadOnlyTool, } from '@jini-ai/core';
|
|
50
50
|
import { authorizeToolInvocation } from '@jini-ai/core/composition';
|
|
51
51
|
import { toolCandidates } from './core/tool-candidates.js';
|
|
52
52
|
/**
|
|
@@ -104,6 +104,8 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
104
104
|
// Finished executionIds, oldest first (Set insertion order): the only records `retireAudit` evicts.
|
|
105
105
|
const finishedAudits = new Set();
|
|
106
106
|
const pendingConfirmations = new Map();
|
|
107
|
+
// Owned by the executor, not a bridge: concurrent HTTP/MCP calls create separate bridges.
|
|
108
|
+
const approvalWaits = new Map();
|
|
107
109
|
const activeControllers = new Map();
|
|
108
110
|
/**
|
|
109
111
|
* Marks a pending confirmation `cancel()` just resolved (as opposed to a genuine human `deny`),
|
|
@@ -114,6 +116,34 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
114
116
|
* `requestConfirmation()` settles, so it can never leak onto a later execution.
|
|
115
117
|
*/
|
|
116
118
|
const cancelledConfirmations = new Set();
|
|
119
|
+
/** Records only actual human waits; terminal decisions and aborts release the run's write block.
|
|
120
|
+
* @complexity O(1) time/space per pending execution; empty run entries are removed. */
|
|
121
|
+
function approvalStateChange({ run, executionId, toolId, pending }, _optional = {}) {
|
|
122
|
+
if (pending) {
|
|
123
|
+
const waits = approvalWaits.get(run.id) ?? new Map();
|
|
124
|
+
waits.set(executionId, toolId);
|
|
125
|
+
approvalWaits.set(run.id, waits);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const waits = approvalWaits.get(run.id);
|
|
129
|
+
waits?.delete(executionId);
|
|
130
|
+
if (waits?.size === 0)
|
|
131
|
+
approvalWaits.delete(run.id);
|
|
132
|
+
}
|
|
133
|
+
/** Refuses writes using the registered read-only contract, never a model's claim.
|
|
134
|
+
* The validation result reaches every delegated transport as an actionable error, without
|
|
135
|
+
* resolving or approving the original call. @complexity O(1) time/space. */
|
|
136
|
+
function approvalRefusal({ executionId, run, descriptor }, _optional = {}) {
|
|
137
|
+
const pendingToolId = approvalWaits.get(run.id)?.values().next().value;
|
|
138
|
+
if (pendingToolId === undefined || isReadOnlyTool({ descriptor }))
|
|
139
|
+
return undefined;
|
|
140
|
+
const error = JSON.stringify({
|
|
141
|
+
status: 'awaiting_approval', started: false, toolId: descriptor.id, pendingToolId,
|
|
142
|
+
message: 'Another action is waiting for human confirmation and has not started. This write was refused. Reads may continue. Never say an action has started until its tool result explicitly says it started.',
|
|
143
|
+
});
|
|
144
|
+
appendEvent(executionId, 'failed', error);
|
|
145
|
+
return { executionId, status: 'failed', errorKind: 'validation', error };
|
|
146
|
+
}
|
|
117
147
|
function appendEvent(executionId, phase, detail) {
|
|
118
148
|
// Every call site passes an `executionId` this same `execute()` call
|
|
119
149
|
// already registered in `audits` (right before its first `appendEvent`
|
|
@@ -176,15 +206,45 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
176
206
|
* what distinguishes a `timed-out` result from a `cancelled` one.
|
|
177
207
|
*/
|
|
178
208
|
function startTimeout(controller, timeoutMs) {
|
|
179
|
-
|
|
180
|
-
return { handle: undefined, timedOut: () => false };
|
|
209
|
+
let handle;
|
|
181
210
|
let timedOut = false;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
211
|
+
let waiting = false;
|
|
212
|
+
let approvalWindowUsed = false;
|
|
213
|
+
let remaining = timeoutMs;
|
|
214
|
+
let started = Date.now();
|
|
215
|
+
const stop = () => {
|
|
216
|
+
if (handle === undefined)
|
|
217
|
+
return;
|
|
218
|
+
clearTimeout(handle);
|
|
219
|
+
handle = undefined;
|
|
220
|
+
};
|
|
221
|
+
const arm = (duration) => {
|
|
222
|
+
stop();
|
|
223
|
+
started = Date.now();
|
|
224
|
+
if (duration === undefined)
|
|
225
|
+
return;
|
|
226
|
+
handle = setTimeout(() => { timedOut = true; controller.abort(); }, duration);
|
|
227
|
+
handle.unref?.();
|
|
228
|
+
};
|
|
229
|
+
arm(remaining);
|
|
230
|
+
return {
|
|
231
|
+
stop,
|
|
232
|
+
timedOut: () => timedOut,
|
|
233
|
+
/** Human waiting is bounded independently and cannot replenish the execution budget.
|
|
234
|
+
* Duplicate progress frames cannot extend either deadline. */
|
|
235
|
+
approval: ({ pending }) => {
|
|
236
|
+
if (pending === waiting || controller.signal.aborted)
|
|
237
|
+
return;
|
|
238
|
+
if (pending && approvalWindowUsed)
|
|
239
|
+
return;
|
|
240
|
+
if (pending)
|
|
241
|
+
approvalWindowUsed = true;
|
|
242
|
+
if (pending && remaining !== undefined)
|
|
243
|
+
remaining = Math.max(0, remaining - (Date.now() - started));
|
|
244
|
+
waiting = pending;
|
|
245
|
+
arm(pending ? APPROVAL_WAIT_TIMEOUT_MS : remaining);
|
|
246
|
+
},
|
|
247
|
+
};
|
|
188
248
|
}
|
|
189
249
|
/** Records a terminal transition that carries no detail and returns its bare result. */
|
|
190
250
|
function terminal({ executionId, status }) {
|
|
@@ -242,7 +302,7 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
242
302
|
* Acts on the authorization outcome synchronously: `{ refusal }` if the call ends here
|
|
243
303
|
* (cancelled meanwhile, or denied), otherwise records `authorized` and returns `{ allowed }`.
|
|
244
304
|
*/
|
|
245
|
-
function admitAuthorized({ executionId, resolved, signal }) {
|
|
305
|
+
function admitAuthorized({ executionId, resolved, signal, run }) {
|
|
246
306
|
// `authorize()` isn't itself signal-aware — it's typically a fast policy check, not a
|
|
247
307
|
// human-in-the-loop wait — so this is a post-await check rather than a mid-flight abort, the
|
|
248
308
|
// same idiom the post-handler check below already uses: a signal that fired while `authorize()`
|
|
@@ -253,6 +313,10 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
253
313
|
return { refusal: terminal({ executionId, status: 'denied' }) };
|
|
254
314
|
const { normalizationNotes } = resolved;
|
|
255
315
|
appendEvent(executionId, 'authorized', normalizationNotes.length ? `normalized: ${normalizationNotes.join('; ')}` : undefined);
|
|
316
|
+
// Check after authorization awaits: an approval may have opened while this policy was pending.
|
|
317
|
+
const refusal = approvalRefusal({ executionId, run, descriptor: resolved.descriptor });
|
|
318
|
+
if (refusal)
|
|
319
|
+
return { refusal };
|
|
256
320
|
return { allowed: resolved };
|
|
257
321
|
}
|
|
258
322
|
/**
|
|
@@ -268,13 +332,18 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
268
332
|
async function confirmGate(request) {
|
|
269
333
|
const { executionId } = request;
|
|
270
334
|
let confirmation;
|
|
335
|
+
const state = { run: request.run, executionId, toolId: request.tool.id };
|
|
271
336
|
try {
|
|
337
|
+
approvalStateChange({ ...state, pending: true });
|
|
272
338
|
confirmation = await requestConfirmation(request);
|
|
273
339
|
}
|
|
274
340
|
catch {
|
|
275
341
|
// Same reasoning as the authorization gate: terminal, recorded, and generic.
|
|
276
342
|
return operationalFailure({ executionId, error: 'confirmation failed' });
|
|
277
343
|
}
|
|
344
|
+
finally {
|
|
345
|
+
approvalStateChange({ ...state, pending: false });
|
|
346
|
+
}
|
|
278
347
|
if (confirmation === 'confirm') {
|
|
279
348
|
appendEvent(executionId, 'confirmed');
|
|
280
349
|
return undefined;
|
|
@@ -353,6 +422,10 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
353
422
|
if (controller.signal.aborted) {
|
|
354
423
|
return terminal({ executionId, status: 'cancelled' });
|
|
355
424
|
}
|
|
425
|
+
// Guidance may await I/O after admission; another call can open a confirmation in that gap.
|
|
426
|
+
const refusal = approvalRefusal({ executionId, run, descriptor });
|
|
427
|
+
if (refusal)
|
|
428
|
+
return refusal;
|
|
356
429
|
const timeout = startTimeout(controller, descriptor.timeoutMs);
|
|
357
430
|
// Cleanup (clear the timeout) is repeated at
|
|
358
431
|
// each return below rather than centralized in a `finally` — a
|
|
@@ -363,8 +436,8 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
363
436
|
// the catch). Repeating one call avoids leaving an uncoverable branch
|
|
364
437
|
// behind instead of writing a contrived test or suppressing it.
|
|
365
438
|
const cleanup = () => {
|
|
366
|
-
|
|
367
|
-
|
|
439
|
+
timeout.stop();
|
|
440
|
+
approvalStateChange({ run, executionId, toolId: call.toolId, pending: false });
|
|
368
441
|
};
|
|
369
442
|
appendEvent(executionId, 'started');
|
|
370
443
|
try {
|
|
@@ -379,6 +452,13 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
379
452
|
signal: controller.signal,
|
|
380
453
|
}, {
|
|
381
454
|
...surfaceOption(emitSurface),
|
|
455
|
+
onApprovalStateChange: ({ pending, decision }) => {
|
|
456
|
+
// The tab owns human confirmation; retain its outcome without opening a chat surface.
|
|
457
|
+
if (decision)
|
|
458
|
+
appendEvent(executionId, 'approval-recorded', `frontend approval: ${decision}`);
|
|
459
|
+
timeout.approval({ pending });
|
|
460
|
+
approvalStateChange({ run, executionId, toolId: call.toolId, pending });
|
|
461
|
+
},
|
|
382
462
|
invokeTool: nestedInvoker({ principal, run, toolId: call.toolId, invocationRegistry: call.invocationRegistry, signal: controller.signal }, { emitSurface }),
|
|
383
463
|
});
|
|
384
464
|
cleanup();
|
|
@@ -389,7 +469,7 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
389
469
|
return failureResult({ executionId, err, timedOut: timeout.timedOut(), aborted: controller.signal.aborted }, { normalizationNotes, guidance });
|
|
390
470
|
}
|
|
391
471
|
}
|
|
392
|
-
async function execute({ principal, run, toolId, input }, { signal, emitSurface, invocationRegistry = registry } = {}) {
|
|
472
|
+
async function execute({ principal, run, toolId, input }, { signal, emitSurface, invocationRegistry = registry, requireConfirmation } = {}) {
|
|
393
473
|
// Existence is checked separately from — and before — authorization so the audit record can
|
|
394
474
|
// be opened before the (possibly slow, possibly throwing) authorization gate runs, matching
|
|
395
475
|
// pre-authorizeToolInvocation timing: `requested` used to precede `policy.authorize`, and a
|
|
@@ -443,14 +523,14 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
443
523
|
// `invocationRegistry.has({ toolId })` above already confirmed the tool exists, and `ToolRegistry` is
|
|
444
524
|
// append-only (no unregister — see `createToolRegistry`'s doc), so `authorizeToolInvocation`
|
|
445
525
|
// resolving the same `toolId` against the same `registry` cannot come back `undefined` here.
|
|
446
|
-
const gate = admitAuthorized({ executionId, resolved: resolved, signal: controller.signal });
|
|
526
|
+
const gate = admitAuthorized({ executionId, resolved: resolved, signal: controller.signal, run });
|
|
447
527
|
if ('refusal' in gate) {
|
|
448
528
|
cleanupCancellation();
|
|
449
529
|
return gate.refusal;
|
|
450
530
|
}
|
|
451
531
|
const admitted = gate.allowed;
|
|
452
532
|
const { descriptor } = admitted;
|
|
453
|
-
if (descriptor.requiresConfirmation) {
|
|
533
|
+
if (descriptor.requiresConfirmation || requireConfirmation) {
|
|
454
534
|
const declined = await confirmGate({ executionId, principal, run, tool: descriptor, input: admitted.input });
|
|
455
535
|
if (declined) {
|
|
456
536
|
cleanupCancellation();
|