@opengeni/runtime 0.7.1 → 0.13.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.
Files changed (61) hide show
  1. package/THIRD_PARTY_NOTICES +32 -0
  2. package/dist/agent-UMUIWWD2.js +8 -0
  3. package/dist/agent-UMUIWWD2.js.map +1 -0
  4. package/dist/chunk-4YJ5Z52Z.js +1789 -0
  5. package/dist/chunk-4YJ5Z52Z.js.map +1 -0
  6. package/dist/chunk-65UDUFUO.js +22133 -0
  7. package/dist/chunk-65UDUFUO.js.map +1 -0
  8. package/dist/chunk-DPULLAJG.js +215 -0
  9. package/dist/chunk-DPULLAJG.js.map +1 -0
  10. package/dist/chunk-HAN5D27J.js +292 -0
  11. package/dist/chunk-HAN5D27J.js.map +1 -0
  12. package/dist/chunk-K4V4QYCL.js +37683 -0
  13. package/dist/chunk-K4V4QYCL.js.map +1 -0
  14. package/dist/chunk-PZ5AY32C.js +10 -0
  15. package/dist/chunk-PZ5AY32C.js.map +1 -0
  16. package/dist/{index-DIiiwQY2.d.ts → index-DGUrY_yR.d.ts} +171 -18
  17. package/dist/index.d.ts +404 -125
  18. package/dist/index.js +8857 -412
  19. package/dist/index.js.map +1 -1
  20. package/dist/localSources-T3HZ6KY4.js +15 -0
  21. package/dist/localSources-T3HZ6KY4.js.map +1 -0
  22. package/dist/mcp-network.d.ts +78 -0
  23. package/dist/mcp-network.js +62 -0
  24. package/dist/mcp-network.js.map +1 -0
  25. package/dist/sandbox/index.d.ts +1 -1
  26. package/dist/sandbox/index.js +42 -5
  27. package/dist/skill-library.d.ts +74 -0
  28. package/dist/skill-library.js +18 -0
  29. package/dist/skill-library.js.map +1 -0
  30. package/package.json +18 -5
  31. package/src/bundled_hashicorp_terraform_skills/README.md +3 -3
  32. package/src/bundled_hashicorp_terraform_skills/checkov/SKILL.md +1 -1
  33. package/src/codex-tool-search.ts +20 -1
  34. package/src/context-compaction.ts +421 -24
  35. package/src/history-sanitizer.ts +14 -5
  36. package/src/index.ts +1806 -347
  37. package/src/mcp-network.ts +330 -0
  38. package/src/sandbox/channel-a.ts +526 -113
  39. package/src/sandbox/display-stack.ts +860 -23
  40. package/src/sandbox/exec-banner.ts +67 -0
  41. package/src/sandbox/index.ts +43 -2
  42. package/src/sandbox/providers/modal.ts +29 -2
  43. package/src/sandbox/routing/routing-session.ts +10 -2
  44. package/src/sandbox/run-credentials.ts +818 -0
  45. package/src/sandbox/select.ts +3 -3
  46. package/src/sandbox/selfhosted/capabilities.ts +1 -1
  47. package/src/sandbox/selfhosted/op-stream.ts +40 -0
  48. package/src/sandbox/selfhosted/op-testing.ts +50 -3
  49. package/src/sandbox/selfhosted/session.ts +17 -4
  50. package/src/sandbox/selfhosted/testing.ts +1 -1
  51. package/src/sandbox/stream-port.ts +1 -1
  52. package/src/sandbox/stream-token.ts +1 -1
  53. package/src/sandbox/terminal-server.ts +6 -2
  54. package/src/sandbox/toolspace-token.ts +147 -0
  55. package/src/sandbox/turn-tool-cancellation.ts +940 -0
  56. package/src/sandbox-computer.ts +3 -1
  57. package/src/skill-library.ts +323 -0
  58. package/src/usage-telemetry.ts +495 -51
  59. package/dist/chunk-RYDCVTMW.js +0 -5531
  60. package/dist/chunk-RYDCVTMW.js.map +0 -1
  61. /package/src/{bundled_hashicorp_terraform_skills → bundled_skill_library}/azure-verified-modules/SKILL.md +0 -0
package/src/index.ts CHANGED
@@ -12,19 +12,45 @@ import {
12
12
  sandboxLifecycleHookIds,
13
13
  } from "@opengeni/config";
14
14
  import {
15
+ approvalIdentifier,
15
16
  CAPABILITY_DESCRIPTORS,
17
+ DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
18
+ assertUniqueResourceMountPaths,
19
+ gitCredentialBindingIdForRepository,
20
+ gitCredentialProviderForRepository,
16
21
  isClearedRunStateBlob,
22
+ normalizeRepositorySubpath,
23
+ normalizeResourceMountPath,
17
24
  prefixedMcpToolName as sharedPrefixedMcpToolName,
25
+ resourceMountPath,
26
+ sessionEventMediaPreview,
27
+ sessionEventMediaPreviewFromDataUrl,
18
28
  signDelegatedAccessToken,
29
+ RequestHumanInputToolInput,
19
30
  type GitCredentialProvider,
31
+ type GitCredentialTransport,
32
+ type HumanInputResponse,
20
33
  type McpServerConnectionRef,
21
34
  type Permission,
22
35
  type ReasoningEffort,
23
36
  type ResourceRef,
24
37
  type SessionEventType,
38
+ type SessionEventMediaPreview,
25
39
  type ToolAuthNeededPayload,
26
40
  type ToolRef,
27
41
  } from "@opengeni/contracts";
42
+ import {
43
+ MCP_MAX_CONCURRENT_SERVER_OPERATIONS,
44
+ MCP_MAX_TOOL_RESULT_BYTES,
45
+ McpAggregateToolListBudget,
46
+ assertMcpPayloadWithinBytes,
47
+ assertMcpServerSelectionWithinBounds,
48
+ assertMcpToolListWithinBounds,
49
+ boundedParallelMap,
50
+ cancelMcpResponseBody,
51
+ guardedMcpFetch,
52
+ undiciFetch,
53
+ } from "./mcp-network";
28
54
  import {
29
55
  Agent,
30
56
  AgentsError,
@@ -52,6 +78,7 @@ import {
52
78
  setDefaultOpenAIKey,
53
79
  setOpenAIResponsesTransport,
54
80
  setTracingDisabled,
81
+ tool as agentTool,
55
82
  // Hosted web_search tool factory. Re-exported from @openai/agents-openai via
56
83
  // `export * from '@openai/agents-openai'` in @openai/agents' index (0.11.6);
57
84
  // it returns a { type: 'hosted_tool', providerData: { type: 'web_search' } }
@@ -104,11 +131,14 @@ import {
104
131
  CODEX_APPS_MCP_SERVER_ID,
105
132
  CODEX_MODEL_ID_PREFIX,
106
133
  CODEX_ORIGINATOR,
134
+ CODEX_RESPONSE_SDK_OUTER_TIMEOUT_MS,
135
+ boundModelToolOutputItems,
107
136
  codexAppsSanitizingFetch,
108
137
  codexRequestStorage,
109
138
  codexSubscriptionFetch,
110
139
  } from "@opengeni/codex";
111
140
  import { cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
141
+ import { createHash } from "node:crypto";
112
142
  import { dirname, isAbsolute, join, posix as posixPath, relative } from "node:path";
113
143
  import { fileURLToPath } from "node:url";
114
144
 
@@ -121,22 +151,61 @@ import {
121
151
  import { installCodexToolSearch } from "./codex-tool-search";
122
152
  import {
123
153
  CompactionNeededError,
154
+ CompactionProviderResponseError,
155
+ EmptyCompactionSummaryError,
124
156
  SUMMARY_BUFFER_TOKENS,
125
157
  compactionThresholdTokens,
126
- estimateTokens,
158
+ estimateCompleteModelInput,
159
+ estimateSerializedValueTokens,
160
+ hasModelGeneratedItem,
127
161
  renderCompactionPromptInputForChat,
162
+ type CompleteModelInputFootprint,
163
+ type ProviderContextTokenSignal,
128
164
  } from "./context-compaction";
129
165
  import {
130
166
  createSandboxClient,
131
167
  desktopCapableBackend,
132
168
  restoredSandboxSessionStateFromEntry,
133
169
  setSelfhostedApplyDiff,
170
+ toolspaceTokenFileFromEnvironment,
171
+ withToolspaceTokenClient,
172
+ withToolspaceTokenSession,
173
+ withRunCredentialsClient,
174
+ withRunCredentialsSession,
175
+ type RunCredentialSessionReady,
134
176
  } from "./sandbox";
135
177
  import { runWithToolCallCorrelation } from "./sandbox/op-correlation";
178
+ import {
179
+ createTurnToolCancellationController,
180
+ TurnSandboxCommandCancelledError,
181
+ wrapCapabilityToolsForTurnCancellation,
182
+ type TurnSandboxCommandArgs,
183
+ type TurnSandboxCommandSession,
184
+ type TurnToolCancellationFence,
185
+ } from "./sandbox/turn-tool-cancellation";
136
186
  import { computerUse, type ComputerToolMode } from "./sandbox-computer";
137
187
  import type { RuntimeMetricsHooks } from "./metrics";
188
+ export {
189
+ getSkillLibraryEntry,
190
+ isSkillLibraryEntryId,
191
+ listSkillLibraryEntries,
192
+ loadSkillLibrarySkill,
193
+ type SkillLibraryEntry,
194
+ type SkillLibraryFile,
195
+ type SkillLibrarySkill,
196
+ } from "./skill-library";
138
197
 
139
198
  export type { RuntimeMetricsHooks } from "./metrics";
199
+ export {
200
+ createTurnToolCancellationController,
201
+ TurnSandboxCommandCancelledError,
202
+ } from "./sandbox/turn-tool-cancellation";
203
+ export type {
204
+ TurnSandboxCommandArgs,
205
+ TurnSandboxCommandSession,
206
+ TurnToolCancellationController,
207
+ TurnToolCancellationFence,
208
+ } from "./sandbox/turn-tool-cancellation";
140
209
 
141
210
  // P4.3 computer-use surface (the agent's :0 driver). Re-exported from the barrel
142
211
  // so callers (the worker, live proofs) reach SandboxComputer/ComputerUseCapability
@@ -188,17 +257,25 @@ export { OpenAIChatCompletionsModel, OpenAIResponsesModel } from "@openai/agents
188
257
 
189
258
  export {
190
259
  CompactionNeededError,
260
+ CompactionProviderResponseError,
261
+ EmptyCompactionSummaryError,
191
262
  buildCompactionPromptInput,
192
263
  buildCompactionReplacementHistory,
264
+ compactionReplacementFingerprint,
193
265
  compactionThresholdTokens,
194
266
  clampCompactionThresholdRatio,
195
267
  decideCompaction,
196
268
  buildSummaryItem,
197
269
  findCompactionNeededError,
198
270
  isCompactionSummary,
271
+ latestCompactionReplacementFingerprint,
272
+ prepareCompactionPromptInput,
199
273
  isUserMessage,
200
274
  estimateTokens,
201
275
  estimateItemTokens,
276
+ estimateCompleteModelInput,
277
+ estimateSerializedValueTokens,
278
+ hasModelGeneratedItem,
202
279
  renderCompactionPromptInputForChat,
203
280
  COMPACTION_SUMMARY_MARKER,
204
281
  COMPACTION_PROMPT,
@@ -211,9 +288,22 @@ export {
211
288
  isEphemeralInternalContext,
212
289
  USER_MESSAGE_TRUNCATION_MARKER,
213
290
  } from "./context-compaction";
214
- export type { CompactionDecision, CompactionItem } from "./context-compaction";
215
- export { modelCallUsageTelemetry } from "./usage-telemetry";
216
- export type { ModelCallUsageTelemetry } from "./usage-telemetry";
291
+ export type {
292
+ CompactionDecision,
293
+ CompactionItem,
294
+ PreparedCompactionPromptInput,
295
+ } from "./context-compaction";
296
+ export {
297
+ MAX_MODEL_USAGE_TOKEN_COUNT,
298
+ modelCallUsageTelemetry,
299
+ modelUsageTokenCountOrNull,
300
+ normalizeModelCallUsage,
301
+ } from "./usage-telemetry";
302
+ export type {
303
+ ModelCallUsageInput,
304
+ ModelCallUsageNormalization,
305
+ ModelCallUsageTelemetry,
306
+ } from "./usage-telemetry";
217
307
 
218
308
  ensureReadableStreamFrom();
219
309
 
@@ -232,6 +322,18 @@ export type ModelResponseUsage = {
232
322
  totalTokens?: number;
233
323
  inputTokensDetails?: Record<string, number> | Array<Record<string, number>>;
234
324
  outputTokensDetails?: Record<string, number> | Array<Record<string, number>>;
325
+ requestUsageEntries?: Array<{
326
+ inputTokens?: number;
327
+ input_tokens?: number;
328
+ outputTokens?: number;
329
+ output_tokens?: number;
330
+ totalTokens?: number;
331
+ total_tokens?: number;
332
+ inputTokensDetails?: Record<string, number>;
333
+ input_tokens_details?: Record<string, number>;
334
+ outputTokensDetails?: Record<string, number>;
335
+ output_tokens_details?: Record<string, number>;
336
+ }>;
235
337
  };
236
338
  };
237
339
 
@@ -245,6 +347,8 @@ export type ResolveConnectionCredentialInput = {
245
347
  serverId: string;
246
348
  toolName?: string;
247
349
  connectionRef: McpServerConnectionRef;
350
+ /** Exact MCP destination whose request would receive the resolved headers. */
351
+ destinationUrl: string;
248
352
  forceRefresh?: boolean;
249
353
  };
250
354
 
@@ -259,9 +363,11 @@ export type ResolveConnectionCredentialResult =
259
363
  status: "auth_needed";
260
364
  reason: ToolAuthNeededPayload["reason"];
261
365
  providerDomain: string;
366
+ provider?: string;
262
367
  connectionId?: string;
263
368
  scopes?: string[];
264
369
  resource?: string;
370
+ selectedResources?: McpServerConnectionRef["selectedResources"];
265
371
  authorizationUrl?: string;
266
372
  };
267
373
 
@@ -316,6 +422,11 @@ export type AgentSegmentInput =
316
422
  approvalId: string;
317
423
  decision: "approve" | "reject";
318
424
  message?: string;
425
+ }
426
+ | {
427
+ kind: "human_input";
428
+ serializedRunState: string;
429
+ toolCallId: string;
319
430
  };
320
431
 
321
432
  export type PreparedAgentInput = {
@@ -323,6 +434,13 @@ export type PreparedAgentInput = {
323
434
  sandboxSessionState?: SandboxSessionState;
324
435
  };
325
436
 
437
+ export const HUMAN_INPUT_TOOL_NAME = "request_human_input";
438
+
439
+ export type SerializedHumanInputInterruption = {
440
+ toolCallId: string;
441
+ input: ReturnType<typeof RequestHumanInputToolInput.parse>;
442
+ };
443
+
326
444
  export type SandboxFileDownload = {
327
445
  fileId: string;
328
446
  mountPath: string;
@@ -381,6 +499,7 @@ export type OpenGeniRuntime = {
381
499
  options?: RunAgentStreamOptions,
382
500
  ) => Promise<Awaited<ReturnType<typeof runAgentStream>>>;
383
501
  serializeApprovals: (interruptions: unknown[]) => unknown[];
502
+ serializeHumanInputRequests?: (interruptions: unknown[]) => SerializedHumanInputInterruption[];
384
503
  };
385
504
 
386
505
  export type ProductionRuntimeOverrides = {
@@ -416,6 +535,7 @@ export function createProductionAgentRuntime(
416
535
  sandboxClient: overrides.sandboxClient,
417
536
  }),
418
537
  serializeApprovals,
538
+ serializeHumanInputRequests,
419
539
  };
420
540
  }
421
541
 
@@ -491,6 +611,10 @@ export function buildProviderClient(provider: ResolvedModelProvider, settings: S
491
611
  // SDK retries on network/5xx/partial streams can replay provider work
492
612
  // or external tool side effects without a durable checkpoint.
493
613
  maxRetries: 0,
614
+ // The Codex transport owns finer headers/idle/whole deadlines and
615
+ // emits typed durable evidence. Keep the SDK's opaque envelope beyond
616
+ // that whole-response budget so `Request timed out.` cannot win first.
617
+ timeout: CODEX_RESPONSE_SDK_OUTER_TIMEOUT_MS,
494
618
  fetch: codexSubscriptionFetch(instrumentedModelFetch(provider.id, globalThis.fetch)),
495
619
  })
496
620
  : // ResolvedModelProvider.apiKey is already the resolved key (configuredProviders
@@ -552,7 +676,7 @@ export function resolveTurnModel(
552
676
  return {
553
677
  provider: resolved.provider,
554
678
  client,
555
- model: buildModelInstance(resolved.provider, client, resolved.model.id),
679
+ model: buildModelInstance(resolved.provider, client, resolved.model.upstreamModelId),
556
680
  configured: resolved.model,
557
681
  };
558
682
  }
