@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/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
@@ -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 per-session persona instructions (session-specific, LAST so it
956
- // refines the workspace persona),
957
- // 3. + the one-shot genesis title directive (genesis turn only).
958
- // With no session instructions and no genesis hint this is byte-identical to
959
- // the historical composed instructions.
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
- composeAgentInstructions(options.instructionsTemplate ?? settings.agentInstructionsTemplate, options.workspaceEnvironment),
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
- ...(isCodexAppsMcpServer(config) ? { fetch: codexAppsSanitizingFetch(globalThis.fetch, codexConnectorNamespaces) } : {}),
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 `${registryId}__${toolName}`;
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
- return await this.inner.callTool(unprefixed, args, meta);
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" }>[],