@jini-ai/daemon 0.6.10 → 0.6.12
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 +20 -0
- package/dist/agent-executor/contracts.d.ts +28 -11
- package/dist/agent-executor/events.d.ts +3 -15
- package/dist/agent-executor/events.js +84 -181
- package/dist/agent-executor/launch.d.ts +9 -3
- package/dist/agent-executor/launch.js +95 -8
- package/dist/agent-executor/live-session-state.d.ts +23 -0
- package/dist/agent-executor/live-session-state.js +155 -0
- package/dist/agent-executor/mcp-bridge.d.ts +3 -1
- package/dist/agent-executor/mcp-bridge.js +9 -2
- package/dist/agent-executor/message-relay.d.ts +39 -0
- package/dist/agent-executor/message-relay.js +56 -0
- package/dist/core/read-only-tools.d.ts +9 -4
- package/dist/core/read-only-tools.js +10 -7
- package/dist/core/tool-candidates.d.ts +8 -3
- package/dist/core/tool-candidates.js +39 -4
- package/dist/http/attachments.d.ts +13 -0
- package/dist/http/attachments.js +27 -1
- package/dist/http/delegated-tools.js +1 -1
- package/dist/http/index.d.ts +2 -1
- package/dist/http/index.js +3 -1
- package/dist/http/message-relay.d.ts +21 -0
- package/dist/http/message-relay.js +44 -0
- package/dist/http/runs.d.ts +5 -2
- package/dist/http/runs.js +11 -5
- package/dist/tool-executor.js +5 -4
- package/package.json +10 -10
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { buildAgentEnv } from './agent-env.js';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { supportsMessageRelay } from './message-relay.js';
|
|
2
4
|
import { probeMcpBridge } from './mcp-bridge-probe.js';
|
|
3
5
|
import { spawn as nodeSpawn, } from 'node:child_process';
|
|
4
6
|
import { promises as fsPromises, } from 'node:fs';
|
|
5
7
|
import { tmpdir, } from 'node:os';
|
|
6
8
|
import { join, } from 'node:path';
|
|
7
|
-
import { agentCapabilities, bindRuntimeAgentDef, defaultRuntimeState, getRuntimeModelDiscoveryDeps, userToolchainBinDirs, applyAgentLaunchEnv, ensureAgentCapabilities, resolveModelForLaunch, getAgentDef, resolveAgentLaunch, attachAcpSession, attachPiRpcSession, checkPromptArgvBudget, checkWindowsCmdShimCommandLineBudget, checkWindowsDirectExeCommandLineBudget, prepareAgentLogFile, preparePromptFileForAgent, } from '@jini-ai/agent-runtime';
|
|
9
|
+
import { agentCapabilities, assertClaudeFilesystemSupport, claudeFilesystemRuntimePaths, claudeFilesystemSettings, validateFilesystemLaunch, bindRuntimeAgentDef, defaultRuntimeState, getRuntimeModelDiscoveryDeps, userToolchainBinDirs, applyAgentLaunchEnv, ensureAgentCapabilities, resolveModelForLaunch, getAgentDef, resolveAgentLaunch, attachAcpSession, attachPiRpcSession, checkPromptArgvBudget, checkWindowsCmdShimCommandLineBudget, checkWindowsDirectExeCommandLineBudget, prepareAgentLogFile, preparePromptFileForAgent, } from '@jini-ai/agent-runtime';
|
|
8
10
|
import { createCommandInvocation } from '@jini-ai/platform/command';
|
|
9
11
|
import { collectProcessTreePids, listProcessSnapshots, stopProcesses, } from '@jini-ai/platform/process';
|
|
10
12
|
import { applyImagePromptDelivery, } from '../image-prompt-delivery.js';
|
|
@@ -337,6 +339,10 @@ function nativeBuildOptions({ input, permissionMode }, _options = {}) {
|
|
|
337
339
|
/** Preserve explicit empty restrictions and settings: they have meaning to the native CLI. */
|
|
338
340
|
function settingsBuildOptions({ input }, _options = {}) {
|
|
339
341
|
return {
|
|
342
|
+
...(input.filesystemRoot !== undefined ? { filesystemRoot: input.filesystemRoot } : {}),
|
|
343
|
+
...(input.filesystemSharedTempRoots ? { filesystemSharedTempRoots: input.filesystemSharedTempRoots } : {}),
|
|
344
|
+
...(input.filesystemTempDir ? { filesystemTempDir: input.filesystemTempDir } : {}),
|
|
345
|
+
...(input.filesystemRuntimeReadPaths ? { filesystemRuntimeReadPaths: input.filesystemRuntimeReadPaths } : {}),
|
|
340
346
|
...(input.disallowedTools !== undefined ? { disallowedTools: input.disallowedTools } : {}),
|
|
341
347
|
...(input.allowedTools !== undefined ? { allowedTools: input.allowedTools } : {}),
|
|
342
348
|
...(input.settingSources !== undefined ? { settingSources: input.settingSources } : {}),
|
|
@@ -678,6 +684,15 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
678
684
|
const claudeConfigDirIsolation = options.claudeConfigDirIsolation;
|
|
679
685
|
const claudeConfigDirIsolationEnabled = options.claudeConfigDirIsolationEnabled ?? false;
|
|
680
686
|
const promptAugmenter = options.promptAugmenter;
|
|
687
|
+
const filesystemTemps = new Map();
|
|
688
|
+
/** Remove only this run's private temp directory, on startup failure or child cleanup. */
|
|
689
|
+
async function cleanupFilesystemTemp({ runId }, _optional = {}) {
|
|
690
|
+
const path = filesystemTemps.get(runId);
|
|
691
|
+
if (!path)
|
|
692
|
+
return;
|
|
693
|
+
await fsPromises.rm(path, { recursive: true, force: true }).catch(() => console.warn('[agent-executor] private temp cleanup failed'));
|
|
694
|
+
filesystemTemps.delete(runId);
|
|
695
|
+
}
|
|
681
696
|
/**
|
|
682
697
|
* Transitions `runId` to `'failed'` (idempotent, never resumable — no
|
|
683
698
|
* classifier exists, see index.ts module doc) then rejects with a typed
|
|
@@ -698,6 +713,7 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
698
713
|
await lifecycle
|
|
699
714
|
.emit({ runId: runId, input: { event: 'error', data: { message: `The assistant could not start: ${message.replace(/^AgentExecutor:\s*/, '')}` } } })
|
|
700
715
|
.catch(() => undefined);
|
|
716
|
+
await cleanupFilesystemTemp({ runId }, {});
|
|
701
717
|
await lifecycle.finish({ runId, status: 'failed', code: null, signal: null, resumable: false });
|
|
702
718
|
throw new AgentExecutorError({ code: code, message: message });
|
|
703
719
|
}
|
|
@@ -708,10 +724,12 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
708
724
|
* finished or unknown run reads as `'not-running'`. Bounded by the number of live children.
|
|
709
725
|
*/
|
|
710
726
|
const liveRunInputs = new Map();
|
|
727
|
+
const liveRunRelays = new Map();
|
|
728
|
+
const claimUserMessages = ({ runId, via }) => liveRunRelays.get(runId)?.claim({ via }) ?? [];
|
|
711
729
|
/** `AgentExecutor.sendUserMessage()` — see that interface method's own doc. @complexity O(1). */
|
|
712
|
-
async function sendUserMessage({ runId, text }, { imageContents } = {}) {
|
|
730
|
+
async function sendUserMessage({ runId, text, id = randomUUID(), attachmentIds }, { imageContents } = {}) {
|
|
713
731
|
const deliver = liveRunInputs.get(runId);
|
|
714
|
-
return deliver ? deliver(text, imageContents) : 'not-running';
|
|
732
|
+
return deliver ? deliver({ text, id, ...(attachmentIds ? { attachmentIds } : {}) }, imageContents ? { imageContents } : {}) : 'not-running';
|
|
715
733
|
}
|
|
716
734
|
/** Validates launch input and selects the definition synchronously before yielding. */
|
|
717
735
|
async function prepareRunContext({ requiredArgs, optionalArgs }, _options = {}) {
|
|
@@ -731,6 +749,66 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
731
749
|
return failBeforeSpawn({ runId: input.runId, code: 'NATIVE_TOOL_POLICY_UNSUPPORTED', message: 'This CLI has no native-tool policy and audit adapter. Choose a CLI with native-tool policy support.' });
|
|
732
750
|
}
|
|
733
751
|
}
|
|
752
|
+
/** Validate every directory after image augmentation, before model probes or resource staging.
|
|
753
|
+
* Unknown containment fails this run; O(n) filesystem checks for n attachment directories.
|
|
754
|
+
*/
|
|
755
|
+
async function enforceFilesystemPaths({ input, imageDelivery }, _options = {}) {
|
|
756
|
+
if (input.filesystemRoot === undefined)
|
|
757
|
+
return;
|
|
758
|
+
try {
|
|
759
|
+
input.filesystemRoot = validateFilesystemLaunch({ filesystemRoot: input.filesystemRoot, cwd: input.cwd,
|
|
760
|
+
...(imageDelivery.extraAllowedDirs ? { extraAllowedDirs: imageDelivery.extraAllowedDirs } : {}),
|
|
761
|
+
...(input.imagePaths ? { imagePaths: input.imagePaths } : {}),
|
|
762
|
+
}, {});
|
|
763
|
+
input.cwd = input.filesystemRoot;
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
return failBeforeSpawn({ runId: input.runId, code: 'FILESYSTEM_POLICY_INVALID', message: 'Folder-limited launch refused: invalid filesystem root, working directory or attachment path.' });
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/** Apply supported confinement without removing providers; unknown versions keep the settings.
|
|
770
|
+
* Runtime and git config reads are explicit infrastructure exceptions; network is unchanged.
|
|
771
|
+
* O(1) metadata/staging I/O plus the cached, bounded version probe.
|
|
772
|
+
*/
|
|
773
|
+
async function enforceFilesystemRuntime({ input, def, launch, spawnEnv }, _options = {}) {
|
|
774
|
+
if (input.filesystemRoot === undefined)
|
|
775
|
+
return;
|
|
776
|
+
try {
|
|
777
|
+
const verify = options.assertClaudeFilesystemSupport ?? assertClaudeFilesystemSupport;
|
|
778
|
+
const supported = await verify({ agentId: def.id, launchPath: launch.launchPath, env: spawnEnv }, {});
|
|
779
|
+
const runtimeDir = join(input.filesystemRoot, '.agent-runtime');
|
|
780
|
+
await fsPromises.mkdir(runtimeDir, { recursive: true, mode: 0o700 });
|
|
781
|
+
// Refuse a runtime-folder symlink escaping the site before creating anything beneath it.
|
|
782
|
+
validateFilesystemLaunch({ filesystemRoot: input.filesystemRoot, cwd: input.cwd, extraAllowedDirs: [runtimeDir] }, {});
|
|
783
|
+
input.filesystemTempDir = await fsPromises.realpath(await fsPromises.mkdtemp(join(runtimeDir, 'tmp-')));
|
|
784
|
+
filesystemTemps.set(input.runId, input.filesystemTempDir);
|
|
785
|
+
// Recheck after creation too: swapping the runtime symlink must never grant outside temp access.
|
|
786
|
+
validateFilesystemLaunch({ filesystemRoot: input.filesystemRoot, cwd: input.cwd, extraAllowedDirs: [input.filesystemTempDir] }, {});
|
|
787
|
+
input.filesystemSharedTempRoots = [spawnEnv.TMPDIR, spawnEnv.TMP, spawnEnv.TEMP].filter((path) => Boolean(path));
|
|
788
|
+
spawnEnv.TMPDIR = input.filesystemTempDir;
|
|
789
|
+
spawnEnv.TMP = input.filesystemTempDir;
|
|
790
|
+
spawnEnv.TEMP = input.filesystemTempDir;
|
|
791
|
+
if (supported === false) {
|
|
792
|
+
input.filesystemNotice = 'files and shell are not folder-limited for this agent';
|
|
793
|
+
console.warn('[agent-executor] files and shell are not folder-limited for this agent');
|
|
794
|
+
// Personal Claude hooks/permissions stay excluded even on an unconfined CLI fallback.
|
|
795
|
+
if (def.id === 'claude')
|
|
796
|
+
input.settingSources = [];
|
|
797
|
+
delete input.filesystemRoot;
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
if (supported === undefined)
|
|
801
|
+
console.warn('[agent-executor] filesystem capability unknown; applying read block and sandbox settings');
|
|
802
|
+
input.filesystemRuntimeReadPaths = claudeFilesystemRuntimePaths({ launchPath: launch.launchPath }, {});
|
|
803
|
+
// Validates host settings here so a malformed value fails cleanly; buildArgs merges again (idempotent).
|
|
804
|
+
input.settings = claudeFilesystemSettings({ options: input }, {});
|
|
805
|
+
// A login environment can otherwise force auto-memory back on despite the host setting.
|
|
806
|
+
spawnEnv.CLAUDE_CODE_DISABLE_AUTO_MEMORY = '1';
|
|
807
|
+
}
|
|
808
|
+
catch {
|
|
809
|
+
return failBeforeSpawn({ runId: input.runId, code: 'FILESYSTEM_POLICY_UNSUPPORTED', message: 'Folder-limited launch refused: invalid host settings or private runtime directory.' });
|
|
810
|
+
}
|
|
811
|
+
}
|
|
734
812
|
function resolveRunOverlay({ input, def, imageDelivery }, _options = {}) {
|
|
735
813
|
// Phase 8, hoisted deliberately above EVERY step that writes or sends the prompt. Four
|
|
736
814
|
// different channels carry a prompt in this driver, and they do not all run at the same point:
|
|
@@ -751,7 +829,8 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
751
829
|
// same overlay on every turn, not a first-turn-only one; a caller that needs finer-grained turn
|
|
752
830
|
// numbering can track it itself and ignore this arg.
|
|
753
831
|
const preStagingRuntimeContext = computeRuntimeContext({ preparedPromptFile: null, preparedLogFile: null, mcpBridge: null }, sessionRuntimeContext(input, {}));
|
|
754
|
-
const
|
|
832
|
+
const hostOverlay = computeSystemPromptOverlay(promptAugmenter, def.id);
|
|
833
|
+
const systemPromptOverlay = input.filesystemNotice ? `${hostOverlay ?? ''}\n${input.filesystemNotice}\n` : hostOverlay;
|
|
755
834
|
const overlayDelivery = resolveSystemPromptOverlayDelivery({
|
|
756
835
|
defId: def.id,
|
|
757
836
|
systemPromptDelivery: def.systemPromptDelivery,
|
|
@@ -805,6 +884,7 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
805
884
|
const formatForStdin = { streamFormat };
|
|
806
885
|
const stdinHandle = isStdinDrivenFormat(formatForStdin)
|
|
807
886
|
? wireChildLifecycle({
|
|
887
|
+
...(mcpBridge && supportsMessageRelay({ def }) ? { registerMessageRelay: (relay) => liveRunRelays.set(input.runId, relay) } : {}),
|
|
808
888
|
...(input.model ? { startingModel: input.model } : {}),
|
|
809
889
|
...(modelCatalog ? { modelCatalog } : {}),
|
|
810
890
|
runId: input.runId,
|
|
@@ -831,8 +911,10 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
831
911
|
// Registered with the lifecycle wiring above, before the spawn-confirmation await, so a child
|
|
832
912
|
// that closes immediately never leaves a stale entry behind.
|
|
833
913
|
const runId = input.runId;
|
|
834
|
-
liveRunInputs.set(runId, stdinHandle ? stdinHandle.sendUserMessage : async () =>
|
|
835
|
-
|
|
914
|
+
liveRunInputs.set(runId, stdinHandle ? stdinHandle.sendUserMessage : async ({ text, id = randomUUID(), attachmentIds }, { imageContents } = {}) => {
|
|
915
|
+
return liveRunRelays.get(runId)?.enqueue({ id, text, hasAttachments: (attachmentIds?.length ?? 0) > 0 || (imageContents?.length ?? 0) > 0 }) ?? 'unsupported';
|
|
916
|
+
});
|
|
917
|
+
child.once('close', () => { liveRunInputs.delete(runId); liveRunRelays.delete(runId); });
|
|
836
918
|
return stdinHandle;
|
|
837
919
|
}
|
|
838
920
|
async function dispatchAcpPrompt({ input, def, child, modelCatalog, cleanupStagedFiles, releaseStagedResources, mcpBridge, overlayDelivery }, _options = {}) {
|
|
@@ -859,6 +941,7 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
859
941
|
}, deps: {
|
|
860
942
|
lifecycle,
|
|
861
943
|
attachAcpSession: attachAcpSessionFn,
|
|
944
|
+
...(mcpBridge && supportsMessageRelay({ def }) ? { registerMessageRelay: (relay) => liveRunRelays.set(input.runId, relay) } : {}),
|
|
862
945
|
onPermissionRequest: options.acpPermissionHandler,
|
|
863
946
|
listProcessSnapshots: listProcessSnapshotsFn,
|
|
864
947
|
collectProcessTreePids: collectProcessTreePidsFn,
|
|
@@ -939,8 +1022,10 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
939
1022
|
// treatment (the double-delivery hazard this mechanism exists to avoid).
|
|
940
1023
|
await enforceNativeToolPolicy({ input, def }, {});
|
|
941
1024
|
const imageDelivery = await resolveImageDeliveryAndArgvBudget({ input: { runId: input.runId, def, prompt: input.prompt, imagePaths: input.imagePaths, extraAllowedDirs: input.extraAllowedDirs }, deps: { failBeforeSpawn } });
|
|
942
|
-
|
|
1025
|
+
await enforceFilesystemPaths({ input, imageDelivery }, {});
|
|
943
1026
|
const { launch, spawnEnv } = await resolveRunLaunch({ input, def }, {});
|
|
1027
|
+
await enforceFilesystemRuntime({ input, def, launch, spawnEnv }, {});
|
|
1028
|
+
const { systemPromptOverlay, overlayDelivery } = resolveRunOverlay({ input, def, imageDelivery }, {});
|
|
944
1029
|
const modelCatalog = await selectRunModel({ input, def, launch, spawnEnv }, {});
|
|
945
1030
|
// Fill the def's `--help` capability gate before `buildArgs` reads it. Without this, only a
|
|
946
1031
|
// host that happened to call `detectAgents` in THIS process ever had the gate filled, so e.g.
|
|
@@ -998,6 +1083,7 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
998
1083
|
*/
|
|
999
1084
|
let preparedSystemPromptOverlayFile = null;
|
|
1000
1085
|
const cleanupStagedFiles = async () => {
|
|
1086
|
+
await cleanupFilesystemTemp({ runId: input.runId }, {});
|
|
1001
1087
|
if (preparedPromptFile)
|
|
1002
1088
|
await preparedPromptFile.cleanup();
|
|
1003
1089
|
if (preparedLogFile)
|
|
@@ -1130,6 +1216,7 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
1130
1216
|
}
|
|
1131
1217
|
catch (error) {
|
|
1132
1218
|
liveRunInputs.delete(runId);
|
|
1219
|
+
liveRunRelays.delete(runId);
|
|
1133
1220
|
throw error;
|
|
1134
1221
|
}
|
|
1135
1222
|
// Now — and only now — is there a live process that could consume the locked side effect, so
|
|
@@ -1146,6 +1233,6 @@ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
|
|
|
1146
1233
|
}
|
|
1147
1234
|
dispatchStdinPrompt({ input, def, child, overlayDelivery, imageDelivery, stdinHandle }, {});
|
|
1148
1235
|
}
|
|
1149
|
-
return { run, sendUserMessage };
|
|
1236
|
+
return { run, sendUserMessage, claimUserMessages };
|
|
1150
1237
|
}
|
|
1151
1238
|
//# sourceMappingURL=launch.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RunAgentPayload } from '@jini-ai/protocol';
|
|
2
|
+
/** Per-process receipts and task projection for the existing stdin lifecycle. O(p + t) retained
|
|
3
|
+
* state for pending inputs and observed tasks; each frame is O(frame size + affected tasks).
|
|
4
|
+
* The injected emitter uses the lifecycle's existing FIFO, preserving ordering with tool output.
|
|
5
|
+
*/
|
|
6
|
+
export declare function createLiveSessionState({ emit }: {
|
|
7
|
+
emit: (payload: RunAgentPayload) => void;
|
|
8
|
+
}, _options?: {}): {
|
|
9
|
+
queue({ id, text }: {
|
|
10
|
+
id: string;
|
|
11
|
+
text: string;
|
|
12
|
+
}): void;
|
|
13
|
+
observe: ({ line }: {
|
|
14
|
+
line: string;
|
|
15
|
+
}) => {
|
|
16
|
+
result: boolean;
|
|
17
|
+
startedInput: boolean;
|
|
18
|
+
};
|
|
19
|
+
hasPendingInput: () => boolean;
|
|
20
|
+
backgroundTaskCount: () => number;
|
|
21
|
+
endSession(): void;
|
|
22
|
+
};
|
|
23
|
+
//# sourceMappingURL=live-session-state.d.ts.map
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { isRecord } from './values.js';
|
|
2
|
+
/** Parse only records; malformed stdout remains the stream parser's responsibility. */
|
|
3
|
+
function frameRecord(line) {
|
|
4
|
+
try {
|
|
5
|
+
const value = JSON.parse(line);
|
|
6
|
+
return isRecord(value) ? value : undefined;
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** UUID attribution is a consumption receipt; an echoed user line or HTTP 202 is not. */
|
|
13
|
+
function consumedIds(frame) {
|
|
14
|
+
if (!['assistant', 'stream_event', 'result'].includes(String(frame.type)))
|
|
15
|
+
return [];
|
|
16
|
+
const ids = Array.isArray(frame.user_message_uuids) ? frame.user_message_uuids : [frame.user_message_uuid];
|
|
17
|
+
return ids.filter((id) => typeof id === 'string');
|
|
18
|
+
}
|
|
19
|
+
/** Preserve unknown task outcomes rather than guessing success or who stopped them. */
|
|
20
|
+
function taskStatus(value) {
|
|
21
|
+
if (value === 'killed')
|
|
22
|
+
return 'stopped';
|
|
23
|
+
if (value === 'running' || value === 'completed' || value === 'failed' || value === 'stopped')
|
|
24
|
+
return value;
|
|
25
|
+
return 'unknown';
|
|
26
|
+
}
|
|
27
|
+
/** Per-process receipts and task projection for the existing stdin lifecycle. O(p + t) retained
|
|
28
|
+
* state for pending inputs and observed tasks; each frame is O(frame size + affected tasks).
|
|
29
|
+
* The injected emitter uses the lifecycle's existing FIFO, preserving ordering with tool output.
|
|
30
|
+
*/
|
|
31
|
+
export function createLiveSessionState({ emit }, _options = {}) {
|
|
32
|
+
const pending = new Map();
|
|
33
|
+
// Ids written since the last result. Only these and the CLI's own queued_turn_count hold stdin:
|
|
34
|
+
// a CLI that never attributes a receipt must not keep the run open forever. Closing after a
|
|
35
|
+
// result is safe for a line still in the pipe: the CLI answers everything written before EOF.
|
|
36
|
+
const unanswered = new Set();
|
|
37
|
+
const tasks = new Map();
|
|
38
|
+
let queuedTurns = 0;
|
|
39
|
+
let backgroundTasks = 0;
|
|
40
|
+
function publishTask(task) {
|
|
41
|
+
tasks.set(task.id, task);
|
|
42
|
+
const { local: _local, ...payload } = task;
|
|
43
|
+
emit(payload);
|
|
44
|
+
}
|
|
45
|
+
function taskDetails(frame, previous) {
|
|
46
|
+
const patch = isRecord(frame.patch) ? frame.patch : frame;
|
|
47
|
+
const description = typeof patch.description === 'string' ? patch.description : previous?.description;
|
|
48
|
+
const detail = typeof frame.summary === 'string' ? frame.summary : previous?.detail;
|
|
49
|
+
const local = frame.task_type === 'local_agent' || typeof frame.subagent_type === 'string' || previous?.local === true;
|
|
50
|
+
return { local, ...(description === undefined ? {} : { description }), ...(detail === undefined ? {} : { detail }) };
|
|
51
|
+
}
|
|
52
|
+
function nextTaskStatus(frame, previous) {
|
|
53
|
+
if (frame.subtype === 'task_started')
|
|
54
|
+
return 'running';
|
|
55
|
+
const patch = isRecord(frame.patch) ? frame.patch : frame;
|
|
56
|
+
if (patch.status === undefined)
|
|
57
|
+
return previous?.status ?? 'unknown';
|
|
58
|
+
return taskStatus(patch.status);
|
|
59
|
+
}
|
|
60
|
+
/** Older task runs and duplicate starts cannot overwrite a confirmed terminal receipt. */
|
|
61
|
+
function staleTaskUpdate(previous, taskRunId, status) {
|
|
62
|
+
if (!previous)
|
|
63
|
+
return false;
|
|
64
|
+
// The CLI mints run ids as zero-padded base36 time plus random hex, so they sort by start.
|
|
65
|
+
if (previous.taskRunId && taskRunId && taskRunId < previous.taskRunId)
|
|
66
|
+
return true;
|
|
67
|
+
const terminal = ['completed', 'failed', 'stopped'].includes(previous.status);
|
|
68
|
+
return terminal && previous.taskRunId === taskRunId && status === 'running';
|
|
69
|
+
}
|
|
70
|
+
function updateTask(frame) {
|
|
71
|
+
if (typeof frame.task_id !== 'string')
|
|
72
|
+
return;
|
|
73
|
+
const previous = tasks.get(frame.task_id);
|
|
74
|
+
const taskRunId = typeof frame.run_id === 'string' ? frame.run_id : previous?.taskRunId;
|
|
75
|
+
const status = nextTaskStatus(frame, previous);
|
|
76
|
+
// Resumed tasks reuse task_id. A late frame from an older run cannot stop its replacement.
|
|
77
|
+
if (staleTaskUpdate(previous, taskRunId, status))
|
|
78
|
+
return;
|
|
79
|
+
publishTask({ type: 'background_task', id: frame.task_id, status, ...taskDetails(frame, previous),
|
|
80
|
+
...(taskRunId === undefined ? {} : { taskRunId }) });
|
|
81
|
+
}
|
|
82
|
+
function updateMembership(frame) {
|
|
83
|
+
if (!Array.isArray(frame.tasks))
|
|
84
|
+
return;
|
|
85
|
+
backgroundTasks = frame.tasks.length;
|
|
86
|
+
const listed = new Set();
|
|
87
|
+
for (const task of frame.tasks) {
|
|
88
|
+
if (!isRecord(task))
|
|
89
|
+
continue;
|
|
90
|
+
if (typeof task.task_id === 'string')
|
|
91
|
+
listed.add(task.task_id);
|
|
92
|
+
updateTask({ ...task, subtype: 'task_started' });
|
|
93
|
+
}
|
|
94
|
+
// Level and edge notifications have unspecified ordering. Disappearance proves neither
|
|
95
|
+
// completion nor cancellation; a later task_notification supplies the actual outcome.
|
|
96
|
+
for (const task of tasks.values()) {
|
|
97
|
+
if (task.status === 'running' && !listed.has(task.id))
|
|
98
|
+
publishTask({ ...task, status: 'unknown', detail: 'status unknown' });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function consume(frame) {
|
|
102
|
+
let consumed = false;
|
|
103
|
+
for (const id of consumedIds(frame)) {
|
|
104
|
+
const text = pending.get(id);
|
|
105
|
+
if (text === undefined)
|
|
106
|
+
continue;
|
|
107
|
+
pending.delete(id);
|
|
108
|
+
unanswered.delete(id);
|
|
109
|
+
emit({ type: 'user_message', id, text, deliveryState: 'consumed' });
|
|
110
|
+
consumed = true;
|
|
111
|
+
}
|
|
112
|
+
return consumed;
|
|
113
|
+
}
|
|
114
|
+
function observe({ line }) {
|
|
115
|
+
const frame = frameRecord(line);
|
|
116
|
+
if (!frame)
|
|
117
|
+
return { result: false, startedInput: false };
|
|
118
|
+
const consumed = consume(frame);
|
|
119
|
+
if (frame.type === 'result') {
|
|
120
|
+
queuedTurns = typeof frame.queued_turn_count === 'number' ? Math.max(0, frame.queued_turn_count) : 0;
|
|
121
|
+
unanswered.clear();
|
|
122
|
+
}
|
|
123
|
+
if (frame.type === 'system')
|
|
124
|
+
observeSystem(frame);
|
|
125
|
+
return { result: frame.type === 'result', startedInput: consumed && frame.type !== 'result' };
|
|
126
|
+
}
|
|
127
|
+
function observeSystem(frame) {
|
|
128
|
+
if (frame.subtype === 'background_tasks_changed')
|
|
129
|
+
updateMembership(frame);
|
|
130
|
+
if (['task_started', 'task_notification', 'task_updated'].includes(String(frame.subtype)))
|
|
131
|
+
updateTask(frame);
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
queue({ id, text }) {
|
|
135
|
+
pending.set(id, text);
|
|
136
|
+
unanswered.add(id);
|
|
137
|
+
emit({ type: 'user_message', id, text, deliveryState: 'queued' });
|
|
138
|
+
},
|
|
139
|
+
observe,
|
|
140
|
+
hasPendingInput: () => unanswered.size > 0 || queuedTurns > 0,
|
|
141
|
+
backgroundTaskCount: () => backgroundTasks,
|
|
142
|
+
endSession() {
|
|
143
|
+
for (const task of tasks.values()) {
|
|
144
|
+
if (task.status !== 'running' && task.status !== 'unknown')
|
|
145
|
+
continue;
|
|
146
|
+
// A task already missing from membership may have completed without a receipt. Only
|
|
147
|
+
// a still-running, process-local subagent is known to stop with this session.
|
|
148
|
+
const stopped = task.local && task.status === 'running';
|
|
149
|
+
publishTask({ ...task, status: stopped ? 'stopped' : 'unknown',
|
|
150
|
+
detail: stopped ? 'Stopped because the agent session ended' : 'status unknown' });
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=live-session-state.js.map
|
|
@@ -34,8 +34,9 @@ export declare function bridgeUnavailableMessage(status: string, probe?: McpBrid
|
|
|
34
34
|
export declare function buildMcpJsonServerEntry({ runId, options }: {
|
|
35
35
|
readonly runId: string;
|
|
36
36
|
readonly options: Pick<McpJsonInjectionOptions, 'command' | 'args' | 'daemonUrl' | 'env' | 'diagnosticToolIds' | 'startupToolIds'>;
|
|
37
|
-
}, { credential }?: {
|
|
37
|
+
}, { credential, messageRelayNonce }?: {
|
|
38
38
|
readonly credential?: string;
|
|
39
|
+
readonly messageRelayNonce?: string;
|
|
39
40
|
}): McpJsonServerEntry;
|
|
40
41
|
/**
|
|
41
42
|
* Merges {@link JINI_MCP_SERVER_KEY} into an existing `.mcp.json`'s `mcpServers` map, preserving
|
|
@@ -235,6 +236,7 @@ export declare function buildMcpBridgeDelivery(input: {
|
|
|
235
236
|
readonly strategy: RuntimeAgentDef['externalMcpInjection'];
|
|
236
237
|
readonly options: McpJsonInjectionOptions | undefined;
|
|
237
238
|
readonly credential: string | undefined;
|
|
239
|
+
readonly messageRelayNonce?: string;
|
|
238
240
|
}): McpBridgeDelivery | null;
|
|
239
241
|
export declare function defaultRemoveMcpJsonFile({ path }: {
|
|
240
242
|
readonly path: string;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { promises as fsPromises, } from 'node:fs';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { supportsMessageRelay } from './message-relay.js';
|
|
2
4
|
import { ensureAntigravityMcpPermission } from './antigravity-permissions.js';
|
|
3
5
|
import { homedir, tmpdir, } from 'node:os';
|
|
4
6
|
import { join, resolve, } from 'node:path';
|
|
@@ -46,7 +48,7 @@ export function bridgeUnavailableMessage(status, probe) {
|
|
|
46
48
|
* entirely. Omitting produces byte-identical output to before this parameter existed.
|
|
47
49
|
* @complexity O(t) output space/time for t launch environment and registered ID bytes.
|
|
48
50
|
*/
|
|
49
|
-
export function buildMcpJsonServerEntry({ runId, options }, { credential } = {}) {
|
|
51
|
+
export function buildMcpJsonServerEntry({ runId, options }, { credential, messageRelayNonce } = {}) {
|
|
50
52
|
const hostEnv = Object.entries(options.env ?? {}).filter(([key]) => !key.startsWith('JINI_'));
|
|
51
53
|
return {
|
|
52
54
|
command: options.command,
|
|
@@ -58,6 +60,7 @@ export function buildMcpJsonServerEntry({ runId, options }, { credential } = {})
|
|
|
58
60
|
...(options.diagnosticToolIds ? { JINI_DIAGNOSTIC_TOOL_IDS: JSON.stringify(options.diagnosticToolIds()) } : {}),
|
|
59
61
|
...(options.startupToolIds ? { JINI_STARTUP_TOOL_IDS: JSON.stringify(options.startupToolIds({ runId })) } : {}),
|
|
60
62
|
...(credential !== undefined ? { JINI_DAEMON_TOKEN: credential } : {}),
|
|
63
|
+
...(messageRelayNonce ? { JINI_MESSAGE_RELAY: '1', JINI_MESSAGE_RELAY_NONCE: messageRelayNonce } : {}),
|
|
61
64
|
},
|
|
62
65
|
};
|
|
63
66
|
}
|
|
@@ -479,7 +482,10 @@ export function buildMcpBridgeDelivery(input) {
|
|
|
479
482
|
const { cwd, runId, strategy, options, credential } = input;
|
|
480
483
|
if (options === undefined || strategy === undefined)
|
|
481
484
|
return null;
|
|
482
|
-
const serverEntry = buildMcpJsonServerEntry({ runId: runId, options: options },
|
|
485
|
+
const serverEntry = buildMcpJsonServerEntry({ runId: runId, options: options }, {
|
|
486
|
+
...(credential === undefined ? {} : { credential }),
|
|
487
|
+
...(input.messageRelayNonce ? { messageRelayNonce: input.messageRelayNonce } : {}),
|
|
488
|
+
});
|
|
483
489
|
switch (strategy) {
|
|
484
490
|
case 'claude-mcp-json':
|
|
485
491
|
return { kind: 'claude-mcp-json', mcpJsonPath: mcpJsonPathForRun(cwd, runId), serverEntry };
|
|
@@ -789,6 +795,7 @@ export async function resolveMcpBridgeForRun({ input, deps }) {
|
|
|
789
795
|
strategy: input.def.externalMcpInjection,
|
|
790
796
|
options: deps.mcpJsonInjection,
|
|
791
797
|
credential,
|
|
798
|
+
...(supportsMessageRelay({ def: input.def }) ? { messageRelayNonce: randomBytes(24).toString('hex') } : {}),
|
|
792
799
|
});
|
|
793
800
|
if (input.def.id === 'antigravity' && delivery?.kind === 'env-passthrough') {
|
|
794
801
|
await (deps.ensureAntigravityMcpPermission ?? ensureAntigravityMcpPermission)({
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { RuntimeAgentDef } from '@jini-ai/agent-runtime';
|
|
2
|
+
import type { RunAgentPayload, RelayedUserMessage, MessageRelayVia } from '@jini-ai/protocol';
|
|
3
|
+
export interface RunMessageRelay {
|
|
4
|
+
enqueue(input: {
|
|
5
|
+
id: string;
|
|
6
|
+
text: string;
|
|
7
|
+
hasAttachments: boolean;
|
|
8
|
+
}): 'delivered' | 'not-running';
|
|
9
|
+
claim(input: {
|
|
10
|
+
via: MessageRelayVia;
|
|
11
|
+
}): readonly RelayedUserMessage[];
|
|
12
|
+
close(): void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Whether a def's live process can take another user message mid-run: only a stream-json stdin
|
|
16
|
+
* transport keeps stdin open for more JSONL user lines (see `RuntimeAgentDef.promptInputFormat`).
|
|
17
|
+
* Deliberately not `resolveContinuationTransport`: that answers how a tool result gets back, and
|
|
18
|
+
* reports `'mcp-callback'` for Claude even though its stdin takes user lines all the same.
|
|
19
|
+
* Native stream-json input wins even when the engine also has MCP.
|
|
20
|
+
*/
|
|
21
|
+
export declare function acceptsMidRunUserMessages({ def }: {
|
|
22
|
+
def: RuntimeAgentDef;
|
|
23
|
+
}, _options?: {}): boolean;
|
|
24
|
+
/** A definition without an injected MCP bridge must retain next-turn fallback. */
|
|
25
|
+
export declare function supportsMessageRelay({ def }: {
|
|
26
|
+
def: RuntimeAgentDef;
|
|
27
|
+
}, _options?: {}): boolean;
|
|
28
|
+
/** One synchronous FIFO claim per process: parallel tool results get disjoint prefixes.
|
|
29
|
+
* The browser retains the fallback copy until the emitted consumption receipt arrives.
|
|
30
|
+
* @param emit - Enqueues receipts on the driver's existing lifecycle FIFO; never waits on I/O.
|
|
31
|
+
* @returns A text-only inbox, closed synchronously before the driver's terminal event.
|
|
32
|
+
* @complexity O(n) retained messages; each claim scans its prefix and removes it in one splice.
|
|
33
|
+
*/
|
|
34
|
+
export declare function createMessageRelay({ emit }: {
|
|
35
|
+
emit: (payload: RunAgentPayload) => void;
|
|
36
|
+
}, { maxChars }?: {
|
|
37
|
+
maxChars?: number;
|
|
38
|
+
}): RunMessageRelay;
|
|
39
|
+
//# sourceMappingURL=message-relay.d.ts.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a def's live process can take another user message mid-run: only a stream-json stdin
|
|
3
|
+
* transport keeps stdin open for more JSONL user lines (see `RuntimeAgentDef.promptInputFormat`).
|
|
4
|
+
* Deliberately not `resolveContinuationTransport`: that answers how a tool result gets back, and
|
|
5
|
+
* reports `'mcp-callback'` for Claude even though its stdin takes user lines all the same.
|
|
6
|
+
* Native stream-json input wins even when the engine also has MCP.
|
|
7
|
+
*/
|
|
8
|
+
export function acceptsMidRunUserMessages({ def }, _options = {}) {
|
|
9
|
+
return def.promptViaStdin === true && def.promptInputFormat === 'stream-json';
|
|
10
|
+
}
|
|
11
|
+
/** A definition without an injected MCP bridge must retain next-turn fallback. */
|
|
12
|
+
export function supportsMessageRelay({ def }, _options = {}) {
|
|
13
|
+
return def.externalMcpInjection !== undefined && !acceptsMidRunUserMessages({ def });
|
|
14
|
+
}
|
|
15
|
+
/** One synchronous FIFO claim per process: parallel tool results get disjoint prefixes.
|
|
16
|
+
* The browser retains the fallback copy until the emitted consumption receipt arrives.
|
|
17
|
+
* @param emit - Enqueues receipts on the driver's existing lifecycle FIFO; never waits on I/O.
|
|
18
|
+
* @returns A text-only inbox, closed synchronously before the driver's terminal event.
|
|
19
|
+
* @complexity O(n) retained messages; each claim scans its prefix and removes it in one splice.
|
|
20
|
+
*/
|
|
21
|
+
export function createMessageRelay({ emit }, { maxChars = 32_000 } = {}) {
|
|
22
|
+
const pending = [];
|
|
23
|
+
let seq = 0;
|
|
24
|
+
let closed = false;
|
|
25
|
+
return {
|
|
26
|
+
enqueue({ id, text, hasAttachments }) {
|
|
27
|
+
if (closed)
|
|
28
|
+
return 'not-running';
|
|
29
|
+
pending.push({ id, text, hasAttachments, seq: ++seq });
|
|
30
|
+
emit({ type: 'user_message', id, text, deliveryState: 'queued' });
|
|
31
|
+
return 'delivered';
|
|
32
|
+
},
|
|
33
|
+
claim({ via }) {
|
|
34
|
+
if (closed)
|
|
35
|
+
return [];
|
|
36
|
+
let count = 0;
|
|
37
|
+
let chars = 0;
|
|
38
|
+
for (const message of pending) {
|
|
39
|
+
if (message.hasAttachments)
|
|
40
|
+
break;
|
|
41
|
+
const size = JSON.stringify({ id: message.id, seq: message.seq, text: message.text }).length;
|
|
42
|
+
// Whole messages only; a single oversized message is delivered alone.
|
|
43
|
+
if (count > 0 && chars + size > maxChars)
|
|
44
|
+
break;
|
|
45
|
+
chars += size;
|
|
46
|
+
count++;
|
|
47
|
+
}
|
|
48
|
+
return pending.splice(0, count).map(({ id, seq, text }) => {
|
|
49
|
+
emit({ type: 'user_message', id, text, deliveryState: 'consumed', via });
|
|
50
|
+
return { id, seq, text };
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
close() { closed = true; pending.length = 0; },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=message-relay.js.map
|
|
@@ -55,6 +55,8 @@ export interface ReadOnlyToolMessages {
|
|
|
55
55
|
}
|
|
56
56
|
export interface ReadOnlyToolCheckRequired {
|
|
57
57
|
readonly toolId: string;
|
|
58
|
+
/** Diagnostic selector only; never changes which registration is checked or executed. */
|
|
59
|
+
readonly input?: unknown;
|
|
58
60
|
readonly registry: Pick<ToolRegistry, 'list'> | undefined;
|
|
59
61
|
readonly messages: ReadOnlyToolMessages;
|
|
60
62
|
}
|
|
@@ -94,8 +96,9 @@ export declare function readOnlyToolRefusalMessage({ toolId, messages }: {
|
|
|
94
96
|
/**
|
|
95
97
|
* Single read-only registration decision, shared by routes and inner dispatch.
|
|
96
98
|
* @returns Null for an explicitly read-only registration, otherwise host refusal text.
|
|
99
|
+
* @complexity O(n) for known tools; unknown-tool diagnostics add bounded name scoring and O(n log n) sorting.
|
|
97
100
|
*/
|
|
98
|
-
export declare function checkReadOnlyTool({ toolId, registry, messages }: ReadOnlyToolCheckRequired, _optional?: Record<string, never>): string | null;
|
|
101
|
+
export declare function checkReadOnlyTool({ toolId, input, registry, messages }: ReadOnlyToolCheckRequired, _optional?: Record<string, never>): string | null;
|
|
99
102
|
/**
|
|
100
103
|
* THE read-only dispatch decision for any tool id a consumer's own `ToolExecutor` composition is
|
|
101
104
|
* about to invoke — the outer call the route already checked, or a nested one a decorator issues
|
|
@@ -110,8 +113,9 @@ export declare function checkReadOnlyTool({ toolId, registry, messages }: ReadOn
|
|
|
110
113
|
* @param required.registry - Descriptors to resolve `toolId` against; `undefined` ⇒ refuse (an
|
|
111
114
|
* unverifiable constrained dispatch is refused, never waived, matching `checkReadOnlyTool`).
|
|
112
115
|
* @returns `null` when the dispatch may proceed, otherwise the refusal text to report.
|
|
113
|
-
* @complexity O(1) for an unconstrained principal; O(n) in registered tool count for a
|
|
114
|
-
*
|
|
116
|
+
* @complexity O(1) for an unconstrained principal; O(n) in registered tool count for a known tool,
|
|
117
|
+
* since `ToolRegistry` exposes enumeration rather than lookup by id. Unknown ids also incur
|
|
118
|
+
* the bounded similarity scoring and sorting cost documented by `toolCandidates`.
|
|
115
119
|
*/
|
|
116
120
|
export declare function refuseNonReadOnlyDispatch(required: ReadOnlyToolCheckRequired & {
|
|
117
121
|
readonly principal: Principal;
|
|
@@ -147,7 +151,8 @@ export declare function readOnlyRemedyRefusalMessage({ refusal, formatMessage }:
|
|
|
147
151
|
*
|
|
148
152
|
* @returns A drop-in `ToolExecutor`; `resumeConfirmation`/`cancel`/`getAuditRecord` delegate
|
|
149
153
|
* straight through.
|
|
150
|
-
* @complexity One extra property read per unconstrained call; one registry scan per constrained one
|
|
154
|
+
* @complexity One extra property read per unconstrained call; one registry scan per constrained one,
|
|
155
|
+
* plus bounded similarity scoring and sorting for an unknown id.
|
|
151
156
|
*/
|
|
152
157
|
export declare function withReadOnlyToolConstraint({ inner, registry, idGenerator, messages }: ReadOnlyToolConstraintRequired, _optional?: Record<string, never>): ToolExecutor;
|
|
153
158
|
//# sourceMappingURL=read-only-tools.d.ts.map
|
|
@@ -60,14 +60,15 @@ export function readOnlyToolRefusalMessage({ toolId, messages }, _optional = {})
|
|
|
60
60
|
/**
|
|
61
61
|
* Single read-only registration decision, shared by routes and inner dispatch.
|
|
62
62
|
* @returns Null for an explicitly read-only registration, otherwise host refusal text.
|
|
63
|
+
* @complexity O(n) for known tools; unknown-tool diagnostics add bounded name scoring and O(n log n) sorting.
|
|
63
64
|
*/
|
|
64
|
-
export function checkReadOnlyTool({ toolId, registry, messages }, _optional = {}) {
|
|
65
|
+
export function checkReadOnlyTool({ toolId, input, registry, messages }, _optional = {}) {
|
|
65
66
|
if (registry === undefined)
|
|
66
67
|
return messages.unverifiableMessage;
|
|
67
68
|
const tools = registry.list({});
|
|
68
69
|
const descriptor = tools.find((candidate) => candidate.id === toolId);
|
|
69
70
|
if (!descriptor)
|
|
70
|
-
return messages.unknownToolMessage({ toolId, candidates: toolCandidates({ toolId, tools }) });
|
|
71
|
+
return messages.unknownToolMessage({ toolId, candidates: toolCandidates({ toolId, tools, input }) });
|
|
71
72
|
if (isReadOnlyTool({ descriptor }))
|
|
72
73
|
return null;
|
|
73
74
|
return readOnlyToolRefusalMessage({ toolId, messages });
|
|
@@ -86,8 +87,9 @@ export function checkReadOnlyTool({ toolId, registry, messages }, _optional = {}
|
|
|
86
87
|
* @param required.registry - Descriptors to resolve `toolId` against; `undefined` ⇒ refuse (an
|
|
87
88
|
* unverifiable constrained dispatch is refused, never waived, matching `checkReadOnlyTool`).
|
|
88
89
|
* @returns `null` when the dispatch may proceed, otherwise the refusal text to report.
|
|
89
|
-
* @complexity O(1) for an unconstrained principal; O(n) in registered tool count for a
|
|
90
|
-
*
|
|
90
|
+
* @complexity O(1) for an unconstrained principal; O(n) in registered tool count for a known tool,
|
|
91
|
+
* since `ToolRegistry` exposes enumeration rather than lookup by id. Unknown ids also incur
|
|
92
|
+
* the bounded similarity scoring and sorting cost documented by `toolCandidates`.
|
|
91
93
|
*/
|
|
92
94
|
export function refuseNonReadOnlyDispatch(required, _optional = {}) {
|
|
93
95
|
if (!principalIsReadOnlyConstrained({ principal: required.principal }))
|
|
@@ -127,13 +129,14 @@ export function readOnlyRemedyRefusalMessage({ refusal, formatMessage }, _option
|
|
|
127
129
|
*
|
|
128
130
|
* @returns A drop-in `ToolExecutor`; `resumeConfirmation`/`cancel`/`getAuditRecord` delegate
|
|
129
131
|
* straight through.
|
|
130
|
-
* @complexity One extra property read per unconstrained call; one registry scan per constrained one
|
|
132
|
+
* @complexity One extra property read per unconstrained call; one registry scan per constrained one,
|
|
133
|
+
* plus bounded similarity scoring and sorting for an unknown id.
|
|
131
134
|
*/
|
|
132
135
|
export function withReadOnlyToolConstraint({ inner, registry, idGenerator, messages }, _optional = {}) {
|
|
133
136
|
return {
|
|
134
137
|
execute: async (requiredArgs, optionalArgs = {}) => {
|
|
135
|
-
const { principal, toolId } = requiredArgs;
|
|
136
|
-
const refusal = refuseNonReadOnlyDispatch({ principal, toolId, registry, messages });
|
|
138
|
+
const { principal, toolId, input } = requiredArgs;
|
|
139
|
+
const refusal = refuseNonReadOnlyDispatch({ principal, toolId, input, registry, messages });
|
|
137
140
|
// Deny before inner: no handler or kernel audit record exists. Use the authorization result
|
|
138
141
|
// union instead of throwing, and mint a denial ID because there is no inner execution ID.
|
|
139
142
|
if (refusal !== null)
|