@@ -804,19 +928,28 @@ export async function summarizeForCompaction(
804
928
  const maxTokens = options.maxOutputTokens ?? SUMMARY_BUFFER_TOKENS;
805
929
  if (api === "chat") {
806
930
  const transcript = renderCompactionPromptInputForChat(input);
807
- const completion = await client.chat.completions.create({
808
- model,
809
- max_tokens: maxTokens,
810
- messages: [{ role: "user", content: transcript }],
811
- ...(options.promptCacheKey ? { prompt_cache_key: options.promptCacheKey } : {}),
812
- } as any);
931
+ let completion: unknown;
932
+ try {
933
+ completion = await client.chat.completions.create({
934
+ model,
935
+ max_tokens: maxTokens,
936
+ messages: [{ role: "user", content: transcript }],
937
+ ...(options.promptCacheKey ? { prompt_cache_key: options.promptCacheKey } : {}),
938
+ } as any);
939
+ } catch (error) {
940
+ throw new CompactionProviderResponseError(compactionProviderFailureDiagnostics(error), error);
941
+ }
813
942
  const usage = modelResponseUsageFromResponse(completion);
814
943
  if (usage) {
815
944
  await options.onUsage?.(usage);
816
945
  }
817
946
  const text = (completion as { choices?: Array<{ message?: { content?: unknown } }> })
818
947
  .choices?.[0]?.message?.content;
819
- return typeof text === "string" ? text.trim() : "";
948
+ const summary = typeof text === "string" ? text.trim() : "";
949
+ if (!summary) {
950
+ throw new EmptyCompactionSummaryError(compactionResponseDiagnostics(completion, summary));
951
+ }
952
+ return summary;
820
953
  }
821
954
  // Use the same SDK Responses adapter as the real agent call. It converts the
822
955
  // structured AgentInputItems (callId/providerData/etc.) to provider wire
@@ -839,12 +972,205 @@ export async function summarizeForCompaction(
839
972
  handoffs: [],
840
973
  tracing: false,
841
974
  };
842
- const response = await new CompactionResponsesModel(client, model).fetchResponse(request);
975
+ let response: unknown;
976
+ try {
977
+ response = await new CompactionResponsesModel(client, model).fetchResponse(request);
978
+ } catch (error) {
979
+ throw new CompactionProviderResponseError(compactionProviderFailureDiagnostics(error), error);
980
+ }
843
981
  const usage = modelResponseUsageFromResponse(response);
844
982
  if (usage) {
845
983
  await options.onUsage?.(usage);
846
984
  }
847
- return extractResponseOutputText(response).trim();
985
+ if (isFailedCompactionProviderResponse(response)) {
986
+ throw new CompactionProviderResponseError(compactionProviderFailureDiagnostics(response));
987
+ }
988
+ const summary = extractResponseOutputText(response).trim();
989
+ if (!summary) {
990
+ throw new EmptyCompactionSummaryError(compactionResponseDiagnostics(response, summary));
991
+ }
992
+ return summary;
993
+ }
994
+
995
+ function isFailedCompactionProviderResponse(response: unknown): boolean {
996
+ if (!response || typeof response !== "object") return false;
997
+ const record = response as Record<string, unknown>;
998
+ return (
999
+ record.status === "failed" ||
1000
+ record.status === "incomplete" ||
1001
+ (record.error !== null && record.error !== undefined)
1002
+ );
1003
+ }
1004
+
1005
+ /** Bounded provider diagnostics: identifiers and classifications, never messages or model data. */
1006
+ export function compactionProviderFailureDiagnostics(error: unknown): Record<string, unknown> {
1007
+ let current = error;
1008
+ let errorName: string | null = null;
1009
+ let httpStatus: number | null = null;
1010
+ let responseStatus: string | null = null;
1011
+ let responseId: string | null = null;
1012
+ let code: string | null = null;
1013
+ let type: string | null = null;
1014
+ let requestId: string | null = null;
1015
+ let eventType: string | null = null;
1016
+ const seen = new Set<object>();
1017
+ for (let depth = 0; depth < 6 && current && typeof current === "object"; depth += 1) {
1018
+ if (seen.has(current)) break;
1019
+ seen.add(current);
1020
+ const record = current as Record<string, unknown>;
1021
+ if (!errorName && current instanceof Error) {
1022
+ errorName = boundCompactionDiagnosticField(current.name);
1023
+ }
1024
+ if (
1025
+ httpStatus === null &&
1026
+ typeof record.status === "number" &&
1027
+ Number.isFinite(record.status)
1028
+ ) {
1029
+ httpStatus = record.status;
1030
+ }
1031
+ if (responseStatus === null && typeof record.status === "string") {
1032
+ responseStatus = boundCompactionDiagnosticField(record.status);
1033
+ }
1034
+ const directResponseStatus = record.response_status ?? record.responseStatus;
1035
+ if (responseStatus === null && typeof directResponseStatus === "string") {
1036
+ responseStatus = boundCompactionDiagnosticField(directResponseStatus);
1037
+ }
1038
+ const directResponseId = record.response_id ?? record.responseId ?? record.id;
1039
+ if (responseId === null && typeof directResponseId === "string") {
1040
+ responseId = boundCompactionDiagnosticField(directResponseId);
1041
+ }
1042
+ if (code === null && typeof record.code === "string") {
1043
+ code = boundCompactionDiagnosticField(record.code);
1044
+ }
1045
+ if (type === null && typeof record.type === "string") {
1046
+ type = boundCompactionDiagnosticField(record.type);
1047
+ }
1048
+ const directEventType = record.event_type ?? record.eventType;
1049
+ if (eventType === null && typeof directEventType === "string") {
1050
+ eventType = boundCompactionDiagnosticField(directEventType);
1051
+ }
1052
+ if (requestId === null) {
1053
+ const directRequestId = record.request_id ?? record.requestId ?? record._request_id;
1054
+ if (typeof directRequestId === "string") {
1055
+ requestId = boundCompactionDiagnosticField(directRequestId);
1056
+ }
1057
+ const headers = record.headers;
1058
+ if (!requestId && headers && typeof headers === "object") {
1059
+ const get = (headers as { get?: unknown }).get;
1060
+ if (typeof get === "function") {
1061
+ const headerId = get.call(headers, "x-request-id");
1062
+ if (typeof headerId === "string") {
1063
+ requestId = boundCompactionDiagnosticField(headerId);
1064
+ }
1065
+ }
1066
+ }
1067
+ }
1068
+ const nestedError = record.error;
1069
+ if (nestedError && typeof nestedError === "object" && !seen.has(nestedError)) {
1070
+ const nested = nestedError as Record<string, unknown>;
1071
+ if (code === null && typeof nested.code === "string") {
1072
+ code = boundCompactionDiagnosticField(nested.code);
1073
+ }
1074
+ if (type === null && typeof nested.type === "string") {
1075
+ type = boundCompactionDiagnosticField(nested.type);
1076
+ }
1077
+ const nestedResponseStatus = nested.response_status ?? nested.responseStatus;
1078
+ if (responseStatus === null && typeof nestedResponseStatus === "string") {
1079
+ responseStatus = boundCompactionDiagnosticField(nestedResponseStatus);
1080
+ }
1081
+ const nestedResponseId = nested.response_id ?? nested.responseId ?? nested.id;
1082
+ if (responseId === null && typeof nestedResponseId === "string") {
1083
+ responseId = boundCompactionDiagnosticField(nestedResponseId);
1084
+ }
1085
+ const nestedEventType = nested.event_type ?? nested.eventType;
1086
+ if (eventType === null && typeof nestedEventType === "string") {
1087
+ eventType = boundCompactionDiagnosticField(nestedEventType);
1088
+ }
1089
+ }
1090
+ current = record.cause;
1091
+ }
1092
+ return {
1093
+ errorName,
1094
+ httpStatus,
1095
+ responseStatus,
1096
+ responseId,
1097
+ code,
1098
+ type,
1099
+ requestId,
1100
+ ...(eventType ? { eventType } : {}),
1101
+ };
1102
+ }
1103
+
1104
+ const COMPACTION_DIAGNOSTIC_FIELD_MAX_BYTES = 256;
1105
+ const COMPACTION_DIAGNOSTIC_TRUNCATION_MARKER = "…[truncated]";
1106
+
1107
+ function boundCompactionDiagnosticField(value: string): string {
1108
+ const bytes = Buffer.from(value, "utf8");
1109
+ if (bytes.length <= COMPACTION_DIAGNOSTIC_FIELD_MAX_BYTES) return value;
1110
+ const markerBytes = Buffer.byteLength(COMPACTION_DIAGNOSTIC_TRUNCATION_MARKER, "utf8");
1111
+ let end = COMPACTION_DIAGNOSTIC_FIELD_MAX_BYTES - markerBytes;
1112
+ while (end > 0 && isUtf8ContinuationByte(bytes[end]!)) end -= 1;
1113
+ return `${bytes.subarray(0, end).toString("utf8")}${COMPACTION_DIAGNOSTIC_TRUNCATION_MARKER}`;
1114
+ }
1115
+
1116
+ function isUtf8ContinuationByte(value: number): boolean {
1117
+ return (value & 0xc0) === 0x80;
1118
+ }
1119
+
1120
+ /** Bounded, content-free diagnostics for a semantically empty checkpoint. */
1121
+ export function compactionResponseDiagnostics(
1122
+ response: unknown,
1123
+ extractedText = extractResponseOutputText(response).trim(),
1124
+ ): Record<string, unknown> {
1125
+ if (!response || typeof response !== "object") {
1126
+ return { responseShape: typeof response, extractedTextLength: extractedText.length };
1127
+ }
1128
+ const record = response as Record<string, unknown>;
1129
+ const output = Array.isArray(record.output) ? record.output : [];
1130
+ const outputItems = output.slice(0, 50).map((item) => {
1131
+ if (!item || typeof item !== "object") return { type: typeof item };
1132
+ const value = item as Record<string, unknown>;
1133
+ const content = Array.isArray(value.content) ? value.content : [];
1134
+ return {
1135
+ type: typeof value.type === "string" ? value.type : null,
1136
+ role: typeof value.role === "string" ? value.role : null,
1137
+ status: typeof value.status === "string" ? value.status : null,
1138
+ contentPartTypes: content
1139
+ .slice(0, 50)
1140
+ .map((part) =>
1141
+ part && typeof part === "object" && typeof (part as { type?: unknown }).type === "string"
1142
+ ? (part as { type: string }).type
1143
+ : typeof part,
1144
+ ),
1145
+ contentPartCount: content.length,
1146
+ };
1147
+ });
1148
+ const choices = Array.isArray(record.choices) ? record.choices : [];
1149
+ const usage = modelResponseUsageFromResponse(response)?.usage;
1150
+ const incomplete =
1151
+ record.incomplete_details && typeof record.incomplete_details === "object"
1152
+ ? (record.incomplete_details as Record<string, unknown>)
1153
+ : null;
1154
+ return {
1155
+ responseId: typeof record.id === "string" ? record.id : null,
1156
+ status: typeof record.status === "string" ? record.status : null,
1157
+ outputItemCount: output.length,
1158
+ outputItems,
1159
+ choiceCount: choices.length,
1160
+ finishReasons: choices
1161
+ .slice(0, 20)
1162
+ .map((choice) =>
1163
+ choice && typeof choice === "object"
1164
+ ? ((choice as Record<string, unknown>).finish_reason ?? null)
1165
+ : null,
1166
+ ),
1167
+ incompleteReason:
1168
+ incomplete && typeof incomplete.reason === "string" ? incomplete.reason : null,
1169
+ inputTokens: usage?.inputTokens ?? null,
1170
+ outputTokens: usage?.outputTokens ?? null,
1171
+ totalTokens: usage?.totalTokens ?? null,
1172
+ extractedTextLength: extractedText.length,
1173
+ };
848
1174
  }
849
1175
 
850
1176
  /**
@@ -909,10 +1235,50 @@ class CompactionResponsesModel extends OpenAIResponsesModel {
909
1235
  }
910
1236
 
911
1237
  export type GitTokenSeeds = Partial<Record<GitCredentialProvider, string>>;
1238
+ export type GitCredentialBindingSeed = {
1239
+ credentialBindingId: string;
1240
+ provider: GitCredentialProvider;
1241
+ token: string;
1242
+ transport?: GitCredentialTransport;
1243
+ expiresAt?: string;
1244
+ /** Total active bindings for this provider, used to suppress unsafe aliases. */
1245
+ providerBindingCount?: number;
1246
+ };
912
1247
  export type GitCredentialTokenWriterSession = SandboxSessionLike;
1248
+ export type ToolspaceTokenWriterSession = SandboxSessionLike;
1249
+
1250
+ export type EffectiveSkillSelection = Readonly<{
1251
+ id: string;
1252
+ name: string;
1253
+ source: "bundled" | "library" | "pack";
1254
+ version: string | null;
1255
+ contentSha256: string | null;
1256
+ reason: string;
1257
+ }>;
1258
+
1259
+ const agentSkillSelections = new WeakMap<object, readonly EffectiveSkillSelection[]>();
1260
+ const emptySkillSelections: readonly EffectiveSkillSelection[] = Object.freeze([]);
1261
+
1262
+ /**
1263
+ * Read-only, secret-free skill provenance for an already-built agent. This is
1264
+ * intentionally separate from the model instructions and sandbox manifest so
1265
+ * effective configuration inspection cannot accidentally expose credentials or
1266
+ * turn skill activation into an authorization change.
1267
+ */
1268
+ export function effectiveSkillSelectionsForAgent(
1269
+ agent: object,
1270
+ ): readonly EffectiveSkillSelection[] {
1271
+ return agentSkillSelections.get(agent) ?? emptySkillSelections;
1272
+ }
913
1273
 
