@opengeni/runtime 0.3.0 → 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,
@@ -27,6 +27,7 @@ import {
27
27
  setDefaultOpenAIClient,
28
28
  setDefaultOpenAIKey,
29
29
  setOpenAIResponsesTransport,
30
+ setTracingDisabled,
30
31
  // Hosted web_search tool factory. Re-exported from @openai/agents-openai via
31
32
  // `export * from '@openai/agents-openai'` in @openai/agents' index (0.11.6);
32
33
  // it returns a { type: 'hosted_tool', providerData: { type: 'web_search' } }
@@ -42,9 +43,11 @@ import {
42
43
  type AgentInputItem,
43
44
  type CallModelInputFilter,
44
45
  type MCPServer,
46
+ type MCPToolErrorFunction,
45
47
  type Model,
46
48
  type ModelProvider,
47
49
  type RunStreamEvent,
50
+ type Tool,
48
51
  } from "@openai/agents";
49
52
  import {
50
53
  localDirLazySkillSource,
@@ -101,6 +104,9 @@ import {
101
104
  setSelfhostedApplyDiff,
102
105
  } from "./sandbox";
103
106
  import { computerUse, type ComputerToolMode } from "./sandbox-computer";
107
+ import type { RuntimeMetricsHooks } from "./metrics";
108
+
109
+ export type { RuntimeMetricsHooks } from "./metrics";
104
110
 
105
111
  // P4.3 computer-use surface (the agent's :0 driver). Re-exported from the barrel
106
112
  // so callers (the worker, live proofs) reach SandboxComputer/ComputerUseCapability
@@ -145,8 +151,10 @@ export { OpenAIChatCompletionsModel, OpenAIResponsesModel } from "@openai/agents
145
151
  export {
146
152
  CompactionNeededError,
147
153
  buildCompactionPromptInput,
154
+ buildDeterministicFallbackCompactionHistory,
148
155
  buildCompactionReplacementHistory,
149
156
  clientCompactionThresholdTokens,
157
+ clampCompactionThresholdRatio,
150
158
  decideClientCompaction,
151
159
  enforceInputBudget,
152
160
  buildSummaryItem,
@@ -160,7 +168,9 @@ export {
160
168
  COMPACTION_SUMMARY_MARKER,
161
169
  COMPACTION_PROMPT,
162
170
  COMPACT_USER_MESSAGE_MAX_TOKENS,
163
- CLIENT_COMPACTION_TRIGGER_FRACTION,
171
+ DEFAULT_COMPACTION_THRESHOLD_RATIO,
172
+ MIN_COMPACTION_THRESHOLD_RATIO,
173
+ MAX_COMPACTION_THRESHOLD_RATIO,
164
174
  SUMMARY_BUFFER_TOKENS,
165
175
  SUMMARY_PREFIX,
166
176
  USER_MESSAGE_TRUNCATION_MARKER,
@@ -193,6 +203,29 @@ export type ModelResponseUsage = {
193
203
 
194
204
  type RuntimeMcpTool = Awaited<ReturnType<MCPServer["listTools"]>>[number];
195
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
+
196
229
  export function ensureReadableStreamFrom(): void {
197
230
  const ctor = globalThis.ReadableStream as (typeof ReadableStream & {
198
231
  from?: <T>(source: Iterable<T> | AsyncIterable<T>) => ReadableStream<T>;
@@ -253,6 +286,12 @@ export type SandboxFileDownload = {
253
286
  sizeBytes?: number;
254
287
  };
255
288
 
289
+ let runtimeMetricsHooks: RuntimeMetricsHooks | null = null;
290
+
291
+ export function configureRuntimeMetricsHooks(hooks: RuntimeMetricsHooks | null | undefined): void {
292
+ runtimeMetricsHooks = hooks ?? null;
293
+ }
294
+
256
295
  export type OpenGeniRuntime = {
257
296
  configure: (settings: Settings) => void;
258
297
  // Multi-provider per-turn model routing. Returns the resolved provider, its
@@ -270,11 +309,15 @@ export type OpenGeniRuntime = {
270
309
  export type ProductionRuntimeOverrides = {
271
310
  model?: Model;
272
311
  sandboxClient?: unknown;
312
+ metrics?: RuntimeMetricsHooks;
273
313
  };
274
314
 
275
315
  export function createProductionAgentRuntime(overrides: ProductionRuntimeOverrides = {}): OpenGeniRuntime {
276
316
  return {
277
- configure: configureOpenAI,
317
+ configure: (settings) => {
318
+ configureRuntimeMetricsHooks(overrides.metrics);
319
+ configureOpenAI(settings);
320
+ },
278
321
  // A test/override model shadows the registry routing entirely (the scripted
279
322
  // model used in worker tests is not in any provider's allow-list), so when
280
323
  // one is supplied resolveTurnModel reports "no resolution" and the caller
@@ -301,7 +344,7 @@ export function createProductionAgentRuntime(overrides: ProductionRuntimeOverrid
301
344
  * the OpenAI-platform path has only a key (the SDK default client is used via
302
345
  * setDefaultOpenAIKey there); the caller then constructs a key-only client.
303
346
  */
304
- export function buildOpenAIClientFromSettings(settings: Settings): OpenAI {
347
+ export function buildOpenAIClientFromSettings(settings: Settings, providerId: string = settings.openaiProvider): OpenAI {
305
348
  if (settings.openaiProvider === "azure") {
306
349
  const baseURL = settings.azureOpenaiBaseUrl ?? azureDeploymentBaseUrl(settings);
307
350
  const apiKey = settings.azureOpenaiApiKey ?? settings.azureOpenaiAdToken ?? "azure-ad-token";
@@ -318,13 +361,14 @@ export function buildOpenAIClientFromSettings(settings: Settings): OpenAI {
318
361
  // seam — below the SDK responses converter, which always re-synthesizes BOTH
319
362
  // `action` and `actions` (rejected 400 "exactly one of action or actions").
320
363
  // See computerCallNormalizingFetch / rewriteComputerCallsToActionsOnly.
321
- fetch: computerCallNormalizingFetch(globalThis.fetch),
364
+ fetch: computerCallNormalizingFetch(instrumentedModelFetch(providerId, globalThis.fetch)),
322
365
  });
323
366
  }
324
367
  return new OpenAI({
325
368
  apiKey: settings.openaiApiKey ?? process.env.OPENAI_API_KEY,
326
369
  ...(settings.openaiBaseUrl ? { baseURL: settings.openaiBaseUrl } : {}),
327
370
  maxRetries: settings.openaiMaxRetries,
371
+ fetch: instrumentedModelFetch(providerId, globalThis.fetch),
328
372
  });
329
373
  }
330
374
 
@@ -346,7 +390,7 @@ export function buildProviderClient(provider: ResolvedModelProvider, settings: S
346
390
  return cached;
347
391
  }
348
392
  const client = provider.builtin
349
- ? buildOpenAIClientFromSettings(settings)
393
+ ? buildOpenAIClientFromSettings(settings, provider.id)
350
394
  : provider.kind === "codex-subscription"
351
395
  // Codex subscription: the static apiKey is a placeholder — the real per-request
352
396
  // bearer + ChatGPT-Account-ID, the /responses->/codex/responses rewrite, and the
@@ -358,7 +402,7 @@ export function buildProviderClient(provider: ResolvedModelProvider, settings: S
358
402
  apiKey: provider.apiKey ?? "codex-subscription",
359
403
  ...(provider.baseUrl ? { baseURL: provider.baseUrl } : {}),
360
404
  maxRetries: settings.openaiMaxRetries,
361
- fetch: codexSubscriptionFetch(globalThis.fetch),
405
+ fetch: codexSubscriptionFetch(instrumentedModelFetch(provider.id, globalThis.fetch)),
362
406
  })
363
407
  // ResolvedModelProvider.apiKey is already the resolved key (configuredProviders
364
408
  // ran resolveProviderApiKey at config time, collapsing apiKey/apiKeyEnv), so it
@@ -369,6 +413,7 @@ export function buildProviderClient(provider: ResolvedModelProvider, settings: S
369
413
  maxRetries: settings.openaiMaxRetries,
370
414
  ...(provider.defaultQuery ? { defaultQuery: provider.defaultQuery } : {}),
371
415
  ...(provider.defaultHeaders ? { defaultHeaders: provider.defaultHeaders } : {}),
416
+ fetch: instrumentedModelFetch(provider.id, globalThis.fetch),
372
417
  });
373
418
  providerClientCache.set(provider.id, client);
374
419
  return client;
@@ -521,6 +566,7 @@ export class CodexSubscriptionUnavailableError extends Error {
521
566
 
522
567
  export function configureOpenAI(settings: Settings): void {
523
568
  setOpenAIResponsesTransport(settings.openaiResponsesTransport);
569
+ setTracingDisabled(settings.disableOpenaiTracing || !settings.observabilityOtlpEndpoint);
524
570
  // Install the registry-aware router as the process default model provider so a
525
571
  // model name re-resolved on the SandboxAgent/Modal path (where a Model instance
526
572
  // does not survive) routes to its provider instead of the built-in client.
@@ -540,13 +586,59 @@ export function configureOpenAI(settings: Settings): void {
540
586
  setDefaultModelProvider(router);
541
587
  }
542
588
 
589
+ function instrumentedModelFetch(provider: string, inner: typeof fetch): typeof fetch {
590
+ return (async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
591
+ if (!isModelCallFetch(input)) {
592
+ return await inner(input, init);
593
+ }
594
+ const started = performance.now();
595
+ try {
596
+ const response = await inner(input, init);
597
+ recordModelCallMetric(provider, response.ok ? "completed" : "failed", started);
598
+ return response;
599
+ } catch (error) {
600
+ recordModelCallMetric(provider, "failed", started);
601
+ throw error;
602
+ }
603
+ }) as typeof fetch;
604
+ }
605
+
606
+ function isModelCallFetch(input: Parameters<typeof fetch>[0]): boolean {
607
+ const rawUrl = typeof input === "string"
608
+ ? input
609
+ : input instanceof URL
610
+ ? input.toString()
611
+ : (input as { url?: unknown }).url;
612
+ if (typeof rawUrl !== "string" || rawUrl.length === 0) {
613
+ return false;
614
+ }
615
+ try {
616
+ const pathname = new URL(rawUrl, "http://opengeni.local").pathname;
617
+ return pathname.endsWith("/responses")
618
+ || pathname.endsWith("/chat/completions")
619
+ || pathname.endsWith("/codex/responses");
620
+ } catch {
621
+ return /\/(?:codex\/)?responses(?:\?|$)|\/chat\/completions(?:\?|$)/.test(rawUrl);
622
+ }
623
+ }
624
+
625
+ function recordModelCallMetric(provider: string, outcome: "completed" | "failed", started: number): void {
626
+ const durationSeconds = Math.max(0, (performance.now() - started) / 1000);
627
+ try {
628
+ runtimeMetricsHooks?.onModelCall?.({ provider, outcome, durationSeconds });
629
+ } catch {
630
+ // Metrics emission must never affect a model call.
631
+ }
632
+ }
633
+
543
634
  /**
544
635
  * Run the compaction summarizer as one plain, tool-less, non-streaming model
545
636
  * call against the resolved provider. `input` is the active history plus
546
637
  * Codex's checkpoint prompt. Returns the trimmed summary text, or null on any
547
- * failure (the caller treats a failed summarize as "skip compaction this turn"
548
- * - never fatal). The call deliberately does NOT request reasoning encryption,
549
- * 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.
550
642
  *
551
643
  * Provider-aware: the summary always runs on the SAME provider that serves the
552
644
  * turn (registry providers can't summarize through OpenAI/Azure, and vice
@@ -560,18 +652,24 @@ export function configureOpenAI(settings: Settings): void {
560
652
  export async function summarizeForCompaction(
561
653
  settings: Settings,
562
654
  input: Array<Record<string, unknown>>,
563
- options: { client?: OpenAI; api?: ModelProviderApi; maxOutputTokens?: number; model?: string } = {},
655
+ options: { client?: OpenAI; api?: ModelProviderApi; maxOutputTokens?: number; model?: string; maxTranscriptTokens?: number } = {},
564
656
  ): Promise<string | null> {
565
657
  const client = options.client ?? buildOpenAIClientFromSettings(settings);
566
658
  const api = options.api ?? "responses";
567
659
  const model = options.model ?? settings.openaiModel;
568
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
+ );
569
667
  try {
570
668
  if (api === "chat") {
571
669
  const completion = await client.chat.completions.create({
572
670
  model,
573
671
  max_tokens: maxTokens,
574
- messages: [{ role: "user", content: renderCompactionPromptInputForChat(input) }],
672
+ messages: [{ role: "user", content: transcript }],
575
673
  } as any);
576
674
  const text = (completion as { choices?: Array<{ message?: { content?: unknown } }> }).choices?.[0]?.message?.content;
577
675
  const trimmed = typeof text === "string" ? text.trim() : "";
@@ -584,13 +682,13 @@ export async function summarizeForCompaction(
584
682
  // built-in path (api "responses"), so gate it on the built-in provider.
585
683
  ...(settings.openaiProvider === "azure" ? {} : { store: false }),
586
684
  max_output_tokens: maxTokens,
587
- input,
685
+ input: transcript,
588
686
  } as any);
589
687
  const text = extractResponseOutputText(response);
590
688
  const trimmed = text.trim();
591
689
  return trimmed.length > 0 ? trimmed : null;
592
690
  } catch (error) {
593
- console.error("context compaction summarize failed (compaction skipped this turn)", error);
691
+ console.error("context compaction summarize failed", error);
594
692
  return null;
595
693
  }
596
694
  }
@@ -718,6 +816,11 @@ export type BuildAgentOptions = {
718
816
  // seeds it to the box's token FILE before the clone runs. Omitted on the
719
817
  // selfhosted path (the machine uses its own git creds) — a NO-OP there.
720
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;
721
824
  // Genesis turn only: append a one-shot instruction to the agent's system
722
825
  // prompt telling it to title the session via opengeni__set_session_title
723
826
  // before responding. Delivered through the instructions channel (where the
@@ -851,11 +954,48 @@ const agentRepositoryCloneHooks = new WeakMap<object, SandboxLifecycleHook[]>();
851
954
  // session env; runStream reads it to build the clone hook context. Absent when
852
955
  // no repo is attached / on the selfhosted path.
853
956
  const agentGitTokenSeed = new WeakMap<object, string>();
957
+ const agentToolspaceTokenSeed = new WeakMap<object, string>();
854
958
  // The EFFECTIVE backend the turn resolved for this agent (undefined -> the home
855
959
  // backend). Read by runStream's owned branch to keep platform box-setup hooks off
856
960
  // connected machines (a user's real computer).
857
961
  const agentActiveSandboxBackend = new WeakMap<object, Settings["sandboxBackend"]>();
858
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
+
859
999
  export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[], options: BuildAgentOptions = {}): Agent<any, any> {
860
1000
  // Resolved per-turn gating. Each override defaults to today's settings-derived
861
1001
  // behaviour, so the legacy global-client callers (no resolved model) build the
@@ -931,11 +1071,19 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
931
1071
  // spread; the SDK concatenates these with MCP and sandbox capability tools.
932
1072
  ...(hostedTools.length ? { tools: hostedTools } : {}),
933
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 },
934
1081
  } as const;
935
1082
 
936
1083
  if (settings.sandboxBackend === "none") {
937
1084
  const agent = new Agent(baseConfig);
938
1085
  maybeInstallCodexToolSearch(agent, settings, options);
1086
+ applyMcpApprovalPolicy(agent, settings);
939
1087
  return agent;
940
1088
  }
941
1089
 
@@ -966,7 +1114,11 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
966
1114
  if (options.gitTokenSeed) {
967
1115
  agentGitTokenSeed.set(agent, options.gitTokenSeed);
968
1116
  }
1117
+ if (options.toolspaceTokenSeed) {
1118
+ agentToolspaceTokenSeed.set(agent, options.toolspaceTokenSeed);
1119
+ }
969
1120
  maybeInstallCodexToolSearch(agent, settings, options);
1121
+ applyMcpApprovalPolicy(agent, settings);
970
1122
  return agent;
971
1123
  }
972
1124
 
@@ -988,6 +1140,101 @@ function maybeInstallCodexToolSearch(agent: Agent<any, any>, settings: Settings,
988
1140
  }
989
1141
  }
990
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
+
991
1238
  /**
992
1239
  * Force a sandbox capability to emit its FUNCTION-transport tool variants instead
993
1240
  * of the hosted ones, by dropping the model instance the SDK's transport
@@ -1156,6 +1403,8 @@ export type PrepareToolsOptions = {
1156
1403
  // delegated token (manager-style sessions). The caller is responsible for
1157
1404
  // having validated the set against the session creator's grant.
1158
1405
  firstPartyPermissions?: Permission[];
1406
+ resolveCredential?: (input: ResolveConnectionCredentialInput) => Promise<ResolveConnectionCredentialResult>;
1407
+ onAuthNeeded?: (payload: ToolAuthNeededPayload) => Promise<void> | void;
1159
1408
  };
1160
1409
 
1161
1410
  export async function prepareAgentTools(settings: Settings, tools: ToolRef[], options: PrepareToolsOptions = {}): Promise<PreparedAgentTools> {
@@ -1173,6 +1422,12 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
1173
1422
  throw new Error(`Unknown MCP server id: ${tool.id}`);
1174
1423
  }
1175
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;
1176
1431
  const server = new PrefixedMcpServer(new MCPServerStreamableHttp({
1177
1432
  url,
1178
1433
  name: config.name ?? config.id,
@@ -1181,7 +1436,7 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
1181
1436
  // MCP SDK's strict Tool schema rejects (fails the turn during tools/list);
1182
1437
  // sanitize the response on the wire before validation. The namespace Set
1183
1438
  // also captures each tool's original connector namespace (P4 Part B.1).
1184
- ...(isCodexAppsMcpServer(config) ? { fetch: codexAppsSanitizingFetch(globalThis.fetch, codexConnectorNamespaces) } : {}),
1439
+ ...(fetchImpl !== globalThis.fetch ? { fetch: fetchImpl } : {}),
1185
1440
  ...await mcpServerRequestInit(settings, config, options),
1186
1441
  ...(config.timeoutMs ? {
1187
1442
  timeout: config.timeoutMs,
@@ -1200,7 +1455,7 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
1200
1455
  // with a warning, never killing the turn before the model runs. Bare
1201
1456
  // refs stay strict (below), preserving the fail-loud default.
1202
1457
  const optional = tool.optional === true;
1203
- return { server, bestEffort: isCodexAppsMcpServer(config) || optional, optional };
1458
+ return { server, bestEffort: isCodexAppsMcpServer(config) || optional || !!config.connectionRef, optional };
1204
1459
  }));
1205
1460
  const requiredServers = servers.filter((entry) => !entry.bestEffort).map((entry) => entry.server);
1206
1461
  const bestEffortServers = servers.filter((entry) => entry.bestEffort).map((entry) => entry.server);
@@ -1244,6 +1499,240 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
1244
1499
  };
1245
1500
  }
1246
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
+
1247
1736
  async function mcpServerRequestInit(settings: Settings, config: Settings["mcpServers"][number], options: PrepareToolsOptions): Promise<{ requestInit: { headers: Record<string, string> } } | {}> {
1248
1737
  // codex_apps is checked FIRST so the static-headers path can never apply to
1249
1738
  // it: its refreshing ChatGPT/Codex bearer is resolved per-connect from the
@@ -1433,7 +1922,7 @@ function normalizeUrl(raw: string): string | null {
1433
1922
  }
1434
1923
 
1435
1924
  export function prefixedMcpToolName(registryId: string, toolName: string): string {
1436
- return `${registryId}__${toolName}`;
1925
+ return sharedPrefixedMcpToolName(registryId, toolName);
1437
1926
  }
1438
1927
 
1439
1928
  class PrefixedMcpServer implements MCPServer {
@@ -1469,7 +1958,18 @@ class PrefixedMcpServer implements MCPServer {
1469
1958
  if (!this.isAllowed(unprefixed)) {
1470
1959
  throw new Error(`MCP tool ${unprefixed} is not allowed for server ${this.name}`);
1471
1960
  }
1472
- 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
+ }
1473
1973
  }
1474
1974
 
1475
1975
  invalidateToolsCache(): Promise<void> {
@@ -1857,8 +2357,10 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
1857
2357
  // TOKEN-BROKER (B1): the per-turn git token seed, forwarded OFF-MANIFEST so the
1858
2358
  // repository-clone hook seeds it to the box's token file before the clone.
1859
2359
  const ownedGitTokenSeed = gitTokenSeedForAgent(agent);
2360
+ const ownedToolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
1860
2361
  const ownedHooks = [
1861
2362
  ...sandboxLifecycleHooksForIds(sandboxLifecycleHookIds(settings)),
2363
+ ...sandboxToolspaceTokenHooksForAgent(agent),
1862
2364
  ...sandboxRepositoryCloneHooksForAgent(agent),
1863
2365
  ];
1864
2366
  const ownedHookContext: SandboxLifecycleHookContext = {
@@ -1866,6 +2368,7 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
1866
2368
  ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
1867
2369
  ...(runAs ? { runAs } : {}),
1868
2370
  ...(ownedGitTokenSeed ? { gitTokenSeed: ownedGitTokenSeed } : {}),
2371
+ ...(ownedToolspaceTokenSeed ? { toolspaceTokenSeed: ownedToolspaceTokenSeed } : {}),
1869
2372
  };
1870
2373
  // OWNED-PATH HOOKS: the SDK NEVER calls client.create/resume when handed a live
1871
2374
  // provided session (SandboxRuntimeManager uses `sandboxConfig.session` directly),
@@ -1940,15 +2443,18 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
1940
2443
  // TOKEN-BROKER (B1): the per-turn git token seed, forwarded OFF-MANIFEST so the
1941
2444
  // repository-clone hook seeds it to the box's token file before the clone.
1942
2445
  const gitTokenSeed = gitTokenSeedForAgent(agent);
2446
+ const toolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
1943
2447
  const client = resourceClient
1944
2448
  ? withSandboxLifecycleHooks(resourceClient, [
1945
2449
  ...sandboxLifecycleHooksForIds(sandboxLifecycleHookIds(settings)),
2450
+ ...sandboxToolspaceTokenHooksForAgent(agent),
1946
2451
  ...sandboxRepositoryCloneHooksForAgent(agent),
1947
2452
  ], {
1948
2453
  environment,
1949
2454
  ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
1950
2455
  ...(runAs ? { runAs } : {}),
1951
2456
  ...(gitTokenSeed ? { gitTokenSeed } : {}),
2457
+ ...(toolspaceTokenSeed ? { toolspaceTokenSeed } : {}),
1952
2458
  })
1953
2459
  : undefined;
1954
2460
  const sandboxSessionState = prepared.sandboxSessionState
@@ -1982,7 +2488,6 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
1982
2488
  // every mid-turn follow-up.
1983
2489
  callModelInputFilter,
1984
2490
  };
1985
- void settings.disableOpenaiTracing;
1986
2491
  if (client) {
1987
2492
  runOptions.sandbox = {
1988
2493
  client,
@@ -2752,6 +3257,7 @@ export type SandboxLifecycleHookContext = {
2752
3257
  // the clone exec's per-call env (OPENGENI_GIT_TOKEN_SEED), NEVER the box/agent
2753
3258
  // manifest env (validateNoEnvironmentDelta must never see a rotating value).
2754
3259
  gitTokenSeed?: string;
3260
+ toolspaceTokenSeed?: string;
2755
3261
  };
2756
3262
 
2757
3263
  export type SandboxLifecycleHook = {
@@ -2852,6 +3358,20 @@ function gitTokenSeedForAgent(agent: Agent<any, any>): string | undefined {
2852
3358
  return agentGitTokenSeed.get(agent);
2853
3359
  }
2854
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
+
2855
3375
  function sandboxRepositoryCloneHooks(
2856
3376
  settings: Settings,
2857
3377
  resources: ResourceRef[],
@@ -3053,6 +3573,53 @@ export function repositoryCloneCommand(resources: Extract<ResourceRef, { kind: "
3053
3573
  return commands.join("\n");
3054
3574
  }
3055
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
+
3056
3623
  export async function runRepositoryCloneHook(
3057
3624
  session: SandboxSessionLike,
3058
3625
  resources: Extract<ResourceRef, { kind: "repository" }>[],