@opengeni/runtime 0.3.1 → 0.4.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/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
- CLIENT_COMPACTION_TRIGGER_FRACTION,
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 treats a failed summarize as "skip compaction this turn"
610
- * - never fatal). The call deliberately does NOT request reasoning encryption,
611
- * tools, or server-side compaction; it is a self-contained summarize.
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: renderCompactionPromptInputForChat(input) }],
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 (compaction skipped this turn)", error);
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
@@ -913,11 +954,48 @@ const agentRepositoryCloneHooks = new WeakMap<object, SandboxLifecycleHook[]>();
913
954
  // session env; runStream reads it to build the clone hook context. Absent when
914
955
  // no repo is attached / on the selfhosted path.
915
956
  const agentGitTokenSeed = new WeakMap<object, string>();
957
+ const agentToolspaceTokenSeed = new WeakMap<object, string>();
916
958
  // The EFFECTIVE backend the turn resolved for this agent (undefined -> the home
917
959
  // backend). Read by runStream's owned branch to keep platform box-setup hooks off
918
960
  // connected machines (a user's real computer).
919
961
  const agentActiveSandboxBackend = new WeakMap<object, Settings["sandboxBackend"]>();
920
962
 
963
+ /**
964
+ * The tool output emitted for an MCP tool call that FAILED with a THROWN error
965
+ * — a JSON-RPC protocol error (e.g. -32602 invalid params), an auth 401/403, a
966
+ * transport failure, a timeout, or tool-not-found. Shaped as an MCP
967
+ * `{ isError: true, content }` result so the failure is CARRIED on the tool
968
+ * output rather than erased.
969
+ *
970
+ * The SDK's `defaultToolErrorFunction` would instead turn a thrown tool error
971
+ * into a plain STRING ("An error occurred while running the tool…") returned as
972
+ * a NORMAL, successful-looking result — so `agent.toolCall.output` carried no
973
+ * error signal and the timeline rendered the failure as success. `normalizeSdkEvent`
974
+ * captures `RunToolCallOutputItem.output` (the raw function return) verbatim, so
975
+ * returning this object makes the emitted payload's `output.isError === true`,
976
+ * which the timeline projection's `isErrorOutput` (packages/react/src/timeline/
977
+ * projection.ts) settles to "failed" and downstream client normalizers read the same field.
978
+ *
979
+ * SCOPE: this only covers THROWN MCP failures (the ones that reach an
980
+ * errorFunction). An MCP tool result carrying `isError: true` INLINE alongside
981
+ * content is stripped to its `content` by @openai/agents-core's streamable-http
982
+ * shim (dist/shims/mcp-server/node.js `callTool` returns `parsed.content`,
983
+ * dropping `parsed.isError`) BEFORE the runtime ever sees it. Covering that mode
984
+ * requires an SDK-level change (preserve `isError`) or reading the raw
985
+ * CallToolResult — tracked separately.
986
+ */
987
+ export function mcpToolErrorOutput(error: unknown): { isError: true; content: [{ type: "text"; text: string }] } {
988
+ const details = error instanceof Error ? error.message : String(error);
989
+ return { isError: true, content: [{ type: "text", text: `An error occurred while running the tool. Please try again. Error: ${details}` }] };
990
+ }
991
+
992
+ // Applied to EVERY MCP server via the agent's `mcpConfig.errorFunction`
993
+ // (server-level errorFunctions would win, but PrefixedMcpServer sets none). The
994
+ // SDK types the return as `string`; the runtime actually stores the raw return
995
+ // as the tool output, so we return the MCP-shaped error object and cast — this
996
+ // is the only way to attach an `isError` flag to a failed MCP tool call's output.
997
+ const mcpToolErrorFunction: MCPToolErrorFunction = ({ error }) => mcpToolErrorOutput(error) as unknown as string;
998
+
921
999
  export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[], options: BuildAgentOptions = {}): Agent<any, any> {
922
1000
  // Resolved per-turn gating. Each override defaults to today's settings-derived
923
1001
  // behaviour, so the legacy global-client callers (no resolved model) build the
@@ -993,11 +1071,19 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
993
1071
  // spread; the SDK concatenates these with MCP and sandbox capability tools.
994
1072
  ...(hostedTools.length ? { tools: hostedTools } : {}),
995
1073
  ...(options.mcpServers?.length ? { mcpServers: options.mcpServers } : {}),
1074
+ // Surface FAILED MCP tool calls as `{ isError: true }` tool output (see
1075
+ // mcpToolErrorFunction / mcpToolErrorOutput) instead of the SDK's default
1076
+ // flat error string, so a thrown MCP failure (protocol error, auth, timeout,
1077
+ // tool-not-found) settles the timeline tool to "failed" rather than rendering
1078
+ // as success. Applies to every MCP server on this agent (session, first-party,
1079
+ // capability, codex_apps) since none set a server-level errorFunction.
1080
+ mcpConfig: { errorFunction: mcpToolErrorFunction },
996
1081
  } as const;