914
1274
  export type BuildAgentOptions = {
915
1275
  model?: Model;
1276
+ /** Settled response for the one internal human-input interruption resumed by this run. */
1277
+ humanInputResponse?: {
1278
+ requestId: string;
1279
+ toolCallId: string;
1280
+ response: HumanInputResponse;
1281
+ };
916
1282
  reasoningEffort?: ReasoningEffort;
917
1283
  // Per-turn gating overrides for the multi-provider path. Each defaults to
918
1284
  // today's settings-derived behaviour when omitted, so the legacy
@@ -1008,11 +1374,18 @@ export type BuildAgentOptions = {
1008
1374
  // repository-clone hooks; runStream forwards them into the hook context, which
1009
1375
  // seeds provider token FILES before the clone/setup runs.
1010
1376
  gitTokenSeeds?: GitTokenSeeds;
1377
+ // Provider-neutral, independently mintable credentials. Binding ids remain
1378
+ // off-manifest and are hashed before they influence sandbox paths.
1379
+ gitCredentialBindings?: GitCredentialBindingSeed[];
1011
1380
  // TOOLSPACE: the run-scoped delegated token to seed into
1012
1381
  // $OPENGENI_TOOLSPACE_TOKEN_FILE. Like gitTokenSeed, this stays off the
1013
1382
  // manifest/env delta and is written into the sandbox filesystem by a lifecycle
1014
1383
  // hook before the agent starts.
1015
1384
  toolspaceTokenSeed?: string;
1385
+ // Durable OpenGeni session identity used only to derive the off-manifest,
1386
+ // per-session token file. Required together with toolspaceTokenSeed so two
1387
+ // sessions sharing one box never overwrite the same pointer.
1388
+ toolspaceTokenSessionId?: string;
1016
1389
  // Genesis turn only: inject a one-shot instruction into the FIRST model
1017
1390
  // call telling it to title the session via opengeni__set_session_title.
1018
1391
  // Keeping this out of the persistent Agent.instructions prevents every
@@ -1040,10 +1413,32 @@ export type BuildAgentOptions = {
1040
1413
  // timeline message. Omitted ⇒ the composed instructions are byte-identical to
1041
1414
  // a workspace-only persona.
1042
1415
  sessionInstructions?: string;
1416
+ // Host context for this exact accepted turn. Composed system-level after the
1417
+ // durable session persona and omitted from all later turns.
1418
+ turnInstructions?: string;
1043
1419
  // Skills delivered by enabled capability packs. They join the bundled
1044
1420
  // skills in the sandbox skill index (mounted under .agents/) so
1045
1421
  // skills/<name> references resolve like any other indexed skill.
1046
1422
  packSkills?: PackSkill[];
1423
+ // Explicitly selected, immutable curated-library skill content. These are
1424
+ // separate from packSkills so repository-local/pack compatibility does not
1425
+ // turn a curated entry into a mutable override.
1426
+ skillLibrarySkills?: PackSkill[];
1427
+ // Secret-free provenance for effective-configuration inspection. The worker
1428
+ // resolves exact ids/versions/hashes before constructing the agent.
1429
+ skillLibrarySelections?: EffectiveSkillSelection[];
1430
+ /**
1431
+ * Internal per-attempt cancellation boundary. The worker supplies Temporal's
1432
+ * signal so an in-flight shell process is interrupted immediately instead of
1433
+ * holding Steer/Pause behind its requested yield or natural exit.
1434
+ */
1435
+ turnCancellationSignal?: AbortSignal;
1436
+ /**
1437
+ * Receives the physical sandbox-tool fence at agent construction time. A
1438
+ * cancelled/fenced worker attempt must await it before publishing its durable
1439
+ * attempt-quiesced receipt.
1440
+ */
1441
+ onToolCancellationFence?: (fence: TurnToolCancellationFence) => void;
1047
1442
  };
1048
1443
 
1049
1444
  export type PackSkillFile = {
@@ -1171,6 +1566,12 @@ export function appendSessionInstructions(composed: string, sessionInstructions?
1171
1566
  return trimmed ? `${composed} ${trimmed}` : composed;
1172
1567
  }
1173
1568
 
1569
+ /** Append system instructions that apply to this exact turn only. */
1570
+ export function appendTurnInstructions(composed: string, turnInstructions?: string): string {
1571
+ const trimmed = turnInstructions?.trim();
1572
+ return trimmed ? `${composed} ${trimmed}` : composed;
1573
+ }
1574
+
1174
1575
  /**
1175
1576
  * Append the durable session metadata the model otherwise cannot observe.
1176
1577
  * This is deliberately declarative and excludes the title text: the title is
@@ -1239,7 +1640,9 @@ const agentRepositoryCloneHooks = new WeakMap<object, SandboxLifecycleHook[]>();
1239
1640
  // provided-session env; runStream reads them to build the clone hook context.
1240
1641
  // Absent when no brokered repo is attached / on the selfhosted path.
1241
1642
  const agentGitTokenSeeds = new WeakMap<object, GitTokenSeeds>();
1643
+ const agentGitCredentialBindings = new WeakMap<object, GitCredentialBindingSeed[]>();
1242
1644
  const agentToolspaceTokenSeed = new WeakMap<object, string>();
1645
+ const agentToolspaceTokenSessionId = new WeakMap<object, string>();
1243
1646
  // A genesis directive is consumed by runAgentStream exactly once for the
1244
1647
  // freshly-built agent. It must not remain in Agent.instructions: those
1245
1648
  // instructions are presented again on every internal model/tool loop.
@@ -1308,6 +1711,9 @@ export function buildOpenGeniAgent(
1308
1711
  resources: ResourceRef[],
1309
1712
  options: BuildAgentOptions = {},
1310
1713
  ): Agent<any, any> {
1714
+ if (Boolean(options.toolspaceTokenSeed) !== Boolean(options.toolspaceTokenSessionId)) {
1715
+ throw new Error("toolspaceTokenSeed and toolspaceTokenSessionId must be supplied together");
1716
+ }
1311
1717
  // Resolved per-turn gating. Each override defaults to today's settings-derived
1312
1718
  // behaviour, so the legacy global-client callers (no resolved model) build the
1313
1719
  // exact same agent as before; the multi-provider worker path passes the
@@ -1329,6 +1735,28 @@ export function buildOpenGeniAgent(
1329
1735
  // [...agent.tools, ...capability.tools()]), so hosted web_search coexists with
1330
1736
  // both rather than overriding them.
1331
1737
  const hostedTools = hostedWebSearch ? [webSearchTool()] : [];
1738
+ const humanInputTool = agentTool({
1739
+ name: HUMAN_INPUT_TOOL_NAME,
1740
+ description:
1741
+ "Pause this turn and request structured human input. Use for decisions or missing information that only a person can provide. Supports free text, single-select, multi-select, an optional Other value, multiple questions, explicit skip policy, and an optional expiry.",
1742
+ parameters: RequestHumanInputToolInput,
1743
+ needsApproval: true,
1744
+ // A missing/mismatched durable response is a protocol integrity failure,
1745
+ // not model-visible tool output the agent may reason past.
1746
+ errorFunction: null,
1747
+ execute: (_input, _context, details) => {
1748
+ const settled = options.humanInputResponse;
1749
+ if (!settled) {
1750
+ throw new Error("Human-input tool resumed without a durable response");
1751
+ }
1752
+ const resumedCallId = details?.toolCall?.callId;
1753
+ if (resumedCallId && resumedCallId !== settled.toolCallId) {
1754
+ throw new Error("Human-input response does not belong to the resumed tool call");
1755
+ }
1756
+ return JSON.stringify({ requestId: settled.requestId, ...settled.response });
1757
+ },
1758
+ });
1759
+ const agentTools = [...hostedTools, humanInputTool];
1332
1760
  const baseConfig = {
1333
1761
  name: "OpenGeni Agent",
1334
1762
  model: options.model ?? settings.openaiModel,
@@ -1351,24 +1779,28 @@ export function buildOpenGeniAgent(
1351
1779
  // and the worker resolved a nonblank block — appendWorkspaceMemory,
1352
1780
  // 4. + the per-session persona instructions (session-specific, so it
1353
1781
  // refines both the workspace persona and the substrate note),
1354
- // 5. + durable session-setting state (title present + child notification
1782
+ // 5. + host context for this exact turn, when supplied,
1783
+ // 6. + durable session-setting state (title present + child notification
1355
1784
  // mode), when supplied by the worker,
1356
1785
  // The genesis title directive is deliberately NOT part of this persistent
1357
1786
  // string. runAgentStream injects it into the first model call only.
1358
1787
  instructions: appendPersistentSessionSettings(
1359
- appendSessionInstructions(
1360
- appendWorkspaceMemory(
1361
- appendToolspaceInstructions(
1362
- composeAgentInstructions(
1363
- options.instructionsTemplate ?? settings.agentInstructionsTemplate,
1364
- options.workspaceEnvironment,
1365
- options.rig,
1788
+ appendTurnInstructions(
1789
+ appendSessionInstructions(
1790
+ appendWorkspaceMemory(
1791
+ appendToolspaceInstructions(
1792
+ composeAgentInstructions(
1793
+ options.instructionsTemplate ?? settings.agentInstructionsTemplate,
1794
+ options.workspaceEnvironment,
1795
+ options.rig,
1796
+ ),
1797
+ settings.toolspaceEnabled && Boolean(options.toolspaceTokenSeed),
1366
1798
  ),
1367
- settings.toolspaceEnabled && Boolean(options.toolspaceTokenSeed),
1799
+ options.workspaceMemory,
1368
1800
  ),
1369
- options.workspaceMemory,
1801
+ options.sessionInstructions,
1370
1802
  ),
1371
- options.sessionInstructions,
1803
+ options.turnInstructions,
1372
1804
  ),
1373
1805
  options.persistentSessionSettings,
1374
1806
  ),
@@ -1391,7 +1823,7 @@ export function buildOpenGeniAgent(
1391
1823
  // `new Agent(baseConfig)` path (sandboxBackend === "none") and the
1392
1824
  // `new SandboxAgent({ ...baseConfig, ... })` path via the shared baseConfig
1393
1825
  // spread; the SDK concatenates these with MCP and sandbox capability tools.
1394
- ...(hostedTools.length ? { tools: hostedTools } : {}),
1826
+ tools: agentTools,
1395
1827
  ...(options.mcpServers?.length ? { mcpServers: options.mcpServers } : {}),
1396
1828
  // Surface FAILED MCP tool calls as `{ isError: true }` tool output (see
1397
1829
  // mcpToolErrorFunction / mcpToolErrorOutput) instead of the SDK's default
@@ -1423,6 +1855,9 @@ export function buildOpenGeniAgent(
1423
1855
  ),
1424
1856
  ...(runAs ? { runAs } : {}),
1425
1857
  capabilities: buildAgentCapabilities(settings, options.packSkills ?? [], {
1858
+ ...(options.skillLibrarySkills?.length
1859
+ ? { skillLibrarySkills: options.skillLibrarySkills }
1860
+ : {}),
1426
1861
  ...(options.structuredToolTransport !== undefined
1427
1862
  ? { structuredToolTransport: options.structuredToolTransport }
1428
1863
  : {}),
@@ -1430,8 +1865,24 @@ export function buildOpenGeniAgent(
1430
1865
  ? { computerToolMode: options.computerToolMode }
1431
1866
  : {}),
1432
1867
  ...(options.onComputerUseReady ? { onComputerUseReady: options.onComputerUseReady } : {}),
1868
+ ...(options.turnCancellationSignal
1869
+ ? { turnCancellationSignal: options.turnCancellationSignal }
1870
+ : {}),
1871
+ ...(options.onToolCancellationFence
1872
+ ? { onToolCancellationFence: options.onToolCancellationFence }
1873
+ : {}),
1433
1874
  }),
1434
1875
  });
1876
+ agentSkillSelections.set(
1877
+ agent,
1878
+ Object.freeze(
1879
+ effectiveSkillSelections(
1880
+ options.skillLibrarySelections ?? [],
1881
+ options.skillLibrarySkills ?? [],
1882
+ options.packSkills ?? [],
1883
+ ).map((selection) => Object.freeze(selection)),
1884
+ ),
1885
+ );
1435
1886
  if (options.genesisTitleHint) {
1436
1887
  agentsNeedingGenesisTitleDirective.add(agent);
1437
1888
  }
@@ -1463,8 +1914,12 @@ export function buildOpenGeniAgent(
1463
1914
  if (Object.keys(gitTokenSeeds).length > 0) {
1464
1915
  agentGitTokenSeeds.set(agent, gitTokenSeeds);
1465
1916
  }
1917
+ if (options.gitCredentialBindings && options.gitCredentialBindings.length > 0) {
1918
+ agentGitCredentialBindings.set(agent, options.gitCredentialBindings);
1919
+ }
1466
1920
  if (options.toolspaceTokenSeed) {
1467
1921
  agentToolspaceTokenSeed.set(agent, options.toolspaceTokenSeed);
1922
+ agentToolspaceTokenSessionId.set(agent, options.toolspaceTokenSessionId!);
1468
1923
  }
1469
1924
  // M3: stash the rig setup descriptor + RESOLVE the rig credential hooks now.
1470
1925
  // sandboxLifecycleHooksForIds throws on an unknown hook name, so a typo'd rig
@@ -1504,14 +1959,17 @@ function maybeInstallCodexToolSearch(
1504
1959
  }
1505
1960
 
1506
1961
  /** True when the unprefixed tool `name` requires approval under `policy`. */
1507
- function mcpToolRequiresApproval(policy: boolean | string[], unprefixedName: string): boolean {
1508
- return policy === true || (Array.isArray(policy) && policy.includes(unprefixedName));
1962
+ function mcpToolRequiresApproval(
1963
+ policy: boolean | ReadonlySet<string>,
1964
+ unprefixedName: string,
1965
+ ): boolean {
1966
+ return policy === true || (policy !== false && policy.has(unprefixedName));
1509
1967
  }
1510
1968
 
1511
1969
  /** A per-server approval policy keyed by the server's `<id>__` tool prefix. */
1512
1970
  type McpApprovalPolicy = {
1513
1971
  prefix: string;
1514
- requireApproval: boolean | string[];
1972
+ requireApproval: boolean | ReadonlySet<string>;
1515
1973
  };
1516
1974
 
1517
1975
  /** The subset of the agent surface the approval wrap needs — including `clone`. */
@@ -1602,7 +2060,8 @@ function applyMcpApprovalPolicy(agent: Agent<any, any>, settings: Settings): voi
1602
2060
  )
1603
2061
  .map((server) => ({
1604
2062
  prefix: prefixedMcpToolName(server.id, ""),
1605
- requireApproval: server.requireApproval as boolean | string[],
2063
+ requireApproval:
2064
+ server.requireApproval === true ? true : new Set(server.requireApproval as string[]),
1606
2065
  }))
1607
2066
  .sort((a, b) => b.prefix.length - a.prefix.length);
1608
2067
  if (policies.length === 0) {
@@ -1721,6 +2180,7 @@ export function buildAgentCapabilities(
1721
2180
  settings: Settings,
1722
2181
  packSkills: PackSkill[],
1723
2182
  options: {
2183
+ skillLibrarySkills?: PackSkill[];
1724
2184
  structuredToolTransport?: boolean;
1725
2185
  // EXPLICIT computer-use transport (see BuildAgentOptions.computerToolMode). When
1726
2186
  // present, computerUse() is handed the mode directly and its tools() obeys it
@@ -1728,8 +2188,15 @@ export function buildAgentCapabilities(
1728
2188
  // imageFunctionResults path (driven by structuredToolTransport) is unchanged.
1729
2189
  computerToolMode?: ComputerToolMode;
1730
2190
  onComputerUseReady?: (session: SandboxSessionLike) => Promise<void>;
2191
+ turnCancellationSignal?: AbortSignal;
2192
+ onToolCancellationFence?: (fence: TurnToolCancellationFence) => void;
1731
2193
  } = {},
1732
2194
  ): ReturnType<typeof Capabilities.default> {
2195
+ const toolCancellation =
2196
+ options.turnCancellationSignal || options.onToolCancellationFence
2197
+ ? createTurnToolCancellationController(options.turnCancellationSignal)
2198
+ : null;
2199
+ if (toolCancellation) options.onToolCancellationFence?.(toolCancellation);
1733
2200
  // The `filesystem()` capability picks hosted-vs-function tool variants from the
1734
2201
  // bound model instance (supportsApplyPatchTransport / structured tool output).
1735
2202
  // When the caller declares the backend does NOT support that structured/hosted
@@ -1748,9 +2215,13 @@ export function buildAgentCapabilities(
1748
2215
  }
1749
2216
  const caps: ReturnType<typeof Capabilities.default> = [
1750
2217
  filesystemCapability,
1751
- shell({ configureTools: withExecOpCorrelation }),
2218
+ shell({ ...(toolCancellation ? {} : { configureTools: withExecOpCorrelation }) }),
1752
2219
  ];
1753
- caps.push(skills({ lazyFrom: lazySkillSourceWithPackSkills(packSkills) }));
2220
+ caps.push(
2221
+ skills({
2222
+ lazyFrom: lazySkillSourceWithPackSkills(packSkills, options.skillLibrarySkills ?? []),
2223
+ }),
2224
+ );
1754
2225
  // P4.3 computer-use: the agent drives the SAME :0 humans watch (xdotool/XTEST +
1755
2226
  // scrot), but only when the desktop tier is ON, computer-use is enabled, and the
1756
2227
  // backend is one whose image carries the X stack (descriptorgate — honest about
@@ -1802,6 +2273,14 @@ export function buildAgentCapabilities(
1802
2273
  }
1803
2274
  caps.push(computerCapability as unknown as ReturnType<typeof Capabilities.default>[number]);
1804
2275
  }
2276
+ if (toolCancellation) {
2277
+ for (const capability of caps) {
2278
+ wrapCapabilityToolsForTurnCancellation(
2279
+ capability as unknown as { tools(): Tool<unknown>[] },
2280
+ toolCancellation,
2281
+ );
2282
+ }
2283
+ }
1805
2284
  return caps;
1806
2285
  }
1807
2286
 
@@ -1843,8 +2322,73 @@ export type PrepareToolsOptions = {
1843
2322
  input: ResolveConnectionCredentialInput,
1844
2323
  ) => Promise<ResolveConnectionCredentialResult>;
1845
2324
  onAuthNeeded?: (payload: ToolAuthNeededPayload) => Promise<void> | void;
2325
+ /** Injectable final MCP transport for tests and embedded hosts. */
2326
+ mcpFetchImpl?: FetchLike;
2327
+ };
2328
+
2329
+ type ConnectedMcpServerBatch = Awaited<ReturnType<typeof connectMcpServers>>;
2330
+
2331
+ export type ConnectedMcpServerBatches = {
2332
+ active: MCPServer[];
2333
+ failed: MCPServer[];
2334
+ errors: ReadonlyMap<MCPServer, Error>;
2335
+ close: () => Promise<void>;
1846
2336
  };
1847
2337
 
2338
+ /**
2339
+ * Connect SDK-managed MCP servers in stable, bounded batches. The SDK cleans a
2340
+ * failing strict batch; this wrapper additionally closes every earlier batch
2341
+ * before rethrowing, so a later-batch failure cannot leak live connections.
2342
+ */
2343
+ export async function connectMcpServersInBatches(
2344
+ servers: MCPServer[],
2345
+ options: { strict: boolean },
2346
+ ): Promise<ConnectedMcpServerBatches> {
2347
+ assertMcpServerSelectionWithinBounds(servers);
2348
+ const batches: ConnectedMcpServerBatch[] = [];
2349
+ try {
2350
+ for (let offset = 0; offset < servers.length; offset += MCP_MAX_CONCURRENT_SERVER_OPERATIONS) {
2351
+ batches.push(
2352
+ await connectMcpServers(
2353
+ servers.slice(offset, offset + MCP_MAX_CONCURRENT_SERVER_OPERATIONS),
2354
+ {
2355
+ connectInParallel: true,
2356
+ strict: options.strict,
2357
+ },
2358
+ ),
2359
+ );
2360
+ }
2361
+ } catch (error) {
2362
+ await closeMcpServerBatches(batches).catch(() => undefined);
2363
+ throw error;
2364
+ }
2365
+
2366
+ const errors = new Map<MCPServer, Error>();
2367
+ for (const batch of batches) {
2368
+ for (const [server, error] of batch.errors) errors.set(server, error);
2369
+ }
2370
+ return {
2371
+ active: batches.flatMap((batch) => batch.active),
2372
+ failed: batches.flatMap((batch) => batch.failed),
2373
+ errors,
2374
+ close: async () => {
2375
+ await closeMcpServerBatches(batches);
2376
+ },
2377
+ };
2378
+ }
2379
+
2380
+ async function closeMcpServerBatches(batches: ConnectedMcpServerBatch[]): Promise<void> {
2381
+ let firstError: unknown;
2382
+ for (const batch of [...batches].reverse()) {
2383
+ try {
2384
+ await batch.close();
2385
+ } catch (error) {
2386
+ firstError ??= error;
2387
+ }
2388
+ }
2389
+ if (firstError !== undefined) throw firstError;
2390
+ }
2391
+
1848
2392
  export async function prepareAgentTools(
1849
2393
  settings: Settings,
1850
2394
  tools: ToolRef[],
@@ -1854,25 +2398,36 @@ export async function prepareAgentTools(
1854
2398
  // codex_apps sanitizing fetch so every tools/list this turn accumulates the
1855
2399
  // account's connector namespaces. Surfaced on PreparedAgentTools for the worker.
1856
2400
  const codexConnectorNamespaces = new Set<string>();
2401
+ assertMcpServerSelectionWithinBounds(tools);
1857
2402
  if (tools.length === 0) {
1858
2403
  return { mcpServers: [], close: async () => {}, codexConnectorNamespaces };
1859
2404
  }
1860
2405
  const registry = new Map(settings.mcpServers.map((server) => [server.id, server]));
1861
- const servers = await Promise.all(
1862
- tools.map(async (tool) => {
2406
+ const aggregateToolBudget = new McpAggregateToolListBudget();
2407
+ const mcpFetchImpl = options.mcpFetchImpl ?? undiciFetch;
2408
+ const servers = await boundedParallelMap(
2409
+ tools,
2410
+ MCP_MAX_CONCURRENT_SERVER_OPERATIONS,
2411
+ async (tool, index) => {
1863
2412
  const config = registry.get(tool.id);
1864
2413
  if (!config) {
1865
2414
  throw new Error(`Unknown MCP server id: ${tool.id}`);
1866
2415
  }
1867
2416
  const url = firstPartyMcpServerUrlForRun(settings, config, options.workspaceId) ?? config.url;
2417
+ const firstParty = isFirstPartyMcpServer(settings, config);
1868
2418
  const baseFetch = isCodexAppsMcpServer(config)
1869
- ? codexAppsSanitizingFetch(globalThis.fetch, codexConnectorNamespaces)
1870
- : globalThis.fetch;
2419
+ ? codexAppsSanitizingFetch(mcpFetchImpl, codexConnectorNamespaces)
2420
+ : mcpFetchImpl;
2421
+ const guardedFetch = guardedMcpFetch(
2422
+ firstParty ? { ...settings, integrationsAllowPrivateNetworkTargets: true } : settings,
2423
+ baseFetch,
2424
+ firstParty ? { requireHttpsOutsideLocalTest: false } : {},
2425
+ );
1871
2426
  const fetchImpl = config.connectionRef
1872
- ? connectionBrokerFetch(baseFetch, config, options)
1873
- : isFirstPartyMcpServer(settings, config)
1874
- ? firstPartyAuthFetch(baseFetch, settings, options)
1875
- : baseFetch;
2427
+ ? connectionBrokerFetch(guardedFetch, config, options)
2428
+ : firstParty
2429
+ ? firstPartyAuthFetch(guardedFetch, settings, options)
2430
+ : guardedFetch;
1876
2431
  // A server is connected BEST-EFFORT (a connect OR tools-list failure drops
1877
2432
  // it — its tools go unavailable for the turn — instead of failing the turn)
1878
2433
  // in two cases:
@@ -1904,7 +2459,7 @@ export async function prepareAgentTools(
1904
2459
  // MCP SDK's strict Tool schema rejects (fails the turn during tools/list);
1905
2460
  // sanitize the response on the wire before validation. The namespace Set
1906
2461
  // also captures each tool's original connector namespace (P4 Part B.1).
1907
- ...(fetchImpl !== globalThis.fetch ? { fetch: fetchImpl } : {}),
2462
+ fetch: fetchImpl,
1908
2463
  ...(await mcpServerRequestInit(settings, config)),
1909
2464
  ...(config.timeoutMs
1910
2465
  ? {
@@ -1916,13 +2471,15 @@ export async function prepareAgentTools(
1916
2471
  config.id,
1917
2472
  config.allowedTools,
1918
2473
  bestEffort,
2474
+ aggregateToolBudget,
2475
+ `${config.id}:${index}`,
1919
2476
  );
1920
2477
  return {
1921
2478
  server,
1922
2479
  bestEffort,
1923
2480
  optional,
1924
2481
  };
1925
- }),
2482
+ },
1926
2483
  );
1927
2484
  const requiredServers = servers.filter((entry) => !entry.bestEffort).map((entry) => entry.server);
1928
2485
  const bestEffortServers = servers
@@ -1934,18 +2491,21 @@ export async function prepareAgentTools(
1934
2491
  const optionalServerNames = new Set(
1935
2492
  servers.filter((entry) => entry.optional).map((entry) => entry.server.name),
1936
2493
  );
1937
- const connectedRequired = await connectMcpServers(requiredServers, {
1938
- connectInParallel: true,
1939
- strict: true,
1940
- });
1941
- const connectedBestEffort = bestEffortServers.length
1942
- ? await connectMcpServers(bestEffortServers, {
1943
- connectInParallel: true,
1944
- strict: false,
1945
- })
1946
- : null;
2494
+ const connectedRequired = await connectMcpServersInBatches(requiredServers, { strict: true });
2495
+ let connectedBestEffort: ConnectedMcpServerBatches | null = null;
2496
+ try {
2497
+ connectedBestEffort = bestEffortServers.length
2498
+ ? await connectMcpServersInBatches(bestEffortServers, { strict: false })
2499
+ : null;
2500
+ } catch (error) {
2501
+ await connectedRequired.close().catch(() => undefined);
2502
+ throw error;
2503
+ }
1947
2504
  if (connectedBestEffort) {
1948
2505
  for (const failed of connectedBestEffort.failed) {
2506
+ if (failed instanceof PrefixedMcpServer) {
2507
+ failed.releaseAggregateBudget();
2508
+ }
1949
2509
  if (!optionalServerNames.has(failed.name)) {
1950
2510
  continue;
1951
2511
  }
@@ -1959,10 +2519,20 @@ export async function prepareAgentTools(
1959
2519
  return {
1960
2520
  mcpServers: [...connectedRequired.active, ...(connectedBestEffort?.active ?? [])],
1961
2521
  close: async () => {
1962
- await connectedRequired.close();
2522
+ let firstError: unknown;
1963
2523
  if (connectedBestEffort) {
1964
- await connectedBestEffort.close();
2524
+ try {
2525
+ await connectedBestEffort.close();
2526
+ } catch (error) {
2527
+ firstError ??= error;
2528
+ }
1965
2529
  }
2530
+ try {
2531
+ await connectedRequired.close();
2532
+ } catch (error) {
2533
+ firstError ??= error;
2534
+ }
2535
+ if (firstError !== undefined) throw firstError;
1966
2536
  },
1967
2537
  codexConnectorNamespaces,
1968
2538
  };
@@ -1979,10 +2549,12 @@ function connectionBrokerFetch(
1979
2549
  }
1980
2550
  return async (input, init) => {
1981
2551
  const request = await mcpRequestInfo(input, init);
2552
+ const destinationUrl = mcpRequestDestinationUrl(input);
1982
2553
  const first = await resolveConnectionForRequest(
1983
2554
  options,
1984
2555
  config.id,
1985
2556
  connectionRef,
2557
+ destinationUrl,
1986
2558
  request.toolName,
1987
2559
  false,
1988
2560
  );
@@ -1994,10 +2566,12 @@ function connectionBrokerFetch(
1994
2566
  withConnectionHeaders(input, init, first.headers),
1995
2567
  );
1996
2568
  if (response.status === 401) {
2569
+ await cancelMcpResponseBody(response);
1997
2570
  const refreshed = await resolveConnectionForRequest(
1998
2571
  options,
1999
2572
  config.id,
2000
2573
  connectionRef,
2574
+ destinationUrl,
2001
2575
  request.toolName,
2002
2576
  true,
2003
2577
  );
@@ -2010,11 +2584,14 @@ function connectionBrokerFetch(
2010
2584
  );
2011
2585
  if (retry.status === 403) {
2012
2586
  const auth = insufficientScopeAuth(retry.headers, connectionRef, refreshed.connectionId);
2013
- return auth
2014
- ? await authNeededFetchResponse(options, config.id, request, auth, connectionRef)
2015
- : retry;
2587
+ if (auth) {
2588
+ await cancelMcpResponseBody(retry);
2589
+ return await authNeededFetchResponse(options, config.id, request, auth, connectionRef);
2590
+ }
2591
+ return retry;
2016
2592
  }
2017
2593
  if (retry.status === 401) {
2594
+ await cancelMcpResponseBody(retry);
2018
2595
  return await authNeededFetchResponse(
2019
2596
  options,
2020
2597
  config.id,
@@ -2023,9 +2600,13 @@ function connectionBrokerFetch(
2023
2600
  status: "auth_needed",
2024
2601
  reason: "expired",
2025
2602
  providerDomain: connectionRef.providerDomain,
2603
+ ...(connectionRef.provider ? { provider: connectionRef.provider } : {}),
2026
2604
  connectionId: refreshed.connectionId,
2027
2605
  ...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
2028
2606
  ...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
2607
+ ...(connectionRef.selectedResources
2608
+ ? { selectedResources: connectionRef.selectedResources }
2609
+ : {}),
2029
2610
  },
2030
2611
  connectionRef,
2031
2612
  );
@@ -2034,9 +2615,11 @@ function connectionBrokerFetch(
2034
2615
  }
2035
2616
  if (response.status === 403) {
2036
2617
  const auth = insufficientScopeAuth(response.headers, connectionRef, first.connectionId);
2037
- return auth
2038
- ? await authNeededFetchResponse(options, config.id, request, auth, connectionRef)
2039
- : response;
2618
+ if (auth) {
2619
+ await cancelMcpResponseBody(response);
2620
+ return await authNeededFetchResponse(options, config.id, request, auth, connectionRef);
2621
+ }
2622
+ return response;
2040
2623
  }
2041
2624
  return response;
2042
2625
  };
@@ -2046,6 +2629,7 @@ async function resolveConnectionForRequest(
2046
2629
  options: PrepareToolsOptions,
2047
2630
  serverId: string,
2048
2631
  connectionRef: McpServerConnectionRef,
2632
+ destinationUrl: string,
2049
2633
  toolName: string | undefined,
2050
2634
  forceRefresh: boolean,
2051
2635
  ): Promise<ResolveConnectionCredentialResult> {
@@ -2054,15 +2638,20 @@ async function resolveConnectionForRequest(
2054
2638
  status: "auth_needed",
2055
2639
  reason: "missing_connection",
2056
2640
  providerDomain: connectionRef.providerDomain,
2641
+ ...(connectionRef.provider ? { provider: connectionRef.provider } : {}),
2057
2642
  ...(connectionRef.connectionId ? { connectionId: connectionRef.connectionId } : {}),
2058
2643
  ...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
2059
2644
  ...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
2645
+ ...(connectionRef.selectedResources
2646
+ ? { selectedResources: connectionRef.selectedResources }
2647
+ : {}),
2060
2648
  };
2061
2649
  }
2062
2650
  const request: ResolveConnectionCredentialInput = {
2063
2651
  workspaceId: options.workspaceId,
2064
2652
  serverId,
2065
2653
  connectionRef,
2654
+ destinationUrl,
2066
2655
  forceRefresh,
2067
2656
  ...(toolName ? { toolName } : {}),
2068
2657
  ...(options.subjectId ? { subjectId: options.subjectId } : {}),
@@ -2074,9 +2663,13 @@ async function resolveConnectionForRequest(
2074
2663
  status: "auth_needed",
2075
2664
  reason: "refresh_failed",
2076
2665
  providerDomain: connectionRef.providerDomain,
2666
+ ...(connectionRef.provider ? { provider: connectionRef.provider } : {}),
2077
2667
  ...(connectionRef.connectionId ? { connectionId: connectionRef.connectionId } : {}),
2078
2668
  ...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
2079
2669
  ...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
2670
+ ...(connectionRef.selectedResources
2671
+ ? { selectedResources: connectionRef.selectedResources }
2672
+ : {}),
2080
2673
  };
2081
2674
  }
2082
2675
  }
@@ -2094,6 +2687,7 @@ function insufficientScopeAuth(
2094
2687
  status: "auth_needed",
2095
2688
  reason: "insufficient_scope",
2096
2689
  providerDomain: connectionRef.providerDomain,
2690
+ ...(connectionRef.provider ? { provider: connectionRef.provider } : {}),
2097
2691
  connectionId,
2098
2692
  ...(challenge.scope?.length
2099
2693
  ? { scopes: challenge.scope }
@@ -2105,6 +2699,9 @@ function insufficientScopeAuth(
2105
2699
  : connectionRef.resource
2106
2700
  ? { resource: connectionRef.resource }
2107
2701
  : {}),
2702
+ ...(connectionRef.selectedResources
2703
+ ? { selectedResources: connectionRef.selectedResources }
2704
+ : {}),
2108
2705
  };
2109
2706
  }
2110
2707
 
@@ -2120,6 +2717,11 @@ async function authNeededFetchResponse(
2120
2717
  serverId,
2121
2718
  toolName: request.toolName ?? null,
2122
2719
  providerDomain: auth.providerDomain,
2720
+ ...(auth.provider
2721
+ ? { provider: auth.provider }
2722
+ : connectionRef.provider
2723
+ ? { provider: connectionRef.provider }
2724
+ : {}),
2123
2725
  reason: auth.reason,
2124
2726
  ...(connectionId ? { connectionId } : {}),
2125
2727
  ...(auth.scopes
@@ -2132,6 +2734,11 @@ async function authNeededFetchResponse(
2132
2734
  : connectionRef.resource
2133
2735
  ? { resource: connectionRef.resource }
2134
2736
  : {}),
2737
+ ...(auth.selectedResources
2738
+ ? { selectedResources: auth.selectedResources }
2739
+ : connectionRef.selectedResources
2740
+ ? { selectedResources: connectionRef.selectedResources }
2741
+ : {}),
2135
2742
  ...(auth.authorizationUrl ? { authorizationUrl: auth.authorizationUrl } : {}),
2136
2743
  ...(options.subjectId ? { subjectId: options.subjectId } : {}),
2137
2744
  });
@@ -2161,6 +2768,10 @@ type McpRequestInfo = {
2161
2768
  toolName?: string;
2162
2769
  };
2163
2770
 
2771
+ function mcpRequestDestinationUrl(input: string | URL | Request): string {
2772
+ return new URL(input instanceof Request ? input.url : input.toString()).toString();
2773
+ }
2774
+
2164
2775
  async function mcpRequestInfo(
2165
2776
  input: string | URL | Request,
2166
2777
  init?: RequestInit,
@@ -2389,7 +3000,7 @@ async function signFirstPartyDelegatedBearer(
2389
3000
  workspaceId: options.workspaceId,
2390
3001
  subjectId: options.subjectId ?? "worker:first-party-mcp",
2391
3002
  ...(options.subjectLabel ? { subjectLabel: options.subjectLabel } : {}),
2392
- permissions: options.firstPartyPermissions ?? firstPartyMcpPermissions,
3003
+ permissions: options.firstPartyPermissions ?? [...DEFAULT_FIRST_PARTY_MCP_PERMISSIONS],
2393
3004
  ...(options.sessionId ? { sessionId: options.sessionId } : {}),
2394
3005
  ...(options.turnId ? { turnId: options.turnId } : {}),
2395
3006
  ...(options.attemptId ? { attemptId: options.attemptId } : {}),
@@ -2473,22 +3084,6 @@ async function codexAppsMcpRequestInit(
2473
3084
  // level scopes (billing/account/members/api_keys/workspace:admin) are
2474
3085
  // intentionally excluded: they gate no first-party tool and are not agent
2475
3086
  // capabilities. (A finer-grained capability model comes later.)
2476
- const firstPartyMcpPermissions: Permission[] = [
2477
- "workspace:read",
2478
- "files:read",
2479
- "documents:search",
2480
- "scheduled_tasks:manage",
2481
- "scheduled_tasks:run",
2482
- "goals:manage",
2483
- "sessions:read",
2484
- "sessions:create",
2485
- "sessions:control",
2486
- "variable-sets:use",
2487
- "variable-sets:manage",
2488
- "rigs:use",
2489
- "github:use",
2490
- ];
2491
-
2492
3087
  // codex_apps is third-party-by-trust (the external ChatGPT connectors backend)
2493
3088
  // but needs DYNAMIC auth, so it is its own category — deliberately NOT folded
2494
3089
  // into the first-party allowlist, which would wrongly sign an OpenGeni delegated
@@ -2588,12 +3183,15 @@ class PrefixedMcpServer implements MCPServer {
2588
3183
  // doesn't spam the log when the SDK re-lists across model turns.
2589
3184
  private readonly bestEffort: boolean;
2590
3185
  private loggedListToolsFailure = false;
3186
+ private listedToolSchemaTokens = 0;
2591
3187
 
2592
3188
  constructor(
2593
3189
  private readonly inner: MCPServer,
2594
3190
  registryId: string,
2595
3191
  allowedTools?: string[],
2596
3192
  bestEffort = false,
3193
+ private readonly aggregateToolBudget?: McpAggregateToolListBudget,
3194
+ private readonly aggregateSourceId = registryId,
2597
3195
  ) {
2598
3196
  this.name = registryId;
2599
3197
  this.prefix = prefixedMcpToolName(registryId, "");
@@ -2607,13 +3205,27 @@ class PrefixedMcpServer implements MCPServer {
2607
3205
  }
2608
3206
 
2609
3207
  close(): Promise<void> {
3208
+ this.releaseAggregateBudget();
2610
3209
  return this.inner.close();
2611
3210
  }
2612
3211
 
3212
+ releaseAggregateBudget(): void {
3213
+ this.aggregateToolBudget?.remove(this.aggregateSourceId);
3214
+ }
3215
+
2613
3216
  async listTools(): Promise<RuntimeMcpTool[]> {
2614
- let tools: RuntimeMcpTool[];
2615
3217
  try {
2616
- tools = await this.inner.listTools();
3218
+ const tools = assertMcpToolListWithinBounds(await this.inner.listTools()) as RuntimeMcpTool[];
3219
+ const exposed = tools
3220
+ .filter((tool) => this.isAllowed(tool.name))
3221
+ .map((tool) => ({
3222
+ ...tool,
3223
+ name: prefixedMcpToolName(this.name, tool.name),
3224
+ }));
3225
+ const bounded = (this.aggregateToolBudget?.replace(this.aggregateSourceId, exposed) ??
3226
+ assertMcpToolListWithinBounds(exposed)) as RuntimeMcpTool[];
3227
+ this.listedToolSchemaTokens = estimateSerializedValueTokens(bounded);
3228
+ return bounded;
2617
3229
  } catch (error) {
2618
3230
  // A REQUIRED server's tools/list failure is fatal (fail-loud default): the
2619
3231
  // caller explicitly requested it, so its absence must fail the turn.
@@ -2642,14 +3254,14 @@ class PrefixedMcpServer implements MCPServer {
2642
3254
  { serverId: this.name, ...safeMcpErrorFields(error) },
2643
3255
  );
2644
3256
  }
3257
+ this.releaseAggregateBudget();
2645
3258
  return [];
2646
3259
  }
2647
- return tools
2648
- .filter((tool) => this.isAllowed(tool.name))
2649
- .map((tool) => ({
2650
- ...tool,
2651
- name: prefixedMcpToolName(this.name, tool.name),
2652
- }));
3260
+ }
3261
+
3262
+ /** Latest exact tools/list projection used to build the model request. */
3263
+ modelToolSchemaTokens(): number {
3264
+ return this.listedToolSchemaTokens;
2653
3265
  }
2654
3266
 
2655
3267
  async callTool(
@@ -2662,7 +3274,9 @@ class PrefixedMcpServer implements MCPServer {
2662
3274
  throw new Error(`MCP tool ${unprefixed} is not allowed for server ${this.name}`);
2663
3275
  }
2664
3276
  try {
2665
- return await this.inner.callTool(unprefixed, args, meta);
3277
+ const output = await this.inner.callTool(unprefixed, args, meta);
3278
+ assertMcpPayloadWithinBytes(output, MCP_MAX_TOOL_RESULT_BYTES, "MCP tool result");
3279
+ return output;
2666
3280
  } catch (error) {
2667
3281
  // The connection broker's auth-needed short-circuit arrives as a thrown
2668
3282
  // JSON-RPC error (an inline isError result would be stripped by the SDK
@@ -2813,22 +3427,25 @@ export async function prepareRunInput(
2813
3427
  ...(sandboxSessionState ? { sandboxSessionState } : {}),
2814
3428
  };
2815
3429
  }
2816
- // An approval can only be resumed against a real saved run state. If the
3430
+ // An interrupted tool can only be resumed against a real saved run state. If the
2817
3431
  // latest blob is the cleared sentinel the awaiting turn was wiped (the API
2818
3432
  // refuses clear in requires_action, so this is a defensive guard) — fail with
2819
3433
  // an honest message instead of the cryptic SDK "missing schema version".
2820
3434
  if (isClearedRunStateBlob(input.serializedRunState)) {
2821
3435
  throw new Error(
2822
- "Cannot resume an approval: the session context was cleared, so the awaiting run state no longer exists.",
3436
+ "Cannot resume an interrupted tool: the session context was cleared, so the awaiting run state no longer exists.",
2823
3437
  );
2824
3438
  }
2825
3439
  const state = await RunState.fromString(agent, input.serializedRunState);
2826
3440
  const interruptions = state.getInterruptions();
2827
- const target = interruptions.find((item: any) => approvalIdentifier(item) === input.approvalId);
3441
+ const interruptionId = input.kind === "human_input" ? input.toolCallId : input.approvalId;
3442
+ const target = interruptions.find((item: any) => approvalIdentifier(item) === interruptionId);
2828
3443
  if (!target) {
2829
- throw new Error(`Approval not found in saved run state: ${input.approvalId}`);
3444
+ throw new Error(`Interrupted tool not found in saved run state: ${interruptionId}`);
2830
3445
  }
2831
- if (input.decision === "approve") {
3446
+ if (input.kind === "human_input") {
3447
+ state.approve(target as any);
3448
+ } else if (input.decision === "approve") {
2832
3449
  state.approve(target as any);
2833
3450
  } else {
2834
3451
  state.reject(target as any, input.message ? { message: input.message } : undefined);
@@ -2842,13 +3459,21 @@ export type RunAgentStreamOptions = {
2842
3459
  sandboxClient?: unknown;
2843
3460
  sandboxEnvironment?: Record<string, string>;
2844
3461
  onRuntimeEvent?: (event: NormalizedRuntimeEvent) => Promise<void> | void;
2845
- contextCompactionSignalTokens?: () => number | null | undefined;
3462
+ contextCompactionSignal?: () => ProviderContextTokenSignal | null | undefined;
2846
3463
  contextCompactionRequested?: () => boolean | Promise<boolean>;
2847
3464
  // Host-managed git credential renewal registration. Called only after the
2848
3465
  // initial token-file seed completed on a real provisioned box. The worker
2849
3466
  // owns the multi-day timer and uses this pinned, un-proxied session to
2850
3467
  // atomically replace token files; runtime never mints credentials itself.
2851
3468
  onGitCredentialSessionReady?: (session: GitCredentialTokenWriterSession) => Promise<void> | void;
3469
+ // OpenGeni-minted Toolspace token renewal registration. Called only after the
3470
+ // initial token file reached the real sandbox session.
3471
+ onToolspaceTokenSessionReady?: (session: ToolspaceTokenWriterSession) => Promise<void> | void;
3472
+ // Host-owned run material is seeded off-manifest before setup and every
3473
+ // agent-created process sources the active immutable generation. The worker
3474
+ // owns resolution/renewal/fencing; runtime owns sandbox transport.
3475
+ runCredentialSessionId?: string;
3476
+ onRunCredentialSessionReady?: RunCredentialSessionReady;
2852
3477
  // OWNERSHIP INVERSION (P1.2): an externally-owned, already-live sandbox
2853
3478
  // session resolved by the per-turn resume-by-id path. When present,
2854
3479
  // runAgentStream does NOT build (or resume, or discard) a client — it threads
@@ -2879,6 +3504,13 @@ export type RunAgentStreamOptions = {
2879
3504
  // after establish, so runAgentStream must not run it eagerly here.
2880
3505
  deferredSetup?: boolean;
2881
3506
  };
3507
+ /**
3508
+ * The attempt's authoritative physical sandbox-operation fence. Platform
3509
+ * lifecycle commands use its command runner too, so Steer/Pause can interrupt
3510
+ * repository clone, rig setup, file materialization, and credential seeding
3511
+ * before the attempt-quiesced receipt opens queue admission.
3512
+ */
3513
+ turnToolCancellationFence?: TurnToolCancellationFence;
2882
3514
  // A per-turn model-input filter chained AFTER the provider-item-id strip.
2883
3515
  // Used by the genesis-title injection to prepend a hidden, NON-PERSISTED
2884
3516
  // directive: a callModelInputFilter mutates only `modelData.input` for each
@@ -2888,7 +3520,7 @@ export type RunAgentStreamOptions = {
2888
3520
  };
2889
3521
 
2890
3522
  export type ContextRobustnessFilterOptions = {
2891
- contextCompactionSignalTokens?: () => number | null | undefined;
3523
+ contextCompactionSignal?: () => ProviderContextTokenSignal | null | undefined;
2892
3524
  contextCompactionRequested?: () => boolean | Promise<boolean>;
2893
3525
  throwOnCompactionNeeded?: boolean;
2894
3526
  };
@@ -2934,9 +3566,10 @@ function takeGenesisTitleInputFilter(agent: Agent<any, any>): CallModelInputFilt
2934
3566
  // Generic substrate prompting for programmatic tool calling (toolspace). Same
2935
3567
  // text for every host; gated per-turn by appendToolspaceInstructions on the
2936
3568
  // presence of a minted toolspace token, so it only appears when the sandbox
2937
- // actually exposes the ogtool CLI + $OPENGENI_TOOLSPACE_URL/_TOKEN_FILE.
3569
+ // exposes $OPENGENI_TOOLSPACE_URL/_TOKEN_FILE. Stock images carry ogtool;
3570
+ // custom environments get only an exact deployment-pinned bootstrap hint.
2938
3571
  export const TOOLSPACE_PROGRAMMATIC_DIRECTIVE =
2939
- "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.";
3572
+ "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. If `ogtool` is installed, run `ogtool list` to see the available tools and their input schemas (from tools/list), then `ogtool call <tool-name> '<json-args>'`. If it is absent and both npm and $OPENGENI_OGTOOL_PACKAGE_SPEC are available, run the exact deployment-pinned package with `npm exec --yes --package=\"$OPENGENI_OGTOOL_PACKAGE_SPEC\" -- ogtool ...`; never guess a version or install `latest`. Otherwise POST MCP JSON-RPC directly 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.";
2940
3573
 
2941
3574
  /**
2942
3575
  * callModelInputFilter that removes provider-assigned item ids (rs_/msg_/fc_…)
@@ -2995,24 +3628,89 @@ export const elideSupersededViewImagesFilter: CallModelInputFilter = ({ modelDat
2995
3628
  ) as unknown as AgentInputItem[],
2996
3629
  });
2997
3630
 
3631
+ /**
3632
+ * Canonical Codex-style tool-result bound at the final model-input seam. The
3633
+ * identical pure normalizer also runs before conversation rows are persisted,
3634
+ * so this is a live-turn defense rather than a request-only alternate history.
3635
+ */
3636
+ export function boundModelToolOutputsFilterForSettings(settings: Settings): CallModelInputFilter {
3637
+ return ({ modelData }) => ({
3638
+ ...modelData,
3639
+ input: boundModelToolOutputItems(
3640
+ modelData.input as unknown as Array<Record<string, unknown>>,
3641
+ settings.modelToolOutputTruncationTokens,
3642
+ ) as unknown as AgentInputItem[],
3643
+ });
3644
+ }
3645
+
3646
+ function estimateAgentToolSchemaTokens(agent: Agent<any, any>): number {
3647
+ const localTools = Array.isArray((agent as { tools?: unknown }).tools)
3648
+ ? ((agent as { tools: unknown[] }).tools ?? [])
3649
+ : [];
3650
+ const localDescriptors = localTools.map((candidate) => {
3651
+ if (!candidate || typeof candidate !== "object") return candidate;
3652
+ const tool = candidate as Record<string, unknown>;
3653
+ return {
3654
+ type: tool.type,
3655
+ name: tool.name,
3656
+ description: tool.description,
3657
+ parameters: tool.parameters,
3658
+ inputSchema: tool.inputSchema,
3659
+ strict: tool.strict,
3660
+ providerData: tool.providerData,
3661
+ };
3662
+ });
3663
+ const mcpServers = Array.isArray((agent as { mcpServers?: unknown }).mcpServers)
3664
+ ? ((agent as { mcpServers: unknown[] }).mcpServers ?? [])
3665
+ : [];
3666
+ const mcpTokens = mcpServers.reduce<number>((total, server) => {
3667
+ const getter = (server as { modelToolSchemaTokens?: () => number } | null)
3668
+ ?.modelToolSchemaTokens;
3669
+ return total + (typeof getter === "function" ? getter.call(server) : 0);
3670
+ }, 0);
3671
+ return estimateSerializedValueTokens(localDescriptors) + mcpTokens;
3672
+ }
3673
+
2998
3674
  export function contextRobustnessFilterForSettings(
2999
3675
  settings: Settings,
3000
3676
  options: ContextRobustnessFilterOptions = {},
3001
3677
  ): CallModelInputFilter {
3002
3678
  const thresholdTokens = compactionThresholdTokens(settings);
3003
- return async ({ modelData }) => {
3679
+ let previousRequest: { revision: number; footprint: CompleteModelInputFootprint } | null = null;
3680
+ let requestRevision = 0;
3681
+ return async ({ modelData, agent }) => {
3004
3682
  const input = modelData.input;
3005
3683
  if (options.throwOnCompactionNeeded) {
3006
- const reported = options.contextCompactionSignalTokens?.();
3007
- const hasReported = typeof reported === "number" && reported > 0;
3008
- const signalTokens = hasReported
3009
- ? reported
3010
- : estimateTokens(input as unknown as Array<Record<string, unknown>>);
3684
+ const reported = options.contextCompactionSignal?.();
3685
+ const current: CompleteModelInputFootprint = {
3686
+ input: input as unknown as Array<Record<string, unknown>>,
3687
+ instructionsTokens: estimateSerializedValueTokens(modelData.instructions ?? ""),
3688
+ toolSchemaTokens: estimateAgentToolSchemaTokens(agent),
3689
+ };
3690
+ // Stream consumption can lag the SDK's background model loop. A provider
3691
+ // usage signal is safe only when its response revision belongs to the
3692
+ // immediately preceding request. Never attach a delayed response count to
3693
+ // a newer footprint: doing so can hide all model output produced between
3694
+ // them and recreate an under-counting compaction loop.
3695
+ const boundProvider =
3696
+ reported &&
3697
+ previousRequest &&
3698
+ reported.revision === previousRequest.revision &&
3699
+ hasModelGeneratedItem(current.input)
3700
+ ? reported
3701
+ : null;
3702
+ const estimate = estimateCompleteModelInput({
3703
+ current,
3704
+ provider: boundProvider,
3705
+ providerRequestFootprint: boundProvider ? (previousRequest?.footprint ?? null) : null,
3706
+ });
3707
+ const signalTokens = estimate.tokens;
3708
+ previousRequest = { revision: ++requestRevision, footprint: current };
3011
3709
  if (await options.contextCompactionRequested?.()) {
3012
3710
  throw new CompactionNeededError({
3013
3711
  signalTokens,
3014
3712
  thresholdTokens,
3015
- signalSource: hasReported ? "provider" : "estimate",
3713
+ signalSource: boundProvider ? "provider" : "estimate",
3016
3714
  trigger: "operator",
3017
3715
  });
3018
3716
  }
@@ -3020,7 +3718,7 @@ export function contextRobustnessFilterForSettings(
3020
3718
  throw new CompactionNeededError({
3021
3719
  signalTokens,
3022
3720
  thresholdTokens,
3023
- signalSource: hasReported ? "provider" : "estimate",
3721
+ signalSource: boundProvider ? "provider" : "estimate",
3024
3722
  });
3025
3723
  }
3026
3724
  }
@@ -3057,6 +3755,7 @@ export function callModelInputFilterForSettings(
3057
3755
  const filters: CallModelInputFilter[] = [
3058
3756
  normalizeComputerCallsFilter,
3059
3757
  elideSupersededViewImagesFilter,
3758
+ boundModelToolOutputsFilterForSettings(settings),
3060
3759
  ];
3061
3760
  if (settings.openaiProviderItemIds === "strip") {
3062
3761
  filters.push(stripProviderItemIdsFilter);
@@ -3074,7 +3773,11 @@ export async function runAgentStream(
3074
3773
  const prepared: PreparedAgentInput =
3075
3774
  typeof input === "string" || input instanceof RunState ? { input } : input;
3076
3775
  const environment = overrides.sandboxEnvironment ?? collectSandboxEnvironment(settings);
3776
+ const toolspaceTokenFile = toolspaceTokenFileForAgent(agent, environment);
3077
3777
  const genesisTitleInputFilter = takeGenesisTitleInputFilter(agent);
3778
+ if (overrides.onRunCredentialSessionReady && !overrides.runCredentialSessionId) {
3779
+ throw new Error("runCredentialSessionId is required when run credential setup is enabled");
3780
+ }
3078
3781
 
3079
3782
  // OWNED PATH (P1.2 ownership inversion): the per-turn resume path injected a
3080
3783
  // live, externally-owned box. We thread the live `session` straight into
@@ -3093,11 +3796,24 @@ export async function runAgentStream(
3093
3796
  // whose per-op pointer re-read could land these execs on a machine swapped in
3094
3797
  // mid-turn.
3095
3798
  const setupSession = (overrides.ownedSandbox.setupSession ?? session) as SandboxSessionLike;
3799
+ const credentialAgentSession = overrides.runCredentialSessionId
3800
+ ? withRunCredentialsSession(session as SandboxSessionLike, overrides.runCredentialSessionId)
3801
+ : (session as SandboxSessionLike);
3802
+ const agentSession = toolspaceTokenFile
3803
+ ? withToolspaceTokenSession(credentialAgentSession, toolspaceTokenFile)
3804
+ : credentialAgentSession;
3805
+ const credentialSetupSession = overrides.runCredentialSessionId
3806
+ ? withRunCredentialsSession(setupSession, overrides.runCredentialSessionId)
3807
+ : setupSession;
3808
+ const decoratedSetupSession = toolspaceTokenFile
3809
+ ? withToolspaceTokenSession(credentialSetupSession, toolspaceTokenFile)
3810
+ : credentialSetupSession;
3096
3811
  // Platform setup (manifest-env pin + beforeAgentStart hooks + file downloads)
3097
3812
  // against the UN-proxied established box — the ONE-TRUTH helper shared with the
3098
3813
  // lazy provisioner. Eager path: runs here, before the run starts (unchanged).
3099
3814
  if (!overrides.ownedSandbox.deferredSetup) {
3100
- await runOwnedSandboxSetup(agent, session as SandboxSessionLike, setupSession, {
3815
+ await overrides.onRunCredentialSessionReady?.(session as SandboxSessionLike);
3816
+ await runOwnedSandboxSetup(agent, session as SandboxSessionLike, decoratedSetupSession, {
3101
3817
  settings,
3102
3818
  environment,
3103
3819
  preparedInput: prepared,
@@ -3105,7 +3821,17 @@ export async function runAgentStream(
3105
3821
  ? { fileDownloadsMaterialized: true }
3106
3822
  : {}),
3107
3823
  ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
3824
+ ...(overrides.turnToolCancellationFence
3825
+ ? {
3826
+ commandRunner: overrides.turnToolCancellationFence.runSandboxCommand.bind(
3827
+ overrides.turnToolCancellationFence,
3828
+ ),
3829
+ }
3830
+ : {}),
3108
3831
  });
3832
+ if (toolspaceTokenSeedForAgent(agent)) {
3833
+ await overrides.onToolspaceTokenSessionReady?.(agentSession);
3834
+ }
3109
3835
  await overrides.onGitCredentialSessionReady?.(setupSession);
3110
3836
  }
3111
3837
  const runAs = sandboxRunAs(settings);
@@ -3115,11 +3841,19 @@ export async function runAgentStream(
3115
3841
  ? withSandboxFileDownloads(ownedClient as SandboxClient, fileDownloads, {
3116
3842
  ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
3117
3843
  ...(runAs ? { runAs } : {}),
3844
+ ...(overrides.turnToolCancellationFence
3845
+ ? {
3846
+ commandRunner: overrides.turnToolCancellationFence.runSandboxCommand.bind(
3847
+ overrides.turnToolCancellationFence,
3848
+ ),
3849
+ }
3850
+ : {}),
3118
3851
  })
3119
3852
  : (ownedClient as SandboxClient);
3120
3853
  // TOKEN-BROKER (B1): the per-turn git token seed, forwarded OFF-MANIFEST so the
3121
3854
  // repository-clone hook seeds it to the box's token file before the clone.
3122
3855
  const ownedGitTokenSeeds = gitTokenSeedsForAgent(agent);
3856
+ const ownedGitCredentialBindings = gitCredentialBindingsForAgent(agent);
3123
3857
  const ownedToolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
3124
3858
  const ownedRigSetup = rigSetupDescriptorForAgent(agent);
3125
3859
  const ownedHooks = [
@@ -3132,6 +3866,9 @@ export async function runAgentStream(
3132
3866
  rigCredentialHooksForAgent(agent),
3133
3867
  ),
3134
3868
  ...sandboxToolspaceTokenHooksForAgent(agent),
3869
+ ...toolspaceTokenSessionRegistrationHooks(
3870
+ ownedToolspaceTokenSeed ? overrides.onToolspaceTokenSessionReady : undefined,
3871
+ ),
3135
3872
  ...sandboxRepositoryCloneHooksForAgent(agent),
3136
3873
  ...gitCredentialSessionRegistrationHooks(overrides.onGitCredentialSessionReady),
3137
3874
  ];
@@ -3140,31 +3877,49 @@ export async function runAgentStream(
3140
3877
  ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
3141
3878
  ...(runAs ? { runAs } : {}),
3142
3879
  ...(ownedGitTokenSeeds ? { gitTokenSeeds: ownedGitTokenSeeds } : {}),
3880
+ ...(ownedGitCredentialBindings ? { gitCredentialBindings: ownedGitCredentialBindings } : {}),
3143
3881
  ...(ownedToolspaceTokenSeed ? { toolspaceTokenSeed: ownedToolspaceTokenSeed } : {}),
3882
+ ...(toolspaceTokenFile ? { toolspaceTokenFile } : {}),
3144
3883
  ...(ownedRigSetup ? { rigSetup: ownedRigSetup } : {}),
3145
3884
  };
3146
- // Keep the decoration as a safety net for any session the SDK does create/resume
3147
- // through the client during this run (it is inert for the provided session).
3148
- const decoratedClient = withSandboxLifecycleHooks(resourceClient, ownedHooks, ownedHookContext);
3885
+ // Keep both credential seeding and lifecycle decoration as a safety net for
3886
+ // any session the SDK does create/resume during this run. They are inert for
3887
+ // the provided session, which remains the normal ownership-inverted path.
3888
+ const credentialResourceClient = overrides.runCredentialSessionId
3889
+ ? withRunCredentialsClient(
3890
+ resourceClient,
3891
+ overrides.runCredentialSessionId,
3892
+ overrides.onRunCredentialSessionReady,
3893
+ )
3894
+ : resourceClient;
3895
+ const toolspaceResourceClient = toolspaceTokenFile
3896
+ ? withToolspaceTokenClient(credentialResourceClient, toolspaceTokenFile)
3897
+ : credentialResourceClient;
3898
+ const decoratedClient = withSandboxLifecycleHooks(
3899
+ toolspaceResourceClient,
3900
+ ownedHooks,
3901
+ ownedHookContext,
3902
+ );
3149
3903
  const ownedFilter = composeCallModelInputFilters(
3150
3904
  [
3151
- callModelInputFilterForSettings(settings, {
3905
+ callModelInputFilterForSettings(settings),
3906
+ genesisTitleInputFilter,
3907
+ overrides.callModelInputFilter,
3908
+ // A caller filter may synthesize model input. Re-apply the idempotent
3909
+ // canonical bound at the literal final seam before accounting/provider
3910
+ // serialization so no extension can bypass the policy.
3911
+ boundModelToolOutputsFilterForSettings(settings),
3912
+ contextRobustnessFilterForSettings(settings, {
3152
3913
  throwOnCompactionNeeded: Boolean(
3153
- overrides.contextCompactionSignalTokens || overrides.contextCompactionRequested,
3914
+ overrides.contextCompactionSignal || overrides.contextCompactionRequested,
3154
3915
  ),
3155
- ...(overrides.contextCompactionSignalTokens
3156
- ? {
3157
- contextCompactionSignalTokens: overrides.contextCompactionSignalTokens,
3158
- }
3916
+ ...(overrides.contextCompactionSignal
3917
+ ? { contextCompactionSignal: overrides.contextCompactionSignal }
3159
3918
  : {}),
3160
3919
  ...(overrides.contextCompactionRequested
3161
- ? {
3162
- contextCompactionRequested: overrides.contextCompactionRequested,
3163
- }
3920
+ ? { contextCompactionRequested: overrides.contextCompactionRequested }
3164
3921
  : {}),
3165
3922
  }),
3166
- genesisTitleInputFilter,
3167
- overrides.callModelInputFilter,
3168
3923
  ].filter((f): f is CallModelInputFilter => Boolean(f)),
3169
3924
  );
3170
3925
  const ownedRunOptions: Parameters<typeof run>[2] = {
@@ -3175,7 +3930,7 @@ export async function runAgentStream(
3175
3930
  };
3176
3931
  ownedRunOptions.sandbox = {
3177
3932
  client: decoratedClient,
3178
- session,
3933
+ session: agentSession,
3179
3934
  ...(sessionState ? { sessionState } : {}),
3180
3935
  } as SandboxRunConfig;
3181
3936
  return await runScopedRunner(settings).run(agent, prepared.input, ownedRunOptions);
@@ -3200,14 +3955,27 @@ export async function runAgentStream(
3200
3955
  ...(runAs ? { runAs } : {}),
3201
3956
  })
3202
3957
  : refreshedClient;
3958
+ const credentialClient =
3959
+ resourceClient && overrides.runCredentialSessionId
3960
+ ? withRunCredentialsClient(
3961
+ resourceClient,
3962
+ overrides.runCredentialSessionId,
3963
+ overrides.onRunCredentialSessionReady,
3964
+ )
3965
+ : resourceClient;
3966
+ const toolspaceClient =
3967
+ credentialClient && toolspaceTokenFile
3968
+ ? withToolspaceTokenClient(credentialClient, toolspaceTokenFile)
3969
+ : credentialClient;
3203
3970
  // TOKEN-BROKER (B1): the per-turn git token seed, forwarded OFF-MANIFEST so the
3204
3971
  // repository-clone hook seeds it to the box's token file before the clone.
3205
3972
  const gitTokenSeeds = gitTokenSeedsForAgent(agent);
3973
+ const gitCredentialBindings = gitCredentialBindingsForAgent(agent);
3206
3974
  const toolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
3207
3975
  const legacyRigSetup = rigSetupDescriptorForAgent(agent);
3208
- const client = resourceClient
3976
+ const client = toolspaceClient
3209
3977
  ? withSandboxLifecycleHooks(
3210
- resourceClient,
3978
+ toolspaceClient,
3211
3979
  [
3212
3980
  // M3: same rig-setup-first ordering + credential-hook union as the owned
3213
3981
  // path (this legacy create/resume decoration path is byte-for-byte today
@@ -3218,6 +3986,9 @@ export async function runAgentStream(
3218
3986
  rigCredentialHooksForAgent(agent),
3219
3987
  ),
3220
3988
  ...sandboxToolspaceTokenHooksForAgent(agent),
3989
+ ...toolspaceTokenSessionRegistrationHooks(
3990
+ toolspaceTokenSeed ? overrides.onToolspaceTokenSessionReady : undefined,
3991
+ ),
3221
3992
  ...sandboxRepositoryCloneHooksForAgent(agent),
3222
3993
  ...gitCredentialSessionRegistrationHooks(overrides.onGitCredentialSessionReady),
3223
3994
  ],
@@ -3226,7 +3997,9 @@ export async function runAgentStream(
3226
3997
  ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
3227
3998
  ...(runAs ? { runAs } : {}),
3228
3999
  ...(gitTokenSeeds ? { gitTokenSeeds } : {}),
4000
+ ...(gitCredentialBindings ? { gitCredentialBindings } : {}),
3229
4001
  ...(toolspaceTokenSeed ? { toolspaceTokenSeed } : {}),
4002
+ ...(toolspaceTokenFile ? { toolspaceTokenFile } : {}),
3230
4003
  ...(legacyRigSetup ? { rigSetup: legacyRigSetup } : {}),
3231
4004
  },
3232
4005
  )
@@ -3239,21 +4012,21 @@ export async function runAgentStream(
3239
4012
  // OpenGeni's durable conversation truth is still reconciled explicitly below.
3240
4013
  const callModelInputFilter = composeCallModelInputFilters(
3241
4014
  [
3242
- callModelInputFilterForSettings(settings, {
4015
+ callModelInputFilterForSettings(settings),
4016
+ genesisTitleInputFilter,
4017
+ overrides.callModelInputFilter,
4018
+ boundModelToolOutputsFilterForSettings(settings),
4019
+ contextRobustnessFilterForSettings(settings, {
3243
4020
  throwOnCompactionNeeded: Boolean(
3244
- overrides.contextCompactionSignalTokens || overrides.contextCompactionRequested,
4021
+ overrides.contextCompactionSignal || overrides.contextCompactionRequested,
3245
4022
  ),
3246
- ...(overrides.contextCompactionSignalTokens
3247
- ? {
3248
- contextCompactionSignalTokens: overrides.contextCompactionSignalTokens,
3249
- }
4023
+ ...(overrides.contextCompactionSignal
4024
+ ? { contextCompactionSignal: overrides.contextCompactionSignal }
3250
4025
  : {}),
3251
4026
  ...(overrides.contextCompactionRequested
3252
4027
  ? { contextCompactionRequested: overrides.contextCompactionRequested }
3253
4028
  : {}),
3254
4029
  }),
3255
- genesisTitleInputFilter,
3256
- overrides.callModelInputFilter,
3257
4030
  ].filter((f): f is CallModelInputFilter => Boolean(f)),
3258
4031
  );
3259
4032
  const runOptions: Parameters<typeof run>[2] = {
@@ -3656,6 +4429,9 @@ export async function runOwnedSandboxSetup(
3656
4429
  onRuntimeEvent?: SandboxLifecycleHookContext["onRuntimeEvent"];
3657
4430
  gitTokenSeedsOverride?: GitTokenSeeds;
3658
4431
  gitTokenSeedOverride?: string;
4432
+ gitCredentialBindingsOverride?: GitCredentialBindingSeed[];
4433
+ toolspaceTokenSeedOverride?: string;
4434
+ commandRunner?: SandboxLifecycleCommandRunner;
3659
4435
  },
3660
4436
  ): Promise<void> {
3661
4437
  const { settings, environment } = opts;
@@ -3677,7 +4453,11 @@ export async function runOwnedSandboxSetup(
3677
4453
  ...(opts.gitTokenSeedOverride ? { github: opts.gitTokenSeedOverride } : {}),
3678
4454
  ...(opts.gitTokenSeedsOverride ?? {}),
3679
4455
  } satisfies GitTokenSeeds;
3680
- const ownedToolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
4456
+ const ownedGitCredentialBindings =
4457
+ opts.gitCredentialBindingsOverride ?? gitCredentialBindingsForAgent(agent);
4458
+ const ownedToolspaceTokenSeed =
4459
+ opts.toolspaceTokenSeedOverride ?? toolspaceTokenSeedForAgent(agent);
4460
+ const ownedToolspaceTokenFile = toolspaceTokenFileForAgent(agent, environment);
3681
4461
  const ownedRigSetup = rigSetupDescriptorForAgent(agent);
3682
4462
  const ownedHooks = [
3683
4463
  // M3: rig setup runs FIRST so any tooling it installs is present for the
@@ -3699,8 +4479,11 @@ export async function runOwnedSandboxSetup(
3699
4479
  ...(opts.onRuntimeEvent ? { onRuntimeEvent: opts.onRuntimeEvent } : {}),
3700
4480
  ...(runAs ? { runAs } : {}),
3701
4481
  ...(Object.keys(ownedGitTokenSeeds).length > 0 ? { gitTokenSeeds: ownedGitTokenSeeds } : {}),
4482
+ ...(ownedGitCredentialBindings ? { gitCredentialBindings: ownedGitCredentialBindings } : {}),
3702
4483
  ...(ownedToolspaceTokenSeed ? { toolspaceTokenSeed: ownedToolspaceTokenSeed } : {}),
4484
+ ...(ownedToolspaceTokenFile ? { toolspaceTokenFile: ownedToolspaceTokenFile } : {}),
3703
4485
  ...(ownedRigSetup ? { rigSetup: ownedRigSetup } : {}),
4486
+ ...(opts.commandRunner ? { commandRunner: opts.commandRunner } : {}),
3704
4487
  };
3705
4488
  // OWNED-PATH HOOKS: run the beforeAgentStart hooks directly against the provided
3706
4489
  // box, once per turn, BEFORE the run starts (repository-clone hook seeds the B1
@@ -3720,6 +4503,7 @@ export async function runOwnedSandboxSetup(
3720
4503
  const materialized = await materializeSandboxFileDownloads(setupSession, fileDownloads, {
3721
4504
  ...(opts.onRuntimeEvent ? { onRuntimeEvent: opts.onRuntimeEvent } : {}),
3722
4505
  ...(runAs ? { runAs } : {}),
4506
+ ...(opts.commandRunner ? { commandRunner: opts.commandRunner } : {}),
3723
4507
  });
3724
4508
  if (opts.preparedInput) {
3725
4509
  appendSandboxFileDownloadFailureNote(opts.preparedInput, materialized.failures);
@@ -3753,7 +4537,7 @@ function mergePathGrants(
3753
4537
  export function withSandboxFileDownloads(
3754
4538
  client: SandboxClient,
3755
4539
  downloads: SandboxFileDownload[],
3756
- context: Pick<SandboxLifecycleHookContext, "onRuntimeEvent" | "runAs"> = {},
4540
+ context: Pick<SandboxLifecycleHookContext, "onRuntimeEvent" | "runAs" | "commandRunner"> = {},
3757
4541
  ): SandboxClient {
3758
4542
  const normalizedDownloads = normalizeSandboxFileDownloads(downloads);
3759
4543
  if (normalizedDownloads.length === 0) {
@@ -3819,7 +4603,7 @@ export function withSandboxFileDownloads(
3819
4603
  export async function materializeSandboxFileDownloads(
3820
4604
  session: SandboxSessionLike,
3821
4605
  downloads: SandboxFileDownload[],
3822
- context: Pick<SandboxLifecycleHookContext, "onRuntimeEvent" | "runAs"> = {},
4606
+ context: Pick<SandboxLifecycleHookContext, "onRuntimeEvent" | "runAs" | "commandRunner"> = {},
3823
4607
  ): Promise<SandboxFileDownloadMaterializationResult> {
3824
4608
  const normalizedDownloads = normalizeSandboxFileDownloads(downloads);
3825
4609
  if (normalizedDownloads.length === 0) {
@@ -3857,27 +4641,24 @@ export async function materializeSandboxFileDownloads(
3857
4641
  }
3858
4642
  let result: unknown;
3859
4643
  try {
3860
- result = session.exec
3861
- ? await session.exec({
3862
- cmd: sandboxFileDownloadCommand(download, targetPath),
3863
- workdir: "/workspace",
3864
- ...(context.runAs ? { runAs: context.runAs } : {}),
3865
- yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
3866
- maxOutputTokens: 20_000,
3867
- })
3868
- : await session.execCommand!({
3869
- cmd: sandboxFileDownloadCommand(download, targetPath),
3870
- workdir: "/workspace",
3871
- ...(context.runAs ? { runAs: context.runAs } : {}),
3872
- yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
3873
- maxOutputTokens: 20_000,
3874
- });
4644
+ result = await runSandboxLifecycleCommand(
4645
+ session,
4646
+ {
4647
+ cmd: sandboxFileDownloadCommand(download, targetPath),
4648
+ workdir: "/workspace",
4649
+ ...(context.runAs ? { runAs: context.runAs } : {}),
4650
+ yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
4651
+ maxOutputTokens: 20_000,
4652
+ },
4653
+ context.commandRunner,
4654
+ );
3875
4655
  assertSandboxCommandSucceeded(result, `Sandbox file resource download ${download.fileId}`);
3876
4656
  await context.onRuntimeEvent?.({
3877
4657
  type: "sandbox.operation.completed",
3878
4658
  payload: { name: "file-resource-download", ...payload },
3879
4659
  });
3880
4660
  } catch (error) {
4661
+ if (error instanceof TurnSandboxCommandCancelledError) throw error;
3881
4662
  const failure = sandboxFileDownloadFailure(download, targetPath, error, result);
3882
4663
  failures.push(failure);
3883
4664
  await context.onRuntimeEvent?.({
@@ -3954,86 +4735,87 @@ function ensureManifest(
3954
4735
  });
3955
4736
  }
3956
4737
 
3957
- /** Coerce the various binary shapes a tool-output image `data` field can take into
3958
- * a Uint8Array. Handles a live `Uint8Array`, a plain number[] , and the
3959
- * object-of-numbers (`{"0":137,"1":80,…}`) that a `Uint8Array` degrades into after
3960
- * a JSON round-trip — the exact 10x-bloat shape this normalizer exists to kill. */
3961
- function toImageBytes(data: unknown): Uint8Array | null {
3962
- if (data instanceof Uint8Array) {
3963
- return data;
3964
- }
4738
+ function base64DecodedByteLength(value: string): number {
4739
+ const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
4740
+ return Math.max(0, Math.floor((value.length * 3) / 4) - padding);
4741
+ }
4742
+
4743
+ /** Determine image byte length without allocating another binary/base64 copy. */
4744
+ function imageDataByteLength(data: unknown): number | null {
4745
+ if (ArrayBuffer.isView(data)) return data.byteLength;
4746
+ if (data instanceof ArrayBuffer) return data.byteLength;
3965
4747
  if (Array.isArray(data)) {
3966
- return data.every((n) => typeof n === "number") ? Uint8Array.from(data as number[]) : null;
4748
+ return data.every((value) => typeof value === "number") ? data.length : null;
3967
4749
  }
3968
- if (data && typeof data === "object") {
3969
- const values = Object.values(data as Record<string, unknown>);
3970
- if (values.length > 0 && values.every((n) => typeof n === "number")) {
3971
- return Uint8Array.from(values as number[]);
3972
- }
4750
+ if (!data || typeof data !== "object") return null;
4751
+ const record = data as Record<string, unknown>;
4752
+ if (record.type === "Buffer" && Array.isArray(record.data)) {
4753
+ return record.data.every((value) => typeof value === "number") ? record.data.length : null;
3973
4754
  }
3974
- return null;
4755
+ const keys = Object.keys(record);
4756
+ return keys.length > 0 &&
4757
+ keys.every((key) => /^\d+$/.test(key) && typeof record[key] === "number")
4758
+ ? keys.length
4759
+ : null;
3975
4760
  }
3976
4761
 
3977
- /** Compact a structured image tool output — the SDK's `{type:'image', image:{data,mediaType}}`
3978
- * shape (produced by the codex-path `computer_screenshot` function tool) OR the already-
3979
- * normalized protocol `{type:'input_image', image:'data:…'}` item — into a `data:<mt>;base64,…`
3980
- * string. Returns null when `value` is not an image output. */
3981
- function structuredImageToDataUrl(value: unknown): string | null {
3982
- if (!value || typeof value !== "object" || Array.isArray(value)) {
3983
- return null;
3984
- }
3985
- const v = value as { type?: unknown; image?: unknown };
3986
- if (v.type === "input_image") {
3987
- // Protocol item: `image` is already a `data:…` (or plain URL) string.
3988
- return typeof v.image === "string" && v.image.length > 0 ? v.image : null;
3989
- }
3990
- if (v.type !== "image" || !v.image || typeof v.image !== "object") {
3991
- return null;
3992
- }
3993
- const image = v.image as {
3994
- data?: unknown;
3995
- mediaType?: unknown;
3996
- url?: unknown;
3997
- };
3998
- if (typeof image.url === "string" && image.url.length > 0) {
3999
- return image.url;
4000
- }
4762
+ /**
4763
+ * Convert one image-shaped tool result into a content-free audit fact. This is
4764
+ * intentionally different from model history: the model keeps its structured
4765
+ * image item, while `session_events` never becomes an implicit image blob store.
4766
+ */
4767
+ function toolOutputMediaPreview(value: unknown): SessionEventMediaPreview | null {
4768
+ if (typeof value === "string") {
4769
+ return sessionEventMediaPreviewFromDataUrl(value);
4770
+ }
4771
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
4772
+ const record = value as Record<string, unknown>;
4773
+ if (record.type === "input_image") {
4774
+ const source = record.image ?? record.image_url ?? record.imageUrl;
4775
+ const url =
4776
+ typeof source === "string"
4777
+ ? source
4778
+ : source && typeof source === "object"
4779
+ ? (source as Record<string, unknown>).url
4780
+ : null;
4781
+ if (typeof url !== "string" || url.length === 0) return null;
4782
+ return sessionEventMediaPreviewFromDataUrl(url) ?? sessionEventMediaPreview("image/*", null);
4783
+ }
4784
+ if (record.type !== "image" || !record.image || typeof record.image !== "object") return null;
4785
+ const image = record.image as Record<string, unknown>;
4001
4786
  const mediaType =
4002
4787
  typeof image.mediaType === "string" && image.mediaType.length > 0
4003
4788
  ? image.mediaType
4004
4789
  : "image/png";
4790
+ if (typeof image.url === "string" && image.url.length > 0) {
4791
+ return (
4792
+ sessionEventMediaPreviewFromDataUrl(image.url) ?? sessionEventMediaPreview(mediaType, null)
4793
+ );
4794
+ }
4005
4795
  if (typeof image.data === "string") {
4006
- return image.data.startsWith("data:") ? image.data : `data:${mediaType};base64,${image.data}`;
4796
+ return (
4797
+ sessionEventMediaPreviewFromDataUrl(image.data) ??
4798
+ sessionEventMediaPreview(mediaType, base64DecodedByteLength(image.data))
4799
+ );
4007
4800
  }
4008
- const bytes = toImageBytes(image.data);
4009
- return bytes ? `data:${mediaType};base64,${Buffer.from(bytes).toString("base64")}` : null;
4801
+ const byteLength = imageDataByteLength(image.data);
4802
+ return byteLength === null ? null : sessionEventMediaPreview(mediaType, byteLength);
4010
4803
  }
4011
4804
 
4012
4805
  /**
4013
- * Compact a tool-call output for the `agent.toolCall.output` SESSION EVENT so it
4014
- * never carries a raw binary payload. The codex-path `computer_screenshot` function
4015
- * tool returns a structured `{type:'image', image:{data: Uint8Array, mediaType}}`;
4016
- * captured verbatim its `Uint8Array` JSON-serializes as an object-of-numbers (~12.7MB
4017
- * per screenshot in session_events — ~10x the base64 form). This mirrors the desktop
4018
- * screenshot to the SAME compact `data:<mediaType>;base64,…` STRING the HOSTED
4019
- * `computer_call` event already carries (agents-core sets its output to that data-URL),
4020
- * so both computer-use transports emit one representation. The full data-URL is kept
4021
- * (not truncated) because the web timeline RENDERS the screenshot from this event
4022
- * payload — packages/react/src/timeline/tool-renderers.tsx ComputerCallRenderer
4023
- * (`out.startsWith("data:image")` → <ScreenshotFigure src={out}/>) and ViewImageRenderer.
4024
- * Non-image outputs (text strings, MCP `{isError,content}` objects, hosted computer_call
4025
- * data-URL strings) pass through unchanged.
4806
+ * Normalize a tool-call output for the lossy `agent.toolCall.output` audit event.
4807
+ * Inline image bytes/data URLs become a compact `media_preview` with exact byte
4808
+ * length where knowable and `fullOutputAvailable:false`. The model-facing output
4809
+ * is not changed here, and mixed arrays retain their non-image text/error facts.
4026
4810
  */
4027
4811
  export function normalizeToolOutputForEvent(output: unknown): unknown {
4028
- const single = structuredImageToDataUrl(output);
4812
+ const single = toolOutputMediaPreview(output);
4029
4813
  if (single !== null) {
4030
4814
  return single;
4031
4815
  }
4032
4816
  if (Array.isArray(output)) {
4033
- const normalized = output.map((el) => structuredImageToDataUrl(el) ?? el);
4034
- // A lone image content item unwraps to the bare data-URL string the timeline
4035
- // image renderers expect; a mixed/multi array keeps its (now-compact) shape.
4036
- if (normalized.length === 1 && typeof normalized[0] === "string") {
4817
+ const normalized = output.map((el) => toolOutputMediaPreview(el) ?? el);
4818
+ if (normalized.length === 1 && normalized[0]?.type === "media_preview") {
4037
4819
  return normalized[0];
4038
4820
  }
4039
4821
  return normalized;
@@ -4090,8 +4872,8 @@ export function normalizeSdkEvent(event: RunStreamEvent): NormalizedRuntimeEvent
4090
4872
  type: "agent.toolCall.output",
4091
4873
  payload: {
4092
4874
  id: item.rawItem?.callId ?? item.id ?? null,
4093
- // Compact any structured/binary image output to a data-URL string so a
4094
- // screenshot never bloats session_events ~10x as an object-of-numbers.
4875
+ // Inline media becomes a content-free audit fact. Model history keeps
4876
+ // the provider's real structured image output on its separate path.
4095
4877
  output: normalizeToolOutputForEvent(item.output),
4096
4878
  },
4097
4879
  });
@@ -4204,6 +4986,7 @@ function usageFromResponse(response: unknown): ModelResponseUsage["usage"] | nul
4204
4986
  ...numberProp(record, "totalTokens", "totalTokens", "total_tokens"),
4205
4987
  ...inputTokenDetailsProp(record),
4206
4988
  ...outputTokenDetailsProp(record),
4989
+ ...requestUsageEntriesProp(record),
4207
4990
  };
4208
4991
  return Object.keys(usage).length > 0 ? usage : null;
4209
4992
  }
@@ -4214,7 +4997,11 @@ function numberProp(
4214
4997
  ...keys: string[]
4215
4998
  ): Partial<ModelResponseUsage["usage"]> {
4216
4999
  const value = keys.map((key) => raw[key]).find((candidate) => candidate !== undefined);
4217
- return typeof value === "number" && Number.isFinite(value) ? { [outputKey]: value } : {};
5000
+ // Preserve numeric provider values verbatim here, including malformed ones.
5001
+ // The shared usage normalizer is the single bounded validation boundary and
5002
+ // needs to see NaN/infinite/fractional/oversized values so it can emit safe
5003
+ // field-path diagnostics rather than silently erasing the evidence.
5004
+ return typeof value === "number" ? { [outputKey]: value } : {};
4218
5005
  }
4219
5006
 
4220
5007
  function inputTokenDetailsProp(raw: Record<string, unknown>): Partial<ModelResponseUsage["usage"]> {
@@ -4223,7 +5010,7 @@ function inputTokenDetailsProp(raw: Record<string, unknown>): Partial<ModelRespo
4223
5010
  raw.input_tokens_details ??
4224
5011
  raw.promptTokensDetails ??
4225
5012
  raw.prompt_tokens_details;
4226
- if (!details || typeof details !== "object") {
5013
+ if (details === undefined || details === null) {
4227
5014
  return {};
4228
5015
  }
4229
5016
  return {
@@ -4236,7 +5023,7 @@ function outputTokenDetailsProp(
4236
5023
  ): Partial<ModelResponseUsage["usage"]> {
4237
5024
  const details = raw.outputTokensDetails ?? raw.output_tokens_details;
4238
5025
  const normalized = details ?? raw.completionTokensDetails ?? raw.completion_tokens_details;
4239
- if (!normalized || typeof normalized !== "object") {
5026
+ if (normalized === undefined || normalized === null) {
4240
5027
  return {};
4241
5028
  }
4242
5029
  return {
@@ -4244,18 +5031,61 @@ function outputTokenDetailsProp(
4244
5031
  };
4245
5032
  }
4246
5033
 
5034
+ function requestUsageEntriesProp(
5035
+ raw: Record<string, unknown>,
5036
+ ): Partial<ModelResponseUsage["usage"]> {
5037
+ const entries = raw.requestUsageEntries ?? raw.request_usage_entries;
5038
+ if (entries === undefined || entries === null) {
5039
+ return {};
5040
+ }
5041
+ return {
5042
+ // The normalizer validates every entry and all supported field aliases.
5043
+ // Preserve the SDK objects rather than rebuilding them and accidentally
5044
+ // dropping provider detail fields such as cache_write_tokens.
5045
+ requestUsageEntries: entries as NonNullable<ModelResponseUsage["usage"]["requestUsageEntries"]>,
5046
+ };
5047
+ }
5048
+
4247
5049
  export function serializeApprovals(interruptions: unknown[]): unknown[] {
4248
- return interruptions.map((item: any) => {
4249
- if (typeof item?.toJSON === "function") {
4250
- return item.toJSON();
4251
- }
4252
- return {
4253
- id: approvalIdentifier(item),
4254
- name: item?.name ?? item?.rawItem?.name ?? "tool",
4255
- arguments: item?.arguments ?? item?.rawItem?.arguments ?? null,
4256
- raw: item,
4257
- };
4258
- });
5050
+ return interruptions
5051
+ .filter((item) => interruptionToolName(item) !== HUMAN_INPUT_TOOL_NAME)
5052
+ .map((item: any) => {
5053
+ if (typeof item?.toJSON === "function") {
5054
+ return item.toJSON();
5055
+ }
5056
+ return {
5057
+ id: approvalIdentifier(item) ?? "approval",
5058
+ name: item?.name ?? item?.rawItem?.name ?? "tool",
5059
+ arguments: item?.arguments ?? item?.rawItem?.arguments ?? null,
5060
+ raw: item,
5061
+ };
5062
+ });
5063
+ }
5064
+
5065
+ export function serializeHumanInputRequests(
5066
+ interruptions: unknown[],
5067
+ ): SerializedHumanInputInterruption[] {
5068
+ return interruptions
5069
+ .filter((item) => interruptionToolName(item) === HUMAN_INPUT_TOOL_NAME)
5070
+ .map((item: any) => {
5071
+ const rawArguments = item?.arguments ?? item?.rawItem?.arguments;
5072
+ let parsedArguments: unknown = rawArguments;
5073
+ if (typeof rawArguments === "string") {
5074
+ try {
5075
+ parsedArguments = JSON.parse(rawArguments);
5076
+ } catch {
5077
+ throw new Error("Human-input interruption contains invalid JSON arguments");
5078
+ }
5079
+ }
5080
+ const toolCallId = approvalIdentifier(item);
5081
+ if (!toolCallId) {
5082
+ throw new Error("Human-input interruption is missing a stable tool-call identity");
5083
+ }
5084
+ return {
5085
+ toolCallId,
5086
+ input: RequestHumanInputToolInput.parse(parsedArguments),
5087
+ };
5088
+ });
4259
5089
  }
4260
5090
 
4261
5091
  export function buildManifest(
@@ -4264,6 +5094,7 @@ export function buildManifest(
4264
5094
  environment = collectSandboxEnvironment(settings),
4265
5095
  fileResourceDownloads: SandboxFileDownload[] = [],
4266
5096
  ): Manifest {
5097
+ assertUniqueResourceMountPaths(resources);
4267
5098
  const entries: Record<string, any> = {};
4268
5099
  const downloadsByFileId = new Map(
4269
5100
  normalizeSandboxFileDownloads(fileResourceDownloads).map((download) => [
@@ -4274,9 +5105,9 @@ export function buildManifest(
4274
5105
  for (const resource of resources) {
4275
5106
  if (resource.kind === "repository") {
4276
5107
  const url = new URL(resource.uri);
4277
- const host = url.hostname.toLowerCase();
5108
+ const host = url.host.toLowerCase();
4278
5109
  const repo = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
4279
- const mountPath = normalizeManifestPath(resource.mountPath ?? `repos/${repo}`);
5110
+ const mountPath = resourceMountPath(resource);
4280
5111
  if (repositoryUsesSandboxClone(settings, resource)) {
4281
5112
  entries[mountPath] = dir();
4282
5113
  continue;
@@ -4285,12 +5116,12 @@ export function buildManifest(
4285
5116
  host,
4286
5117
  repo,
4287
5118
  ref: resource.ref,
4288
- ...(resource.subpath ? { subpath: normalizeManifestPath(resource.subpath) } : {}),
5119
+ ...(resource.subpath ? { subpath: normalizeRepositorySubpath(resource.subpath) } : {}),
4289
5120
  });
4290
5121
  continue;
4291
5122
  }
4292
5123
  if (resource.kind === "file") {
4293
- const mountPath = normalizeManifestPath(resource.mountPath ?? `files/${resource.fileId}`);
5124
+ const mountPath = resourceMountPath(resource);
4294
5125
  const download = downloadsByFileId.get(resource.fileId);
4295
5126
  entries[mountPath] = download
4296
5127
  ? sandboxDownloadDirectory(download, mountPath)
@@ -4450,11 +5281,7 @@ function parseAzureConnectionString(value: string): Record<string, string> {
4450
5281
  }
4451
5282
 
4452
5283
  function normalizeManifestPath(path: string): string {
4453
- const normalized = path.replace(/^\/+|\/+$/g, "");
4454
- if (!normalized || normalized.includes("..")) {
4455
- throw new Error(`Invalid sandbox resource path: ${path}`);
4456
- }
4457
- return normalized;
5284
+ return normalizeResourceMountPath(path);
4458
5285
  }
4459
5286
 
4460
5287
  function normalizeSandboxFileDownloads(downloads: SandboxFileDownload[]): SandboxFileDownload[] {
@@ -4517,6 +5344,11 @@ function shellQuote(value: string): string {
4517
5344
 
4518
5345
  export type SandboxLifecycleHookPhase = "beforeAgentStart";
4519
5346
 
5347
+ export type SandboxLifecycleCommandRunner = (
5348
+ session: TurnSandboxCommandSession,
5349
+ args: TurnSandboxCommandArgs,
5350
+ ) => Promise<unknown>;
5351
+
4520
5352
  export type SandboxLifecycleHookContext = {
4521
5353
  environment: Record<string, string>;
4522
5354
  onRuntimeEvent?: (event: NormalizedRuntimeEvent) => Promise<void> | void;
@@ -4528,12 +5360,28 @@ export type SandboxLifecycleHookContext = {
4528
5360
  // NEVER the box/agent manifest env (validateNoEnvironmentDelta must never see
4529
5361
  // rotating values).
4530
5362
  gitTokenSeeds?: GitTokenSeeds;
5363
+ gitCredentialBindings?: GitCredentialBindingSeed[];
4531
5364
  toolspaceTokenSeed?: string;
5365
+ toolspaceTokenFile?: string;
4532
5366
  // M3: the rig setup descriptor for the rig-setup hook (the script + marker
4533
5367
  // version id + the rig's own timeout). Present only on a rig-bound turn.
4534
5368
  rigSetup?: RigSetupDescriptor;
5369
+ commandRunner?: SandboxLifecycleCommandRunner;
4535
5370
  };
4536
5371
 
5372
+ async function runSandboxLifecycleCommand(
5373
+ session: SandboxSessionLike,
5374
+ args: TurnSandboxCommandArgs,
5375
+ commandRunner?: SandboxLifecycleCommandRunner,
5376
+ ): Promise<unknown> {
5377
+ if (commandRunner) {
5378
+ return await commandRunner(session as TurnSandboxCommandSession, args);
5379
+ }
5380
+ if (session.exec) return await session.exec(args);
5381
+ if (session.execCommand) return await session.execCommand(args);
5382
+ throw new Error("Sandbox session does not support command execution");
5383
+ }
5384
+
4537
5385
  // M3: everything the rig-setup hook needs to run the frozen rig version's setup
4538
5386
  // script exactly once per box. `versionId` keys the idempotence marker
4539
5387
  // (/var/opengeni/rig-setup-<versionId>.done); `timeoutMs` is the rig-specific
@@ -4683,10 +5531,32 @@ function gitTokenSeedsForAgent(agent: Agent<any, any>): GitTokenSeeds | undefine
4683
5531
  return agentGitTokenSeeds.get(agent);
4684
5532
  }
4685
5533
 
5534
+ function gitCredentialBindingsForAgent(
5535
+ agent: Agent<any, any>,
5536
+ ): GitCredentialBindingSeed[] | undefined {
5537
+ return agentGitCredentialBindings.get(agent);
5538
+ }
5539
+
4686
5540
  function toolspaceTokenSeedForAgent(agent: Agent<any, any>): string | undefined {
4687
5541
  return agentToolspaceTokenSeed.get(agent);
4688
5542
  }
4689
5543
 
5544
+ function toolspaceTokenSessionIdForAgent(agent: Agent<any, any>): string | undefined {
5545
+ return agentToolspaceTokenSessionId.get(agent);
5546
+ }
5547
+
5548
+ function toolspaceTokenFileForAgent(
5549
+ agent: Agent<any, any>,
5550
+ environment: Readonly<Record<string, string>>,
5551
+ ): string | undefined {
5552
+ if (!toolspaceTokenSeedForAgent(agent)) return undefined;
5553
+ const sessionId = toolspaceTokenSessionIdForAgent(agent);
5554
+ if (!sessionId) {
5555
+ throw new Error("Toolspace token seed is missing its session identity");
5556
+ }
5557
+ return toolspaceTokenFileFromEnvironment(environment, sessionId);
5558
+ }
5559
+
4690
5560
  function sandboxToolspaceTokenHooksForAgent(agent: Agent<any, any>): SandboxLifecycleHook[] {
4691
5561
  return toolspaceTokenSeedForAgent(agent)
4692
5562
  ? [
@@ -4756,6 +5626,22 @@ function gitCredentialSessionRegistrationHooks(
4756
5626
  : [];
4757
5627
  }
4758
5628
 
5629
+ function toolspaceTokenSessionRegistrationHooks(
5630
+ callback: RunAgentStreamOptions["onToolspaceTokenSessionReady"],
5631
+ ): SandboxLifecycleHook[] {
5632
+ return callback
5633
+ ? [
5634
+ {
5635
+ id: "toolspace-token-renewal-registration",
5636
+ phase: "beforeAgentStart",
5637
+ run: async (session) => {
5638
+ await callback(session);
5639
+ },
5640
+ },
5641
+ ]
5642
+ : [];
5643
+ }
5644
+
4759
5645
  function sandboxRepositoryCloneHooks(
4760
5646
  settings: Settings,
4761
5647
  resources: ResourceRef[],
@@ -4823,6 +5709,20 @@ function gitProviderSeedEnv(provider: GitCredentialProvider): string {
4823
5709
  return `OPENGENI_GIT_${provider.toUpperCase()}_TOKEN_SEED`;
4824
5710
  }
4825
5711
 
5712
+ export function gitCredentialBindingHash(credentialBindingId: string): string {
5713
+ return createHash("sha256").update(credentialBindingId, "utf8").digest("hex").slice(0, 32);
5714
+ }
5715
+
5716
+ function gitBindingSeedEnv(binding: GitCredentialBindingSeed): string {
5717
+ return `OPENGENI_GIT_BINDING_${gitCredentialBindingHash(binding.credentialBindingId).toUpperCase()}_TOKEN_SEED`;
5718
+ }
5719
+
5720
+ function gitCredentialBindingSeedExportPrefix(bindings: GitCredentialBindingSeed[]): string {
5721
+ return bindings
5722
+ .map((binding) => `export ${gitBindingSeedEnv(binding)}=${shellQuote(binding.token)}`)
5723
+ .join("\n");
5724
+ }
5725
+
4826
5726
  function gitTokenSeedExportPrefix(seeds: GitTokenSeeds): string {
4827
5727
  const lines: string[] = [];
4828
5728
  for (const provider of GIT_CREDENTIAL_PROVIDERS) {
@@ -4838,37 +5738,268 @@ function gitTokenSeedExportPrefix(seeds: GitTokenSeeds): string {
4838
5738
  return lines.join("\n");
4839
5739
  }
4840
5740
 
4841
- function repositoryCredentialProvider(
4842
- resource: Extract<ResourceRef, { kind: "repository" }>,
4843
- ): GitCredentialProvider {
4844
- return resource.provider ?? "github";
5741
+ type RuntimeGitBindingDescriptor = {
5742
+ provider: GitCredentialProvider;
5743
+ credentialBindingId: string;
5744
+ bindingHash: string;
5745
+ protocol: string;
5746
+ host: string;
5747
+ path: string;
5748
+ uri: string;
5749
+ };
5750
+
5751
+ type RuntimeGitHttpBrokerRouteDescriptor = {
5752
+ provider: GitCredentialProvider;
5753
+ credentialBindingId: string;
5754
+ bindingHash: string;
5755
+ repositoryUri: string;
5756
+ brokerUri: string;
5757
+ protocol: "https";
5758
+ host: string;
5759
+ path: string;
5760
+ };
5761
+
5762
+ function runtimeGitBindingDescriptors(
5763
+ resources: Extract<ResourceRef, { kind: "repository" }>[],
5764
+ ): RuntimeGitBindingDescriptor[] {
5765
+ const remoteBindings = new Map<string, string>();
5766
+ const bindingProviders = new Map<string, GitCredentialProvider>();
5767
+ return resources.map((resource) => {
5768
+ const url = new URL(resource.uri);
5769
+ const credentialProvider = gitCredentialProviderForRepository(resource);
5770
+ // Provider-less public/legacy resources retain the historical GitHub
5771
+ // askpass fallback, but credential-bound resources derive through the
5772
+ // shared contracts helper used by the worker and core.
5773
+ const provider = credentialProvider ?? "github";
5774
+ const credentialBindingId =
5775
+ gitCredentialBindingIdForRepository(resource, credentialProvider) ?? provider;
5776
+ const path = url.pathname.replace(/^\/+|\/+$/g, "");
5777
+ const remote = `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}/${path.replace(/\.git$/, "")}`;
5778
+ const bindingKey = `${provider}\u0000${credentialBindingId}`;
5779
+ const boundProvider = bindingProviders.get(credentialBindingId);
5780
+ if (boundProvider && boundProvider !== provider) {
5781
+ throw new Error(
5782
+ `credential binding ${credentialBindingId} is assigned to multiple Git providers`,
5783
+ );
5784
+ }
5785
+ bindingProviders.set(credentialBindingId, provider);
5786
+ const claimed = remoteBindings.get(remote);
5787
+ if (claimed && claimed !== bindingKey) {
5788
+ throw new Error(
5789
+ `repository remote ${resource.uri} is claimed by multiple credential bindings`,
5790
+ );
5791
+ }
5792
+ remoteBindings.set(remote, bindingKey);
5793
+ return {
5794
+ provider,
5795
+ credentialBindingId,
5796
+ bindingHash: gitCredentialBindingHash(credentialBindingId),
5797
+ protocol: url.protocol.replace(/:$/, "").toLowerCase(),
5798
+ host: url.host.toLowerCase(),
5799
+ path,
5800
+ uri: resource.uri,
5801
+ };
5802
+ });
4845
5803
  }
4846
5804
 
4847
- function gitAskpassHostProviderCaseLines(
5805
+ function gitCredentialBindingKey(
5806
+ provider: GitCredentialProvider,
5807
+ credentialBindingId: string,
5808
+ ): string {
5809
+ return `${provider}\u0000${credentialBindingId}`;
5810
+ }
5811
+
5812
+ function brokeredGitCredentialBindingKeys(
5813
+ bindings: GitCredentialBindingSeed[],
5814
+ ): ReadonlySet<string> {
5815
+ return new Set(
5816
+ bindings
5817
+ .filter((binding) => binding.transport?.kind === "http_broker")
5818
+ .map((binding) => gitCredentialBindingKey(binding.provider, binding.credentialBindingId)),
5819
+ );
5820
+ }
5821
+
5822
+ function runtimeGitHttpBrokerRouteDescriptors(
4848
5823
  resources: Extract<ResourceRef, { kind: "repository" }>[],
4849
- ): string[] {
4850
- const hosts = new Map<string, GitCredentialProvider>();
4851
- for (const resource of resources) {
4852
- try {
4853
- const hostname = new URL(resource.uri).hostname.toLowerCase();
4854
- if (!hostname) {
4855
- continue;
5824
+ bindings: GitCredentialBindingSeed[],
5825
+ ): RuntimeGitHttpBrokerRouteDescriptor[] {
5826
+ const resourceDescriptors = runtimeGitBindingDescriptors(resources);
5827
+ const byBindingAndUri = new Map<string, RuntimeGitBindingDescriptor>();
5828
+ for (const descriptor of resourceDescriptors) {
5829
+ byBindingAndUri.set(
5830
+ `${gitCredentialBindingKey(descriptor.provider, descriptor.credentialBindingId)}\u0000${descriptor.uri}`,
5831
+ descriptor,
5832
+ );
5833
+ }
5834
+
5835
+ const routes: RuntimeGitHttpBrokerRouteDescriptor[] = [];
5836
+ const claimedRepositoryUris = new Set<string>();
5837
+ const claimedBrokerUris = new Set<string>();
5838
+ for (const binding of bindings) {
5839
+ if (!binding.transport) continue;
5840
+ if (
5841
+ typeof binding.transport !== "object" ||
5842
+ binding.transport.kind !== "http_broker" ||
5843
+ !Array.isArray(binding.transport.repositories)
5844
+ ) {
5845
+ throw new Error(
5846
+ `Git credential binding ${binding.credentialBindingId} uses an unsupported transport`,
5847
+ );
5848
+ }
5849
+ const bindingKey = gitCredentialBindingKey(binding.provider, binding.credentialBindingId);
5850
+ const expected = resourceDescriptors.filter(
5851
+ (descriptor) =>
5852
+ gitCredentialBindingKey(descriptor.provider, descriptor.credentialBindingId) === bindingKey,
5853
+ );
5854
+ if (expected.length === 0 || binding.transport.repositories.length !== expected.length) {
5855
+ throw new Error(
5856
+ `Git HTTP broker binding ${binding.credentialBindingId} does not cover its exact repository set`,
5857
+ );
5858
+ }
5859
+ for (const route of binding.transport.repositories) {
5860
+ if (
5861
+ !route ||
5862
+ typeof route !== "object" ||
5863
+ typeof route.repositoryUri !== "string" ||
5864
+ typeof route.brokerUri !== "string"
5865
+ ) {
5866
+ throw new Error(
5867
+ `Git HTTP broker binding ${binding.credentialBindingId} contains an invalid repository route`,
5868
+ );
5869
+ }
5870
+ const descriptor = byBindingAndUri.get(`${bindingKey}\u0000${route.repositoryUri}`);
5871
+ if (!descriptor || claimedRepositoryUris.has(route.repositoryUri)) {
5872
+ throw new Error(
5873
+ `Git HTTP broker binding ${binding.credentialBindingId} contains an unexpected repository route`,
5874
+ );
5875
+ }
5876
+ let brokerUrl: URL;
5877
+ try {
5878
+ brokerUrl = new URL(route.brokerUri);
5879
+ } catch {
5880
+ throw new Error(
5881
+ `Git HTTP broker binding ${binding.credentialBindingId} contains an invalid broker URI`,
5882
+ );
5883
+ }
5884
+ if (
5885
+ brokerUrl.protocol !== "https:" ||
5886
+ brokerUrl.username ||
5887
+ brokerUrl.password ||
5888
+ brokerUrl.search ||
5889
+ brokerUrl.hash ||
5890
+ brokerUrl.href !== route.brokerUri ||
5891
+ claimedBrokerUris.has(brokerUrl.href)
5892
+ ) {
5893
+ throw new Error(
5894
+ `Git HTTP broker binding ${binding.credentialBindingId} contains an unsafe broker URI`,
5895
+ );
4856
5896
  }
4857
- hosts.set(hostname, repositoryCredentialProvider(resource));
4858
- } catch {
4859
- // Resource validation catches invalid URIs before normal runtime use. Keep
4860
- // helper generation tolerant so tests for clone failure can still build.
5897
+ claimedRepositoryUris.add(route.repositoryUri);
5898
+ claimedBrokerUris.add(brokerUrl.href);
5899
+ routes.push({
5900
+ provider: binding.provider,
5901
+ credentialBindingId: binding.credentialBindingId,
5902
+ bindingHash: gitCredentialBindingHash(binding.credentialBindingId),
5903
+ repositoryUri: route.repositoryUri,
5904
+ brokerUri: route.brokerUri,
5905
+ protocol: "https",
5906
+ host: brokerUrl.host.toLowerCase(),
5907
+ path: brokerUrl.pathname.replace(/^\/+|\/+$/g, ""),
5908
+ });
4861
5909
  }
4862
5910
  }
4863
- return [...hosts.entries()]
4864
- .sort(([a], [b]) => a.localeCompare(b))
4865
- .map(
4866
- ([hostname, provider]) =>
4867
- ` ${shellQuote(hostname)}) printf '%s\\n' ${provider}; return 0 ;;`,
5911
+ return routes;
5912
+ }
5913
+
5914
+ function gitUsernameForProvider(provider: GitCredentialProvider): string {
5915
+ if (provider === "github") return "x-access-token";
5916
+ if (provider === "gitlab") return "oauth2";
5917
+ return "opengeni";
5918
+ }
5919
+
5920
+ function gitCredentialHelperBindingCaseLines(
5921
+ resources: Extract<ResourceRef, { kind: "repository" }>[],
5922
+ bindings: GitCredentialBindingSeed[],
5923
+ ): string[] {
5924
+ const brokeredBindings = brokeredGitCredentialBindingKeys(bindings);
5925
+ return runtimeGitBindingDescriptors(resources)
5926
+ .filter(
5927
+ (descriptor) =>
5928
+ !brokeredBindings.has(
5929
+ gitCredentialBindingKey(descriptor.provider, descriptor.credentialBindingId),
5930
+ ),
5931
+ )
5932
+ .flatMap((descriptor) => {
5933
+ const paths = new Set([
5934
+ descriptor.path,
5935
+ descriptor.path.replace(/\.git$/, ""),
5936
+ `${descriptor.path.replace(/\.git$/, "")}.git`,
5937
+ ]);
5938
+ return [...paths].map(
5939
+ (path) =>
5940
+ ` ${shellQuote(`${descriptor.protocol}|${descriptor.host}|${path}`)}) username=${shellQuote(gitUsernameForProvider(descriptor.provider))}; token_file="$credential_dir/${descriptor.bindingHash}-token" ;;`,
5941
+ );
5942
+ });
5943
+ }
5944
+
5945
+ function gitCredentialHelperBrokerCaseLines(
5946
+ routes: RuntimeGitHttpBrokerRouteDescriptor[],
5947
+ ): string[] {
5948
+ return routes.flatMap((route) => {
5949
+ const paths = new Set([
5950
+ route.path,
5951
+ route.path.replace(/\.git$/, ""),
5952
+ `${route.path.replace(/\.git$/, "")}.git`,
5953
+ ]);
5954
+ return [...paths].map(
5955
+ (path) =>
5956
+ ` ${shellQuote(`${route.protocol}|${route.host}|${path}`)}) username=opengeni; token_file="$credential_dir/${route.bindingHash}-token" ;;`,
4868
5957
  );
5958
+ });
5959
+ }
5960
+
5961
+ function gitAskpassHostProviderCaseLines(
5962
+ resources: Extract<ResourceRef, { kind: "repository" }>[],
5963
+ brokerRoutes: RuntimeGitHttpBrokerRouteDescriptor[],
5964
+ ): string[] {
5965
+ const hosts = new Map<string, { provider: GitCredentialProvider; bindings: Set<string> }>();
5966
+ for (const descriptor of runtimeGitBindingDescriptors(resources)) {
5967
+ const entry = hosts.get(descriptor.host) ?? {
5968
+ provider: descriptor.provider,
5969
+ bindings: new Set<string>(),
5970
+ };
5971
+ entry.bindings.add(`${descriptor.provider}\u0000${descriptor.credentialBindingId}`);
5972
+ hosts.set(descriptor.host, entry);
5973
+ }
5974
+ const brokerHosts = [...new Set(brokerRoutes.map((route) => route.host))]
5975
+ .sort((a, b) => a.localeCompare(b))
5976
+ .map((hostname) => ` ${shellQuote(hostname)}) printf '\\n'; return 0 ;;`);
5977
+ return [
5978
+ ...brokerHosts,
5979
+ ...[...hosts.entries()]
5980
+ .filter(([, entry]) => entry.bindings.size === 1)
5981
+ .sort(([a], [b]) => a.localeCompare(b))
5982
+ .map(
5983
+ ([hostname, entry]) =>
5984
+ ` ${shellQuote(hostname)}) printf '%s\\n' ${entry.provider}; return 0 ;;`,
5985
+ ),
5986
+ ];
4869
5987
  }
4870
5988
 
4871
- function gitCredentialTokenWriterCommandLines(): string[] {
5989
+ function gitCredentialTokenWriterCommandLines(bindings: GitCredentialBindingSeed[] = []): string[] {
5990
+ const bindingWrites = bindings.flatMap((binding) => {
5991
+ const hash = gitCredentialBindingHash(binding.credentialBindingId);
5992
+ const seedEnv = gitBindingSeedEnv(binding);
5993
+ const count = Math.max(1, binding.providerBindingCount ?? 1);
5994
+ const provider = binding.provider;
5995
+ const lines = [`write_git_binding_token ${shellQuote(hash)} "\${${seedEnv}:-}"`];
5996
+ if (count === 1 && binding.transport?.kind !== "http_broker") {
5997
+ lines.push(`write_git_provider_token ${shellQuote(provider)} "\${${seedEnv}:-}"`);
5998
+ } else {
5999
+ lines.push(`remove_git_provider_token ${shellQuote(provider)}`);
6000
+ }
6001
+ return lines;
6002
+ });
4872
6003
  return [
4873
6004
  // TOKEN-BROKER (B1/B2): seed run-scoped provider tokens into stable files and
4874
6005
  // atomically replace each provider file. Token VALUES are supplied only by
@@ -4882,6 +6013,23 @@ function gitCredentialTokenWriterCommandLines(): string[] {
4882
6013
  " *) printf '%s\\n' \"${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/$provider-token\" ;;",
4883
6014
  " esac",
4884
6015
  "}",
6016
+ "write_git_binding_token() {",
6017
+ ' binding_hash="$1"',
6018
+ ' token="$2"',
6019
+ ' [ -n "$token" ] || return 0',
6020
+ ' credential_dir="${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}"',
6021
+ ' mkdir -p "$credential_dir"',
6022
+ ' token_file="$credential_dir/$binding_hash-token"',
6023
+ ' printf \'%s\' "$token" > "$token_file.tmp.$$"',
6024
+ ' mv -f "$token_file.tmp.$$" "$token_file"',
6025
+ "}",
6026
+ "remove_git_provider_token() {",
6027
+ ' provider="$1"',
6028
+ ' rm -f "$(git_provider_token_file "$provider")"',
6029
+ ' if [ "$provider" = github ]; then',
6030
+ ' rm -f "${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/github-token"',
6031
+ " fi",
6032
+ "}",
4885
6033
  "write_git_provider_token() {",
4886
6034
  ' provider="$1"',
4887
6035
  ' token="$2"',
@@ -4902,16 +6050,109 @@ function gitCredentialTokenWriterCommandLines(): string[] {
4902
6050
  'write_git_provider_token github "${OPENGENI_GIT_GITHUB_TOKEN_SEED:-${OPENGENI_GIT_TOKEN_SEED:-}}"',
4903
6051
  'write_git_provider_token gitlab "${OPENGENI_GIT_GITLAB_TOKEN_SEED:-}"',
4904
6052
  'write_git_provider_token azure_devops "${OPENGENI_GIT_AZURE_DEVOPS_TOKEN_SEED:-}"',
6053
+ ...bindingWrites,
4905
6054
  'umask "$seed_umask"',
4906
6055
  ];
4907
6056
  }
4908
6057
 
6058
+ function gitHttpBrokerConfigCommandLines(routes: RuntimeGitHttpBrokerRouteDescriptor[]): string[] {
6059
+ return [
6060
+ 'git_http_broker_config="${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/http-broker.gitconfig"',
6061
+ 'mkdir -p "$(dirname "$git_http_broker_config")"',
6062
+ 'broker_umask="$(umask)"',
6063
+ "umask 077",
6064
+ ': > "$git_http_broker_config.tmp.$$"',
6065
+ ...routes.map(
6066
+ (route) =>
6067
+ `git config --file "$git_http_broker_config.tmp.$$" --add ${shellQuote(`url.${route.brokerUri}.insteadOf`)} ${shellQuote(route.repositoryUri)}`,
6068
+ ),
6069
+ 'mv -f "$git_http_broker_config.tmp.$$" "$git_http_broker_config"',
6070
+ 'umask "$broker_umask"',
6071
+ 'git config --global --unset-all include.path "$git_http_broker_config" >/dev/null 2>&1 || true',
6072
+ 'git config --global --add include.path "$git_http_broker_config"',
6073
+ ];
6074
+ }
6075
+
4909
6076
  function gitCredentialHelperCommandLines(
4910
6077
  resources: Extract<ResourceRef, { kind: "repository" }>[] = [],
6078
+ bindings: GitCredentialBindingSeed[] = [],
4911
6079
  ): string[] {
4912
- const hostProviderCases = gitAskpassHostProviderCaseLines(resources);
6080
+ const brokerRoutes = runtimeGitHttpBrokerRouteDescriptors(resources, bindings);
6081
+ const hostProviderCases = gitAskpassHostProviderCaseLines(resources, brokerRoutes);
6082
+ const bindingCases = [
6083
+ ...gitCredentialHelperBindingCaseLines(resources, bindings),
6084
+ ...gitCredentialHelperBrokerCaseLines(brokerRoutes),
6085
+ ];
6086
+ const descriptors = runtimeGitBindingDescriptors(resources);
6087
+ const brokeredBindings = brokeredGitCredentialBindingKeys(bindings);
6088
+ const wrapperDescriptors =
6089
+ bindings.length > 0
6090
+ ? descriptors.filter(
6091
+ (descriptor) =>
6092
+ !brokeredBindings.has(
6093
+ gitCredentialBindingKey(descriptor.provider, descriptor.credentialBindingId),
6094
+ ),
6095
+ )
6096
+ : [];
6097
+ const bindingProviders = new Map<GitCredentialProvider, Set<string>>();
6098
+ for (const descriptor of runtimeGitBindingDescriptors(resources)) {
6099
+ const ids = bindingProviders.get(descriptor.provider) ?? new Set<string>();
6100
+ ids.add(descriptor.credentialBindingId);
6101
+ bindingProviders.set(descriptor.provider, ids);
6102
+ }
6103
+ const strictAskpass = [...bindingProviders.values()].some((ids) => ids.size > 1);
6104
+ const allowedWrapperHashes = [
6105
+ ...new Set(wrapperDescriptors.map((item) => `${item.provider}|${item.bindingHash}`)),
6106
+ ].map((key) => ` ${shellQuote(key)}) return 0 ;;`);
6107
+ const originWrapperHashes = wrapperDescriptors.flatMap((item) => {
6108
+ const base = item.uri.replace(/\.git$/, "");
6109
+ return [...new Set([item.uri, base, `${base}.git`])].map(
6110
+ (uri) =>
6111
+ ` ${shellQuote(`${item.provider}|${uri}`)}) printf '%s\\n' ${shellQuote(item.bindingHash)}; return 0 ;;`,
6112
+ );
6113
+ });
6114
+ const soleWrapperHashes = [...bindingProviders.entries()].flatMap(([provider, ids]) => {
6115
+ if (ids.size !== 1) return [];
6116
+ const descriptor = wrapperDescriptors.find((item) => item.provider === provider);
6117
+ return descriptor
6118
+ ? [
6119
+ ` ${shellQuote(provider)}) printf '%s\\n' ${shellQuote(descriptor.bindingHash)}; return 0 ;;`,
6120
+ ]
6121
+ : [];
6122
+ });
6123
+ const multiWrapperProviders =
6124
+ bindings.length > 0
6125
+ ? [...bindingProviders.entries()]
6126
+ .filter(([, ids]) => ids.size > 1)
6127
+ .map(([provider]) => provider)
6128
+ : [];
6129
+ const brokeredOriginCases = brokerRoutes.flatMap((route) => {
6130
+ const base = route.repositoryUri.replace(/\.git$/, "");
6131
+ return [...new Set([route.repositoryUri, base, `${base}.git`])].map(
6132
+ (uri) => ` ${shellQuote(`${route.provider}|${uri}`)}) return 0 ;;`,
6133
+ );
6134
+ });
6135
+ const brokeredBindingHashCases = bindings
6136
+ .filter((binding) => binding.transport?.kind === "http_broker")
6137
+ .map(
6138
+ (binding) =>
6139
+ ` ${shellQuote(`${binding.provider}|${gitCredentialBindingHash(binding.credentialBindingId)}`)}) return 0 ;;`,
6140
+ );
6141
+ const providerBindingKinds = new Map<
6142
+ GitCredentialProvider,
6143
+ { direct: number; brokered: number }
6144
+ >();
6145
+ for (const binding of bindings) {
6146
+ const counts = providerBindingKinds.get(binding.provider) ?? { direct: 0, brokered: 0 };
6147
+ if (binding.transport?.kind === "http_broker") counts.brokered += 1;
6148
+ else counts.direct += 1;
6149
+ providerBindingKinds.set(binding.provider, counts);
6150
+ }
6151
+ const brokerOnlyProviders = [...providerBindingKinds.entries()]
6152
+ .filter(([, counts]) => counts.brokered > 0 && counts.direct === 0)
6153
+ .map(([provider]) => provider);
4913
6154
  return [
4914
- ...gitCredentialTokenWriterCommandLines(),
6155
+ ...gitCredentialTokenWriterCommandLines(bindings),
4915
6156
  // Provision git/provider-CLI helpers at SETUP (runtime) before any clone
4916
6157
  // runs. Renewal updates only token files and deliberately leaves these
4917
6158
  // repository-specific host mappings intact.
@@ -4928,8 +6169,8 @@ function gitCredentialHelperCommandLines(
4928
6169
  ' rest="${prompt_lower#*://}"',
4929
6170
  ' rest="${rest#*@}"',
4930
6171
  ' host="${rest%%/*}"',
4931
- ' host="${host%%:*}"',
4932
6172
  ' host="$(printf \'%s\\n\' "$host" | tr -d "\'")"',
6173
+ ' host="${host%:}"',
4933
6174
  " printf '%s\\n' \"$host\"",
4934
6175
  "}",
4935
6176
  "provider_for_prompt() {",
@@ -4941,7 +6182,7 @@ function gitCredentialHelperCommandLines(
4941
6182
  " *github.com*|*githubusercontent.com*) printf '%s\\n' github ;;",
4942
6183
  " *gitlab*) printf '%s\\n' gitlab ;;",
4943
6184
  " *dev.azure.com*|*.visualstudio.com*) printf '%s\\n' azure_devops ;;",
4944
- " *) printf '%s\\n' github ;;",
6185
+ strictAskpass ? " *) printf '\\n' ;;" : " *) printf '%s\\n' github ;;",
4945
6186
  " esac",
4946
6187
  "}",
4947
6188
  "token_file_for_provider() {",
@@ -4967,6 +6208,41 @@ function gitCredentialHelperCommandLines(
4967
6208
  "ASKPASS_EOF",
4968
6209
  'chmod 0755 "$git_askpass.tmp.$$"',
4969
6210
  'mv -f "$git_askpass.tmp.$$" "$git_askpass"',
6211
+ 'git_credential_helper="${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/helper"',
6212
+ 'mkdir -p "$(dirname "$git_credential_helper")"',
6213
+ "cat > \"$git_credential_helper.tmp.$$\" <<'GIT_CREDENTIAL_HELPER_EOF'",
6214
+ "#!/usr/bin/env sh",
6215
+ "set -eu",
6216
+ '[ "${1:-get}" = get ] || exit 0',
6217
+ "protocol= host= path=",
6218
+ "while IFS='=' read -r key value; do",
6219
+ ' case "$key" in',
6220
+ " protocol) protocol=\"$(printf '%s' \"$value\" | tr '[:upper:]' '[:lower:]')\" ;;",
6221
+ " host) host=\"$(printf '%s' \"$value\" | tr '[:upper:]' '[:lower:]')\" ;;",
6222
+ ' path) path="${value#/}" ;;',
6223
+ " esac",
6224
+ "done",
6225
+ 'credential_dir="${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}"',
6226
+ "username= token_file=",
6227
+ 'case "$protocol|$host|$path" in',
6228
+ ...bindingCases,
6229
+ " *) exit 0 ;;",
6230
+ "esac",
6231
+ '[ -r "$token_file" ] || exit 0',
6232
+ 'password="$(cat "$token_file" 2>/dev/null || true)"',
6233
+ '[ -n "$password" ] || exit 0',
6234
+ 'printf \'username=%s\\npassword=%s\\n\' "$username" "$password"',
6235
+ "GIT_CREDENTIAL_HELPER_EOF",
6236
+ 'chmod 0755 "$git_credential_helper.tmp.$$"',
6237
+ 'mv -f "$git_credential_helper.tmp.$$" "$git_credential_helper"',
6238
+ // Empty helper resets lower-priority/system helpers; our exact path-aware
6239
+ // helper returns no credential for an unbound remote, so multi-binding
6240
+ // sessions fail closed instead of falling through to ambient credentials.
6241
+ "git config --global --unset-all credential.helper >/dev/null 2>&1 || true",
6242
+ "git config --global --add credential.helper ''",
6243
+ 'git config --global --add credential.helper "$git_credential_helper"',
6244
+ "git config --global credential.useHttpPath true",
6245
+ ...gitHttpBrokerConfigCommandLines(brokerRoutes),
4970
6246
  'wrapper_dir="${OPENGENI_GIT_CLI_WRAPPER_DIR:-$HOME/.opengeni/bin}"',
4971
6247
  'mkdir -p "$wrapper_dir"',
4972
6248
  "for opengeni_git_cli_tool in gh glab az; do",
@@ -4981,11 +6257,71 @@ function gitCredentialHelperCommandLines(
4981
6257
  " az) provider=azure_devops; token_env=AZURE_DEVOPS_EXT_PAT ;;",
4982
6258
  " *) provider=; token_env= ;;",
4983
6259
  "esac",
4984
- 'if [ -n "$provider" ]; then',
6260
+ "hash_binding_id() {",
6261
+ " if command -v sha256sum >/dev/null 2>&1; then printf '%s' \"$1\" | sha256sum | cut -c1-32; return; fi",
6262
+ " if command -v shasum >/dev/null 2>&1; then printf '%s' \"$1\" | shasum -a 256 | cut -c1-32; return; fi",
6263
+ " if command -v openssl >/dev/null 2>&1; then printf '%s' \"$1\" | openssl dgst -sha256 | sed 's/^.*= //' | cut -c1-32; return; fi",
6264
+ " printf '%s\\n' 'No SHA-256 utility is available to select OPENGENI_GIT_BINDING' >&2",
6265
+ " return 127",
6266
+ "}",
6267
+ "binding_hash_allowed() {",
6268
+ ' case "$provider|$1" in',
6269
+ ...allowedWrapperHashes,
6270
+ " *) return 1 ;;",
6271
+ " esac",
6272
+ "}",
6273
+ "binding_hash_is_brokered() {",
6274
+ ' case "$provider|$1" in',
6275
+ ...brokeredBindingHashCases,
6276
+ " *) return 1 ;;",
6277
+ " esac",
6278
+ "}",
6279
+ "binding_hash_for_origin() {",
6280
+ ' case "$provider|$1" in',
6281
+ ...originWrapperHashes,
6282
+ " *) return 1 ;;",
6283
+ " esac",
6284
+ "}",
6285
+ "origin_is_brokered() {",
6286
+ ' case "$provider|$1" in',
6287
+ ...brokeredOriginCases,
6288
+ " *) return 1 ;;",
6289
+ " esac",
6290
+ "}",
6291
+ "sole_binding_hash() {",
4985
6292
  ' case "$provider" in',
4986
- ' github) token_file="${OPENGENI_GIT_TOKEN_FILE:-$HOME/.opengeni/git-token}" ;;',
4987
- ' *) token_file="${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/$provider-token" ;;',
6293
+ ...soleWrapperHashes,
6294
+ " *) return 1 ;;",
4988
6295
  " esac",
6296
+ "}",
6297
+ `multi_binding_providers=${shellQuote(multiWrapperProviders.join(" "))}`,
6298
+ `broker_only_providers=${shellQuote(brokerOnlyProviders.join(" "))}`,
6299
+ 'if [ -n "$provider" ]; then',
6300
+ " binding_hash=",
6301
+ ' if [ -n "${OPENGENI_GIT_BINDING:-}" ]; then',
6302
+ ' binding_hash="$(hash_binding_id "$OPENGENI_GIT_BINDING")"',
6303
+ ' binding_hash_is_brokered "$binding_hash" && { printf \'%s\\n\' "$tool provider API authentication is host-brokered for OPENGENI_GIT_BINDING; use the configured provider MCP tools" >&2; exit 2; }',
6304
+ ' binding_hash_allowed "$binding_hash" || { printf \'%s\\n\' "OPENGENI_GIT_BINDING does not select a $provider credential attached to this session" >&2; exit 2; }',
6305
+ " elif command -v git >/dev/null 2>&1; then",
6306
+ ' origin="$(git config --get remote.origin.url 2>/dev/null || true)"',
6307
+ ' [ -z "$origin" ] || ! origin_is_brokered "$origin" || { printf \'%s\\n\' "$tool provider API authentication is host-brokered for this repository; use the configured provider MCP tools" >&2; exit 2; }',
6308
+ ' [ -z "$origin" ] || binding_hash="$(binding_hash_for_origin "$origin" 2>/dev/null || true)"',
6309
+ " fi",
6310
+ ' [ -n "$binding_hash" ] || binding_hash="$(sole_binding_hash 2>/dev/null || true)"',
6311
+ ' if [ -n "$binding_hash" ]; then',
6312
+ ' token_file="${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/$binding_hash-token"',
6313
+ " else",
6314
+ ' case " $broker_only_providers " in',
6315
+ ' *" $provider "*) printf \'%s\\n\' "$tool provider API authentication is host-brokered for this session; use the configured provider MCP tools" >&2; exit 2 ;;',
6316
+ " esac",
6317
+ ' case " $multi_binding_providers " in',
6318
+ ' *" $provider "*) printf \'%s\\n\' "Unable to select one of multiple $provider credentials; run inside an attached repository or set OPENGENI_GIT_BINDING" >&2; exit 2 ;;',
6319
+ " esac",
6320
+ ' case "$provider" in',
6321
+ ' github) token_file="${OPENGENI_GIT_TOKEN_FILE:-$HOME/.opengeni/git-token}" ;;',
6322
+ ' *) token_file="${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/$provider-token" ;;',
6323
+ " esac",
6324
+ " fi",
4989
6325
  ' if [ -f "$token_file" ]; then',
4990
6326
  ' token="$(cat "$token_file" 2>/dev/null || true)"',
4991
6327
  ' if [ -n "$token" ]; then',
@@ -5040,10 +6376,23 @@ export function gitProviderTokenRefreshCommand(seeds: GitTokenSeeds): string {
5040
6376
  ].join("\n");
5041
6377
  }
5042
6378
 
6379
+ export function gitCredentialBindingTokenRefreshCommand(
6380
+ bindings: GitCredentialBindingSeed[],
6381
+ ): string {
6382
+ const seedPrefix = gitCredentialBindingSeedExportPrefix(bindings);
6383
+ if (!seedPrefix) return "";
6384
+ return [
6385
+ seedPrefix,
6386
+ "set -eu",
6387
+ 'export HOME="${HOME:-/workspace}"',
6388
+ ...gitCredentialTokenWriterCommandLines(bindings),
6389
+ ].join("\n");
6390
+ }
6391
+
5043
6392
  export async function refreshGitProviderTokenFiles(
5044
6393
  session: GitCredentialTokenWriterSession,
5045
6394
  seeds: GitTokenSeeds,
5046
- options: { runAs?: string } = {},
6395
+ options: { runAs?: string; commandRunner?: SandboxLifecycleCommandRunner } = {},
5047
6396
  ): Promise<void> {
5048
6397
  const command = gitProviderTokenRefreshCommand(seeds);
5049
6398
  if (!command) {
@@ -5056,23 +6405,41 @@ export async function refreshGitProviderTokenFiles(
5056
6405
  yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
5057
6406
  maxOutputTokens: 4_000,
5058
6407
  };
5059
- if (session.exec) {
5060
- assertSandboxCommandSucceeded(await session.exec(args), "Git credential refresh");
5061
- } else if (session.execCommand) {
5062
- assertSandboxCommandSucceeded(await session.execCommand(args), "Git credential refresh");
5063
- } else {
5064
- throw new Error("Sandbox session does not support command execution");
5065
- }
6408
+ assertSandboxCommandSucceeded(
6409
+ await runSandboxLifecycleCommand(session, args, options.commandRunner),
6410
+ "Git credential refresh",
6411
+ );
6412
+ }
6413
+
6414
+ export async function refreshGitCredentialBindingTokenFiles(
6415
+ session: GitCredentialTokenWriterSession,
6416
+ bindings: GitCredentialBindingSeed[],
6417
+ options: { runAs?: string; commandRunner?: SandboxLifecycleCommandRunner } = {},
6418
+ ): Promise<void> {
6419
+ const command = gitCredentialBindingTokenRefreshCommand(bindings);
6420
+ if (!command) return;
6421
+ const args = {
6422
+ cmd: command,
6423
+ workdir: "/workspace",
6424
+ ...(options.runAs ? { runAs: options.runAs } : {}),
6425
+ yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
6426
+ maxOutputTokens: 4_000,
6427
+ };
6428
+ assertSandboxCommandSucceeded(
6429
+ await runSandboxLifecycleCommand(session, args, options.commandRunner),
6430
+ "Git credential binding refresh",
6431
+ );
5066
6432
  }
5067
6433
 
5068
6434
  export function repositoryCloneCommand(
5069
6435
  resources: Extract<ResourceRef, { kind: "repository" }>[],
6436
+ bindings: GitCredentialBindingSeed[] = [],
5070
6437
  ): string {
6438
+ assertUniqueResourceMountPaths(resources);
5071
6439
  const commands = [
5072
6440
  "set -eu",
5073
6441
  'export HOME="${HOME:-/workspace}"',
5074
6442
  'export GIT_TERMINAL_PROMPT="${GIT_TERMINAL_PROMPT:-0}"',
5075
- ...gitCredentialHelperCommandLines(resources),
5076
6443
  "ensure_git() {",
5077
6444
  " if command -v git >/dev/null 2>&1; then",
5078
6445
  " return 0",
@@ -5088,6 +6455,7 @@ export function repositoryCloneCommand(
5088
6455
  " exit 127",
5089
6456
  "}",
5090
6457
  "ensure_git",
6458
+ ...gitCredentialHelperCommandLines(resources, bindings),
5091
6459
  "clone_repository() {",
5092
6460
  ' target="$1"',
5093
6461
  ' uri="$2"',
@@ -5160,33 +6528,41 @@ export function repositoryCloneCommand(
5160
6528
  "}",
5161
6529
  ];
5162
6530
  for (const resource of resources) {
5163
- const url = new URL(resource.uri);
5164
- const repo = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
5165
- const mountPath = normalizeManifestPath(resource.mountPath ?? `repos/${repo}`);
6531
+ const mountPath = resourceMountPath(resource);
5166
6532
  commands.push(
5167
6533
  [
5168
6534
  "clone_repository",
5169
6535
  shellQuote(posixPath.join("/workspace", mountPath)),
5170
6536
  shellQuote(resource.uri),
5171
6537
  shellQuote(resource.ref),
5172
- shellQuote(resource.subpath ? normalizeManifestPath(resource.subpath) : ""),
6538
+ shellQuote(resource.subpath ? normalizeRepositorySubpath(resource.subpath) : ""),
5173
6539
  ].join(" "),
5174
6540
  );
5175
6541
  }
5176
6542
  return commands.join("\n");
5177
6543
  }
5178
6544
 
5179
- export function toolspaceTokenSeedCommand(): string {
6545
+ export function toolspaceTokenSeedCommand(
6546
+ options: { tokenFile?: string; legacyTokenFile?: string } = {},
6547
+ ): string {
5180
6548
  return [
5181
6549
  "set -eu",
5182
6550
  'export HOME="${HOME:-/workspace}"',
5183
6551
  'if [ -n "${OPENGENI_TOOLSPACE_TOKEN_SEED:-}" ]; then',
5184
6552
  ' seed_umask="$(umask)"',
5185
6553
  " umask 077",
5186
- ' token_file="${OPENGENI_TOOLSPACE_TOKEN_FILE:-$HOME/.opengeni/toolspace-token}"',
6554
+ options.tokenFile
6555
+ ? ` token_file=${shellQuote(options.tokenFile)}`
6556
+ : ' token_file="${OPENGENI_TOOLSPACE_TOKEN_FILE:-$HOME/.opengeni/toolspace-token}"',
6557
+ options.legacyTokenFile
6558
+ ? ` legacy_token_file=${shellQuote(options.legacyTokenFile)}`
6559
+ : ' legacy_token_file=""',
5187
6560
  ' mkdir -p "$(dirname "$token_file")"',
5188
6561
  ' printf \'%s\' "$OPENGENI_TOOLSPACE_TOKEN_SEED" > "$token_file.tmp.$$"',
5189
6562
  ' mv -f "$token_file.tmp.$$" "$token_file"',
6563
+ ' if [ -n "$legacy_token_file" ] && [ "$legacy_token_file" != "$token_file" ]; then',
6564
+ ' rm -f -- "$legacy_token_file"',
6565
+ " fi",
5190
6566
  ' umask "$seed_umask"',
5191
6567
  "fi",
5192
6568
  ].join("\n");
@@ -5199,28 +6575,56 @@ export async function runToolspaceTokenSeedHook(
5199
6575
  if (!context.toolspaceTokenSeed) {
5200
6576
  return;
5201
6577
  }
5202
- const command = `export OPENGENI_TOOLSPACE_TOKEN_SEED=${shellQuote(context.toolspaceTokenSeed)}\n${toolspaceTokenSeedCommand()}`;
5203
- if (session.exec) {
5204
- const result = await session.exec({
6578
+ const command = `export OPENGENI_TOOLSPACE_TOKEN_SEED=${shellQuote(context.toolspaceTokenSeed)}\n${toolspaceTokenSeedCommand(
6579
+ {
6580
+ ...(context.toolspaceTokenFile ? { tokenFile: context.toolspaceTokenFile } : {}),
6581
+ ...(context.toolspaceTokenFile && context.environment.OPENGENI_TOOLSPACE_TOKEN_FILE
6582
+ ? { legacyTokenFile: context.environment.OPENGENI_TOOLSPACE_TOKEN_FILE }
6583
+ : {}),
6584
+ },
6585
+ )}`;
6586
+ const result = await runSandboxLifecycleCommand(
6587
+ session,
6588
+ {
5205
6589
  cmd: command,
5206
6590
  workdir: "/workspace",
5207
6591
  ...(context.runAs ? { runAs: context.runAs } : {}),
5208
6592
  yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
5209
6593
  maxOutputTokens: 4_000,
5210
- });
5211
- assertSandboxCommandSucceeded(result, "Toolspace token seed hook");
5212
- } else if (session.execCommand) {
5213
- const result = await session.execCommand({
6594
+ },
6595
+ context.commandRunner,
6596
+ );
6597
+ assertSandboxCommandSucceeded(result, "Toolspace token seed hook");
6598
+ }
6599
+
6600
+ export async function refreshToolspaceTokenFile(
6601
+ session: ToolspaceTokenWriterSession,
6602
+ token: string,
6603
+ options: {
6604
+ runAs?: string;
6605
+ commandRunner?: SandboxLifecycleCommandRunner;
6606
+ tokenFile?: string;
6607
+ legacyTokenFile?: string;
6608
+ } = {},
6609
+ ): Promise<void> {
6610
+ const command = `export OPENGENI_TOOLSPACE_TOKEN_SEED=${shellQuote(token)}\n${toolspaceTokenSeedCommand(
6611
+ {
6612
+ ...(options.tokenFile ? { tokenFile: options.tokenFile } : {}),
6613
+ ...(options.legacyTokenFile ? { legacyTokenFile: options.legacyTokenFile } : {}),
6614
+ },
6615
+ )}`;
6616
+ const result = await runSandboxLifecycleCommand(
6617
+ session,
6618
+ {
5214
6619
  cmd: command,
5215
6620
  workdir: "/workspace",
5216
- ...(context.runAs ? { runAs: context.runAs } : {}),
6621
+ ...(options.runAs ? { runAs: options.runAs } : {}),
5217
6622
  yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
5218
6623
  maxOutputTokens: 4_000,
5219
- });
5220
- assertSandboxCommandSucceeded(result, "Toolspace token seed hook");
5221
- } else {
5222
- throw new Error("Sandbox session does not support command execution");
5223
- }
6624
+ },
6625
+ options.commandRunner,
6626
+ );
6627
+ assertSandboxCommandSucceeded(result, "Toolspace token refresh");
5224
6628
  }
5225
6629
 
5226
6630
  // Bounds the setup output tail carried on a rig.setup failure event/error so a
@@ -5329,13 +6733,7 @@ export async function runRigSetupHook(
5329
6733
  };
5330
6734
  let result: unknown;
5331
6735
  try {
5332
- if (session.exec) {
5333
- result = await session.exec(execArgs);
5334
- } else if (session.execCommand) {
5335
- result = await session.execCommand(execArgs);
5336
- } else {
5337
- throw new Error("Sandbox session does not support command execution");
5338
- }
6736
+ result = await runSandboxLifecycleCommand(session, execArgs, context.commandRunner);
5339
6737
  } catch (error) {
5340
6738
  const message = error instanceof Error ? error.message : String(error);
5341
6739
  await context.onRuntimeEvent?.({
@@ -5414,31 +6812,27 @@ export async function runRepositoryCloneHook(
5414
6812
  ...(context.gitTokenSeeds ?? {}),
5415
6813
  ...(context.gitTokenSeed ? { github: context.gitTokenSeed } : {}),
5416
6814
  } satisfies GitTokenSeeds;
5417
- const seedPrefix = gitTokenSeedExportPrefix(gitTokenSeeds);
5418
- const command = seedPrefix
5419
- ? `${seedPrefix}\n${repositoryCloneCommand(resources)}`
5420
- : repositoryCloneCommand(resources);
5421
- if (session.exec) {
5422
- const result = await session.exec({
5423
- cmd: command,
5424
- workdir: "/workspace",
5425
- ...(context.runAs ? { runAs: context.runAs } : {}),
5426
- yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
5427
- maxOutputTokens: 20_000,
5428
- });
5429
- assertSandboxCommandSucceeded(result, "Repository clone hook");
5430
- } else if (session.execCommand) {
5431
- const result = await session.execCommand({
6815
+ const gitCredentialBindings = context.gitCredentialBindings ?? [];
6816
+ const seedPrefix = [
6817
+ gitTokenSeedExportPrefix(gitTokenSeeds),
6818
+ gitCredentialBindingSeedExportPrefix(gitCredentialBindings),
6819
+ ]
6820
+ .filter(Boolean)
6821
+ .join("\n");
6822
+ const cloneCommand = repositoryCloneCommand(resources, gitCredentialBindings);
6823
+ const command = seedPrefix ? `${seedPrefix}\n${cloneCommand}` : cloneCommand;
6824
+ const result = await runSandboxLifecycleCommand(
6825
+ session,
6826
+ {
5432
6827
  cmd: command,
5433
6828
  workdir: "/workspace",
5434
6829
  ...(context.runAs ? { runAs: context.runAs } : {}),
5435
6830
  yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
5436
6831
  maxOutputTokens: 20_000,
5437
- });
5438
- assertSandboxCommandSucceeded(result, "Repository clone hook");
5439
- } else {
5440
- throw new Error("Sandbox session does not support command execution");
5441
- }
6832
+ },
6833
+ context.commandRunner,
6834
+ );
6835
+ assertSandboxCommandSucceeded(result, "Repository clone hook");
5442
6836
  await context.onRuntimeEvent?.({
5443
6837
  type: "sandbox.operation.completed",
5444
6838
  payload,
@@ -5564,27 +6958,18 @@ export async function runAzureCliLoginHook(
5564
6958
  payload,
5565
6959
  });
5566
6960
  try {
5567
- if (session.exec) {
5568
- const result = await session.exec({
5569
- cmd: azureCliLoginCommand(),
5570
- workdir: "/workspace",
5571
- ...(context.runAs ? { runAs: context.runAs } : {}),
5572
- yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
5573
- maxOutputTokens: 20_000,
5574
- });
5575
- assertSandboxCommandSucceeded(result, "Azure CLI login hook");
5576
- } else if (session.execCommand) {
5577
- const result = await session.execCommand({
6961
+ const result = await runSandboxLifecycleCommand(
6962
+ session,
6963
+ {
5578
6964
  cmd: azureCliLoginCommand(),
5579
6965
  workdir: "/workspace",
5580
6966
  ...(context.runAs ? { runAs: context.runAs } : {}),
5581
6967
  yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
5582
6968
  maxOutputTokens: 20_000,
5583
- });
5584
- assertSandboxCommandSucceeded(result, "Azure CLI login hook");
5585
- } else {
5586
- throw new Error("Sandbox session does not support command execution");
5587
- }
6969
+ },
6970
+ context.commandRunner,
6971
+ );
6972
+ assertSandboxCommandSucceeded(result, "Azure CLI login hook");
5588
6973
  await context.onRuntimeEvent?.({
5589
6974
  type: "sandbox.operation.completed",
5590
6975
  payload,
@@ -5674,26 +7059,46 @@ function isPathWithin(root: string, candidate: string): boolean {
5674
7059
  }
5675
7060
 
5676
7061
  /**
5677
- * The skill source fed to the SDK Skills capability. Without pack skills this
5678
- * is the plain bundled local-dir source, byte-for-byte the pre-pack behavior.
5679
- * With pack skills it becomes a single in-memory dir source combining bundled
5680
- * skill directories (as local_dir entries the SDK materializes lazily) with
5681
- * pack skill directories built from manifest-carried file content — one skill
5682
- * index, one `## Skills` instruction section, lazy `load_skill` for all of
5683
- * them. A pack skill shadows a bundled skill with the same directory name.
7062
+ * The skill source fed to the SDK Skills capability. Without pack or curated
7063
+ * skills this is the plain bundled local-dir source, byte-for-byte the
7064
+ * pre-pack behavior. With either selected source it becomes a single
7065
+ * in-memory dir source combining bundled skill directories (as local_dir
7066
+ * entries the SDK materializes lazily) with selected in-memory skill
7067
+ * directories — one skill index, one `## Skills` instruction section, lazy
7068
+ * `load_skill` for all of them. A pack skill shadows a bundled or curated
7069
+ * skill with the same directory name, case-insensitively.
5684
7070
  */
5685
- export function lazySkillSourceWithPackSkills(packSkills: PackSkill[]): LocalDirLazySkillSource {
7071
+ export function lazySkillSourceWithPackSkills(
7072
+ packSkills: PackSkill[],
7073
+ skillLibrarySkills: PackSkill[] = [],
7074
+ ): LocalDirLazySkillSource {
5686
7075
  const bundledDir = bundledSkillsDir();
5687
7076
  const bundled = localDirLazySkillSource({ src: bundledDir });
5688
- if (packSkills.length === 0) {
7077
+ if (packSkills.length === 0 && skillLibrarySkills.length === 0) {
5689
7078
  return bundled;
5690
7079
  }
5691
7080
  const children: Record<string, Entry> = {};
5692
7081
  for (const name of bundledSkillDirNames(bundledDir)) {
5693
7082
  children[name] = localDir({ src: join(bundledDir, name) });
5694
7083
  }
7084
+ const libraryIndex: SkillIndexEntry[] = [];
7085
+ const libraryNameKeys = new Set<string>();
7086
+ for (const skill of skillLibrarySkills) {
7087
+ assertSafePackSkillName(skill.name);
7088
+ const key = skill.name.toLowerCase();
7089
+ if (libraryNameKeys.has(key)) {
7090
+ throw new Error(`Duplicate curated skill name: ${skill.name}`);
7091
+ }
7092
+ libraryNameKeys.add(key);
7093
+ removeSkillChildByNameKey(children, key);
7094
+ children[skill.name] = packSkillDirEntry(skill);
7095
+ libraryIndex.push({
7096
+ name: skill.name,
7097
+ description: packSkillDescription(skill),
7098
+ path: skill.name,
7099
+ });
7100
+ }
5695
7101
  const packIndex: SkillIndexEntry[] = [];
5696
- const packNames = new Set<string>();
5697
7102
  const packNameKeys = new Set<string>();
5698
7103
  for (const skill of packSkills) {
5699
7104
  assertSafePackSkillName(skill.name);
@@ -5701,7 +7106,7 @@ export function lazySkillSourceWithPackSkills(packSkills: PackSkill[]): LocalDir
5701
7106
  throw new Error(`Duplicate pack skill name: ${skill.name}`);
5702
7107
  }
5703
7108
  packNameKeys.add(skill.name.toLowerCase());
5704
- packNames.add(skill.name);
7109
+ removeSkillChildByNameKey(children, skill.name.toLowerCase());
5705
7110
  children[skill.name] = packSkillDirEntry(skill);
5706
7111
  packIndex.push({
5707
7112
  name: skill.name,
@@ -5713,13 +7118,62 @@ export function lazySkillSourceWithPackSkills(packSkills: PackSkill[]): LocalDir
5713
7118
  source: dir({ children }),
5714
7119
  getIndex: (manifest, skillsPath) => [
5715
7120
  ...(bundled.getIndex?.(manifest, skillsPath) ?? []).filter(
5716
- (entry) => !packNames.has(entry.path ?? entry.name),
7121
+ (entry) =>
7122
+ !packNameKeys.has((entry.path ?? entry.name).toLowerCase()) &&
7123
+ !libraryNameKeys.has((entry.path ?? entry.name).toLowerCase()),
7124
+ ),
7125
+ ...libraryIndex.filter(
7126
+ (entry) => !packNameKeys.has((entry.path ?? entry.name).toLowerCase()),
5717
7127
  ),
5718
7128
  ...packIndex,
5719
7129
  ],
5720
7130
  };
5721
7131
  }
5722
7132
 
7133
+ function effectiveSkillSelections(
7134
+ librarySelections: readonly EffectiveSkillSelection[],
7135
+ librarySkills: readonly PackSkill[],
7136
+ packSkills: readonly PackSkill[],
7137
+ ): readonly EffectiveSkillSelection[] {
7138
+ const defaultSelections = bundledSkillDirNames(bundledSkillsDir()).map((name) => ({
7139
+ id: `bundled:${name}`,
7140
+ name,
7141
+ source: "bundled" as const,
7142
+ version: null,
7143
+ contentSha256: null,
7144
+ reason: "deployment default skill bundle",
7145
+ }));
7146
+ const libraryNameKeys = new Set(librarySkills.map((skill) => skill.name.toLowerCase()));
7147
+ const packNameKeys = new Set(packSkills.map((skill) => skill.name.toLowerCase()));
7148
+ const selected = librarySelections.filter((selection) =>
7149
+ libraryNameKeys.has(selection.name.toLowerCase()),
7150
+ );
7151
+ return [
7152
+ ...defaultSelections.filter(
7153
+ (selection) =>
7154
+ !libraryNameKeys.has(selection.name.toLowerCase()) &&
7155
+ !packNameKeys.has(selection.name.toLowerCase()),
7156
+ ),
7157
+ ...selected.filter((selection) => !packNameKeys.has(selection.name.toLowerCase())),
7158
+ ...packSkills.map((skill) => ({
7159
+ id: `pack:${skill.name}`,
7160
+ name: skill.name,
7161
+ source: "pack" as const,
7162
+ version: null,
7163
+ contentSha256: null,
7164
+ reason: "enabled capability pack",
7165
+ })),
7166
+ ];
7167
+ }
7168
+
7169
+ function removeSkillChildByNameKey(children: Record<string, Entry>, nameKey: string): void {
7170
+ for (const name of Object.keys(children)) {
7171
+ if (name.toLowerCase() === nameKey) {
7172
+ delete children[name];
7173
+ }
7174
+ }
7175
+ }
7176
+
5723
7177
  function bundledSkillDirNames(root: string): string[] {
5724
7178
  return readdirSync(root, { withFileTypes: true })
5725
7179
  .filter((entry) => entry.isDirectory() && existsSync(join(root, entry.name, "SKILL.md")))
@@ -5863,7 +7317,12 @@ function sortJson(value: unknown): unknown {
5863
7317
  }
5864
7318
  return value;
5865
7319
  }
5866
-
5867
- function approvalIdentifier(item: any): string {
5868
- return String(item?.rawItem?.callId ?? item?.rawItem?.id ?? item?.id ?? item?.name ?? "approval");
7320
+ function interruptionToolName(item: unknown): string {
7321
+ const candidate = item as {
7322
+ toolName?: unknown;
7323
+ name?: unknown;
7324
+ rawItem?: { name?: unknown };
7325
+ };
7326
+ const name = candidate?.toolName ?? candidate?.name ?? candidate?.rawItem?.name;
7327
+ return typeof name === "string" ? name : "";
5869
7328
  }