@opengeni/runtime 0.3.1 → 0.5.0
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/LICENSE +190 -0
- package/dist/{chunk-HGQ252FL.js → chunk-Q36E4MDM.js} +17 -2
- package/dist/chunk-Q36E4MDM.js.map +1 -0
- package/dist/{index-CSGkld-v.d.ts → index-BrdnxaAW.d.ts} +5 -3
- package/dist/index.d.ts +106 -9
- package/dist/index.js +483 -36
- package/dist/index.js.map +1 -1
- package/dist/sandbox/index.d.ts +1 -1
- package/dist/sandbox/index.js +1 -1
- package/package.json +6 -8
- package/src/context-compaction.ts +164 -16
- package/src/history-sanitizer.ts +2 -1
- package/src/index.ts +574 -19
- package/src/sandbox/selfhosted/session.ts +32 -4
- package/dist/chunk-HGQ252FL.js.map +0 -1
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ConfiguredModel, ContextCompactionMode, ModelProviderApi, ResolvedModelProvider, Settings } from "@opengeni/config";
|
|
2
2
|
import { AGENT_INSTRUCTIONS_CORE_PLACEHOLDER, collectSandboxEnvironment, contextInputBudgetTokens, contextServerCompactThreshold, firstPartyMcpBaseUrl, parseExposedPorts, resolveContextCompactionMode, resolveModelProvider, sandboxLifecycleHookIds } from "@opengeni/config";
|
|
3
|
-
import { CAPABILITY_DESCRIPTORS, isClearedRunStateBlob, signDelegatedAccessToken, type Permission, type ReasoningEffort, type ResourceRef, type SessionEventType, type ToolRef } from "@opengeni/contracts";
|
|
3
|
+
import { CAPABILITY_DESCRIPTORS, isClearedRunStateBlob, prefixedMcpToolName as sharedPrefixedMcpToolName, signDelegatedAccessToken, type McpServerConnectionRef, type Permission, type ReasoningEffort, type ResourceRef, type SessionEventType, type ToolAuthNeededPayload, type ToolRef } from "@opengeni/contracts";
|
|
4
4
|
import {
|
|
5
5
|
Agent,
|
|
6
6
|
AgentsError,
|
|
@@ -43,9 +43,11 @@ import {
|
|
|
43
43
|
type AgentInputItem,
|
|
44
44
|
type CallModelInputFilter,
|
|
45
45
|
type MCPServer,
|
|
46
|
+
type MCPToolErrorFunction,
|
|
46
47
|
type Model,
|
|
47
48
|
type ModelProvider,
|
|
48
49
|
type RunStreamEvent,
|
|
50
|
+
type Tool,
|
|
49
51
|
} from "@openai/agents";
|
|
50
52
|
import {
|
|
51
53
|
localDirLazySkillSource,
|
|
@@ -149,8 +151,10 @@ export { OpenAIChatCompletionsModel, OpenAIResponsesModel } from "@openai/agents
|
|
|
149
151
|
export {
|
|
150
152
|
CompactionNeededError,
|
|
151
153
|
buildCompactionPromptInput,
|
|
154
|
+
buildDeterministicFallbackCompactionHistory,
|
|
152
155
|
buildCompactionReplacementHistory,
|
|
153
156
|
clientCompactionThresholdTokens,
|
|
157
|
+
clampCompactionThresholdRatio,
|
|
154
158
|
decideClientCompaction,
|
|
155
159
|
enforceInputBudget,
|
|
156
160
|
buildSummaryItem,
|
|
@@ -164,7 +168,9 @@ export {
|
|
|
164
168
|
COMPACTION_SUMMARY_MARKER,
|
|
165
169
|
COMPACTION_PROMPT,
|
|
166
170
|
COMPACT_USER_MESSAGE_MAX_TOKENS,
|
|
167
|
-
|
|
171
|
+
DEFAULT_COMPACTION_THRESHOLD_RATIO,
|
|
172
|
+
MIN_COMPACTION_THRESHOLD_RATIO,
|
|
173
|
+
MAX_COMPACTION_THRESHOLD_RATIO,
|
|
168
174
|
SUMMARY_BUFFER_TOKENS,
|
|
169
175
|
SUMMARY_PREFIX,
|
|
170
176
|
USER_MESSAGE_TRUNCATION_MARKER,
|
|
@@ -197,6 +203,29 @@ export type ModelResponseUsage = {
|
|
|
197
203
|
|
|
198
204
|
type RuntimeMcpTool = Awaited<ReturnType<MCPServer["listTools"]>>[number];
|
|
199
205
|
|
|
206
|
+
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
207
|
+
|
|
208
|
+
export type ResolveConnectionCredentialInput = {
|
|
209
|
+
workspaceId: string;
|
|
210
|
+
subjectId?: string;
|
|
211
|
+
serverId: string;
|
|
212
|
+
toolName?: string;
|
|
213
|
+
connectionRef: McpServerConnectionRef;
|
|
214
|
+
forceRefresh?: boolean;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export type ResolveConnectionCredentialResult =
|
|
218
|
+
| { status: "ok"; headers: Record<string, string>; connectionId: string; expiresAt?: Date | null }
|
|
219
|
+
| {
|
|
220
|
+
status: "auth_needed";
|
|
221
|
+
reason: ToolAuthNeededPayload["reason"];
|
|
222
|
+
providerDomain: string;
|
|
223
|
+
connectionId?: string;
|
|
224
|
+
scopes?: string[];
|
|
225
|
+
resource?: string;
|
|
226
|
+
authorizationUrl?: string;
|
|
227
|
+
};
|
|
228
|
+
|
|
200
229
|
export function ensureReadableStreamFrom(): void {
|
|
201
230
|
const ctor = globalThis.ReadableStream as (typeof ReadableStream & {
|
|
202
231
|
from?: <T>(source: Iterable<T> | AsyncIterable<T>) => ReadableStream<T>;
|
|
@@ -606,9 +635,10 @@ function recordModelCallMetric(provider: string, outcome: "completed" | "failed"
|
|
|
606
635
|
* Run the compaction summarizer as one plain, tool-less, non-streaming model
|
|
607
636
|
* call against the resolved provider. `input` is the active history plus
|
|
608
637
|
* Codex's checkpoint prompt. Returns the trimmed summary text, or null on any
|
|
609
|
-
* failure (the caller
|
|
610
|
-
* -
|
|
611
|
-
* tools, or server-side compaction; it is a
|
|
638
|
+
* failure (the caller can retry with a harder-trimmed transcript, then fall
|
|
639
|
+
* back to deterministic non-LLM compaction). The call deliberately does NOT
|
|
640
|
+
* request reasoning encryption, tools, or server-side compaction; it is a
|
|
641
|
+
* self-contained summarize.
|
|
612
642
|
*
|
|
613
643
|
* Provider-aware: the summary always runs on the SAME provider that serves the
|
|
614
644
|
* turn (registry providers can't summarize through OpenAI/Azure, and vice
|
|
@@ -622,18 +652,24 @@ function recordModelCallMetric(provider: string, outcome: "completed" | "failed"
|
|
|
622
652
|
export async function summarizeForCompaction(
|
|
623
653
|
settings: Settings,
|
|
624
654
|
input: Array<Record<string, unknown>>,
|
|
625
|
-
options: { client?: OpenAI; api?: ModelProviderApi; maxOutputTokens?: number; model?: string } = {},
|
|
655
|
+
options: { client?: OpenAI; api?: ModelProviderApi; maxOutputTokens?: number; model?: string; maxTranscriptTokens?: number } = {},
|
|
626
656
|
): Promise<string | null> {
|
|
627
657
|
const client = options.client ?? buildOpenAIClientFromSettings(settings);
|
|
628
658
|
const api = options.api ?? "responses";
|
|
629
659
|
const model = options.model ?? settings.openaiModel;
|
|
630
660
|
const maxTokens = options.maxOutputTokens ?? SUMMARY_BUFFER_TOKENS;
|
|
661
|
+
const transcript = renderCompactionPromptInputForChat(
|
|
662
|
+
input,
|
|
663
|
+
typeof options.maxTranscriptTokens === "number" && options.maxTranscriptTokens > 0
|
|
664
|
+
? { maxEstimatedTokens: options.maxTranscriptTokens }
|
|
665
|
+
: {},
|
|
666
|
+
);
|
|
631
667
|
try {
|
|
632
668
|
if (api === "chat") {
|
|
633
669
|
const completion = await client.chat.completions.create({
|
|
634
670
|
model,
|
|
635
671
|
max_tokens: maxTokens,
|
|
636
|
-
messages: [{ role: "user", content:
|
|
672
|
+
messages: [{ role: "user", content: transcript }],
|
|
637
673
|
} as any);
|
|
638
674
|
const text = (completion as { choices?: Array<{ message?: { content?: unknown } }> }).choices?.[0]?.message?.content;
|
|
639
675
|
const trimmed = typeof text === "string" ? text.trim() : "";
|
|
@@ -646,13 +682,13 @@ export async function summarizeForCompaction(
|
|
|
646
682
|
// built-in path (api "responses"), so gate it on the built-in provider.
|
|
647
683
|
...(settings.openaiProvider === "azure" ? {} : { store: false }),
|
|
648
684
|
max_output_tokens: maxTokens,
|
|
649
|
-
input,
|
|
685
|
+
input: transcript,
|
|
650
686
|
} as any);
|
|
651
687
|
const text = extractResponseOutputText(response);
|
|
652
688
|
const trimmed = text.trim();
|
|
653
689
|
return trimmed.length > 0 ? trimmed : null;
|
|
654
690
|
} catch (error) {
|
|
655
|
-
console.error("context compaction summarize failed
|
|
691
|
+
console.error("context compaction summarize failed", error);
|
|
656
692
|
return null;
|
|
657
693
|
}
|
|
658
694
|
}
|
|
@@ -780,6 +816,11 @@ export type BuildAgentOptions = {
|
|
|
780
816
|
// seeds it to the box's token FILE before the clone runs. Omitted on the
|
|
781
817
|
// selfhosted path (the machine uses its own git creds) — a NO-OP there.
|
|
782
818
|
gitTokenSeed?: string;
|
|
819
|
+
// TOOLSPACE: the run-scoped delegated token to seed into
|
|
820
|
+
// $OPENGENI_TOOLSPACE_TOKEN_FILE. Like gitTokenSeed, this stays off the
|
|
821
|
+
// manifest/env delta and is written into the sandbox filesystem by a lifecycle
|
|
822
|
+
// hook before the agent starts.
|
|
823
|
+
toolspaceTokenSeed?: string;
|
|
783
824
|
// Genesis turn only: append a one-shot instruction to the agent's system
|
|
784
825
|
// prompt telling it to title the session via opengeni__set_session_title
|
|
785
826
|
// before responding. Delivered through the instructions channel (where the
|
|
@@ -896,6 +937,25 @@ export function appendSessionInstructions(composed: string, sessionInstructions?
|
|
|
896
937
|
return trimmed ? `${composed} ${trimmed}` : composed;
|
|
897
938
|
}
|
|
898
939
|
|
|
940
|
+
/**
|
|
941
|
+
* Appends the generic programmatic-tool-calling (toolspace) directive to the
|
|
942
|
+
* composed workspace + CORE instructions, joined by " ". This is GENERIC
|
|
943
|
+
* substrate prompting — the same text for every host, never per-host copy.
|
|
944
|
+
*
|
|
945
|
+
* Included ONLY when `toolspaceAvailable` is true, which the caller sets from the
|
|
946
|
+
* exact condition that gates the sandbox token mint: the toolspace feature is
|
|
947
|
+
* enabled AND a toolspace token was minted for THIS turn. That mint now happens
|
|
948
|
+
* on every backend including selfhosted (connected machines get the token too),
|
|
949
|
+
* so the block appears there as well; a turn with no minted token (feature off)
|
|
950
|
+
* has no toolspace URL/token in its sandbox and must not advertise a capability
|
|
951
|
+
* that is not there — the gate is false and this is a no-op. Placed BEFORE the
|
|
952
|
+
* per-session instructions so host/session specificity still wins over this
|
|
953
|
+
* substrate note.
|
|
954
|
+
*/
|
|
955
|
+
export function appendToolspaceInstructions(composed: string, toolspaceAvailable: boolean): string {
|
|
956
|
+
return toolspaceAvailable ? `${composed} ${TOOLSPACE_PROGRAMMATIC_DIRECTIVE}` : composed;
|
|
957
|
+
}
|
|
958
|
+
|
|
899
959
|
/**
|
|
900
960
|
* Appends the one-shot genesis title directive (genesis turn only), joined by
|
|
901
961
|
* " " and always LAST so a white-label persona template or a per-session
|
|
@@ -913,11 +973,48 @@ const agentRepositoryCloneHooks = new WeakMap<object, SandboxLifecycleHook[]>();
|
|
|
913
973
|
// session env; runStream reads it to build the clone hook context. Absent when
|
|
914
974
|
// no repo is attached / on the selfhosted path.
|
|
915
975
|
const agentGitTokenSeed = new WeakMap<object, string>();
|
|
976
|
+
const agentToolspaceTokenSeed = new WeakMap<object, string>();
|
|
916
977
|
// The EFFECTIVE backend the turn resolved for this agent (undefined -> the home
|
|
917
978
|
// backend). Read by runStream's owned branch to keep platform box-setup hooks off
|
|
918
979
|
// connected machines (a user's real computer).
|
|
919
980
|
const agentActiveSandboxBackend = new WeakMap<object, Settings["sandboxBackend"]>();
|
|
920
981
|
|
|
982
|
+
/**
|
|
983
|
+
* The tool output emitted for an MCP tool call that FAILED with a THROWN error
|
|
984
|
+
* — a JSON-RPC protocol error (e.g. -32602 invalid params), an auth 401/403, a
|
|
985
|
+
* transport failure, a timeout, or tool-not-found. Shaped as an MCP
|
|
986
|
+
* `{ isError: true, content }` result so the failure is CARRIED on the tool
|
|
987
|
+
* output rather than erased.
|
|
988
|
+
*
|
|
989
|
+
* The SDK's `defaultToolErrorFunction` would instead turn a thrown tool error
|
|
990
|
+
* into a plain STRING ("An error occurred while running the tool…") returned as
|
|
991
|
+
* a NORMAL, successful-looking result — so `agent.toolCall.output` carried no
|
|
992
|
+
* error signal and the timeline rendered the failure as success. `normalizeSdkEvent`
|
|
993
|
+
* captures `RunToolCallOutputItem.output` (the raw function return) verbatim, so
|
|
994
|
+
* returning this object makes the emitted payload's `output.isError === true`,
|
|
995
|
+
* which the timeline projection's `isErrorOutput` (packages/react/src/timeline/
|
|
996
|
+
* projection.ts) settles to "failed" and downstream client normalizers read the same field.
|
|
997
|
+
*
|
|
998
|
+
* SCOPE: this only covers THROWN MCP failures (the ones that reach an
|
|
999
|
+
* errorFunction). An MCP tool result carrying `isError: true` INLINE alongside
|
|
1000
|
+
* content is stripped to its `content` by @openai/agents-core's streamable-http
|
|
1001
|
+
* shim (dist/shims/mcp-server/node.js `callTool` returns `parsed.content`,
|
|
1002
|
+
* dropping `parsed.isError`) BEFORE the runtime ever sees it. Covering that mode
|
|
1003
|
+
* requires an SDK-level change (preserve `isError`) or reading the raw
|
|
1004
|
+
* CallToolResult — tracked separately.
|
|
1005
|
+
*/
|
|
1006
|
+
export function mcpToolErrorOutput(error: unknown): { isError: true; content: [{ type: "text"; text: string }] } {
|
|
1007
|
+
const details = error instanceof Error ? error.message : String(error);
|
|
1008
|
+
return { isError: true, content: [{ type: "text", text: `An error occurred while running the tool. Please try again. Error: ${details}` }] };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Applied to EVERY MCP server via the agent's `mcpConfig.errorFunction`
|
|
1012
|
+
// (server-level errorFunctions would win, but PrefixedMcpServer sets none). The
|
|
1013
|
+
// SDK types the return as `string`; the runtime actually stores the raw return
|
|
1014
|
+
// as the tool output, so we return the MCP-shaped error object and cast — this
|
|
1015
|
+
// is the only way to attach an `isError` flag to a failed MCP tool call's output.
|
|
1016
|
+
const mcpToolErrorFunction: MCPToolErrorFunction = ({ error }) => mcpToolErrorOutput(error) as unknown as string;
|
|
1017
|
+
|
|
921
1018
|
export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[], options: BuildAgentOptions = {}): Agent<any, any> {
|
|
922
1019
|
// Resolved per-turn gating. Each override defaults to today's settings-derived
|
|
923
1020
|
// behaviour, so the legacy global-client callers (no resolved model) build the
|
|
@@ -952,14 +1049,21 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
|
|
|
952
1049
|
// Persona composition order (all one system-level instructions string):
|
|
953
1050
|
// 1. workspace instructionsTemplate (or deployment default) with the
|
|
954
1051
|
// non-bypassable CORE substituted at {{core}} — composeAgentInstructions,
|
|
955
|
-
// 2. + the
|
|
956
|
-
//
|
|
957
|
-
//
|
|
958
|
-
//
|
|
959
|
-
// the
|
|
1052
|
+
// 2. + the generic programmatic-tool-calling (toolspace) directive, ONLY
|
|
1053
|
+
// when a toolspace token was minted for this turn (feature enabled + a
|
|
1054
|
+
// per-turn seed — the mint gate, which includes selfhosted turns since
|
|
1055
|
+
// they now receive the token too) — appendToolspaceInstructions,
|
|
1056
|
+
// 3. + the per-session persona instructions (session-specific, so it
|
|
1057
|
+
// refines both the workspace persona and the substrate note),
|
|
1058
|
+
// 4. + the one-shot genesis title directive (genesis turn only).
|
|
1059
|
+
// With no toolspace token, no session instructions, and no genesis hint this
|
|
1060
|
+
// is byte-identical to the historical composed instructions.
|
|
960
1061
|
instructions: appendGenesisTitleDirective(
|
|
961
1062
|
appendSessionInstructions(
|
|
962
|
-
|
|
1063
|
+
appendToolspaceInstructions(
|
|
1064
|
+
composeAgentInstructions(options.instructionsTemplate ?? settings.agentInstructionsTemplate, options.workspaceEnvironment),
|
|
1065
|
+
settings.toolspaceEnabled && Boolean(options.toolspaceTokenSeed),
|
|
1066
|
+
),
|
|
963
1067
|
options.sessionInstructions,
|
|
964
1068
|
),
|
|
965
1069
|
options.genesisTitleHint,
|
|
@@ -993,11 +1097,19 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
|
|
|
993
1097
|
// spread; the SDK concatenates these with MCP and sandbox capability tools.
|
|
994
1098
|
...(hostedTools.length ? { tools: hostedTools } : {}),
|
|
995
1099
|
...(options.mcpServers?.length ? { mcpServers: options.mcpServers } : {}),
|
|
1100
|
+
// Surface FAILED MCP tool calls as `{ isError: true }` tool output (see
|
|
1101
|
+
// mcpToolErrorFunction / mcpToolErrorOutput) instead of the SDK's default
|
|
1102
|
+
// flat error string, so a thrown MCP failure (protocol error, auth, timeout,
|
|
1103
|
+
// tool-not-found) settles the timeline tool to "failed" rather than rendering
|
|
1104
|
+
// as success. Applies to every MCP server on this agent (session, first-party,
|
|
1105
|
+
// capability, codex_apps) since none set a server-level errorFunction.
|
|
1106
|
+
mcpConfig: { errorFunction: mcpToolErrorFunction },
|
|
996
1107
|
} as const;
|
|
997
1108
|
|
|
998
1109
|
if (settings.sandboxBackend === "none") {
|
|
999
1110
|
const agent = new Agent(baseConfig);
|
|
1000
1111
|
maybeInstallCodexToolSearch(agent, settings, options);
|
|
1112
|
+
applyMcpApprovalPolicy(agent, settings);
|
|
1001
1113
|
return agent;
|
|
1002
1114
|
}
|
|
1003
1115
|
|
|
@@ -1028,7 +1140,11 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
|
|
|
1028
1140
|
if (options.gitTokenSeed) {
|
|
1029
1141
|
agentGitTokenSeed.set(agent, options.gitTokenSeed);
|
|
1030
1142
|
}
|
|
1143
|
+
if (options.toolspaceTokenSeed) {
|
|
1144
|
+
agentToolspaceTokenSeed.set(agent, options.toolspaceTokenSeed);
|
|
1145
|
+
}
|
|
1031
1146
|
maybeInstallCodexToolSearch(agent, settings, options);
|
|
1147
|
+
applyMcpApprovalPolicy(agent, settings);
|
|
1032
1148
|
return agent;
|
|
1033
1149
|
}
|
|
1034
1150
|
|
|
@@ -1050,6 +1166,101 @@ function maybeInstallCodexToolSearch(agent: Agent<any, any>, settings: Settings,
|
|
|
1050
1166
|
}
|
|
1051
1167
|
}
|
|
1052
1168
|
|
|
1169
|
+
/** True when the unprefixed tool `name` requires approval under `policy`. */
|
|
1170
|
+
function mcpToolRequiresApproval(policy: boolean | string[], unprefixedName: string): boolean {
|
|
1171
|
+
return policy === true || (Array.isArray(policy) && policy.includes(unprefixedName));
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/** A per-server approval policy keyed by the server's `<id>__` tool prefix. */
|
|
1175
|
+
type McpApprovalPolicy = { prefix: string; requireApproval: boolean | string[] };
|
|
1176
|
+
|
|
1177
|
+
/** The subset of the agent surface the approval wrap needs — including `clone`. */
|
|
1178
|
+
type ApprovalCapableAgent = {
|
|
1179
|
+
getMcpTools: (runContext: unknown) => Promise<Tool<any>[]>;
|
|
1180
|
+
clone?: (config: unknown) => ApprovalCapableAgent;
|
|
1181
|
+
};
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* Install the approval wrap on a single agent instance: replace `getMcpTools`
|
|
1185
|
+
* with one that stamps `needsApproval: () => true` on every MCP tool whose
|
|
1186
|
+
* server policy demands it. Tools are matched by the server's `<id>__` prefix
|
|
1187
|
+
* (LONGEST prefix first — see {@link applyMcpApprovalPolicy}), then the
|
|
1188
|
+
* unprefixed tool name.
|
|
1189
|
+
*
|
|
1190
|
+
* CLONE SURVIVAL (mirrors {@link installCodexToolSearch}): the sandbox runtime
|
|
1191
|
+
* resolves tools not on the agent we build here but on a FRESH clone —
|
|
1192
|
+
* `prepareSandboxAgent` calls `agent.clone(...)`, and `SandboxAgent.clone`
|
|
1193
|
+
* reconstructs from a FIXED field list (name/tools/mcpServers/…), so an
|
|
1194
|
+
* instance-own `getMcpTools` override is dropped and approval would silently
|
|
1195
|
+
* bypass on every sandbox turn. We therefore also wrap `clone` to RE-INSTALL the
|
|
1196
|
+
* policy onto every clone, recursively — covering clone-of-clone and the resume
|
|
1197
|
+
* paths. The base (non-sandbox) `Agent.clone` spreads `...this` and would carry
|
|
1198
|
+
* the override, but re-installing is idempotent there and keeps one code path.
|
|
1199
|
+
*/
|
|
1200
|
+
function installMcpApprovalPolicy(agent: ApprovalCapableAgent, policies: McpApprovalPolicy[]): void {
|
|
1201
|
+
const listMcpTools = agent.getMcpTools.bind(agent);
|
|
1202
|
+
agent.getMcpTools = async (runContext: unknown) => {
|
|
1203
|
+
const tools = await listMcpTools(runContext);
|
|
1204
|
+
return tools.map((tool) => {
|
|
1205
|
+
if (tool.type !== "function") {
|
|
1206
|
+
return tool;
|
|
1207
|
+
}
|
|
1208
|
+
const policy = policies.find((entry) => tool.name.startsWith(entry.prefix));
|
|
1209
|
+
if (!policy) {
|
|
1210
|
+
return tool;
|
|
1211
|
+
}
|
|
1212
|
+
const unprefixed = tool.name.slice(policy.prefix.length);
|
|
1213
|
+
return mcpToolRequiresApproval(policy.requireApproval, unprefixed)
|
|
1214
|
+
? { ...tool, needsApproval: async () => true }
|
|
1215
|
+
: tool;
|
|
1216
|
+
});
|
|
1217
|
+
};
|
|
1218
|
+
const originalClone = agent.clone?.bind(agent);
|
|
1219
|
+
if (originalClone) {
|
|
1220
|
+
agent.clone = (config: unknown) => {
|
|
1221
|
+
const cloned = originalClone(config);
|
|
1222
|
+
installMcpApprovalPolicy(cloned, policies);
|
|
1223
|
+
return cloned;
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Enforce per-MCP-server human approval. `settings.mcpServers[].requireApproval`
|
|
1230
|
+
* is `true` (every tool of that server requires approval) or a string[] of
|
|
1231
|
+
* UNPREFIXED tool names (only those do); absent = auto-run. The SDK converts MCP
|
|
1232
|
+
* tools to function tools with `needsApproval` unset (defaults false) and exposes
|
|
1233
|
+
* no per-server/agent approval knob, so we wrap the agent's `getMcpTools` to
|
|
1234
|
+
* attach a `needsApproval: () => true` predicate to the matching tools — matched
|
|
1235
|
+
* by the server's `<id>__` prefix, then the unprefixed tool name. A tool that
|
|
1236
|
+
* needs approval raises a run INTERRUPTION, which the worker turns into
|
|
1237
|
+
* `session.requiresAction` and resolves via `user.approvalDecision`
|
|
1238
|
+
* (resumeApproval) — the same generic path shell/computer-use approvals use, so
|
|
1239
|
+
* no extra plumbing. No-op when no server requests approval, so the default
|
|
1240
|
+
* (auto-run everything) is byte-for-byte unchanged.
|
|
1241
|
+
*
|
|
1242
|
+
* Two robustness properties the wrap must hold:
|
|
1243
|
+
* - LONGEST-PREFIX-FIRST. Server ids can be prefixes of one another (`my` vs
|
|
1244
|
+
* `my_`), so their tool prefixes collide (`my__` vs `my___`): a tool like
|
|
1245
|
+
* `my___run` (from server `my_`) also `startsWith` `my__` (server `my`). A
|
|
1246
|
+
* first-match `find` over unsorted policies could bind it to the WRONG
|
|
1247
|
+
* server's policy and bypass gating. Sorting policies by DESCENDING prefix
|
|
1248
|
+
* length makes the most-specific (longest) prefix win, so each tool resolves
|
|
1249
|
+
* to its own server.
|
|
1250
|
+
* - CLONE SURVIVAL. The wrap is re-installed onto every clone; see
|
|
1251
|
+
* {@link installMcpApprovalPolicy}.
|
|
1252
|
+
*/
|
|
1253
|
+
function applyMcpApprovalPolicy(agent: Agent<any, any>, settings: Settings): void {
|
|
1254
|
+
const policies: McpApprovalPolicy[] = settings.mcpServers
|
|
1255
|
+
.filter((server) => server.requireApproval === true || (Array.isArray(server.requireApproval) && server.requireApproval.length > 0))
|
|
1256
|
+
.map((server) => ({ prefix: prefixedMcpToolName(server.id, ""), requireApproval: server.requireApproval as boolean | string[] }))
|
|
1257
|
+
.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
1258
|
+
if (policies.length === 0) {
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
installMcpApprovalPolicy(agent as unknown as ApprovalCapableAgent, policies);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1053
1264
|
/**
|
|
1054
1265
|
* Force a sandbox capability to emit its FUNCTION-transport tool variants instead
|
|
1055
1266
|
* of the hosted ones, by dropping the model instance the SDK's transport
|
|
@@ -1218,6 +1429,8 @@ export type PrepareToolsOptions = {
|
|
|
1218
1429
|
// delegated token (manager-style sessions). The caller is responsible for
|
|
1219
1430
|
// having validated the set against the session creator's grant.
|
|
1220
1431
|
firstPartyPermissions?: Permission[];
|
|
1432
|
+
resolveCredential?: (input: ResolveConnectionCredentialInput) => Promise<ResolveConnectionCredentialResult>;
|
|
1433
|
+
onAuthNeeded?: (payload: ToolAuthNeededPayload) => Promise<void> | void;
|
|
1221
1434
|
};
|
|
1222
1435
|
|
|
1223
1436
|
export async function prepareAgentTools(settings: Settings, tools: ToolRef[], options: PrepareToolsOptions = {}): Promise<PreparedAgentTools> {
|
|
@@ -1235,6 +1448,12 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
|
|
|
1235
1448
|
throw new Error(`Unknown MCP server id: ${tool.id}`);
|
|
1236
1449
|
}
|
|
1237
1450
|
const url = firstPartyMcpServerUrlForRun(settings, config, options.workspaceId) ?? config.url;
|
|
1451
|
+
const baseFetch = isCodexAppsMcpServer(config)
|
|
1452
|
+
? codexAppsSanitizingFetch(globalThis.fetch, codexConnectorNamespaces)
|
|
1453
|
+
: globalThis.fetch;
|
|
1454
|
+
const fetchImpl = config.connectionRef
|
|
1455
|
+
? connectionBrokerFetch(baseFetch, config, options)
|
|
1456
|
+
: baseFetch;
|
|
1238
1457
|
const server = new PrefixedMcpServer(new MCPServerStreamableHttp({
|
|
1239
1458
|
url,
|
|
1240
1459
|
name: config.name ?? config.id,
|
|
@@ -1243,7 +1462,7 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
|
|
|
1243
1462
|
// MCP SDK's strict Tool schema rejects (fails the turn during tools/list);
|
|
1244
1463
|
// sanitize the response on the wire before validation. The namespace Set
|
|
1245
1464
|
// also captures each tool's original connector namespace (P4 Part B.1).
|
|
1246
|
-
...(
|
|
1465
|
+
...(fetchImpl !== globalThis.fetch ? { fetch: fetchImpl } : {}),
|
|
1247
1466
|
...await mcpServerRequestInit(settings, config, options),
|
|
1248
1467
|
...(config.timeoutMs ? {
|
|
1249
1468
|
timeout: config.timeoutMs,
|
|
@@ -1262,7 +1481,7 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
|
|
|
1262
1481
|
// with a warning, never killing the turn before the model runs. Bare
|
|
1263
1482
|
// refs stay strict (below), preserving the fail-loud default.
|
|
1264
1483
|
const optional = tool.optional === true;
|
|
1265
|
-
return { server, bestEffort: isCodexAppsMcpServer(config) || optional, optional };
|
|
1484
|
+
return { server, bestEffort: isCodexAppsMcpServer(config) || optional || !!config.connectionRef, optional };
|
|
1266
1485
|
}));
|
|
1267
1486
|
const requiredServers = servers.filter((entry) => !entry.bestEffort).map((entry) => entry.server);
|
|
1268
1487
|
const bestEffortServers = servers.filter((entry) => entry.bestEffort).map((entry) => entry.server);
|
|
@@ -1306,6 +1525,240 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
|
|
|
1306
1525
|
};
|
|
1307
1526
|
}
|
|
1308
1527
|
|
|
1528
|
+
function connectionBrokerFetch(
|
|
1529
|
+
baseFetch: FetchLike,
|
|
1530
|
+
config: Settings["mcpServers"][number],
|
|
1531
|
+
options: PrepareToolsOptions,
|
|
1532
|
+
): FetchLike {
|
|
1533
|
+
const connectionRef = config.connectionRef;
|
|
1534
|
+
if (!connectionRef) {
|
|
1535
|
+
return baseFetch;
|
|
1536
|
+
}
|
|
1537
|
+
return async (input, init) => {
|
|
1538
|
+
const request = await mcpRequestInfo(input, init);
|
|
1539
|
+
const first = await resolveConnectionForRequest(options, config.id, connectionRef, request.toolName, false);
|
|
1540
|
+
if (first.status === "auth_needed") {
|
|
1541
|
+
return await authNeededFetchResponse(options, config.id, request, first, connectionRef);
|
|
1542
|
+
}
|
|
1543
|
+
const response = await baseFetch(fetchInputForAttempt(input), withConnectionHeaders(input, init, first.headers));
|
|
1544
|
+
if (response.status === 401) {
|
|
1545
|
+
const refreshed = await resolveConnectionForRequest(options, config.id, connectionRef, request.toolName, true);
|
|
1546
|
+
if (refreshed.status === "auth_needed") {
|
|
1547
|
+
return await authNeededFetchResponse(options, config.id, request, refreshed, connectionRef);
|
|
1548
|
+
}
|
|
1549
|
+
const retry = await baseFetch(fetchInputForAttempt(input), withConnectionHeaders(input, init, refreshed.headers));
|
|
1550
|
+
if (retry.status === 403) {
|
|
1551
|
+
const auth = insufficientScopeAuth(retry.headers, connectionRef, refreshed.connectionId);
|
|
1552
|
+
return auth ? await authNeededFetchResponse(options, config.id, request, auth, connectionRef) : retry;
|
|
1553
|
+
}
|
|
1554
|
+
if (retry.status === 401) {
|
|
1555
|
+
return await authNeededFetchResponse(options, config.id, request, {
|
|
1556
|
+
status: "auth_needed",
|
|
1557
|
+
reason: "expired",
|
|
1558
|
+
providerDomain: connectionRef.providerDomain,
|
|
1559
|
+
connectionId: refreshed.connectionId,
|
|
1560
|
+
...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
|
|
1561
|
+
...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
|
|
1562
|
+
}, connectionRef);
|
|
1563
|
+
}
|
|
1564
|
+
return retry;
|
|
1565
|
+
}
|
|
1566
|
+
if (response.status === 403) {
|
|
1567
|
+
const auth = insufficientScopeAuth(response.headers, connectionRef, first.connectionId);
|
|
1568
|
+
return auth ? await authNeededFetchResponse(options, config.id, request, auth, connectionRef) : response;
|
|
1569
|
+
}
|
|
1570
|
+
return response;
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
async function resolveConnectionForRequest(
|
|
1575
|
+
options: PrepareToolsOptions,
|
|
1576
|
+
serverId: string,
|
|
1577
|
+
connectionRef: McpServerConnectionRef,
|
|
1578
|
+
toolName: string | undefined,
|
|
1579
|
+
forceRefresh: boolean,
|
|
1580
|
+
): Promise<ResolveConnectionCredentialResult> {
|
|
1581
|
+
if (!options.workspaceId || !options.resolveCredential) {
|
|
1582
|
+
return {
|
|
1583
|
+
status: "auth_needed",
|
|
1584
|
+
reason: "missing_connection",
|
|
1585
|
+
providerDomain: connectionRef.providerDomain,
|
|
1586
|
+
...(connectionRef.connectionId ? { connectionId: connectionRef.connectionId } : {}),
|
|
1587
|
+
...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
|
|
1588
|
+
...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
const request: ResolveConnectionCredentialInput = {
|
|
1592
|
+
workspaceId: options.workspaceId,
|
|
1593
|
+
serverId,
|
|
1594
|
+
connectionRef,
|
|
1595
|
+
forceRefresh,
|
|
1596
|
+
...(toolName ? { toolName } : {}),
|
|
1597
|
+
...(options.subjectId ? { subjectId: options.subjectId } : {}),
|
|
1598
|
+
};
|
|
1599
|
+
try {
|
|
1600
|
+
return await options.resolveCredential(request);
|
|
1601
|
+
} catch {
|
|
1602
|
+
return {
|
|
1603
|
+
status: "auth_needed",
|
|
1604
|
+
reason: "refresh_failed",
|
|
1605
|
+
providerDomain: connectionRef.providerDomain,
|
|
1606
|
+
...(connectionRef.connectionId ? { connectionId: connectionRef.connectionId } : {}),
|
|
1607
|
+
...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
|
|
1608
|
+
...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
function insufficientScopeAuth(
|
|
1614
|
+
headers: Headers,
|
|
1615
|
+
connectionRef: McpServerConnectionRef,
|
|
1616
|
+
connectionId: string,
|
|
1617
|
+
): Extract<ResolveConnectionCredentialResult, { status: "auth_needed" }> | null {
|
|
1618
|
+
const challenge = parseWwwAuthenticate(headers.get("www-authenticate"));
|
|
1619
|
+
if (challenge.error !== "insufficient_scope") {
|
|
1620
|
+
return null;
|
|
1621
|
+
}
|
|
1622
|
+
return {
|
|
1623
|
+
status: "auth_needed",
|
|
1624
|
+
reason: "insufficient_scope",
|
|
1625
|
+
providerDomain: connectionRef.providerDomain,
|
|
1626
|
+
connectionId,
|
|
1627
|
+
...(challenge.scope?.length ? { scopes: challenge.scope } : connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
|
|
1628
|
+
...(challenge.resource ? { resource: challenge.resource } : connectionRef.resource ? { resource: connectionRef.resource } : {}),
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
async function authNeededFetchResponse(
|
|
1633
|
+
options: PrepareToolsOptions,
|
|
1634
|
+
serverId: string,
|
|
1635
|
+
request: McpRequestInfo,
|
|
1636
|
+
auth: Extract<ResolveConnectionCredentialResult, { status: "auth_needed" }>,
|
|
1637
|
+
connectionRef: McpServerConnectionRef,
|
|
1638
|
+
): Promise<Response> {
|
|
1639
|
+
const connectionId = auth.connectionId ?? connectionRef.connectionId;
|
|
1640
|
+
await publishAuthNeeded(options, {
|
|
1641
|
+
serverId,
|
|
1642
|
+
toolName: request.toolName ?? null,
|
|
1643
|
+
providerDomain: auth.providerDomain,
|
|
1644
|
+
reason: auth.reason,
|
|
1645
|
+
...(connectionId ? { connectionId } : {}),
|
|
1646
|
+
...(auth.scopes ? { scopes: auth.scopes } : connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
|
|
1647
|
+
...(auth.resource ? { resource: auth.resource } : connectionRef.resource ? { resource: connectionRef.resource } : {}),
|
|
1648
|
+
...(auth.authorizationUrl ? { authorizationUrl: auth.authorizationUrl } : {}),
|
|
1649
|
+
...(options.subjectId ? { subjectId: options.subjectId } : {}),
|
|
1650
|
+
});
|
|
1651
|
+
if (request.method === "tools/call") {
|
|
1652
|
+
return mcpToolAuthNeededResponse(request.id);
|
|
1653
|
+
}
|
|
1654
|
+
return new Response("Authentication required for MCP server connection", { status: 401 });
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
async function publishAuthNeeded(options: PrepareToolsOptions, payload: ToolAuthNeededPayload): Promise<void> {
|
|
1658
|
+
try {
|
|
1659
|
+
await options.onAuthNeeded?.(payload);
|
|
1660
|
+
} catch {
|
|
1661
|
+
// Auth-needed events are advisory UI/audit signals; a publisher failure must
|
|
1662
|
+
// not turn an auth-recoverable tool condition into a failed agent turn.
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
type McpRequestInfo = {
|
|
1667
|
+
method?: string;
|
|
1668
|
+
id?: string | number | null;
|
|
1669
|
+
toolName?: string;
|
|
1670
|
+
};
|
|
1671
|
+
|
|
1672
|
+
async function mcpRequestInfo(input: string | URL | Request, init?: RequestInit): Promise<McpRequestInfo> {
|
|
1673
|
+
const body = typeof init?.body === "string"
|
|
1674
|
+
? init.body
|
|
1675
|
+
: input instanceof Request && (init?.method ?? input.method).toUpperCase() === "POST"
|
|
1676
|
+
? await input.clone().text().catch(() => "")
|
|
1677
|
+
: "";
|
|
1678
|
+
if (!body) {
|
|
1679
|
+
return {};
|
|
1680
|
+
}
|
|
1681
|
+
try {
|
|
1682
|
+
const parsed = JSON.parse(body) as { id?: unknown; method?: unknown; params?: { name?: unknown } };
|
|
1683
|
+
const method = typeof parsed.method === "string" ? parsed.method : undefined;
|
|
1684
|
+
const id = typeof parsed.id === "string" || typeof parsed.id === "number" || parsed.id === null ? parsed.id : undefined;
|
|
1685
|
+
const toolName = method === "tools/call" && typeof parsed.params?.name === "string" ? parsed.params.name : undefined;
|
|
1686
|
+
return {
|
|
1687
|
+
...(method ? { method } : {}),
|
|
1688
|
+
...(id !== undefined ? { id } : {}),
|
|
1689
|
+
...(toolName ? { toolName } : {}),
|
|
1690
|
+
};
|
|
1691
|
+
} catch {
|
|
1692
|
+
return {};
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function withConnectionHeaders(input: string | URL | Request, init: RequestInit | undefined, authHeaders: Record<string, string>): RequestInit {
|
|
1697
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
1698
|
+
for (const [name, value] of Object.entries(authHeaders)) {
|
|
1699
|
+
headers.set(name, value);
|
|
1700
|
+
}
|
|
1701
|
+
return { ...init, headers };
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
function fetchInputForAttempt(input: string | URL | Request): string | URL | Request {
|
|
1705
|
+
return input instanceof Request ? input.clone() : input;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
function parseWwwAuthenticate(header: string | null): { error?: string; scope?: string[]; resource?: string } {
|
|
1709
|
+
if (!header) {
|
|
1710
|
+
return {};
|
|
1711
|
+
}
|
|
1712
|
+
const bearerIndex = header.toLowerCase().indexOf("bearer");
|
|
1713
|
+
if (bearerIndex < 0) {
|
|
1714
|
+
return {};
|
|
1715
|
+
}
|
|
1716
|
+
const paramsText = header.slice(bearerIndex + "bearer".length);
|
|
1717
|
+
const params: Record<string, string> = {};
|
|
1718
|
+
const re = /([a-zA-Z_][a-zA-Z0-9_-]*)\s*=\s*("(?:[^"\\]|\\.)*"|[^,\s]+)/g;
|
|
1719
|
+
let match: RegExpExecArray | null;
|
|
1720
|
+
while ((match = re.exec(paramsText)) !== null) {
|
|
1721
|
+
const raw = match[2]!;
|
|
1722
|
+
params[match[1]!.toLowerCase()] = raw.startsWith("\"") ? raw.slice(1, -1).replace(/\\"/g, "\"") : raw;
|
|
1723
|
+
}
|
|
1724
|
+
return {
|
|
1725
|
+
...(params.error ? { error: params.error } : {}),
|
|
1726
|
+
...(params.scope ? { scope: params.scope.split(/\s+/).filter(Boolean) } : {}),
|
|
1727
|
+
...(params.resource ? { resource: params.resource } : {}),
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
// Application-defined JSON-RPC error code marking "this tool call needs a
|
|
1732
|
+
// connection". A RESULT carrying inline `isError: true` cannot be used here:
|
|
1733
|
+
// the agents SDK's streamable-http shim strips `isError` and returns only the
|
|
1734
|
+
// content, erasing the failure. A JSON-RPC error survives the shim as a thrown
|
|
1735
|
+
// McpError, which PrefixedMcpServer.callTool converts back into an MCP-shaped
|
|
1736
|
+
// `{ isError: true }` output for the model.
|
|
1737
|
+
const MCP_AUTH_NEEDED_ERROR_CODE = -32001;
|
|
1738
|
+
const MCP_AUTH_NEEDED_MESSAGE = "Authentication required - a connection link was posted to the session.";
|
|
1739
|
+
|
|
1740
|
+
function mcpToolAuthNeededResponse(id: string | number | null | undefined): Response {
|
|
1741
|
+
return new Response(JSON.stringify({
|
|
1742
|
+
jsonrpc: "2.0",
|
|
1743
|
+
id: id ?? null,
|
|
1744
|
+
error: {
|
|
1745
|
+
code: MCP_AUTH_NEEDED_ERROR_CODE,
|
|
1746
|
+
message: MCP_AUTH_NEEDED_MESSAGE,
|
|
1747
|
+
},
|
|
1748
|
+
}), {
|
|
1749
|
+
status: 200,
|
|
1750
|
+
headers: { "content-type": "application/json" },
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
function isAuthNeededMcpError(error: unknown): boolean {
|
|
1755
|
+
if (!(error instanceof Error)) {
|
|
1756
|
+
return false;
|
|
1757
|
+
}
|
|
1758
|
+
const code = (error as { code?: unknown }).code;
|
|
1759
|
+
return code === MCP_AUTH_NEEDED_ERROR_CODE || error.message.includes(MCP_AUTH_NEEDED_MESSAGE);
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1309
1762
|
async function mcpServerRequestInit(settings: Settings, config: Settings["mcpServers"][number], options: PrepareToolsOptions): Promise<{ requestInit: { headers: Record<string, string> } } | {}> {
|
|
1310
1763
|
// codex_apps is checked FIRST so the static-headers path can never apply to
|
|
1311
1764
|
// it: its refreshing ChatGPT/Codex bearer is resolved per-connect from the
|
|
@@ -1495,7 +1948,7 @@ function normalizeUrl(raw: string): string | null {
|
|
|
1495
1948
|
}
|
|
1496
1949
|
|
|
1497
1950
|
export function prefixedMcpToolName(registryId: string, toolName: string): string {
|
|
1498
|
-
return
|
|
1951
|
+
return sharedPrefixedMcpToolName(registryId, toolName);
|
|
1499
1952
|
}
|
|
1500
1953
|
|
|
1501
1954
|
class PrefixedMcpServer implements MCPServer {
|
|
@@ -1531,7 +1984,18 @@ class PrefixedMcpServer implements MCPServer {
|
|
|
1531
1984
|
if (!this.isAllowed(unprefixed)) {
|
|
1532
1985
|
throw new Error(`MCP tool ${unprefixed} is not allowed for server ${this.name}`);
|
|
1533
1986
|
}
|
|
1534
|
-
|
|
1987
|
+
try {
|
|
1988
|
+
return await this.inner.callTool(unprefixed, args, meta);
|
|
1989
|
+
} catch (error) {
|
|
1990
|
+
// The connection broker's auth-needed short-circuit arrives as a thrown
|
|
1991
|
+
// JSON-RPC error (an inline isError result would be stripped by the SDK
|
|
1992
|
+
// shim). Surface it to the model as a failed-but-recoverable tool result
|
|
1993
|
+
// instead of failing the turn; the timeline chip was already published.
|
|
1994
|
+
if (isAuthNeededMcpError(error)) {
|
|
1995
|
+
return { isError: true, content: [{ type: "text", text: MCP_AUTH_NEEDED_MESSAGE }] };
|
|
1996
|
+
}
|
|
1997
|
+
throw error;
|
|
1998
|
+
}
|
|
1535
1999
|
}
|
|
1536
2000
|
|
|
1537
2001
|
invalidateToolsCache(): Promise<void> {
|
|
@@ -1756,6 +2220,13 @@ export type ContextRobustnessFilterOptions = {
|
|
|
1756
2220
|
export const GENESIS_TITLE_DIRECTIVE =
|
|
1757
2221
|
"This is the first turn of a new session. Before responding to the user, call the opengeni__set_session_title tool with a concise 3-7 word title that summarizes what this session is about, then address the user's request normally.";
|
|
1758
2222
|
|
|
2223
|
+
// Generic substrate prompting for programmatic tool calling (toolspace). Same
|
|
2224
|
+
// text for every host; gated per-turn by appendToolspaceInstructions on the
|
|
2225
|
+
// presence of a minted toolspace token, so it only appears when the sandbox
|
|
2226
|
+
// actually exposes the ogtool CLI + $OPENGENI_TOOLSPACE_URL/_TOKEN_FILE.
|
|
2227
|
+
export const TOOLSPACE_PROGRAMMATIC_DIRECTIVE =
|
|
2228
|
+
"Every tool on your MCP surface is also callable programmatically from the sandbox shell, so scripts can invoke tools without a model round trip per call. Run `ogtool list` to see the available tools and their input schemas (from tools/list), then `ogtool call <tool-name> '<json-args>'`; equivalently, POST MCP JSON-RPC to $OPENGENI_TOOLSPACE_URL with the bearer token read from $OPENGENI_TOOLSPACE_TOKEN_FILE. Prefer programmatic calls for loops, polling, and bulk filtering: their results stay in the sandbox and do not consume your context window. Tools that require human approval must still be invoked normally — called programmatically they return a typed error.";
|
|
2229
|
+
|
|
1759
2230
|
/**
|
|
1760
2231
|
* callModelInputFilter that removes provider-assigned item ids (rs_/msg_/fc_…)
|
|
1761
2232
|
* from every input item immediately before each model call. Responses-API
|
|
@@ -1919,8 +2390,10 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
|
|
|
1919
2390
|
// TOKEN-BROKER (B1): the per-turn git token seed, forwarded OFF-MANIFEST so the
|
|
1920
2391
|
// repository-clone hook seeds it to the box's token file before the clone.
|
|
1921
2392
|
const ownedGitTokenSeed = gitTokenSeedForAgent(agent);
|
|
2393
|
+
const ownedToolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
|
|
1922
2394
|
const ownedHooks = [
|
|
1923
2395
|
...sandboxLifecycleHooksForIds(sandboxLifecycleHookIds(settings)),
|
|
2396
|
+
...sandboxToolspaceTokenHooksForAgent(agent),
|
|
1924
2397
|
...sandboxRepositoryCloneHooksForAgent(agent),
|
|
1925
2398
|
];
|
|
1926
2399
|
const ownedHookContext: SandboxLifecycleHookContext = {
|
|
@@ -1928,6 +2401,7 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
|
|
|
1928
2401
|
...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
|
|
1929
2402
|
...(runAs ? { runAs } : {}),
|
|
1930
2403
|
...(ownedGitTokenSeed ? { gitTokenSeed: ownedGitTokenSeed } : {}),
|
|
2404
|
+
...(ownedToolspaceTokenSeed ? { toolspaceTokenSeed: ownedToolspaceTokenSeed } : {}),
|
|
1931
2405
|
};
|
|
1932
2406
|
// OWNED-PATH HOOKS: the SDK NEVER calls client.create/resume when handed a live
|
|
1933
2407
|
// provided session (SandboxRuntimeManager uses `sandboxConfig.session` directly),
|
|
@@ -1957,6 +2431,22 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
|
|
|
1957
2431
|
...(runAs ? { runAs } : {}),
|
|
1958
2432
|
});
|
|
1959
2433
|
}
|
|
2434
|
+
} else {
|
|
2435
|
+
// SELFHOSTED TOOLSPACE (parity): the platform setup hooks (repository clone,
|
|
2436
|
+
// az login) and file materialization stay OFF the user's real machine — but
|
|
2437
|
+
// the toolspace token seed is the ONE piece of per-turn material that must
|
|
2438
|
+
// reach it. It writes a scoped, own-session-bound token to
|
|
2439
|
+
// $OPENGENI_TOOLSPACE_TOKEN_FILE over the SAME exec channel the clone-seed
|
|
2440
|
+
// uses (off-manifest, value never on the manifest), and it grants no more
|
|
2441
|
+
// than the machine owner's own authority (toolspace:call, this session, turn
|
|
2442
|
+
// TTL, budgeted, approval-tools excluded) — the invariant that makes it safe
|
|
2443
|
+
// to cross to a user machine when the git token is not. It is also the
|
|
2444
|
+
// machine's only path to programmatic tool calling. Seed it (only) here; the
|
|
2445
|
+
// hook list is empty when no toolspace token was minted for this turn.
|
|
2446
|
+
const toolspaceHooks = sandboxToolspaceTokenHooksForAgent(agent);
|
|
2447
|
+
if (toolspaceHooks.length > 0) {
|
|
2448
|
+
await runBeforeAgentStartHooks(setupSession, toolspaceHooks, ownedHookContext);
|
|
2449
|
+
}
|
|
1960
2450
|
}
|
|
1961
2451
|
// Keep the decoration as a safety net for any session the SDK does create/resume
|
|
1962
2452
|
// through the client during this run (it is inert for the provided session).
|
|
@@ -2002,15 +2492,18 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
|
|
|
2002
2492
|
// TOKEN-BROKER (B1): the per-turn git token seed, forwarded OFF-MANIFEST so the
|
|
2003
2493
|
// repository-clone hook seeds it to the box's token file before the clone.
|
|
2004
2494
|
const gitTokenSeed = gitTokenSeedForAgent(agent);
|
|
2495
|
+
const toolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
|
|
2005
2496
|
const client = resourceClient
|
|
2006
2497
|
? withSandboxLifecycleHooks(resourceClient, [
|
|
2007
2498
|
...sandboxLifecycleHooksForIds(sandboxLifecycleHookIds(settings)),
|
|
2499
|
+
...sandboxToolspaceTokenHooksForAgent(agent),
|
|
2008
2500
|
...sandboxRepositoryCloneHooksForAgent(agent),
|
|
2009
2501
|
], {
|
|
2010
2502
|
environment,
|
|
2011
2503
|
...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
|
|
2012
2504
|
...(runAs ? { runAs } : {}),
|
|
2013
2505
|
...(gitTokenSeed ? { gitTokenSeed } : {}),
|
|
2506
|
+
...(toolspaceTokenSeed ? { toolspaceTokenSeed } : {}),
|
|
2014
2507
|
})
|
|
2015
2508
|
: undefined;
|
|
2016
2509
|
const sandboxSessionState = prepared.sandboxSessionState
|
|
@@ -2813,6 +3306,7 @@ export type SandboxLifecycleHookContext = {
|
|
|
2813
3306
|
// the clone exec's per-call env (OPENGENI_GIT_TOKEN_SEED), NEVER the box/agent
|
|
2814
3307
|
// manifest env (validateNoEnvironmentDelta must never see a rotating value).
|
|
2815
3308
|
gitTokenSeed?: string;
|
|
3309
|
+
toolspaceTokenSeed?: string;
|
|
2816
3310
|
};
|
|
2817
3311
|
|
|
2818
3312
|
export type SandboxLifecycleHook = {
|
|
@@ -2913,6 +3407,20 @@ function gitTokenSeedForAgent(agent: Agent<any, any>): string | undefined {
|
|
|
2913
3407
|
return agentGitTokenSeed.get(agent);
|
|
2914
3408
|
}
|
|
2915
3409
|
|
|
3410
|
+
function toolspaceTokenSeedForAgent(agent: Agent<any, any>): string | undefined {
|
|
3411
|
+
return agentToolspaceTokenSeed.get(agent);
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3414
|
+
function sandboxToolspaceTokenHooksForAgent(agent: Agent<any, any>): SandboxLifecycleHook[] {
|
|
3415
|
+
return toolspaceTokenSeedForAgent(agent)
|
|
3416
|
+
? [{
|
|
3417
|
+
id: "toolspace-token",
|
|
3418
|
+
phase: "beforeAgentStart",
|
|
3419
|
+
run: runToolspaceTokenSeedHook,
|
|
3420
|
+
}]
|
|
3421
|
+
: [];
|
|
3422
|
+
}
|
|
3423
|
+
|
|
2916
3424
|
function sandboxRepositoryCloneHooks(
|
|
2917
3425
|
settings: Settings,
|
|
2918
3426
|
resources: ResourceRef[],
|
|
@@ -3114,6 +3622,53 @@ export function repositoryCloneCommand(resources: Extract<ResourceRef, { kind: "
|
|
|
3114
3622
|
return commands.join("\n");
|
|
3115
3623
|
}
|
|
3116
3624
|
|
|
3625
|
+
export function toolspaceTokenSeedCommand(): string {
|
|
3626
|
+
return [
|
|
3627
|
+
"set -eu",
|
|
3628
|
+
"export HOME=\"${HOME:-/workspace}\"",
|
|
3629
|
+
"if [ -n \"${OPENGENI_TOOLSPACE_TOKEN_SEED:-}\" ]; then",
|
|
3630
|
+
" seed_umask=\"$(umask)\"",
|
|
3631
|
+
" umask 077",
|
|
3632
|
+
" token_file=\"${OPENGENI_TOOLSPACE_TOKEN_FILE:-$HOME/.opengeni/toolspace-token}\"",
|
|
3633
|
+
" mkdir -p \"$(dirname \"$token_file\")\"",
|
|
3634
|
+
" printf '%s' \"$OPENGENI_TOOLSPACE_TOKEN_SEED\" > \"$token_file.tmp.$$\"",
|
|
3635
|
+
" mv -f \"$token_file.tmp.$$\" \"$token_file\"",
|
|
3636
|
+
" umask \"$seed_umask\"",
|
|
3637
|
+
"fi",
|
|
3638
|
+
].join("\n");
|
|
3639
|
+
}
|
|
3640
|
+
|
|
3641
|
+
export async function runToolspaceTokenSeedHook(
|
|
3642
|
+
session: SandboxSessionLike,
|
|
3643
|
+
context: SandboxLifecycleHookContext,
|
|
3644
|
+
): Promise<void> {
|
|
3645
|
+
if (!context.toolspaceTokenSeed) {
|
|
3646
|
+
return;
|
|
3647
|
+
}
|
|
3648
|
+
const command = `export OPENGENI_TOOLSPACE_TOKEN_SEED=${shellQuote(context.toolspaceTokenSeed)}\n${toolspaceTokenSeedCommand()}`;
|
|
3649
|
+
if (session.exec) {
|
|
3650
|
+
const result = await session.exec({
|
|
3651
|
+
cmd: command,
|
|
3652
|
+
workdir: "/workspace",
|
|
3653
|
+
...(context.runAs ? { runAs: context.runAs } : {}),
|
|
3654
|
+
yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
|
|
3655
|
+
maxOutputTokens: 4_000,
|
|
3656
|
+
});
|
|
3657
|
+
assertSandboxCommandSucceeded(result, "Toolspace token seed hook");
|
|
3658
|
+
} else if (session.execCommand) {
|
|
3659
|
+
const result = await session.execCommand({
|
|
3660
|
+
cmd: command,
|
|
3661
|
+
workdir: "/workspace",
|
|
3662
|
+
...(context.runAs ? { runAs: context.runAs } : {}),
|
|
3663
|
+
yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
|
|
3664
|
+
maxOutputTokens: 4_000,
|
|
3665
|
+
});
|
|
3666
|
+
assertSandboxCommandSucceeded(result, "Toolspace token seed hook");
|
|
3667
|
+
} else {
|
|
3668
|
+
throw new Error("Sandbox session does not support command execution");
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
|
|
3117
3672
|
export async function runRepositoryCloneHook(
|
|
3118
3673
|
session: SandboxSessionLike,
|
|
3119
3674
|
resources: Extract<ResourceRef, { kind: "repository" }>[],
|