997
1082
 
998
1083
  if (settings.sandboxBackend === "none") {
999
1084
  const agent = new Agent(baseConfig);
1000
1085
  maybeInstallCodexToolSearch(agent, settings, options);
1086
+ applyMcpApprovalPolicy(agent, settings);
1001
1087
  return agent;
1002
1088
  }
1003
1089
 
@@ -1028,7 +1114,11 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
1028
1114
  if (options.gitTokenSeed) {
1029
1115
  agentGitTokenSeed.set(agent, options.gitTokenSeed);
1030
1116
  }
1117
+ if (options.toolspaceTokenSeed) {
1118
+ agentToolspaceTokenSeed.set(agent, options.toolspaceTokenSeed);
1119
+ }
1031
1120
  maybeInstallCodexToolSearch(agent, settings, options);
1121
+ applyMcpApprovalPolicy(agent, settings);
1032
1122
  return agent;
1033
1123
  }
1034
1124
 
@@ -1050,6 +1140,101 @@ function maybeInstallCodexToolSearch(agent: Agent<any, any>, settings: Settings,
1050
1140
  }
1051
1141
  }
1052
1142
 
1143
+ /** True when the unprefixed tool `name` requires approval under `policy`. */
1144
+ function mcpToolRequiresApproval(policy: boolean | string[], unprefixedName: string): boolean {
1145
+ return policy === true || (Array.isArray(policy) && policy.includes(unprefixedName));
1146
+ }
1147
+
1148
+ /** A per-server approval policy keyed by the server's `<id>__` tool prefix. */
1149
+ type McpApprovalPolicy = { prefix: string; requireApproval: boolean | string[] };
1150
+
1151
+ /** The subset of the agent surface the approval wrap needs — including `clone`. */
1152
+ type ApprovalCapableAgent = {
1153
+ getMcpTools: (runContext: unknown) => Promise<Tool<any>[]>;
1154
+ clone?: (config: unknown) => ApprovalCapableAgent;
1155
+ };
1156
+
1157
+ /**
1158
+ * Install the approval wrap on a single agent instance: replace `getMcpTools`
1159
+ * with one that stamps `needsApproval: () => true` on every MCP tool whose
1160
+ * server policy demands it. Tools are matched by the server's `<id>__` prefix
1161
+ * (LONGEST prefix first — see {@link applyMcpApprovalPolicy}), then the
1162
+ * unprefixed tool name.
1163
+ *
1164
+ * CLONE SURVIVAL (mirrors {@link installCodexToolSearch}): the sandbox runtime
1165
+ * resolves tools not on the agent we build here but on a FRESH clone —
1166
+ * `prepareSandboxAgent` calls `agent.clone(...)`, and `SandboxAgent.clone`
1167
+ * reconstructs from a FIXED field list (name/tools/mcpServers/…), so an
1168
+ * instance-own `getMcpTools` override is dropped and approval would silently
1169
+ * bypass on every sandbox turn. We therefore also wrap `clone` to RE-INSTALL the
1170
+ * policy onto every clone, recursively — covering clone-of-clone and the resume
1171
+ * paths. The base (non-sandbox) `Agent.clone` spreads `...this` and would carry
1172
+ * the override, but re-installing is idempotent there and keeps one code path.
1173
+ */
1174
+ function installMcpApprovalPolicy(agent: ApprovalCapableAgent, policies: McpApprovalPolicy[]): void {
1175
+ const listMcpTools = agent.getMcpTools.bind(agent);
1176
+ agent.getMcpTools = async (runContext: unknown) => {
1177
+ const tools = await listMcpTools(runContext);
1178
+ return tools.map((tool) => {
1179
+ if (tool.type !== "function") {
1180
+ return tool;
1181
+ }
1182
+ const policy = policies.find((entry) => tool.name.startsWith(entry.prefix));
1183
+ if (!policy) {
1184
+ return tool;
1185
+ }
1186
+ const unprefixed = tool.name.slice(policy.prefix.length);
1187
+ return mcpToolRequiresApproval(policy.requireApproval, unprefixed)
1188
+ ? { ...tool, needsApproval: async () => true }
1189
+ : tool;
1190
+ });
1191
+ };
1192
+ const originalClone = agent.clone?.bind(agent);
1193
+ if (originalClone) {
1194
+ agent.clone = (config: unknown) => {
1195
+ const cloned = originalClone(config);
1196
+ installMcpApprovalPolicy(cloned, policies);
1197
+ return cloned;
1198
+ };
1199
+ }
1200
+ }
1201
+
1202
+ /**
1203
+ * Enforce per-MCP-server human approval. `settings.mcpServers[].requireApproval`
1204
+ * is `true` (every tool of that server requires approval) or a string[] of
1205
+ * UNPREFIXED tool names (only those do); absent = auto-run. The SDK converts MCP
1206
+ * tools to function tools with `needsApproval` unset (defaults false) and exposes
1207
+ * no per-server/agent approval knob, so we wrap the agent's `getMcpTools` to
1208
+ * attach a `needsApproval: () => true` predicate to the matching tools — matched
1209
+ * by the server's `<id>__` prefix, then the unprefixed tool name. A tool that
1210
+ * needs approval raises a run INTERRUPTION, which the worker turns into
1211
+ * `session.requiresAction` and resolves via `user.approvalDecision`
1212
+ * (resumeApproval) — the same generic path shell/computer-use approvals use, so
1213
+ * no extra plumbing. No-op when no server requests approval, so the default
1214
+ * (auto-run everything) is byte-for-byte unchanged.
1215
+ *
1216
+ * Two robustness properties the wrap must hold:
1217
+ * - LONGEST-PREFIX-FIRST. Server ids can be prefixes of one another (`my` vs
1218
+ * `my_`), so their tool prefixes collide (`my__` vs `my___`): a tool like
1219
+ * `my___run` (from server `my_`) also `startsWith` `my__` (server `my`). A
1220
+ * first-match `find` over unsorted policies could bind it to the WRONG
1221
+ * server's policy and bypass gating. Sorting policies by DESCENDING prefix
1222
+ * length makes the most-specific (longest) prefix win, so each tool resolves
1223
+ * to its own server.
1224
+ * - CLONE SURVIVAL. The wrap is re-installed onto every clone; see
1225
+ * {@link installMcpApprovalPolicy}.
1226
+ */
1227
+ function applyMcpApprovalPolicy(agent: Agent<any, any>, settings: Settings): void {
1228
+ const policies: McpApprovalPolicy[] = settings.mcpServers
1229
+ .filter((server) => server.requireApproval === true || (Array.isArray(server.requireApproval) && server.requireApproval.length > 0))
1230
+ .map((server) => ({ prefix: prefixedMcpToolName(server.id, ""), requireApproval: server.requireApproval as boolean | string[] }))
1231
+ .sort((a, b) => b.prefix.length - a.prefix.length);
1232
+ if (policies.length === 0) {
1233
+ return;
1234
+ }
1235
+ installMcpApprovalPolicy(agent as unknown as ApprovalCapableAgent, policies);
1236
+ }
1237
+
1053
1238
  /**
1054
1239
  * Force a sandbox capability to emit its FUNCTION-transport tool variants instead
1055
1240
  * of the hosted ones, by dropping the model instance the SDK's transport
@@ -1218,6 +1403,8 @@ export type PrepareToolsOptions = {
1218
1403
  // delegated token (manager-style sessions). The caller is responsible for
1219
1404
  // having validated the set against the session creator's grant.
1220
1405
  firstPartyPermissions?: Permission[];
1406
+ resolveCredential?: (input: ResolveConnectionCredentialInput) => Promise<ResolveConnectionCredentialResult>;
1407
+ onAuthNeeded?: (payload: ToolAuthNeededPayload) => Promise<void> | void;
1221
1408
  };
1222
1409
 
1223
1410
  export async function prepareAgentTools(settings: Settings, tools: ToolRef[], options: PrepareToolsOptions = {}): Promise<PreparedAgentTools> {
@@ -1235,6 +1422,12 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
1235
1422
  throw new Error(`Unknown MCP server id: ${tool.id}`);
1236
1423
  }
1237
1424
  const url = firstPartyMcpServerUrlForRun(settings, config, options.workspaceId) ?? config.url;
1425
+ const baseFetch = isCodexAppsMcpServer(config)
1426
+ ? codexAppsSanitizingFetch(globalThis.fetch, codexConnectorNamespaces)
1427
+ : globalThis.fetch;
1428
+ const fetchImpl = config.connectionRef
1429
+ ? connectionBrokerFetch(baseFetch, config, options)
1430
+ : baseFetch;
1238
1431
  const server = new PrefixedMcpServer(new MCPServerStreamableHttp({
1239
1432
  url,
1240
1433
  name: config.name ?? config.id,
@@ -1243,7 +1436,7 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
1243
1436
  // MCP SDK's strict Tool schema rejects (fails the turn during tools/list);
1244
1437
  // sanitize the response on the wire before validation. The namespace Set
1245
1438
  // also captures each tool's original connector namespace (P4 Part B.1).
1246
- ...(isCodexAppsMcpServer(config) ? { fetch: codexAppsSanitizingFetch(globalThis.fetch, codexConnectorNamespaces) } : {}),
1439
+ ...(fetchImpl !== globalThis.fetch ? { fetch: fetchImpl } : {}),
1247
1440
  ...await mcpServerRequestInit(settings, config, options),
1248
1441
  ...(config.timeoutMs ? {
1249
1442
  timeout: config.timeoutMs,
@@ -1262,7 +1455,7 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
1262
1455
  // with a warning, never killing the turn before the model runs. Bare
1263
1456
  // refs stay strict (below), preserving the fail-loud default.
1264
1457
  const optional = tool.optional === true;
1265
- return { server, bestEffort: isCodexAppsMcpServer(config) || optional, optional };
1458
+ return { server, bestEffort: isCodexAppsMcpServer(config) || optional || !!config.connectionRef, optional };
1266
1459
  }));
1267
1460
  const requiredServers = servers.filter((entry) => !entry.bestEffort).map((entry) => entry.server);
1268
1461
  const bestEffortServers = servers.filter((entry) => entry.bestEffort).map((entry) => entry.server);
@@ -1306,6 +1499,240 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
1306
1499
  };
1307
1500
  }
1308
1501
 
1502
+ function connectionBrokerFetch(
1503
+ baseFetch: FetchLike,
1504
+ config: Settings["mcpServers"][number],
1505
+ options: PrepareToolsOptions,
1506
+ ): FetchLike {
1507
+ const connectionRef = config.connectionRef;
1508
+ if (!connectionRef) {
1509
+ return baseFetch;
1510
+ }
1511
+ return async (input, init) => {
1512
+ const request = await mcpRequestInfo(input, init);
1513
+ const first = await resolveConnectionForRequest(options, config.id, connectionRef, request.toolName, false);
1514
+ if (first.status === "auth_needed") {
1515
+ return await authNeededFetchResponse(options, config.id, request, first, connectionRef);
1516
+ }
1517
+ const response = await baseFetch(fetchInputForAttempt(input), withConnectionHeaders(input, init, first.headers));
1518
+ if (response.status === 401) {
1519
+ const refreshed = await resolveConnectionForRequest(options, config.id, connectionRef, request.toolName, true);
1520
+ if (refreshed.status === "auth_needed") {
1521
+ return await authNeededFetchResponse(options, config.id, request, refreshed, connectionRef);
1522
+ }
1523
+ const retry = await baseFetch(fetchInputForAttempt(input), withConnectionHeaders(input, init, refreshed.headers));
1524
+ if (retry.status === 403) {
1525
+ const auth = insufficientScopeAuth(retry.headers, connectionRef, refreshed.connectionId);
1526
+ return auth ? await authNeededFetchResponse(options, config.id, request, auth, connectionRef) : retry;
1527
+ }
1528
+ if (retry.status === 401) {
1529
+ return await authNeededFetchResponse(options, config.id, request, {
1530
+ status: "auth_needed",
1531
+ reason: "expired",
1532
+ providerDomain: connectionRef.providerDomain,
1533
+ connectionId: refreshed.connectionId,
1534
+ ...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
1535
+ ...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
1536
+ }, connectionRef);
1537
+ }
1538
+ return retry;
1539
+ }
1540
+ if (response.status === 403) {
1541
+ const auth = insufficientScopeAuth(response.headers, connectionRef, first.connectionId);
1542
+ return auth ? await authNeededFetchResponse(options, config.id, request, auth, connectionRef) : response;
1543
+ }
1544
+ return response;
1545
+ };
1546
+ }
1547
+
1548
+ async function resolveConnectionForRequest(
1549
+ options: PrepareToolsOptions,
1550
+ serverId: string,
1551
+ connectionRef: McpServerConnectionRef,
1552
+ toolName: string | undefined,
1553
+ forceRefresh: boolean,
1554
+ ): Promise<ResolveConnectionCredentialResult> {
1555
+ if (!options.workspaceId || !options.resolveCredential) {
1556
+ return {
1557
+ status: "auth_needed",
1558
+ reason: "missing_connection",
1559
+ providerDomain: connectionRef.providerDomain,
1560
+ ...(connectionRef.connectionId ? { connectionId: connectionRef.connectionId } : {}),
1561
+ ...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
1562
+ ...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
1563
+ };
1564
+ }
1565
+ const request: ResolveConnectionCredentialInput = {
1566
+ workspaceId: options.workspaceId,
1567
+ serverId,
1568
+ connectionRef,
1569
+ forceRefresh,
1570
+ ...(toolName ? { toolName } : {}),
1571
+ ...(options.subjectId ? { subjectId: options.subjectId } : {}),
1572
+ };
1573
+ try {
1574
+ return await options.resolveCredential(request);
1575
+ } catch {
1576
+ return {
1577
+ status: "auth_needed",
1578
+ reason: "refresh_failed",
1579
+ providerDomain: connectionRef.providerDomain,
1580
+ ...(connectionRef.connectionId ? { connectionId: connectionRef.connectionId } : {}),
1581
+ ...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
1582
+ ...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
1583
+ };
1584
+ }
1585
+ }
1586
+
1587
+ function insufficientScopeAuth(
1588
+ headers: Headers,
1589
+ connectionRef: McpServerConnectionRef,
1590
+ connectionId: string,
1591
+ ): Extract<ResolveConnectionCredentialResult, { status: "auth_needed" }> | null {
1592
+ const challenge = parseWwwAuthenticate(headers.get("www-authenticate"));
1593
+ if (challenge.error !== "insufficient_scope") {
1594
+ return null;
1595
+ }
1596
+ return {
1597
+ status: "auth_needed",
1598
+ reason: "insufficient_scope",
1599
+ providerDomain: connectionRef.providerDomain,
1600
+ connectionId,
1601
+ ...(challenge.scope?.length ? { scopes: challenge.scope } : connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
1602
+ ...(challenge.resource ? { resource: challenge.resource } : connectionRef.resource ? { resource: connectionRef.resource } : {}),
1603
+ };
1604
+ }
1605
+
1606
+ async function authNeededFetchResponse(
1607
+ options: PrepareToolsOptions,
1608
+ serverId: string,
1609
+ request: McpRequestInfo,
1610
+ auth: Extract<ResolveConnectionCredentialResult, { status: "auth_needed" }>,
1611
+ connectionRef: McpServerConnectionRef,
1612
+ ): Promise<Response> {
1613
+ const connectionId = auth.connectionId ?? connectionRef.connectionId;
1614
+ await publishAuthNeeded(options, {
1615
+ serverId,
1616
+ toolName: request.toolName ?? null,
1617
+ providerDomain: auth.providerDomain,
1618
+ reason: auth.reason,
1619
+ ...(connectionId ? { connectionId } : {}),
1620
+ ...(auth.scopes ? { scopes: auth.scopes } : connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
1621
+ ...(auth.resource ? { resource: auth.resource } : connectionRef.resource ? { resource: connectionRef.resource } : {}),
1622
+ ...(auth.authorizationUrl ? { authorizationUrl: auth.authorizationUrl } : {}),
1623
+ ...(options.subjectId ? { subjectId: options.subjectId } : {}),
1624
+ });
1625
+ if (request.method === "tools/call") {
1626
+ return mcpToolAuthNeededResponse(request.id);
1627
+ }
1628
+ return new Response("Authentication required for MCP server connection", { status: 401 });
1629
+ }
1630
+
1631
+ async function publishAuthNeeded(options: PrepareToolsOptions, payload: ToolAuthNeededPayload): Promise<void> {
1632
+ try {
1633
+ await options.onAuthNeeded?.(payload);
1634
+ } catch {
1635
+ // Auth-needed events are advisory UI/audit signals; a publisher failure must
1636
+ // not turn an auth-recoverable tool condition into a failed agent turn.
1637
+ }
1638
+ }
1639
+
1640
+ type McpRequestInfo = {
1641
+ method?: string;
1642
+ id?: string | number | null;
1643
+ toolName?: string;
1644
+ };
1645
+
1646
+ async function mcpRequestInfo(input: string | URL | Request, init?: RequestInit): Promise<McpRequestInfo> {
1647
+ const body = typeof init?.body === "string"
1648
+ ? init.body
1649
+ : input instanceof Request && (init?.method ?? input.method).toUpperCase() === "POST"
1650
+ ? await input.clone().text().catch(() => "")
1651
+ : "";
1652
+ if (!body) {
1653
+ return {};
1654
+ }
1655
+ try {
1656
+ const parsed = JSON.parse(body) as { id?: unknown; method?: unknown; params?: { name?: unknown } };
1657
+ const method = typeof parsed.method === "string" ? parsed.method : undefined;
1658
+ const id = typeof parsed.id === "string" || typeof parsed.id === "number" || parsed.id === null ? parsed.id : undefined;
1659
+ const toolName = method === "tools/call" && typeof parsed.params?.name === "string" ? parsed.params.name : undefined;
1660
+ return {
1661
+ ...(method ? { method } : {}),
1662
+ ...(id !== undefined ? { id } : {}),
1663
+ ...(toolName ? { toolName } : {}),
1664
+ };
1665
+ } catch {
1666
+ return {};
1667
+ }
1668
+ }
1669
+
1670
+ function withConnectionHeaders(input: string | URL | Request, init: RequestInit | undefined, authHeaders: Record<string, string>): RequestInit {
1671
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
1672
+ for (const [name, value] of Object.entries(authHeaders)) {
1673
+ headers.set(name, value);
1674
+ }
1675
+ return { ...init, headers };
1676
+ }
1677
+
1678
+ function fetchInputForAttempt(input: string | URL | Request): string | URL | Request {
1679
+ return input instanceof Request ? input.clone() : input;
1680
+ }
1681
+
1682
+ function parseWwwAuthenticate(header: string | null): { error?: string; scope?: string[]; resource?: string } {
1683
+ if (!header) {
1684
+ return {};
1685
+ }
1686
+ const bearerIndex = header.toLowerCase().indexOf("bearer");
1687
+ if (bearerIndex < 0) {
1688
+ return {};
1689
+ }
1690
+ const paramsText = header.slice(bearerIndex + "bearer".length);
1691
+ const params: Record<string, string> = {};
1692
+ const re = /([a-zA-Z_][a-zA-Z0-9_-]*)\s*=\s*("(?:[^"\\]|\\.)*"|[^,\s]+)/g;
1693
+ let match: RegExpExecArray | null;
1694
+ while ((match = re.exec(paramsText)) !== null) {
1695
+ const raw = match[2]!;
1696
+ params[match[1]!.toLowerCase()] = raw.startsWith("\"") ? raw.slice(1, -1).replace(/\\"/g, "\"") : raw;
1697
+ }
1698
+ return {
1699
+ ...(params.error ? { error: params.error } : {}),
1700
+ ...(params.scope ? { scope: params.scope.split(/\s+/).filter(Boolean) } : {}),
1701
+ ...(params.resource ? { resource: params.resource } : {}),
1702
+ };
1703
+ }
1704
+
1705
+ // Application-defined JSON-RPC error code marking "this tool call needs a
1706
+ // connection". A RESULT carrying inline `isError: true` cannot be used here:
1707
+ // the agents SDK's streamable-http shim strips `isError` and returns only the
1708
+ // content, erasing the failure. A JSON-RPC error survives the shim as a thrown
1709
+ // McpError, which PrefixedMcpServer.callTool converts back into an MCP-shaped
1710
+ // `{ isError: true }` output for the model.
1711
+ const MCP_AUTH_NEEDED_ERROR_CODE = -32001;
1712
+ const MCP_AUTH_NEEDED_MESSAGE = "Authentication required - a connection link was posted to the session.";
1713
+
1714
+ function mcpToolAuthNeededResponse(id: string | number | null | undefined): Response {
1715
+ return new Response(JSON.stringify({
1716
+ jsonrpc: "2.0",
1717
+ id: id ?? null,
1718
+ error: {
1719
+ code: MCP_AUTH_NEEDED_ERROR_CODE,
1720
+ message: MCP_AUTH_NEEDED_MESSAGE,
1721
+ },
1722
+ }), {
1723
+ status: 200,
1724
+ headers: { "content-type": "application/json" },
1725
+ });
1726
+ }
1727
+
1728
+ function isAuthNeededMcpError(error: unknown): boolean {
1729
+ if (!(error instanceof Error)) {
1730
+ return false;
1731
+ }
1732
+ const code = (error as { code?: unknown }).code;
1733
+ return code === MCP_AUTH_NEEDED_ERROR_CODE || error.message.includes(MCP_AUTH_NEEDED_MESSAGE);
1734
+ }
1735
+
1309
1736
  async function mcpServerRequestInit(settings: Settings, config: Settings["mcpServers"][number], options: PrepareToolsOptions): Promise<{ requestInit: { headers: Record<string, string> } } | {}> {
1310
1737
  // codex_apps is checked FIRST so the static-headers path can never apply to
1311
1738
  // it: its refreshing ChatGPT/Codex bearer is resolved per-connect from the
@@ -1495,7 +1922,7 @@ function normalizeUrl(raw: string): string | null {
1495
1922
  }
1496
1923
 
1497
1924
  export function prefixedMcpToolName(registryId: string, toolName: string): string {
1498
- return `${registryId}__${toolName}`;
1925
+ return sharedPrefixedMcpToolName(registryId, toolName);
1499
1926
  }
1500
1927
 
1501
1928
  class PrefixedMcpServer implements MCPServer {
@@ -1531,7 +1958,18 @@ class PrefixedMcpServer implements MCPServer {
1531
1958
  if (!this.isAllowed(unprefixed)) {
1532
1959
  throw new Error(`MCP tool ${unprefixed} is not allowed for server ${this.name}`);
1533
1960
  }
1534
- return await this.inner.callTool(unprefixed, args, meta);
1961
+ try {
1962
+ return await this.inner.callTool(unprefixed, args, meta);
1963
+ } catch (error) {
1964
+ // The connection broker's auth-needed short-circuit arrives as a thrown
1965
+ // JSON-RPC error (an inline isError result would be stripped by the SDK
1966
+ // shim). Surface it to the model as a failed-but-recoverable tool result
1967
+ // instead of failing the turn; the timeline chip was already published.
1968
+ if (isAuthNeededMcpError(error)) {
1969
+ return { isError: true, content: [{ type: "text", text: MCP_AUTH_NEEDED_MESSAGE }] };
1970
+ }
1971
+ throw error;
1972
+ }
1535
1973
  }
1536
1974
 
1537
1975
  invalidateToolsCache(): Promise<void> {
@@ -1919,8 +2357,10 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
1919
2357
  // TOKEN-BROKER (B1): the per-turn git token seed, forwarded OFF-MANIFEST so the
1920
2358
  // repository-clone hook seeds it to the box's token file before the clone.
1921
2359
  const ownedGitTokenSeed = gitTokenSeedForAgent(agent);
2360
+ const ownedToolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
1922
2361
  const ownedHooks = [
1923
2362
  ...sandboxLifecycleHooksForIds(sandboxLifecycleHookIds(settings)),
2363
+ ...sandboxToolspaceTokenHooksForAgent(agent),
1924
2364
  ...sandboxRepositoryCloneHooksForAgent(agent),
1925
2365
  ];
1926
2366
  const ownedHookContext: SandboxLifecycleHookContext = {
@@ -1928,6 +2368,7 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
1928
2368
  ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
1929
2369
  ...(runAs ? { runAs } : {}),
1930
2370
  ...(ownedGitTokenSeed ? { gitTokenSeed: ownedGitTokenSeed } : {}),
2371
+ ...(ownedToolspaceTokenSeed ? { toolspaceTokenSeed: ownedToolspaceTokenSeed } : {}),
1931
2372
  };
1932
2373
  // OWNED-PATH HOOKS: the SDK NEVER calls client.create/resume when handed a live
1933
2374
  // provided session (SandboxRuntimeManager uses `sandboxConfig.session` directly),
@@ -2002,15 +2443,18 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
2002
2443
  // TOKEN-BROKER (B1): the per-turn git token seed, forwarded OFF-MANIFEST so the
2003
2444
  // repository-clone hook seeds it to the box's token file before the clone.
2004
2445
  const gitTokenSeed = gitTokenSeedForAgent(agent);
2446
+ const toolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
2005
2447
  const client = resourceClient
2006
2448
  ? withSandboxLifecycleHooks(resourceClient, [
2007
2449
  ...sandboxLifecycleHooksForIds(sandboxLifecycleHookIds(settings)),
2450
+ ...sandboxToolspaceTokenHooksForAgent(agent),
2008
2451
  ...sandboxRepositoryCloneHooksForAgent(agent),
2009
2452
  ], {
2010
2453
  environment,
2011
2454
  ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
2012
2455
  ...(runAs ? { runAs } : {}),
2013
2456
  ...(gitTokenSeed ? { gitTokenSeed } : {}),
2457
+ ...(toolspaceTokenSeed ? { toolspaceTokenSeed } : {}),
2014
2458
  })
2015
2459
  : undefined;
2016
2460
  const sandboxSessionState = prepared.sandboxSessionState
@@ -2813,6 +3257,7 @@ export type SandboxLifecycleHookContext = {
2813
3257
  // the clone exec's per-call env (OPENGENI_GIT_TOKEN_SEED), NEVER the box/agent
2814
3258
  // manifest env (validateNoEnvironmentDelta must never see a rotating value).
2815
3259
  gitTokenSeed?: string;
3260
+ toolspaceTokenSeed?: string;
2816
3261
  };
2817
3262
 
2818
3263
  export type SandboxLifecycleHook = {
@@ -2913,6 +3358,20 @@ function gitTokenSeedForAgent(agent: Agent<any, any>): string | undefined {
2913
3358
  return agentGitTokenSeed.get(agent);
2914
3359
  }
2915
3360
 
3361
+ function toolspaceTokenSeedForAgent(agent: Agent<any, any>): string | undefined {
3362
+ return agentToolspaceTokenSeed.get(agent);
3363
+ }
3364
+
3365
+ function sandboxToolspaceTokenHooksForAgent(agent: Agent<any, any>): SandboxLifecycleHook[] {
3366
+ return toolspaceTokenSeedForAgent(agent)
3367
+ ? [{
3368
+ id: "toolspace-token",
3369
+ phase: "beforeAgentStart",
3370
+ run: runToolspaceTokenSeedHook,
3371
+ }]
3372
+ : [];
3373
+ }
3374
+
2916
3375
  function sandboxRepositoryCloneHooks(
2917
3376
  settings: Settings,
2918
3377
  resources: ResourceRef[],
@@ -3114,6 +3573,53 @@ export function repositoryCloneCommand(resources: Extract<ResourceRef, { kind: "
3114
3573
  return commands.join("\n");
3115
3574
  }
3116
3575
 
3576
+ export function toolspaceTokenSeedCommand(): string {
3577
+ return [
3578
+ "set -eu",
3579
+ "export HOME=\"${HOME:-/workspace}\"",
3580
+ "if [ -n \"${OPENGENI_TOOLSPACE_TOKEN_SEED:-}\" ]; then",
3581
+ " seed_umask=\"$(umask)\"",
3582
+ " umask 077",
3583
+ " token_file=\"${OPENGENI_TOOLSPACE_TOKEN_FILE:-$HOME/.opengeni/toolspace-token}\"",
3584
+ " mkdir -p \"$(dirname \"$token_file\")\"",
3585
+ " printf '%s' \"$OPENGENI_TOOLSPACE_TOKEN_SEED\" > \"$token_file.tmp.$$\"",
3586
+ " mv -f \"$token_file.tmp.$$\" \"$token_file\"",
3587
+ " umask \"$seed_umask\"",
3588
+ "fi",
3589
+ ].join("\n");
3590
+ }
3591
+
3592
+ export async function runToolspaceTokenSeedHook(
3593
+ session: SandboxSessionLike,
3594
+ context: SandboxLifecycleHookContext,
3595
+ ): Promise<void> {
3596
+ if (!context.toolspaceTokenSeed) {
3597
+ return;
3598
+ }
3599
+ const command = `export OPENGENI_TOOLSPACE_TOKEN_SEED=${shellQuote(context.toolspaceTokenSeed)}\n${toolspaceTokenSeedCommand()}`;
3600
+ if (session.exec) {
3601
+ const result = await session.exec({
3602
+ cmd: command,
3603
+ workdir: "/workspace",
3604
+ ...(context.runAs ? { runAs: context.runAs } : {}),
3605
+ yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
3606
+ maxOutputTokens: 4_000,
3607
+ });
3608
+ assertSandboxCommandSucceeded(result, "Toolspace token seed hook");
3609
+ } else if (session.execCommand) {
3610
+ const result = await session.execCommand({
3611
+ cmd: command,
3612
+ workdir: "/workspace",
3613
+ ...(context.runAs ? { runAs: context.runAs } : {}),
3614
+ yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
3615
+ maxOutputTokens: 4_000,
3616
+ });
3617
+ assertSandboxCommandSucceeded(result, "Toolspace token seed hook");
3618
+ } else {
3619
+ throw new Error("Sandbox session does not support command execution");
3620
+ }
3621
+ }
3622
+
3117
3623
  export async function runRepositoryCloneHook(
3118
3624
  session: SandboxSessionLike,
3119
3625
  resources: Extract<ResourceRef, { kind: "repository" }>[],