@opengeni/runtime 0.7.0 → 0.11.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 (49) 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-DPULLAJG.js +215 -0
  7. package/dist/chunk-DPULLAJG.js.map +1 -0
  8. package/dist/chunk-K4V4QYCL.js +37683 -0
  9. package/dist/chunk-K4V4QYCL.js.map +1 -0
  10. package/dist/chunk-PZ5AY32C.js +10 -0
  11. package/dist/chunk-PZ5AY32C.js.map +1 -0
  12. package/dist/chunk-YUAFQEFY.js +21522 -0
  13. package/dist/chunk-YUAFQEFY.js.map +1 -0
  14. package/dist/{index-DIiiwQY2.d.ts → index-D6yi-JcU.d.ts} +138 -15
  15. package/dist/index.d.ts +333 -122
  16. package/dist/index.js +8260 -349
  17. package/dist/index.js.map +1 -1
  18. package/dist/localSources-T3HZ6KY4.js +15 -0
  19. package/dist/localSources-T3HZ6KY4.js.map +1 -0
  20. package/dist/sandbox/index.d.ts +1 -1
  21. package/dist/sandbox/index.js +42 -5
  22. package/dist/skill-library.d.ts +74 -0
  23. package/dist/skill-library.js +18 -0
  24. package/dist/skill-library.js.map +1 -0
  25. package/package.json +13 -5
  26. package/src/bundled_hashicorp_terraform_skills/README.md +3 -3
  27. package/src/bundled_hashicorp_terraform_skills/checkov/SKILL.md +1 -1
  28. package/src/context-compaction.ts +421 -24
  29. package/src/history-sanitizer.ts +14 -5
  30. package/src/index.ts +1451 -304
  31. package/src/sandbox/channel-a.ts +535 -97
  32. package/src/sandbox/index.ts +36 -2
  33. package/src/sandbox/routing/routing-session.ts +10 -2
  34. package/src/sandbox/run-credentials.ts +818 -0
  35. package/src/sandbox/select.ts +3 -3
  36. package/src/sandbox/selfhosted/capabilities.ts +1 -1
  37. package/src/sandbox/selfhosted/op-stream.ts +40 -0
  38. package/src/sandbox/selfhosted/op-testing.ts +50 -3
  39. package/src/sandbox/selfhosted/session.ts +17 -4
  40. package/src/sandbox/selfhosted/testing.ts +1 -1
  41. package/src/sandbox/stream-port.ts +1 -1
  42. package/src/sandbox/stream-token.ts +1 -1
  43. package/src/sandbox/terminal-server.ts +6 -2
  44. package/src/sandbox/toolspace-token.ts +147 -0
  45. package/src/sandbox/turn-tool-cancellation.ts +940 -0
  46. package/src/skill-library.ts +323 -0
  47. package/dist/chunk-RYDCVTMW.js +0 -5531
  48. package/dist/chunk-RYDCVTMW.js.map +0 -1
  49. /package/src/{bundled_hashicorp_terraform_skills → bundled_skill_library}/azure-verified-modules/SKILL.md +0 -0
package/src/index.ts CHANGED
@@ -12,16 +12,28 @@ import {
12
12
  sandboxLifecycleHookIds,
13
13
  } from "@opengeni/config";
14
14
  import {
15
+ approvalIdentifier,
15
16
  CAPABILITY_DESCRIPTORS,
17
+ assertUniqueResourceMountPaths,
18
+ gitCredentialBindingIdForRepository,
19
+ gitCredentialProviderForRepository,
16
20
  isClearedRunStateBlob,
21
+ normalizeRepositorySubpath,
22
+ normalizeResourceMountPath,
17
23
  prefixedMcpToolName as sharedPrefixedMcpToolName,
24
+ resourceMountPath,
25
+ sessionEventMediaPreview,
26
+ sessionEventMediaPreviewFromDataUrl,
18
27
  signDelegatedAccessToken,
28
+ RequestHumanInputToolInput,
19
29
  type GitCredentialProvider,
30
+ type HumanInputResponse,
20
31
  type McpServerConnectionRef,
21
32
  type Permission,
22
33
  type ReasoningEffort,
23
34
  type ResourceRef,
24
35
  type SessionEventType,
36
+ type SessionEventMediaPreview,
25
37
  type ToolAuthNeededPayload,
26
38
  type ToolRef,
27
39
  } from "@opengeni/contracts";
@@ -52,6 +64,7 @@ import {
52
64
  setDefaultOpenAIKey,
53
65
  setOpenAIResponsesTransport,
54
66
  setTracingDisabled,
67
+ tool as agentTool,
55
68
  // Hosted web_search tool factory. Re-exported from @openai/agents-openai via
56
69
  // `export * from '@openai/agents-openai'` in @openai/agents' index (0.11.6);
57
70
  // it returns a { type: 'hosted_tool', providerData: { type: 'web_search' } }
@@ -104,11 +117,13 @@ import {
104
117
  CODEX_APPS_MCP_SERVER_ID,
105
118
  CODEX_MODEL_ID_PREFIX,
106
119
  CODEX_ORIGINATOR,
120
+ boundModelToolOutputItems,
107
121
  codexAppsSanitizingFetch,
108
122
  codexRequestStorage,
109
123
  codexSubscriptionFetch,
110
124
  } from "@opengeni/codex";
111
125
  import { cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
126
+ import { createHash } from "node:crypto";
112
127
  import { dirname, isAbsolute, join, posix as posixPath, relative } from "node:path";
113
128
  import { fileURLToPath } from "node:url";
114
129
 
@@ -121,22 +136,52 @@ import {
121
136
  import { installCodexToolSearch } from "./codex-tool-search";
122
137
  import {
123
138
  CompactionNeededError,
139
+ CompactionProviderResponseError,
140
+ EmptyCompactionSummaryError,
124
141
  SUMMARY_BUFFER_TOKENS,
125
142
  compactionThresholdTokens,
126
- estimateTokens,
143
+ estimateCompleteModelInput,
144
+ estimateSerializedValueTokens,
145
+ hasModelGeneratedItem,
127
146
  renderCompactionPromptInputForChat,
147
+ type CompleteModelInputFootprint,
148
+ type ProviderContextTokenSignal,
128
149
  } from "./context-compaction";
129
150
  import {
130
151
  createSandboxClient,
131
152
  desktopCapableBackend,
132
153
  restoredSandboxSessionStateFromEntry,
133
154
  setSelfhostedApplyDiff,
155
+ toolspaceTokenFileFromEnvironment,
156
+ withToolspaceTokenClient,
157
+ withToolspaceTokenSession,
158
+ withRunCredentialsClient,
159
+ withRunCredentialsSession,
160
+ type RunCredentialSessionReady,
134
161
  } from "./sandbox";
135
162
  import { runWithToolCallCorrelation } from "./sandbox/op-correlation";
163
+ import {
164
+ createTurnToolCancellationController,
165
+ TurnSandboxCommandCancelledError,
166
+ wrapCapabilityToolsForTurnCancellation,
167
+ type TurnSandboxCommandArgs,
168
+ type TurnSandboxCommandSession,
169
+ type TurnToolCancellationFence,
170
+ } from "./sandbox/turn-tool-cancellation";
136
171
  import { computerUse, type ComputerToolMode } from "./sandbox-computer";
137
172
  import type { RuntimeMetricsHooks } from "./metrics";
173
+ export {
174
+ getSkillLibraryEntry,
175
+ isSkillLibraryEntryId,
176
+ listSkillLibraryEntries,
177
+ loadSkillLibrarySkill,
178
+ type SkillLibraryEntry,
179
+ type SkillLibraryFile,
180
+ type SkillLibrarySkill,
181
+ } from "./skill-library";
138
182
 
139
183
  export type { RuntimeMetricsHooks } from "./metrics";
184
+ export type { TurnToolCancellationFence } from "./sandbox/turn-tool-cancellation";
140
185
 
141
186
  // P4.3 computer-use surface (the agent's :0 driver). Re-exported from the barrel
142
187
  // so callers (the worker, live proofs) reach SandboxComputer/ComputerUseCapability
@@ -188,17 +233,25 @@ export { OpenAIChatCompletionsModel, OpenAIResponsesModel } from "@openai/agents
188
233
 
189
234
  export {
190
235
  CompactionNeededError,
236
+ CompactionProviderResponseError,
237
+ EmptyCompactionSummaryError,
191
238
  buildCompactionPromptInput,
192
239
  buildCompactionReplacementHistory,
240
+ compactionReplacementFingerprint,
193
241
  compactionThresholdTokens,
194
242
  clampCompactionThresholdRatio,
195
243
  decideCompaction,
196
244
  buildSummaryItem,
197
245
  findCompactionNeededError,
198
246
  isCompactionSummary,
247
+ latestCompactionReplacementFingerprint,
248
+ prepareCompactionPromptInput,
199
249
  isUserMessage,
200
250
  estimateTokens,
201
251
  estimateItemTokens,
252
+ estimateCompleteModelInput,
253
+ estimateSerializedValueTokens,
254
+ hasModelGeneratedItem,
202
255
  renderCompactionPromptInputForChat,
203
256
  COMPACTION_SUMMARY_MARKER,
204
257
  COMPACTION_PROMPT,
@@ -211,7 +264,11 @@ export {
211
264
  isEphemeralInternalContext,
212
265
  USER_MESSAGE_TRUNCATION_MARKER,
213
266
  } from "./context-compaction";
214
- export type { CompactionDecision, CompactionItem } from "./context-compaction";
267
+ export type {
268
+ CompactionDecision,
269
+ CompactionItem,
270
+ PreparedCompactionPromptInput,
271
+ } from "./context-compaction";
215
272
  export { modelCallUsageTelemetry } from "./usage-telemetry";
216
273
  export type { ModelCallUsageTelemetry } from "./usage-telemetry";
217
274
 
@@ -249,14 +306,21 @@ export type ResolveConnectionCredentialInput = {
249
306
  };
250
307
 
251
308
  export type ResolveConnectionCredentialResult =
252
- | { status: "ok"; headers: Record<string, string>; connectionId: string; expiresAt?: Date | null }
309
+ | {
310
+ status: "ok";
311
+ headers: Record<string, string>;
312
+ connectionId: string;
313
+ expiresAt?: Date | null;
314
+ }
253
315
  | {
254
316
  status: "auth_needed";
255
317
  reason: ToolAuthNeededPayload["reason"];
256
318
  providerDomain: string;
319
+ provider?: string;
257
320
  connectionId?: string;
258
321
  scopes?: string[];
259
322
  resource?: string;
323
+ selectedResources?: McpServerConnectionRef["selectedResources"];
260
324
  authorizationUrl?: string;
261
325
  };
262
326
 
@@ -311,6 +375,11 @@ export type AgentSegmentInput =
311
375
  approvalId: string;
312
376
  decision: "approve" | "reject";
313
377
  message?: string;
378
+ }
379
+ | {
380
+ kind: "human_input";
381
+ serializedRunState: string;
382
+ toolCallId: string;
314
383
  };
315
384
 
316
385
  export type PreparedAgentInput = {
@@ -318,6 +387,13 @@ export type PreparedAgentInput = {
318
387
  sandboxSessionState?: SandboxSessionState;
319
388
  };
320
389
 
390
+ export const HUMAN_INPUT_TOOL_NAME = "request_human_input";
391
+
392
+ export type SerializedHumanInputInterruption = {
393
+ toolCallId: string;
394
+ input: ReturnType<typeof RequestHumanInputToolInput.parse>;
395
+ };
396
+
321
397
  export type SandboxFileDownload = {
322
398
  fileId: string;
323
399
  mountPath: string;
@@ -376,6 +452,7 @@ export type OpenGeniRuntime = {
376
452
  options?: RunAgentStreamOptions,
377
453
  ) => Promise<Awaited<ReturnType<typeof runAgentStream>>>;
378
454
  serializeApprovals: (interruptions: unknown[]) => unknown[];
455
+ serializeHumanInputRequests?: (interruptions: unknown[]) => SerializedHumanInputInterruption[];
379
456
  };
380
457
 
381
458
  export type ProductionRuntimeOverrides = {
@@ -411,6 +488,7 @@ export function createProductionAgentRuntime(
411
488
  sandboxClient: overrides.sandboxClient,
412
489
  }),
413
490
  serializeApprovals,
491
+ serializeHumanInputRequests,
414
492
  };
415
493
  }
416
494
 
@@ -799,19 +877,28 @@ export async function summarizeForCompaction(
799
877
  const maxTokens = options.maxOutputTokens ?? SUMMARY_BUFFER_TOKENS;
800
878
  if (api === "chat") {
801
879
  const transcript = renderCompactionPromptInputForChat(input);
802
- const completion = await client.chat.completions.create({
803
- model,
804
- max_tokens: maxTokens,
805
- messages: [{ role: "user", content: transcript }],
806
- ...(options.promptCacheKey ? { prompt_cache_key: options.promptCacheKey } : {}),
807
- } as any);
880
+ let completion: unknown;
881
+ try {
882
+ completion = await client.chat.completions.create({
883
+ model,
884
+ max_tokens: maxTokens,
885
+ messages: [{ role: "user", content: transcript }],
886
+ ...(options.promptCacheKey ? { prompt_cache_key: options.promptCacheKey } : {}),
887
+ } as any);
888
+ } catch (error) {
889
+ throw new CompactionProviderResponseError(compactionProviderFailureDiagnostics(error), error);
890
+ }
808
891
  const usage = modelResponseUsageFromResponse(completion);
809
892
  if (usage) {
810
893
  await options.onUsage?.(usage);
811
894
  }
812
895
  const text = (completion as { choices?: Array<{ message?: { content?: unknown } }> })
813
896
  .choices?.[0]?.message?.content;
814
- return typeof text === "string" ? text.trim() : "";
897
+ const summary = typeof text === "string" ? text.trim() : "";
898
+ if (!summary) {
899
+ throw new EmptyCompactionSummaryError(compactionResponseDiagnostics(completion, summary));
900
+ }
901
+ return summary;
815
902
  }
816
903
  // Use the same SDK Responses adapter as the real agent call. It converts the
817
904
  // structured AgentInputItems (callId/providerData/etc.) to provider wire
@@ -834,12 +921,205 @@ export async function summarizeForCompaction(
834
921
  handoffs: [],
835
922
  tracing: false,
836
923
  };
837
- const response = await new CompactionResponsesModel(client, model).fetchResponse(request);
924
+ let response: unknown;
925
+ try {
926
+ response = await new CompactionResponsesModel(client, model).fetchResponse(request);
927
+ } catch (error) {
928
+ throw new CompactionProviderResponseError(compactionProviderFailureDiagnostics(error), error);
929
+ }
838
930
  const usage = modelResponseUsageFromResponse(response);
839
931
  if (usage) {
840
932
  await options.onUsage?.(usage);
841
933
  }
842
- return extractResponseOutputText(response).trim();
934
+ if (isFailedCompactionProviderResponse(response)) {
935
+ throw new CompactionProviderResponseError(compactionProviderFailureDiagnostics(response));
936
+ }
937
+ const summary = extractResponseOutputText(response).trim();
938
+ if (!summary) {
939
+ throw new EmptyCompactionSummaryError(compactionResponseDiagnostics(response, summary));
940
+ }
941
+ return summary;
942
+ }
943
+
944
+ function isFailedCompactionProviderResponse(response: unknown): boolean {
945
+ if (!response || typeof response !== "object") return false;
946
+ const record = response as Record<string, unknown>;
947
+ return (
948
+ record.status === "failed" ||
949
+ record.status === "incomplete" ||
950
+ (record.error !== null && record.error !== undefined)
951
+ );
952
+ }
953
+
954
+ /** Bounded provider diagnostics: identifiers and classifications, never messages or model data. */
955
+ export function compactionProviderFailureDiagnostics(error: unknown): Record<string, unknown> {
956
+ let current = error;
957
+ let errorName: string | null = null;
958
+ let httpStatus: number | null = null;
959
+ let responseStatus: string | null = null;
960
+ let responseId: string | null = null;
961
+ let code: string | null = null;
962
+ let type: string | null = null;
963
+ let requestId: string | null = null;
964
+ let eventType: string | null = null;
965
+ const seen = new Set<object>();
966
+ for (let depth = 0; depth < 6 && current && typeof current === "object"; depth += 1) {
967
+ if (seen.has(current)) break;
968
+ seen.add(current);
969
+ const record = current as Record<string, unknown>;
970
+ if (!errorName && current instanceof Error) {
971
+ errorName = boundCompactionDiagnosticField(current.name);
972
+ }
973
+ if (
974
+ httpStatus === null &&
975
+ typeof record.status === "number" &&
976
+ Number.isFinite(record.status)
977
+ ) {
978
+ httpStatus = record.status;
979
+ }
980
+ if (responseStatus === null && typeof record.status === "string") {
981
+ responseStatus = boundCompactionDiagnosticField(record.status);
982
+ }
983
+ const directResponseStatus = record.response_status ?? record.responseStatus;
984
+ if (responseStatus === null && typeof directResponseStatus === "string") {
985
+ responseStatus = boundCompactionDiagnosticField(directResponseStatus);
986
+ }
987
+ const directResponseId = record.response_id ?? record.responseId ?? record.id;
988
+ if (responseId === null && typeof directResponseId === "string") {
989
+ responseId = boundCompactionDiagnosticField(directResponseId);
990
+ }
991
+ if (code === null && typeof record.code === "string") {
992
+ code = boundCompactionDiagnosticField(record.code);
993
+ }
994
+ if (type === null && typeof record.type === "string") {
995
+ type = boundCompactionDiagnosticField(record.type);
996
+ }
997
+ const directEventType = record.event_type ?? record.eventType;
998
+ if (eventType === null && typeof directEventType === "string") {
999
+ eventType = boundCompactionDiagnosticField(directEventType);
1000
+ }
1001
+ if (requestId === null) {
1002
+ const directRequestId = record.request_id ?? record.requestId ?? record._request_id;
1003
+ if (typeof directRequestId === "string") {
1004
+ requestId = boundCompactionDiagnosticField(directRequestId);
1005
+ }
1006
+ const headers = record.headers;
1007
+ if (!requestId && headers && typeof headers === "object") {
1008
+ const get = (headers as { get?: unknown }).get;
1009
+ if (typeof get === "function") {
1010
+ const headerId = get.call(headers, "x-request-id");
1011
+ if (typeof headerId === "string") {
1012
+ requestId = boundCompactionDiagnosticField(headerId);
1013
+ }
1014
+ }
1015
+ }
1016
+ }
1017
+ const nestedError = record.error;
1018
+ if (nestedError && typeof nestedError === "object" && !seen.has(nestedError)) {
1019
+ const nested = nestedError as Record<string, unknown>;
1020
+ if (code === null && typeof nested.code === "string") {
1021
+ code = boundCompactionDiagnosticField(nested.code);
1022
+ }
1023
+ if (type === null && typeof nested.type === "string") {
1024
+ type = boundCompactionDiagnosticField(nested.type);
1025
+ }
1026
+ const nestedResponseStatus = nested.response_status ?? nested.responseStatus;
1027
+ if (responseStatus === null && typeof nestedResponseStatus === "string") {
1028
+ responseStatus = boundCompactionDiagnosticField(nestedResponseStatus);
1029
+ }
1030
+ const nestedResponseId = nested.response_id ?? nested.responseId ?? nested.id;
1031
+ if (responseId === null && typeof nestedResponseId === "string") {
1032
+ responseId = boundCompactionDiagnosticField(nestedResponseId);
1033
+ }
1034
+ const nestedEventType = nested.event_type ?? nested.eventType;
1035
+ if (eventType === null && typeof nestedEventType === "string") {
1036
+ eventType = boundCompactionDiagnosticField(nestedEventType);
1037
+ }
1038
+ }
1039
+ current = record.cause;
1040
+ }
1041
+ return {
1042
+ errorName,
1043
+ httpStatus,
1044
+ responseStatus,
1045
+ responseId,
1046
+ code,
1047
+ type,
1048
+ requestId,
1049
+ ...(eventType ? { eventType } : {}),
1050
+ };
1051
+ }
1052
+
1053
+ const COMPACTION_DIAGNOSTIC_FIELD_MAX_BYTES = 256;
1054
+ const COMPACTION_DIAGNOSTIC_TRUNCATION_MARKER = "…[truncated]";
1055
+
1056
+ function boundCompactionDiagnosticField(value: string): string {
1057
+ const bytes = Buffer.from(value, "utf8");
1058
+ if (bytes.length <= COMPACTION_DIAGNOSTIC_FIELD_MAX_BYTES) return value;
1059
+ const markerBytes = Buffer.byteLength(COMPACTION_DIAGNOSTIC_TRUNCATION_MARKER, "utf8");
1060
+ let end = COMPACTION_DIAGNOSTIC_FIELD_MAX_BYTES - markerBytes;
1061
+ while (end > 0 && isUtf8ContinuationByte(bytes[end]!)) end -= 1;
1062
+ return `${bytes.subarray(0, end).toString("utf8")}${COMPACTION_DIAGNOSTIC_TRUNCATION_MARKER}`;
1063
+ }
1064
+
1065
+ function isUtf8ContinuationByte(value: number): boolean {
1066
+ return (value & 0xc0) === 0x80;
1067
+ }
1068
+
1069
+ /** Bounded, content-free diagnostics for a semantically empty checkpoint. */
1070
+ export function compactionResponseDiagnostics(
1071
+ response: unknown,
1072
+ extractedText = extractResponseOutputText(response).trim(),
1073
+ ): Record<string, unknown> {
1074
+ if (!response || typeof response !== "object") {
1075
+ return { responseShape: typeof response, extractedTextLength: extractedText.length };
1076
+ }
1077
+ const record = response as Record<string, unknown>;
1078
+ const output = Array.isArray(record.output) ? record.output : [];
1079
+ const outputItems = output.slice(0, 50).map((item) => {
1080
+ if (!item || typeof item !== "object") return { type: typeof item };
1081
+ const value = item as Record<string, unknown>;
1082
+ const content = Array.isArray(value.content) ? value.content : [];
1083
+ return {
1084
+ type: typeof value.type === "string" ? value.type : null,
1085
+ role: typeof value.role === "string" ? value.role : null,
1086
+ status: typeof value.status === "string" ? value.status : null,
1087
+ contentPartTypes: content
1088
+ .slice(0, 50)
1089
+ .map((part) =>
1090
+ part && typeof part === "object" && typeof (part as { type?: unknown }).type === "string"
1091
+ ? (part as { type: string }).type
1092
+ : typeof part,
1093
+ ),
1094
+ contentPartCount: content.length,
1095
+ };
1096
+ });
1097
+ const choices = Array.isArray(record.choices) ? record.choices : [];
1098
+ const usage = modelResponseUsageFromResponse(response)?.usage;
1099
+ const incomplete =
1100
+ record.incomplete_details && typeof record.incomplete_details === "object"
1101
+ ? (record.incomplete_details as Record<string, unknown>)
1102
+ : null;
1103
+ return {
1104
+ responseId: typeof record.id === "string" ? record.id : null,
1105
+ status: typeof record.status === "string" ? record.status : null,
1106
+ outputItemCount: output.length,
1107
+ outputItems,
1108
+ choiceCount: choices.length,
1109
+ finishReasons: choices
1110
+ .slice(0, 20)
1111
+ .map((choice) =>
1112
+ choice && typeof choice === "object"
1113
+ ? ((choice as Record<string, unknown>).finish_reason ?? null)
1114
+ : null,
1115
+ ),
1116
+ incompleteReason:
1117
+ incomplete && typeof incomplete.reason === "string" ? incomplete.reason : null,
1118
+ inputTokens: usage?.inputTokens ?? null,
1119
+ outputTokens: usage?.outputTokens ?? null,
1120
+ totalTokens: usage?.totalTokens ?? null,
1121
+ extractedTextLength: extractedText.length,
1122
+ };
843
1123
  }
844
1124
 
845
1125
  /**
@@ -904,10 +1184,49 @@ class CompactionResponsesModel extends OpenAIResponsesModel {
904
1184
  }
905
1185
 
906
1186
  export type GitTokenSeeds = Partial<Record<GitCredentialProvider, string>>;
1187
+ export type GitCredentialBindingSeed = {
1188
+ credentialBindingId: string;
1189
+ provider: GitCredentialProvider;
1190
+ token: string;
1191
+ expiresAt?: string;
1192
+ /** Total active bindings for this provider, used to suppress unsafe aliases. */
1193
+ providerBindingCount?: number;
1194
+ };
907
1195
  export type GitCredentialTokenWriterSession = SandboxSessionLike;
1196
+ export type ToolspaceTokenWriterSession = SandboxSessionLike;
1197
+
1198
+ export type EffectiveSkillSelection = Readonly<{
1199
+ id: string;
1200
+ name: string;
1201
+ source: "bundled" | "library" | "pack";
1202
+ version: string | null;
1203
+ contentSha256: string | null;
1204
+ reason: string;
1205
+ }>;
1206
+
1207
+ const agentSkillSelections = new WeakMap<object, readonly EffectiveSkillSelection[]>();
1208
+ const emptySkillSelections: readonly EffectiveSkillSelection[] = Object.freeze([]);
1209
+
1210
+ /**
1211
+ * Read-only, secret-free skill provenance for an already-built agent. This is
1212
+ * intentionally separate from the model instructions and sandbox manifest so
1213
+ * effective configuration inspection cannot accidentally expose credentials or
1214
+ * turn skill activation into an authorization change.
1215
+ */
1216
+ export function effectiveSkillSelectionsForAgent(
1217
+ agent: object,
1218
+ ): readonly EffectiveSkillSelection[] {
1219
+ return agentSkillSelections.get(agent) ?? emptySkillSelections;
1220
+ }
908
1221
 
909
1222
  export type BuildAgentOptions = {
910
1223
  model?: Model;
1224
+ /** Settled response for the one internal human-input interruption resumed by this run. */
1225
+ humanInputResponse?: {
1226
+ requestId: string;
1227
+ toolCallId: string;
1228
+ response: HumanInputResponse;
1229
+ };
911
1230
  reasoningEffort?: ReasoningEffort;
912
1231
  // Per-turn gating overrides for the multi-provider path. Each defaults to
913
1232
  // today's settings-derived behaviour when omitted, so the legacy
@@ -1003,11 +1322,18 @@ export type BuildAgentOptions = {
1003
1322
  // repository-clone hooks; runStream forwards them into the hook context, which
1004
1323
  // seeds provider token FILES before the clone/setup runs.
1005
1324
  gitTokenSeeds?: GitTokenSeeds;
1325
+ // Provider-neutral, independently mintable credentials. Binding ids remain
1326
+ // off-manifest and are hashed before they influence sandbox paths.
1327
+ gitCredentialBindings?: GitCredentialBindingSeed[];
1006
1328
  // TOOLSPACE: the run-scoped delegated token to seed into
1007
1329
  // $OPENGENI_TOOLSPACE_TOKEN_FILE. Like gitTokenSeed, this stays off the
1008
1330
  // manifest/env delta and is written into the sandbox filesystem by a lifecycle
1009
1331
  // hook before the agent starts.
1010
1332
  toolspaceTokenSeed?: string;
1333
+ // Durable OpenGeni session identity used only to derive the off-manifest,
1334
+ // per-session token file. Required together with toolspaceTokenSeed so two
1335
+ // sessions sharing one box never overwrite the same pointer.
1336
+ toolspaceTokenSessionId?: string;
1011
1337
  // Genesis turn only: inject a one-shot instruction into the FIRST model
1012
1338
  // call telling it to title the session via opengeni__set_session_title.
1013
1339
  // Keeping this out of the persistent Agent.instructions prevents every
@@ -1035,10 +1361,32 @@ export type BuildAgentOptions = {
1035
1361
  // timeline message. Omitted ⇒ the composed instructions are byte-identical to
1036
1362
  // a workspace-only persona.
1037
1363
  sessionInstructions?: string;
1364
+ // Host context for this exact accepted turn. Composed system-level after the
1365
+ // durable session persona and omitted from all later turns.
1366
+ turnInstructions?: string;
1038
1367
  // Skills delivered by enabled capability packs. They join the bundled
1039
1368
  // skills in the sandbox skill index (mounted under .agents/) so
1040
1369
  // skills/<name> references resolve like any other indexed skill.
1041
1370
  packSkills?: PackSkill[];
1371
+ // Explicitly selected, immutable curated-library skill content. These are
1372
+ // separate from packSkills so repository-local/pack compatibility does not
1373
+ // turn a curated entry into a mutable override.
1374
+ skillLibrarySkills?: PackSkill[];
1375
+ // Secret-free provenance for effective-configuration inspection. The worker
1376
+ // resolves exact ids/versions/hashes before constructing the agent.
1377
+ skillLibrarySelections?: EffectiveSkillSelection[];
1378
+ /**
1379
+ * Internal per-attempt cancellation boundary. The worker supplies Temporal's
1380
+ * signal so an in-flight shell process is interrupted immediately instead of
1381
+ * holding Steer/Pause behind its requested yield or natural exit.
1382
+ */
1383
+ turnCancellationSignal?: AbortSignal;
1384
+ /**
1385
+ * Receives the physical sandbox-tool fence at agent construction time. A
1386
+ * cancelled/fenced worker attempt must await it before publishing its durable
1387
+ * attempt-quiesced receipt.
1388
+ */
1389
+ onToolCancellationFence?: (fence: TurnToolCancellationFence) => void;
1042
1390
  };
1043
1391
 
1044
1392
  export type PackSkillFile = {
@@ -1070,7 +1418,6 @@ export type WorkspaceEnvironmentContext = {
1070
1418
 
1071
1419
  export type PersistentSessionSettings = {
1072
1420
  titleIsSet: boolean;
1073
- childNotificationsMode?: "digest" | "passive";
1074
1421
  };
1075
1422
 
1076
1423
  /**
@@ -1167,6 +1514,12 @@ export function appendSessionInstructions(composed: string, sessionInstructions?
1167
1514
  return trimmed ? `${composed} ${trimmed}` : composed;
1168
1515
  }
1169
1516
 
1517
+ /** Append system instructions that apply to this exact turn only. */
1518
+ export function appendTurnInstructions(composed: string, turnInstructions?: string): string {
1519
+ const trimmed = turnInstructions?.trim();
1520
+ return trimmed ? `${composed} ${trimmed}` : composed;
1521
+ }
1522
+
1170
1523
  /**
1171
1524
  * Append the durable session metadata the model otherwise cannot observe.
1172
1525
  * This is deliberately declarative and excludes the title text: the title is
@@ -1181,13 +1534,7 @@ export function appendPersistentSessionSettings(
1181
1534
  if (!settings) {
1182
1535
  return composed;
1183
1536
  }
1184
- const childState = settings.childNotificationsMode
1185
- ? `; spawned-worker completion notifications are ${settings.childNotificationsMode}`
1186
- : "";
1187
- const persistentTools = settings.childNotificationsMode
1188
- ? "opengeni__set_session_title or opengeni__set_child_notifications_mode"
1189
- : "opengeni__set_session_title";
1190
- return `${composed} Persistent session settings already in effect: the display title is ${settings.titleIsSet ? "set" : "not set"}${childState}. These settings persist across turns, continuations, and interruptions. Do not call ${persistentTools} merely to reassert an unchanged value; call them only when the current value actually needs to change.`;
1537
+ return `${composed} Persistent session settings already in effect: the display title is ${settings.titleIsSet ? "set" : "not set"}. This setting persists across turns, continuations, and interruptions. Do not call opengeni__set_session_title merely to reassert an unchanged value; call it only when the current value actually needs to change.`;
1191
1538
  }
1192
1539
 
1193
1540
  /**
@@ -1241,7 +1588,9 @@ const agentRepositoryCloneHooks = new WeakMap<object, SandboxLifecycleHook[]>();
1241
1588
  // provided-session env; runStream reads them to build the clone hook context.
1242
1589
  // Absent when no brokered repo is attached / on the selfhosted path.
1243
1590
  const agentGitTokenSeeds = new WeakMap<object, GitTokenSeeds>();
1591
+ const agentGitCredentialBindings = new WeakMap<object, GitCredentialBindingSeed[]>();
1244
1592
  const agentToolspaceTokenSeed = new WeakMap<object, string>();
1593
+ const agentToolspaceTokenSessionId = new WeakMap<object, string>();
1245
1594
  // A genesis directive is consumed by runAgentStream exactly once for the
1246
1595
  // freshly-built agent. It must not remain in Agent.instructions: those
1247
1596
  // instructions are presented again on every internal model/tool loop.
@@ -1310,6 +1659,9 @@ export function buildOpenGeniAgent(
1310
1659
  resources: ResourceRef[],
1311
1660
  options: BuildAgentOptions = {},
1312
1661
  ): Agent<any, any> {
1662
+ if (Boolean(options.toolspaceTokenSeed) !== Boolean(options.toolspaceTokenSessionId)) {
1663
+ throw new Error("toolspaceTokenSeed and toolspaceTokenSessionId must be supplied together");
1664
+ }
1313
1665
  // Resolved per-turn gating. Each override defaults to today's settings-derived
1314
1666
  // behaviour, so the legacy global-client callers (no resolved model) build the
1315
1667
  // exact same agent as before; the multi-provider worker path passes the
@@ -1331,6 +1683,28 @@ export function buildOpenGeniAgent(
1331
1683
  // [...agent.tools, ...capability.tools()]), so hosted web_search coexists with
1332
1684
  // both rather than overriding them.
1333
1685
  const hostedTools = hostedWebSearch ? [webSearchTool()] : [];
1686
+ const humanInputTool = agentTool({
1687
+ name: HUMAN_INPUT_TOOL_NAME,
1688
+ description:
1689
+ "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.",
1690
+ parameters: RequestHumanInputToolInput,
1691
+ needsApproval: true,
1692
+ // A missing/mismatched durable response is a protocol integrity failure,
1693
+ // not model-visible tool output the agent may reason past.
1694
+ errorFunction: null,
1695
+ execute: (_input, _context, details) => {
1696
+ const settled = options.humanInputResponse;
1697
+ if (!settled) {
1698
+ throw new Error("Human-input tool resumed without a durable response");
1699
+ }
1700
+ const resumedCallId = details?.toolCall?.callId;
1701
+ if (resumedCallId && resumedCallId !== settled.toolCallId) {
1702
+ throw new Error("Human-input response does not belong to the resumed tool call");
1703
+ }
1704
+ return JSON.stringify({ requestId: settled.requestId, ...settled.response });
1705
+ },
1706
+ });
1707
+ const agentTools = [...hostedTools, humanInputTool];
1334
1708
  const baseConfig = {
1335
1709
  name: "OpenGeni Agent",
1336
1710
  model: options.model ?? settings.openaiModel,
@@ -1353,24 +1727,28 @@ export function buildOpenGeniAgent(
1353
1727
  // and the worker resolved a nonblank block — appendWorkspaceMemory,
1354
1728
  // 4. + the per-session persona instructions (session-specific, so it
1355
1729
  // refines both the workspace persona and the substrate note),
1356
- // 5. + durable session-setting state (title present + child notification
1730
+ // 5. + host context for this exact turn, when supplied,
1731
+ // 6. + durable session-setting state (title present + child notification
1357
1732
  // mode), when supplied by the worker,
1358
1733
  // The genesis title directive is deliberately NOT part of this persistent
1359
1734
  // string. runAgentStream injects it into the first model call only.
1360
1735
  instructions: appendPersistentSessionSettings(
1361
- appendSessionInstructions(
1362
- appendWorkspaceMemory(
1363
- appendToolspaceInstructions(
1364
- composeAgentInstructions(
1365
- options.instructionsTemplate ?? settings.agentInstructionsTemplate,
1366
- options.workspaceEnvironment,
1367
- options.rig,
1736
+ appendTurnInstructions(
1737
+ appendSessionInstructions(
1738
+ appendWorkspaceMemory(
1739
+ appendToolspaceInstructions(
1740
+ composeAgentInstructions(
1741
+ options.instructionsTemplate ?? settings.agentInstructionsTemplate,
1742
+ options.workspaceEnvironment,
1743
+ options.rig,
1744
+ ),
1745
+ settings.toolspaceEnabled && Boolean(options.toolspaceTokenSeed),
1368
1746
  ),
1369
- settings.toolspaceEnabled && Boolean(options.toolspaceTokenSeed),
1747
+ options.workspaceMemory,
1370
1748
  ),
1371
- options.workspaceMemory,
1749
+ options.sessionInstructions,
1372
1750
  ),
1373
- options.sessionInstructions,
1751
+ options.turnInstructions,
1374
1752
  ),
1375
1753
  options.persistentSessionSettings,
1376
1754
  ),
@@ -1393,7 +1771,7 @@ export function buildOpenGeniAgent(
1393
1771
  // `new Agent(baseConfig)` path (sandboxBackend === "none") and the
1394
1772
  // `new SandboxAgent({ ...baseConfig, ... })` path via the shared baseConfig
1395
1773
  // spread; the SDK concatenates these with MCP and sandbox capability tools.
1396
- ...(hostedTools.length ? { tools: hostedTools } : {}),
1774
+ tools: agentTools,
1397
1775
  ...(options.mcpServers?.length ? { mcpServers: options.mcpServers } : {}),
1398
1776
  // Surface FAILED MCP tool calls as `{ isError: true }` tool output (see
1399
1777
  // mcpToolErrorFunction / mcpToolErrorOutput) instead of the SDK's default
@@ -1425,6 +1803,9 @@ export function buildOpenGeniAgent(
1425
1803
  ),
1426
1804
  ...(runAs ? { runAs } : {}),
1427
1805
  capabilities: buildAgentCapabilities(settings, options.packSkills ?? [], {
1806
+ ...(options.skillLibrarySkills?.length
1807
+ ? { skillLibrarySkills: options.skillLibrarySkills }
1808
+ : {}),
1428
1809
  ...(options.structuredToolTransport !== undefined
1429
1810
  ? { structuredToolTransport: options.structuredToolTransport }
1430
1811
  : {}),
@@ -1432,8 +1813,24 @@ export function buildOpenGeniAgent(
1432
1813
  ? { computerToolMode: options.computerToolMode }
1433
1814
  : {}),
1434
1815
  ...(options.onComputerUseReady ? { onComputerUseReady: options.onComputerUseReady } : {}),
1816
+ ...(options.turnCancellationSignal
1817
+ ? { turnCancellationSignal: options.turnCancellationSignal }
1818
+ : {}),
1819
+ ...(options.onToolCancellationFence
1820
+ ? { onToolCancellationFence: options.onToolCancellationFence }
1821
+ : {}),
1435
1822
  }),
1436
1823
  });
1824
+ agentSkillSelections.set(
1825
+ agent,
1826
+ Object.freeze(
1827
+ effectiveSkillSelections(
1828
+ options.skillLibrarySelections ?? [],
1829
+ options.skillLibrarySkills ?? [],
1830
+ options.packSkills ?? [],
1831
+ ).map((selection) => Object.freeze(selection)),
1832
+ ),
1833
+ );
1437
1834
  if (options.genesisTitleHint) {
1438
1835
  agentsNeedingGenesisTitleDirective.add(agent);
1439
1836
  }
@@ -1465,8 +1862,12 @@ export function buildOpenGeniAgent(
1465
1862
  if (Object.keys(gitTokenSeeds).length > 0) {
1466
1863
  agentGitTokenSeeds.set(agent, gitTokenSeeds);
1467
1864
  }
1865
+ if (options.gitCredentialBindings && options.gitCredentialBindings.length > 0) {
1866
+ agentGitCredentialBindings.set(agent, options.gitCredentialBindings);
1867
+ }
1468
1868
  if (options.toolspaceTokenSeed) {
1469
1869
  agentToolspaceTokenSeed.set(agent, options.toolspaceTokenSeed);
1870
+ agentToolspaceTokenSessionId.set(agent, options.toolspaceTokenSessionId!);
1470
1871
  }
1471
1872
  // M3: stash the rig setup descriptor + RESOLVE the rig credential hooks now.
1472
1873
  // sandboxLifecycleHooksForIds throws on an unknown hook name, so a typo'd rig
@@ -1511,7 +1912,10 @@ function mcpToolRequiresApproval(policy: boolean | string[], unprefixedName: str
1511
1912
  }
1512
1913
 
1513
1914
  /** A per-server approval policy keyed by the server's `<id>__` tool prefix. */
1514
- type McpApprovalPolicy = { prefix: string; requireApproval: boolean | string[] };
1915
+ type McpApprovalPolicy = {
1916
+ prefix: string;
1917
+ requireApproval: boolean | string[];
1918
+ };
1515
1919
 
1516
1920
  /** The subset of the agent surface the approval wrap needs — including `clone`. */
1517
1921
  type ApprovalCapableAgent = {
@@ -1720,6 +2124,7 @@ export function buildAgentCapabilities(
1720
2124
  settings: Settings,
1721
2125
  packSkills: PackSkill[],
1722
2126
  options: {
2127
+ skillLibrarySkills?: PackSkill[];
1723
2128
  structuredToolTransport?: boolean;
1724
2129
  // EXPLICIT computer-use transport (see BuildAgentOptions.computerToolMode). When
1725
2130
  // present, computerUse() is handed the mode directly and its tools() obeys it
@@ -1727,8 +2132,15 @@ export function buildAgentCapabilities(
1727
2132
  // imageFunctionResults path (driven by structuredToolTransport) is unchanged.
1728
2133
  computerToolMode?: ComputerToolMode;
1729
2134
  onComputerUseReady?: (session: SandboxSessionLike) => Promise<void>;
2135
+ turnCancellationSignal?: AbortSignal;
2136
+ onToolCancellationFence?: (fence: TurnToolCancellationFence) => void;
1730
2137
  } = {},
1731
2138
  ): ReturnType<typeof Capabilities.default> {
2139
+ const toolCancellation =
2140
+ options.turnCancellationSignal || options.onToolCancellationFence
2141
+ ? createTurnToolCancellationController(options.turnCancellationSignal)
2142
+ : null;
2143
+ if (toolCancellation) options.onToolCancellationFence?.(toolCancellation);
1732
2144
  // The `filesystem()` capability picks hosted-vs-function tool variants from the
1733
2145
  // bound model instance (supportsApplyPatchTransport / structured tool output).
1734
2146
  // When the caller declares the backend does NOT support that structured/hosted
@@ -1747,9 +2159,13 @@ export function buildAgentCapabilities(
1747
2159
  }
1748
2160
  const caps: ReturnType<typeof Capabilities.default> = [
1749
2161
  filesystemCapability,
1750
- shell({ configureTools: withExecOpCorrelation }),
2162
+ shell({ ...(toolCancellation ? {} : { configureTools: withExecOpCorrelation }) }),
1751
2163
  ];
1752
- caps.push(skills({ lazyFrom: lazySkillSourceWithPackSkills(packSkills) }));
2164
+ caps.push(
2165
+ skills({
2166
+ lazyFrom: lazySkillSourceWithPackSkills(packSkills, options.skillLibrarySkills ?? []),
2167
+ }),
2168
+ );
1753
2169
  // P4.3 computer-use: the agent drives the SAME :0 humans watch (xdotool/XTEST +
1754
2170
  // scrot), but only when the desktop tier is ON, computer-use is enabled, and the
1755
2171
  // backend is one whose image carries the X stack (descriptorgate — honest about
@@ -1801,6 +2217,14 @@ export function buildAgentCapabilities(
1801
2217
  }
1802
2218
  caps.push(computerCapability as unknown as ReturnType<typeof Capabilities.default>[number]);
1803
2219
  }
2220
+ if (toolCancellation) {
2221
+ for (const capability of caps) {
2222
+ wrapCapabilityToolsForTurnCancellation(
2223
+ capability as unknown as { tools(): Tool<unknown>[] },
2224
+ toolCancellation,
2225
+ );
2226
+ }
2227
+ }
1804
2228
  return caps;
1805
2229
  }
1806
2230
 
@@ -1829,6 +2253,9 @@ export type PrepareToolsOptions = {
1829
2253
  // The calling turn's id, signed into the token so tools can classify the
1830
2254
  // caller from its own identity instead of the session's live active pointer.
1831
2255
  turnId?: string;
2256
+ // The exact executing attempt that owns the MCP call.
2257
+ attemptId?: string;
2258
+ executionGeneration?: number;
1832
2259
  subjectId?: string;
1833
2260
  subjectLabel?: string;
1834
2261
  // Overrides the fixed first-party MCP permission set for this session's
@@ -2019,9 +2446,13 @@ function connectionBrokerFetch(
2019
2446
  status: "auth_needed",
2020
2447
  reason: "expired",
2021
2448
  providerDomain: connectionRef.providerDomain,
2449
+ ...(connectionRef.provider ? { provider: connectionRef.provider } : {}),
2022
2450
  connectionId: refreshed.connectionId,
2023
2451
  ...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
2024
2452
  ...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
2453
+ ...(connectionRef.selectedResources
2454
+ ? { selectedResources: connectionRef.selectedResources }
2455
+ : {}),
2025
2456
  },
2026
2457
  connectionRef,
2027
2458
  );
@@ -2050,9 +2481,13 @@ async function resolveConnectionForRequest(
2050
2481
  status: "auth_needed",
2051
2482
  reason: "missing_connection",
2052
2483
  providerDomain: connectionRef.providerDomain,
2484
+ ...(connectionRef.provider ? { provider: connectionRef.provider } : {}),
2053
2485
  ...(connectionRef.connectionId ? { connectionId: connectionRef.connectionId } : {}),
2054
2486
  ...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
2055
2487
  ...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
2488
+ ...(connectionRef.selectedResources
2489
+ ? { selectedResources: connectionRef.selectedResources }
2490
+ : {}),
2056
2491
  };
2057
2492
  }
2058
2493
  const request: ResolveConnectionCredentialInput = {
@@ -2070,9 +2505,13 @@ async function resolveConnectionForRequest(
2070
2505
  status: "auth_needed",
2071
2506
  reason: "refresh_failed",
2072
2507
  providerDomain: connectionRef.providerDomain,
2508
+ ...(connectionRef.provider ? { provider: connectionRef.provider } : {}),
2073
2509
  ...(connectionRef.connectionId ? { connectionId: connectionRef.connectionId } : {}),
2074
2510
  ...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
2075
2511
  ...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
2512
+ ...(connectionRef.selectedResources
2513
+ ? { selectedResources: connectionRef.selectedResources }
2514
+ : {}),
2076
2515
  };
2077
2516
  }
2078
2517
  }
@@ -2090,6 +2529,7 @@ function insufficientScopeAuth(
2090
2529
  status: "auth_needed",
2091
2530
  reason: "insufficient_scope",
2092
2531
  providerDomain: connectionRef.providerDomain,
2532
+ ...(connectionRef.provider ? { provider: connectionRef.provider } : {}),
2093
2533
  connectionId,
2094
2534
  ...(challenge.scope?.length
2095
2535
  ? { scopes: challenge.scope }
@@ -2101,6 +2541,9 @@ function insufficientScopeAuth(
2101
2541
  : connectionRef.resource
2102
2542
  ? { resource: connectionRef.resource }
2103
2543
  : {}),
2544
+ ...(connectionRef.selectedResources
2545
+ ? { selectedResources: connectionRef.selectedResources }
2546
+ : {}),
2104
2547
  };
2105
2548
  }
2106
2549
 
@@ -2116,6 +2559,11 @@ async function authNeededFetchResponse(
2116
2559
  serverId,
2117
2560
  toolName: request.toolName ?? null,
2118
2561
  providerDomain: auth.providerDomain,
2562
+ ...(auth.provider
2563
+ ? { provider: auth.provider }
2564
+ : connectionRef.provider
2565
+ ? { provider: connectionRef.provider }
2566
+ : {}),
2119
2567
  reason: auth.reason,
2120
2568
  ...(connectionId ? { connectionId } : {}),
2121
2569
  ...(auth.scopes
@@ -2128,13 +2576,20 @@ async function authNeededFetchResponse(
2128
2576
  : connectionRef.resource
2129
2577
  ? { resource: connectionRef.resource }
2130
2578
  : {}),
2579
+ ...(auth.selectedResources
2580
+ ? { selectedResources: auth.selectedResources }
2581
+ : connectionRef.selectedResources
2582
+ ? { selectedResources: connectionRef.selectedResources }
2583
+ : {}),
2131
2584
  ...(auth.authorizationUrl ? { authorizationUrl: auth.authorizationUrl } : {}),
2132
2585
  ...(options.subjectId ? { subjectId: options.subjectId } : {}),
2133
2586
  });
2134
2587
  if (request.method === "tools/call") {
2135
2588
  return mcpToolAuthNeededResponse(request.id);
2136
2589
  }
2137
- return new Response("Authentication required for MCP server connection", { status: 401 });
2590
+ return new Response("Authentication required for MCP server connection", {
2591
+ status: 401,
2592
+ });
2138
2593
  }
2139
2594
 
2140
2595
  async function publishAuthNeeded(
@@ -2295,7 +2750,10 @@ function mcpToolUnavailableMessage(reason: string): string {
2295
2750
  // transport error carries the raw response BODY in its `.message` (a broker
2296
2751
  // 401/403 body can echo request detail), so `.message` is never included; the
2297
2752
  // numeric `.code`/`.status` (e.g. 401) is safe and useful.
2298
- function safeMcpErrorFields(error: unknown): { errorClass: string; status?: number } {
2753
+ function safeMcpErrorFields(error: unknown): {
2754
+ errorClass: string;
2755
+ status?: number;
2756
+ } {
2299
2757
  const errorClass = error instanceof Error ? error.constructor.name : typeof error;
2300
2758
  const raw = (error as { code?: unknown; status?: unknown } | null)?.code;
2301
2759
  const status = typeof raw === "number" ? raw : undefined;
@@ -2383,6 +2841,8 @@ async function signFirstPartyDelegatedBearer(
2383
2841
  permissions: options.firstPartyPermissions ?? firstPartyMcpPermissions,
2384
2842
  ...(options.sessionId ? { sessionId: options.sessionId } : {}),
2385
2843
  ...(options.turnId ? { turnId: options.turnId } : {}),
2844
+ ...(options.attemptId ? { attemptId: options.attemptId } : {}),
2845
+ ...(options.executionGeneration ? { executionGeneration: options.executionGeneration } : {}),
2386
2846
  exp: Math.floor(Date.now() / 1000) + 60 * 60,
2387
2847
  });
2388
2848
  }
@@ -2577,6 +3037,7 @@ class PrefixedMcpServer implements MCPServer {
2577
3037
  // doesn't spam the log when the SDK re-lists across model turns.
2578
3038
  private readonly bestEffort: boolean;
2579
3039
  private loggedListToolsFailure = false;
3040
+ private listedToolSchemaTokens = 0;
2580
3041
 
2581
3042
  constructor(
2582
3043
  private readonly inner: MCPServer,
@@ -2633,9 +3094,19 @@ class PrefixedMcpServer implements MCPServer {
2633
3094
  }
2634
3095
  return [];
2635
3096
  }
2636
- return tools
3097
+ const exposed = tools
2637
3098
  .filter((tool) => this.isAllowed(tool.name))
2638
- .map((tool) => ({ ...tool, name: prefixedMcpToolName(this.name, tool.name) }));
3099
+ .map((tool) => ({
3100
+ ...tool,
3101
+ name: prefixedMcpToolName(this.name, tool.name),
3102
+ }));
3103
+ this.listedToolSchemaTokens = estimateSerializedValueTokens(exposed);
3104
+ return exposed;
3105
+ }
3106
+
3107
+ /** Latest exact tools/list projection used to build the model request. */
3108
+ modelToolSchemaTokens(): number {
3109
+ return this.listedToolSchemaTokens;
2639
3110
  }
2640
3111
 
2641
3112
  async callTool(
@@ -2657,7 +3128,10 @@ class PrefixedMcpServer implements MCPServer {
2657
3128
  // This applies to ANY server — an auth-needed is recoverable once the user
2658
3129
  // re-links, so even a required tool degrades gracefully here.
2659
3130
  if (isAuthNeededMcpError(error)) {
2660
- return { isError: true, content: [{ type: "text", text: MCP_AUTH_NEEDED_MESSAGE }] };
3131
+ return {
3132
+ isError: true,
3133
+ content: [{ type: "text", text: MCP_AUTH_NEEDED_MESSAGE }],
3134
+ };
2661
3135
  }
2662
3136
  // Best-effort INVOCATION isolation (sibling to the listTools guard). When
2663
3137
  // the model calls a best-effort server's tool and the call throws for ANY
@@ -2679,7 +3153,12 @@ class PrefixedMcpServer implements MCPServer {
2679
3153
  );
2680
3154
  return {
2681
3155
  isError: true,
2682
- content: [{ type: "text", text: mcpToolUnavailableMessage(mcpErrorReason(fields)) }],
3156
+ content: [
3157
+ {
3158
+ type: "text",
3159
+ text: mcpToolUnavailableMessage(mcpErrorReason(fields)),
3160
+ },
3161
+ ],
2683
3162
  };
2684
3163
  }
2685
3164
  throw error;
@@ -2791,22 +3270,25 @@ export async function prepareRunInput(
2791
3270
  ...(sandboxSessionState ? { sandboxSessionState } : {}),
2792
3271
  };
2793
3272
  }
2794
- // An approval can only be resumed against a real saved run state. If the
3273
+ // An interrupted tool can only be resumed against a real saved run state. If the
2795
3274
  // latest blob is the cleared sentinel the awaiting turn was wiped (the API
2796
3275
  // refuses clear in requires_action, so this is a defensive guard) — fail with
2797
3276
  // an honest message instead of the cryptic SDK "missing schema version".
2798
3277
  if (isClearedRunStateBlob(input.serializedRunState)) {
2799
3278
  throw new Error(
2800
- "Cannot resume an approval: the session context was cleared, so the awaiting run state no longer exists.",
3279
+ "Cannot resume an interrupted tool: the session context was cleared, so the awaiting run state no longer exists.",
2801
3280
  );
2802
3281
  }
2803
3282
  const state = await RunState.fromString(agent, input.serializedRunState);
2804
3283
  const interruptions = state.getInterruptions();
2805
- const target = interruptions.find((item: any) => approvalIdentifier(item) === input.approvalId);
3284
+ const interruptionId = input.kind === "human_input" ? input.toolCallId : input.approvalId;
3285
+ const target = interruptions.find((item: any) => approvalIdentifier(item) === interruptionId);
2806
3286
  if (!target) {
2807
- throw new Error(`Approval not found in saved run state: ${input.approvalId}`);
3287
+ throw new Error(`Interrupted tool not found in saved run state: ${interruptionId}`);
2808
3288
  }
2809
- if (input.decision === "approve") {
3289
+ if (input.kind === "human_input") {
3290
+ state.approve(target as any);
3291
+ } else if (input.decision === "approve") {
2810
3292
  state.approve(target as any);
2811
3293
  } else {
2812
3294
  state.reject(target as any, input.message ? { message: input.message } : undefined);
@@ -2815,16 +3297,26 @@ export async function prepareRunInput(
2815
3297
  }
2816
3298
 
2817
3299
  export type RunAgentStreamOptions = {
3300
+ /** Abort the provider/tool loop when the owning activity is cancelled. */
3301
+ signal?: AbortSignal;
2818
3302
  sandboxClient?: unknown;
2819
3303
  sandboxEnvironment?: Record<string, string>;
2820
3304
  onRuntimeEvent?: (event: NormalizedRuntimeEvent) => Promise<void> | void;
2821
- contextCompactionSignalTokens?: () => number | null | undefined;
3305
+ contextCompactionSignal?: () => ProviderContextTokenSignal | null | undefined;
2822
3306
  contextCompactionRequested?: () => boolean | Promise<boolean>;
2823
3307
  // Host-managed git credential renewal registration. Called only after the
2824
3308
  // initial token-file seed completed on a real provisioned box. The worker
2825
3309
  // owns the multi-day timer and uses this pinned, un-proxied session to
2826
3310
  // atomically replace token files; runtime never mints credentials itself.
2827
3311
  onGitCredentialSessionReady?: (session: GitCredentialTokenWriterSession) => Promise<void> | void;
3312
+ // OpenGeni-minted Toolspace token renewal registration. Called only after the
3313
+ // initial token file reached the real sandbox session.
3314
+ onToolspaceTokenSessionReady?: (session: ToolspaceTokenWriterSession) => Promise<void> | void;
3315
+ // Host-owned run material is seeded off-manifest before setup and every
3316
+ // agent-created process sources the active immutable generation. The worker
3317
+ // owns resolution/renewal/fencing; runtime owns sandbox transport.
3318
+ runCredentialSessionId?: string;
3319
+ onRunCredentialSessionReady?: RunCredentialSessionReady;
2828
3320
  // OWNERSHIP INVERSION (P1.2): an externally-owned, already-live sandbox
2829
3321
  // session resolved by the per-turn resume-by-id path. When present,
2830
3322
  // runAgentStream does NOT build (or resume, or discard) a client — it threads
@@ -2855,6 +3347,13 @@ export type RunAgentStreamOptions = {
2855
3347
  // after establish, so runAgentStream must not run it eagerly here.
2856
3348
  deferredSetup?: boolean;
2857
3349
  };
3350
+ /**
3351
+ * The attempt's authoritative physical sandbox-operation fence. Platform
3352
+ * lifecycle commands use its command runner too, so Steer/Pause can interrupt
3353
+ * repository clone, rig setup, file materialization, and credential seeding
3354
+ * before the attempt-quiesced receipt opens queue admission.
3355
+ */
3356
+ turnToolCancellationFence?: TurnToolCancellationFence;
2858
3357
  // A per-turn model-input filter chained AFTER the provider-item-id strip.
2859
3358
  // Used by the genesis-title injection to prepend a hidden, NON-PERSISTED
2860
3359
  // directive: a callModelInputFilter mutates only `modelData.input` for each
@@ -2864,7 +3363,7 @@ export type RunAgentStreamOptions = {
2864
3363
  };
2865
3364
 
2866
3365
  export type ContextRobustnessFilterOptions = {
2867
- contextCompactionSignalTokens?: () => number | null | undefined;
3366
+ contextCompactionSignal?: () => ProviderContextTokenSignal | null | undefined;
2868
3367
  contextCompactionRequested?: () => boolean | Promise<boolean>;
2869
3368
  throwOnCompactionNeeded?: boolean;
2870
3369
  };
@@ -2910,9 +3409,10 @@ function takeGenesisTitleInputFilter(agent: Agent<any, any>): CallModelInputFilt
2910
3409
  // Generic substrate prompting for programmatic tool calling (toolspace). Same
2911
3410
  // text for every host; gated per-turn by appendToolspaceInstructions on the
2912
3411
  // presence of a minted toolspace token, so it only appears when the sandbox
2913
- // actually exposes the ogtool CLI + $OPENGENI_TOOLSPACE_URL/_TOKEN_FILE.
3412
+ // exposes $OPENGENI_TOOLSPACE_URL/_TOKEN_FILE. Stock images carry ogtool;
3413
+ // custom environments get only an exact deployment-pinned bootstrap hint.
2914
3414
  export const TOOLSPACE_PROGRAMMATIC_DIRECTIVE =
2915
- "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.";
3415
+ "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.";
2916
3416
 
2917
3417
  /**
2918
3418
  * callModelInputFilter that removes provider-assigned item ids (rs_/msg_/fc_…)
@@ -2971,24 +3471,89 @@ export const elideSupersededViewImagesFilter: CallModelInputFilter = ({ modelDat
2971
3471
  ) as unknown as AgentInputItem[],
2972
3472
  });
2973
3473
 
3474
+ /**
3475
+ * Canonical Codex-style tool-result bound at the final model-input seam. The
3476
+ * identical pure normalizer also runs before conversation rows are persisted,
3477
+ * so this is a live-turn defense rather than a request-only alternate history.
3478
+ */
3479
+ export function boundModelToolOutputsFilterForSettings(settings: Settings): CallModelInputFilter {
3480
+ return ({ modelData }) => ({
3481
+ ...modelData,
3482
+ input: boundModelToolOutputItems(
3483
+ modelData.input as unknown as Array<Record<string, unknown>>,
3484
+ settings.modelToolOutputTruncationTokens,
3485
+ ) as unknown as AgentInputItem[],
3486
+ });
3487
+ }
3488
+
3489
+ function estimateAgentToolSchemaTokens(agent: Agent<any, any>): number {
3490
+ const localTools = Array.isArray((agent as { tools?: unknown }).tools)
3491
+ ? ((agent as { tools: unknown[] }).tools ?? [])
3492
+ : [];
3493
+ const localDescriptors = localTools.map((candidate) => {
3494
+ if (!candidate || typeof candidate !== "object") return candidate;
3495
+ const tool = candidate as Record<string, unknown>;
3496
+ return {
3497
+ type: tool.type,
3498
+ name: tool.name,
3499
+ description: tool.description,
3500
+ parameters: tool.parameters,
3501
+ inputSchema: tool.inputSchema,
3502
+ strict: tool.strict,
3503
+ providerData: tool.providerData,
3504
+ };
3505
+ });
3506
+ const mcpServers = Array.isArray((agent as { mcpServers?: unknown }).mcpServers)
3507
+ ? ((agent as { mcpServers: unknown[] }).mcpServers ?? [])
3508
+ : [];
3509
+ const mcpTokens = mcpServers.reduce<number>((total, server) => {
3510
+ const getter = (server as { modelToolSchemaTokens?: () => number } | null)
3511
+ ?.modelToolSchemaTokens;
3512
+ return total + (typeof getter === "function" ? getter.call(server) : 0);
3513
+ }, 0);
3514
+ return estimateSerializedValueTokens(localDescriptors) + mcpTokens;
3515
+ }
3516
+
2974
3517
  export function contextRobustnessFilterForSettings(
2975
3518
  settings: Settings,
2976
3519
  options: ContextRobustnessFilterOptions = {},
2977
3520
  ): CallModelInputFilter {
2978
3521
  const thresholdTokens = compactionThresholdTokens(settings);
2979
- return async ({ modelData }) => {
3522
+ let previousRequest: { revision: number; footprint: CompleteModelInputFootprint } | null = null;
3523
+ let requestRevision = 0;
3524
+ return async ({ modelData, agent }) => {
2980
3525
  const input = modelData.input;
2981
3526
  if (options.throwOnCompactionNeeded) {
2982
- const reported = options.contextCompactionSignalTokens?.();
2983
- const hasReported = typeof reported === "number" && reported > 0;
2984
- const signalTokens = hasReported
2985
- ? reported
2986
- : estimateTokens(input as unknown as Array<Record<string, unknown>>);
3527
+ const reported = options.contextCompactionSignal?.();
3528
+ const current: CompleteModelInputFootprint = {
3529
+ input: input as unknown as Array<Record<string, unknown>>,
3530
+ instructionsTokens: estimateSerializedValueTokens(modelData.instructions ?? ""),
3531
+ toolSchemaTokens: estimateAgentToolSchemaTokens(agent),
3532
+ };
3533
+ // Stream consumption can lag the SDK's background model loop. A provider
3534
+ // usage signal is safe only when its response revision belongs to the
3535
+ // immediately preceding request. Never attach a delayed response count to
3536
+ // a newer footprint: doing so can hide all model output produced between
3537
+ // them and recreate an under-counting compaction loop.
3538
+ const boundProvider =
3539
+ reported &&
3540
+ previousRequest &&
3541
+ reported.revision === previousRequest.revision &&
3542
+ hasModelGeneratedItem(current.input)
3543
+ ? reported
3544
+ : null;
3545
+ const estimate = estimateCompleteModelInput({
3546
+ current,
3547
+ provider: boundProvider,
3548
+ providerRequestFootprint: boundProvider ? (previousRequest?.footprint ?? null) : null,
3549
+ });
3550
+ const signalTokens = estimate.tokens;
3551
+ previousRequest = { revision: ++requestRevision, footprint: current };
2987
3552
  if (await options.contextCompactionRequested?.()) {
2988
3553
  throw new CompactionNeededError({
2989
3554
  signalTokens,
2990
3555
  thresholdTokens,
2991
- signalSource: hasReported ? "provider" : "estimate",
3556
+ signalSource: boundProvider ? "provider" : "estimate",
2992
3557
  trigger: "operator",
2993
3558
  });
2994
3559
  }
@@ -2996,7 +3561,7 @@ export function contextRobustnessFilterForSettings(
2996
3561
  throw new CompactionNeededError({
2997
3562
  signalTokens,
2998
3563
  thresholdTokens,
2999
- signalSource: hasReported ? "provider" : "estimate",
3564
+ signalSource: boundProvider ? "provider" : "estimate",
3000
3565
  });
3001
3566
  }
3002
3567
  }
@@ -3033,6 +3598,7 @@ export function callModelInputFilterForSettings(
3033
3598
  const filters: CallModelInputFilter[] = [
3034
3599
  normalizeComputerCallsFilter,
3035
3600
  elideSupersededViewImagesFilter,
3601
+ boundModelToolOutputsFilterForSettings(settings),
3036
3602
  ];
3037
3603
  if (settings.openaiProviderItemIds === "strip") {
3038
3604
  filters.push(stripProviderItemIdsFilter);
@@ -3050,7 +3616,11 @@ export async function runAgentStream(
3050
3616
  const prepared: PreparedAgentInput =
3051
3617
  typeof input === "string" || input instanceof RunState ? { input } : input;
3052
3618
  const environment = overrides.sandboxEnvironment ?? collectSandboxEnvironment(settings);
3619
+ const toolspaceTokenFile = toolspaceTokenFileForAgent(agent, environment);
3053
3620
  const genesisTitleInputFilter = takeGenesisTitleInputFilter(agent);
3621
+ if (overrides.onRunCredentialSessionReady && !overrides.runCredentialSessionId) {
3622
+ throw new Error("runCredentialSessionId is required when run credential setup is enabled");
3623
+ }
3054
3624
 
3055
3625
  // OWNED PATH (P1.2 ownership inversion): the per-turn resume path injected a
3056
3626
  // live, externally-owned box. We thread the live `session` straight into
@@ -3069,11 +3639,24 @@ export async function runAgentStream(
3069
3639
  // whose per-op pointer re-read could land these execs on a machine swapped in
3070
3640
  // mid-turn.
3071
3641
  const setupSession = (overrides.ownedSandbox.setupSession ?? session) as SandboxSessionLike;
3642
+ const credentialAgentSession = overrides.runCredentialSessionId
3643
+ ? withRunCredentialsSession(session as SandboxSessionLike, overrides.runCredentialSessionId)
3644
+ : (session as SandboxSessionLike);
3645
+ const agentSession = toolspaceTokenFile
3646
+ ? withToolspaceTokenSession(credentialAgentSession, toolspaceTokenFile)
3647
+ : credentialAgentSession;
3648
+ const credentialSetupSession = overrides.runCredentialSessionId
3649
+ ? withRunCredentialsSession(setupSession, overrides.runCredentialSessionId)
3650
+ : setupSession;
3651
+ const decoratedSetupSession = toolspaceTokenFile
3652
+ ? withToolspaceTokenSession(credentialSetupSession, toolspaceTokenFile)
3653
+ : credentialSetupSession;
3072
3654
  // Platform setup (manifest-env pin + beforeAgentStart hooks + file downloads)
3073
3655
  // against the UN-proxied established box — the ONE-TRUTH helper shared with the
3074
3656
  // lazy provisioner. Eager path: runs here, before the run starts (unchanged).
3075
3657
  if (!overrides.ownedSandbox.deferredSetup) {
3076
- await runOwnedSandboxSetup(agent, session as SandboxSessionLike, setupSession, {
3658
+ await overrides.onRunCredentialSessionReady?.(session as SandboxSessionLike);
3659
+ await runOwnedSandboxSetup(agent, session as SandboxSessionLike, decoratedSetupSession, {
3077
3660
  settings,
3078
3661
  environment,
3079
3662
  preparedInput: prepared,
@@ -3081,7 +3664,17 @@ export async function runAgentStream(
3081
3664
  ? { fileDownloadsMaterialized: true }
3082
3665
  : {}),
3083
3666
  ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
3667
+ ...(overrides.turnToolCancellationFence
3668
+ ? {
3669
+ commandRunner: overrides.turnToolCancellationFence.runSandboxCommand.bind(
3670
+ overrides.turnToolCancellationFence,
3671
+ ),
3672
+ }
3673
+ : {}),
3084
3674
  });
3675
+ if (toolspaceTokenSeedForAgent(agent)) {
3676
+ await overrides.onToolspaceTokenSessionReady?.(agentSession);
3677
+ }
3085
3678
  await overrides.onGitCredentialSessionReady?.(setupSession);
3086
3679
  }
3087
3680
  const runAs = sandboxRunAs(settings);
@@ -3091,11 +3684,19 @@ export async function runAgentStream(
3091
3684
  ? withSandboxFileDownloads(ownedClient as SandboxClient, fileDownloads, {
3092
3685
  ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
3093
3686
  ...(runAs ? { runAs } : {}),
3687
+ ...(overrides.turnToolCancellationFence
3688
+ ? {
3689
+ commandRunner: overrides.turnToolCancellationFence.runSandboxCommand.bind(
3690
+ overrides.turnToolCancellationFence,
3691
+ ),
3692
+ }
3693
+ : {}),
3094
3694
  })
3095
3695
  : (ownedClient as SandboxClient);
3096
3696
  // TOKEN-BROKER (B1): the per-turn git token seed, forwarded OFF-MANIFEST so the
3097
3697
  // repository-clone hook seeds it to the box's token file before the clone.
3098
3698
  const ownedGitTokenSeeds = gitTokenSeedsForAgent(agent);
3699
+ const ownedGitCredentialBindings = gitCredentialBindingsForAgent(agent);
3099
3700
  const ownedToolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
3100
3701
  const ownedRigSetup = rigSetupDescriptorForAgent(agent);
3101
3702
  const ownedHooks = [
@@ -3108,6 +3709,9 @@ export async function runAgentStream(
3108
3709
  rigCredentialHooksForAgent(agent),
3109
3710
  ),
3110
3711
  ...sandboxToolspaceTokenHooksForAgent(agent),
3712
+ ...toolspaceTokenSessionRegistrationHooks(
3713
+ ownedToolspaceTokenSeed ? overrides.onToolspaceTokenSessionReady : undefined,
3714
+ ),
3111
3715
  ...sandboxRepositoryCloneHooksForAgent(agent),
3112
3716
  ...gitCredentialSessionRegistrationHooks(overrides.onGitCredentialSessionReady),
3113
3717
  ];
@@ -3116,37 +3720,60 @@ export async function runAgentStream(
3116
3720
  ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
3117
3721
  ...(runAs ? { runAs } : {}),
3118
3722
  ...(ownedGitTokenSeeds ? { gitTokenSeeds: ownedGitTokenSeeds } : {}),
3723
+ ...(ownedGitCredentialBindings ? { gitCredentialBindings: ownedGitCredentialBindings } : {}),
3119
3724
  ...(ownedToolspaceTokenSeed ? { toolspaceTokenSeed: ownedToolspaceTokenSeed } : {}),
3725
+ ...(toolspaceTokenFile ? { toolspaceTokenFile } : {}),
3120
3726
  ...(ownedRigSetup ? { rigSetup: ownedRigSetup } : {}),
3121
3727
  };
3122
- // Keep the decoration as a safety net for any session the SDK does create/resume
3123
- // through the client during this run (it is inert for the provided session).
3124
- const decoratedClient = withSandboxLifecycleHooks(resourceClient, ownedHooks, ownedHookContext);
3728
+ // Keep both credential seeding and lifecycle decoration as a safety net for
3729
+ // any session the SDK does create/resume during this run. They are inert for
3730
+ // the provided session, which remains the normal ownership-inverted path.
3731
+ const credentialResourceClient = overrides.runCredentialSessionId
3732
+ ? withRunCredentialsClient(
3733
+ resourceClient,
3734
+ overrides.runCredentialSessionId,
3735
+ overrides.onRunCredentialSessionReady,
3736
+ )
3737
+ : resourceClient;
3738
+ const toolspaceResourceClient = toolspaceTokenFile
3739
+ ? withToolspaceTokenClient(credentialResourceClient, toolspaceTokenFile)
3740
+ : credentialResourceClient;
3741
+ const decoratedClient = withSandboxLifecycleHooks(
3742
+ toolspaceResourceClient,
3743
+ ownedHooks,
3744
+ ownedHookContext,
3745
+ );
3125
3746
  const ownedFilter = composeCallModelInputFilters(
3126
3747
  [
3127
- callModelInputFilterForSettings(settings, {
3748
+ callModelInputFilterForSettings(settings),
3749
+ genesisTitleInputFilter,
3750
+ overrides.callModelInputFilter,
3751
+ // A caller filter may synthesize model input. Re-apply the idempotent
3752
+ // canonical bound at the literal final seam before accounting/provider
3753
+ // serialization so no extension can bypass the policy.
3754
+ boundModelToolOutputsFilterForSettings(settings),
3755
+ contextRobustnessFilterForSettings(settings, {
3128
3756
  throwOnCompactionNeeded: Boolean(
3129
- overrides.contextCompactionSignalTokens || overrides.contextCompactionRequested,
3757
+ overrides.contextCompactionSignal || overrides.contextCompactionRequested,
3130
3758
  ),
3131
- ...(overrides.contextCompactionSignalTokens
3132
- ? { contextCompactionSignalTokens: overrides.contextCompactionSignalTokens }
3759
+ ...(overrides.contextCompactionSignal
3760
+ ? { contextCompactionSignal: overrides.contextCompactionSignal }
3133
3761
  : {}),
3134
3762
  ...(overrides.contextCompactionRequested
3135
3763
  ? { contextCompactionRequested: overrides.contextCompactionRequested }
3136
3764
  : {}),
3137
3765
  }),
3138
- genesisTitleInputFilter,
3139
- overrides.callModelInputFilter,
3140
3766
  ].filter((f): f is CallModelInputFilter => Boolean(f)),
3141
3767
  );
3142
3768
  const ownedRunOptions: Parameters<typeof run>[2] = {
3143
3769
  stream: true,
3144
3770
  maxTurns: settings.agentMaxModelCallsPerTurn,
3145
3771
  callModelInputFilter: ownedFilter,
3772
+ ...(overrides.signal ? { signal: overrides.signal } : {}),
3146
3773
  };
3147
3774
  ownedRunOptions.sandbox = {
3148
3775
  client: decoratedClient,
3149
- session,
3776
+ session: agentSession,
3150
3777
  ...(sessionState ? { sessionState } : {}),
3151
3778
  } as SandboxRunConfig;
3152
3779
  return await runScopedRunner(settings).run(agent, prepared.input, ownedRunOptions);
@@ -3171,14 +3798,27 @@ export async function runAgentStream(
3171
3798
  ...(runAs ? { runAs } : {}),
3172
3799
  })
3173
3800
  : refreshedClient;
3801
+ const credentialClient =
3802
+ resourceClient && overrides.runCredentialSessionId
3803
+ ? withRunCredentialsClient(
3804
+ resourceClient,
3805
+ overrides.runCredentialSessionId,
3806
+ overrides.onRunCredentialSessionReady,
3807
+ )
3808
+ : resourceClient;
3809
+ const toolspaceClient =
3810
+ credentialClient && toolspaceTokenFile
3811
+ ? withToolspaceTokenClient(credentialClient, toolspaceTokenFile)
3812
+ : credentialClient;
3174
3813
  // TOKEN-BROKER (B1): the per-turn git token seed, forwarded OFF-MANIFEST so the
3175
3814
  // repository-clone hook seeds it to the box's token file before the clone.
3176
3815
  const gitTokenSeeds = gitTokenSeedsForAgent(agent);
3816
+ const gitCredentialBindings = gitCredentialBindingsForAgent(agent);
3177
3817
  const toolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
3178
3818
  const legacyRigSetup = rigSetupDescriptorForAgent(agent);
3179
- const client = resourceClient
3819
+ const client = toolspaceClient
3180
3820
  ? withSandboxLifecycleHooks(
3181
- resourceClient,
3821
+ toolspaceClient,
3182
3822
  [
3183
3823
  // M3: same rig-setup-first ordering + credential-hook union as the owned
3184
3824
  // path (this legacy create/resume decoration path is byte-for-byte today
@@ -3189,6 +3829,9 @@ export async function runAgentStream(
3189
3829
  rigCredentialHooksForAgent(agent),
3190
3830
  ),
3191
3831
  ...sandboxToolspaceTokenHooksForAgent(agent),
3832
+ ...toolspaceTokenSessionRegistrationHooks(
3833
+ toolspaceTokenSeed ? overrides.onToolspaceTokenSessionReady : undefined,
3834
+ ),
3192
3835
  ...sandboxRepositoryCloneHooksForAgent(agent),
3193
3836
  ...gitCredentialSessionRegistrationHooks(overrides.onGitCredentialSessionReady),
3194
3837
  ],
@@ -3197,7 +3840,9 @@ export async function runAgentStream(
3197
3840
  ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
3198
3841
  ...(runAs ? { runAs } : {}),
3199
3842
  ...(gitTokenSeeds ? { gitTokenSeeds } : {}),
3843
+ ...(gitCredentialBindings ? { gitCredentialBindings } : {}),
3200
3844
  ...(toolspaceTokenSeed ? { toolspaceTokenSeed } : {}),
3845
+ ...(toolspaceTokenFile ? { toolspaceTokenFile } : {}),
3201
3846
  ...(legacyRigSetup ? { rigSetup: legacyRigSetup } : {}),
3202
3847
  },
3203
3848
  )
@@ -3210,19 +3855,21 @@ export async function runAgentStream(
3210
3855
  // OpenGeni's durable conversation truth is still reconciled explicitly below.
3211
3856
  const callModelInputFilter = composeCallModelInputFilters(
3212
3857
  [
3213
- callModelInputFilterForSettings(settings, {
3858
+ callModelInputFilterForSettings(settings),
3859
+ genesisTitleInputFilter,
3860
+ overrides.callModelInputFilter,
3861
+ boundModelToolOutputsFilterForSettings(settings),
3862
+ contextRobustnessFilterForSettings(settings, {
3214
3863
  throwOnCompactionNeeded: Boolean(
3215
- overrides.contextCompactionSignalTokens || overrides.contextCompactionRequested,
3864
+ overrides.contextCompactionSignal || overrides.contextCompactionRequested,
3216
3865
  ),
3217
- ...(overrides.contextCompactionSignalTokens
3218
- ? { contextCompactionSignalTokens: overrides.contextCompactionSignalTokens }
3866
+ ...(overrides.contextCompactionSignal
3867
+ ? { contextCompactionSignal: overrides.contextCompactionSignal }
3219
3868
  : {}),
3220
3869
  ...(overrides.contextCompactionRequested
3221
3870
  ? { contextCompactionRequested: overrides.contextCompactionRequested }
3222
3871
  : {}),
3223
3872
  }),
3224
- genesisTitleInputFilter,
3225
- overrides.callModelInputFilter,
3226
3873
  ].filter((f): f is CallModelInputFilter => Boolean(f)),
3227
3874
  );
3228
3875
  const runOptions: Parameters<typeof run>[2] = {
@@ -3233,6 +3880,7 @@ export async function runAgentStream(
3233
3880
  // raise the proactive compaction signal. This runs for turn-start replay AND
3234
3881
  // every mid-turn follow-up.
3235
3882
  callModelInputFilter,
3883
+ ...(overrides.signal ? { signal: overrides.signal } : {}),
3236
3884
  };
3237
3885
  if (client) {
3238
3886
  runOptions.sandbox = {
@@ -3282,7 +3930,9 @@ function appendSandboxFileDownloadFailureNote(
3282
3930
  * default runner. setDefaultModelProvider remains only as a boot-time fallback.
3283
3931
  */
3284
3932
  function runScopedRunner(settings: Settings): Runner {
3285
- return new Runner({ modelProvider: new MultiProviderModelProvider(settings) });
3933
+ return new Runner({
3934
+ modelProvider: new MultiProviderModelProvider(settings),
3935
+ });
3286
3936
  }
3287
3937
 
3288
3938
  export { MaxTurnsExceededError } from "@openai/agents";
@@ -3338,7 +3988,9 @@ export function withManifestRefreshOnResume(
3338
3988
  ? { supportsDefaultOptions: client.supportsDefaultOptions }
3339
3989
  : {}),
3340
3990
  ...(client.create
3341
- ? { create: async (...args: any[]) => await (client.create as any)(...args) }
3991
+ ? {
3992
+ create: async (...args: any[]) => await (client.create as any)(...args),
3993
+ }
3342
3994
  : {}),
3343
3995
  resume: async (state: SandboxSessionState) => {
3344
3996
  const session = await client.resume!(state);
@@ -3346,7 +3998,9 @@ export function withManifestRefreshOnResume(
3346
3998
  return session;
3347
3999
  },
3348
4000
  ...(client.delete
3349
- ? { delete: async (state: SandboxSessionState) => await client.delete!(state) }
4001
+ ? {
4002
+ delete: async (state: SandboxSessionState) => await client.delete!(state),
4003
+ }
3350
4004
  : {}),
3351
4005
  ...(client.serializeSessionState
3352
4006
  ? {
@@ -3395,7 +4049,11 @@ export async function applyMissingManifestEntries(
3395
4049
  state?: {
3396
4050
  manifest?:
3397
4051
  | Manifest
3398
- | { root?: string; entries?: Record<string, any>; environment?: Record<string, any> };
4052
+ | {
4053
+ root?: string;
4054
+ entries?: Record<string, any>;
4055
+ environment?: Record<string, any>;
4056
+ };
3399
4057
  };
3400
4058
  }
3401
4059
  ).state?.manifest;
@@ -3505,7 +4163,11 @@ export function manifestEnvironmentDrift(
3505
4163
  if (added.length === 0 && removed.length === 0 && changed.length === 0) {
3506
4164
  return null;
3507
4165
  }
3508
- return { added: added.sort(), removed: removed.sort(), changed: changed.sort() };
4166
+ return {
4167
+ added: added.sort(),
4168
+ removed: removed.sort(),
4169
+ changed: changed.sort(),
4170
+ };
3509
4171
  }
3510
4172
 
3511
4173
  async function reportManifestEnvironmentDrift(
@@ -3520,7 +4182,10 @@ async function reportManifestEnvironmentDrift(
3520
4182
  // Reporting must never break a resume: the drift itself is benign under the
3521
4183
  // env pin (the box keeps running on its baked env); only the SIGNAL matters.
3522
4184
  try {
3523
- await context.onRuntimeEvent?.({ type: "sandbox.env.drift", payload: drift });
4185
+ await context.onRuntimeEvent?.({
4186
+ type: "sandbox.env.drift",
4187
+ payload: drift,
4188
+ });
3524
4189
  } catch {
3525
4190
  // Swallow: a failed emit must not fail the turn.
3526
4191
  }
@@ -3549,7 +4214,11 @@ export async function pinProvidedSessionManifestEnvironment(
3549
4214
  state?: {
3550
4215
  manifest?:
3551
4216
  | Manifest
3552
- | { root?: string; entries?: Record<string, any>; environment?: Record<string, any> };
4217
+ | {
4218
+ root?: string;
4219
+ entries?: Record<string, any>;
4220
+ environment?: Record<string, any>;
4221
+ };
3553
4222
  };
3554
4223
  }
3555
4224
  ).state?.manifest;
@@ -3603,6 +4272,9 @@ export async function runOwnedSandboxSetup(
3603
4272
  onRuntimeEvent?: SandboxLifecycleHookContext["onRuntimeEvent"];
3604
4273
  gitTokenSeedsOverride?: GitTokenSeeds;
3605
4274
  gitTokenSeedOverride?: string;
4275
+ gitCredentialBindingsOverride?: GitCredentialBindingSeed[];
4276
+ toolspaceTokenSeedOverride?: string;
4277
+ commandRunner?: SandboxLifecycleCommandRunner;
3606
4278
  },
3607
4279
  ): Promise<void> {
3608
4280
  const { settings, environment } = opts;
@@ -3624,7 +4296,11 @@ export async function runOwnedSandboxSetup(
3624
4296
  ...(opts.gitTokenSeedOverride ? { github: opts.gitTokenSeedOverride } : {}),
3625
4297
  ...(opts.gitTokenSeedsOverride ?? {}),
3626
4298
  } satisfies GitTokenSeeds;
3627
- const ownedToolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
4299
+ const ownedGitCredentialBindings =
4300
+ opts.gitCredentialBindingsOverride ?? gitCredentialBindingsForAgent(agent);
4301
+ const ownedToolspaceTokenSeed =
4302
+ opts.toolspaceTokenSeedOverride ?? toolspaceTokenSeedForAgent(agent);
4303
+ const ownedToolspaceTokenFile = toolspaceTokenFileForAgent(agent, environment);
3628
4304
  const ownedRigSetup = rigSetupDescriptorForAgent(agent);
3629
4305
  const ownedHooks = [
3630
4306
  // M3: rig setup runs FIRST so any tooling it installs is present for the
@@ -3646,8 +4322,11 @@ export async function runOwnedSandboxSetup(
3646
4322
  ...(opts.onRuntimeEvent ? { onRuntimeEvent: opts.onRuntimeEvent } : {}),
3647
4323
  ...(runAs ? { runAs } : {}),
3648
4324
  ...(Object.keys(ownedGitTokenSeeds).length > 0 ? { gitTokenSeeds: ownedGitTokenSeeds } : {}),
4325
+ ...(ownedGitCredentialBindings ? { gitCredentialBindings: ownedGitCredentialBindings } : {}),
3649
4326
  ...(ownedToolspaceTokenSeed ? { toolspaceTokenSeed: ownedToolspaceTokenSeed } : {}),
4327
+ ...(ownedToolspaceTokenFile ? { toolspaceTokenFile: ownedToolspaceTokenFile } : {}),
3650
4328
  ...(ownedRigSetup ? { rigSetup: ownedRigSetup } : {}),
4329
+ ...(opts.commandRunner ? { commandRunner: opts.commandRunner } : {}),
3651
4330
  };
3652
4331
  // OWNED-PATH HOOKS: run the beforeAgentStart hooks directly against the provided
3653
4332
  // box, once per turn, BEFORE the run starts (repository-clone hook seeds the B1
@@ -3667,6 +4346,7 @@ export async function runOwnedSandboxSetup(
3667
4346
  const materialized = await materializeSandboxFileDownloads(setupSession, fileDownloads, {
3668
4347
  ...(opts.onRuntimeEvent ? { onRuntimeEvent: opts.onRuntimeEvent } : {}),
3669
4348
  ...(runAs ? { runAs } : {}),
4349
+ ...(opts.commandRunner ? { commandRunner: opts.commandRunner } : {}),
3670
4350
  });
3671
4351
  if (opts.preparedInput) {
3672
4352
  appendSandboxFileDownloadFailureNote(opts.preparedInput, materialized.failures);
@@ -3700,7 +4380,7 @@ function mergePathGrants(
3700
4380
  export function withSandboxFileDownloads(
3701
4381
  client: SandboxClient,
3702
4382
  downloads: SandboxFileDownload[],
3703
- context: Pick<SandboxLifecycleHookContext, "onRuntimeEvent" | "runAs"> = {},
4383
+ context: Pick<SandboxLifecycleHookContext, "onRuntimeEvent" | "runAs" | "commandRunner"> = {},
3704
4384
  ): SandboxClient {
3705
4385
  const normalizedDownloads = normalizeSandboxFileDownloads(downloads);
3706
4386
  if (normalizedDownloads.length === 0) {
@@ -3732,7 +4412,9 @@ export function withSandboxFileDownloads(
3732
4412
  }
3733
4413
  : {}),
3734
4414
  ...(client.delete
3735
- ? { delete: async (state: SandboxSessionState) => await client.delete!(state) }
4415
+ ? {
4416
+ delete: async (state: SandboxSessionState) => await client.delete!(state),
4417
+ }
3736
4418
  : {}),
3737
4419
  ...(client.serializeSessionState
3738
4420
  ? {
@@ -3764,7 +4446,7 @@ export function withSandboxFileDownloads(
3764
4446
  export async function materializeSandboxFileDownloads(
3765
4447
  session: SandboxSessionLike,
3766
4448
  downloads: SandboxFileDownload[],
3767
- context: Pick<SandboxLifecycleHookContext, "onRuntimeEvent" | "runAs"> = {},
4449
+ context: Pick<SandboxLifecycleHookContext, "onRuntimeEvent" | "runAs" | "commandRunner"> = {},
3768
4450
  ): Promise<SandboxFileDownloadMaterializationResult> {
3769
4451
  const normalizedDownloads = normalizeSandboxFileDownloads(downloads);
3770
4452
  if (normalizedDownloads.length === 0) {
@@ -3802,27 +4484,24 @@ export async function materializeSandboxFileDownloads(
3802
4484
  }
3803
4485
  let result: unknown;
3804
4486
  try {
3805
- result = session.exec
3806
- ? await session.exec({
3807
- cmd: sandboxFileDownloadCommand(download, targetPath),
3808
- workdir: "/workspace",
3809
- ...(context.runAs ? { runAs: context.runAs } : {}),
3810
- yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
3811
- maxOutputTokens: 20_000,
3812
- })
3813
- : await session.execCommand!({
3814
- cmd: sandboxFileDownloadCommand(download, targetPath),
3815
- workdir: "/workspace",
3816
- ...(context.runAs ? { runAs: context.runAs } : {}),
3817
- yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
3818
- maxOutputTokens: 20_000,
3819
- });
4487
+ result = await runSandboxLifecycleCommand(
4488
+ session,
4489
+ {
4490
+ cmd: sandboxFileDownloadCommand(download, targetPath),
4491
+ workdir: "/workspace",
4492
+ ...(context.runAs ? { runAs: context.runAs } : {}),
4493
+ yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
4494
+ maxOutputTokens: 20_000,
4495
+ },
4496
+ context.commandRunner,
4497
+ );
3820
4498
  assertSandboxCommandSucceeded(result, `Sandbox file resource download ${download.fileId}`);
3821
4499
  await context.onRuntimeEvent?.({
3822
4500
  type: "sandbox.operation.completed",
3823
4501
  payload: { name: "file-resource-download", ...payload },
3824
4502
  });
3825
4503
  } catch (error) {
4504
+ if (error instanceof TurnSandboxCommandCancelledError) throw error;
3826
4505
  const failure = sandboxFileDownloadFailure(download, targetPath, error, result);
3827
4506
  failures.push(failure);
3828
4507
  await context.onRuntimeEvent?.({
@@ -3899,82 +4578,87 @@ function ensureManifest(
3899
4578
  });
3900
4579
  }
3901
4580
 
3902
- /** Coerce the various binary shapes a tool-output image `data` field can take into
3903
- * a Uint8Array. Handles a live `Uint8Array`, a plain number[] , and the
3904
- * object-of-numbers (`{"0":137,"1":80,…}`) that a `Uint8Array` degrades into after
3905
- * a JSON round-trip — the exact 10x-bloat shape this normalizer exists to kill. */
3906
- function toImageBytes(data: unknown): Uint8Array | null {
3907
- if (data instanceof Uint8Array) {
3908
- return data;
3909
- }
4581
+ function base64DecodedByteLength(value: string): number {
4582
+ const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
4583
+ return Math.max(0, Math.floor((value.length * 3) / 4) - padding);
4584
+ }
4585
+
4586
+ /** Determine image byte length without allocating another binary/base64 copy. */
4587
+ function imageDataByteLength(data: unknown): number | null {
4588
+ if (ArrayBuffer.isView(data)) return data.byteLength;
4589
+ if (data instanceof ArrayBuffer) return data.byteLength;
3910
4590
  if (Array.isArray(data)) {
3911
- return data.every((n) => typeof n === "number") ? Uint8Array.from(data as number[]) : null;
4591
+ return data.every((value) => typeof value === "number") ? data.length : null;
3912
4592
  }
3913
- if (data && typeof data === "object") {
3914
- const values = Object.values(data as Record<string, unknown>);
3915
- if (values.length > 0 && values.every((n) => typeof n === "number")) {
3916
- return Uint8Array.from(values as number[]);
3917
- }
4593
+ if (!data || typeof data !== "object") return null;
4594
+ const record = data as Record<string, unknown>;
4595
+ if (record.type === "Buffer" && Array.isArray(record.data)) {
4596
+ return record.data.every((value) => typeof value === "number") ? record.data.length : null;
3918
4597
  }
3919
- return null;
4598
+ const keys = Object.keys(record);
4599
+ return keys.length > 0 &&
4600
+ keys.every((key) => /^\d+$/.test(key) && typeof record[key] === "number")
4601
+ ? keys.length
4602
+ : null;
3920
4603
  }
3921
4604
 
3922
- /** Compact a structured image tool output — the SDK's `{type:'image', image:{data,mediaType}}`
3923
- * shape (produced by the codex-path `computer_screenshot` function tool) OR the already-
3924
- * normalized protocol `{type:'input_image', image:'data:…'}` item — into a `data:<mt>;base64,…`
3925
- * string. Returns null when `value` is not an image output. */
3926
- function structuredImageToDataUrl(value: unknown): string | null {
3927
- if (!value || typeof value !== "object" || Array.isArray(value)) {
3928
- return null;
3929
- }
3930
- const v = value as { type?: unknown; image?: unknown };
3931
- if (v.type === "input_image") {
3932
- // Protocol item: `image` is already a `data:…` (or plain URL) string.
3933
- return typeof v.image === "string" && v.image.length > 0 ? v.image : null;
3934
- }
3935
- if (v.type !== "image" || !v.image || typeof v.image !== "object") {
3936
- return null;
3937
- }
3938
- const image = v.image as { data?: unknown; mediaType?: unknown; url?: unknown };
3939
- if (typeof image.url === "string" && image.url.length > 0) {
3940
- return image.url;
3941
- }
4605
+ /**
4606
+ * Convert one image-shaped tool result into a content-free audit fact. This is
4607
+ * intentionally different from model history: the model keeps its structured
4608
+ * image item, while `session_events` never becomes an implicit image blob store.
4609
+ */
4610
+ function toolOutputMediaPreview(value: unknown): SessionEventMediaPreview | null {
4611
+ if (typeof value === "string") {
4612
+ return sessionEventMediaPreviewFromDataUrl(value);
4613
+ }
4614
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
4615
+ const record = value as Record<string, unknown>;
4616
+ if (record.type === "input_image") {
4617
+ const source = record.image ?? record.image_url ?? record.imageUrl;
4618
+ const url =
4619
+ typeof source === "string"
4620
+ ? source
4621
+ : source && typeof source === "object"
4622
+ ? (source as Record<string, unknown>).url
4623
+ : null;
4624
+ if (typeof url !== "string" || url.length === 0) return null;
4625
+ return sessionEventMediaPreviewFromDataUrl(url) ?? sessionEventMediaPreview("image/*", null);
4626
+ }
4627
+ if (record.type !== "image" || !record.image || typeof record.image !== "object") return null;
4628
+ const image = record.image as Record<string, unknown>;
3942
4629
  const mediaType =
3943
4630
  typeof image.mediaType === "string" && image.mediaType.length > 0
3944
4631
  ? image.mediaType
3945
4632
  : "image/png";
4633
+ if (typeof image.url === "string" && image.url.length > 0) {
4634
+ return (
4635
+ sessionEventMediaPreviewFromDataUrl(image.url) ?? sessionEventMediaPreview(mediaType, null)
4636
+ );
4637
+ }
3946
4638
  if (typeof image.data === "string") {
3947
- return image.data.startsWith("data:") ? image.data : `data:${mediaType};base64,${image.data}`;
4639
+ return (
4640
+ sessionEventMediaPreviewFromDataUrl(image.data) ??
4641
+ sessionEventMediaPreview(mediaType, base64DecodedByteLength(image.data))
4642
+ );
3948
4643
  }
3949
- const bytes = toImageBytes(image.data);
3950
- return bytes ? `data:${mediaType};base64,${Buffer.from(bytes).toString("base64")}` : null;
4644
+ const byteLength = imageDataByteLength(image.data);
4645
+ return byteLength === null ? null : sessionEventMediaPreview(mediaType, byteLength);
3951
4646
  }
3952
4647
 
3953
4648
  /**
3954
- * Compact a tool-call output for the `agent.toolCall.output` SESSION EVENT so it
3955
- * never carries a raw binary payload. The codex-path `computer_screenshot` function
3956
- * tool returns a structured `{type:'image', image:{data: Uint8Array, mediaType}}`;
3957
- * captured verbatim its `Uint8Array` JSON-serializes as an object-of-numbers (~12.7MB
3958
- * per screenshot in session_events — ~10x the base64 form). This mirrors the desktop
3959
- * screenshot to the SAME compact `data:<mediaType>;base64,…` STRING the HOSTED
3960
- * `computer_call` event already carries (agents-core sets its output to that data-URL),
3961
- * so both computer-use transports emit one representation. The full data-URL is kept
3962
- * (not truncated) because the web timeline RENDERS the screenshot from this event
3963
- * payload — packages/react/src/timeline/tool-renderers.tsx ComputerCallRenderer
3964
- * (`out.startsWith("data:image")` → <ScreenshotFigure src={out}/>) and ViewImageRenderer.
3965
- * Non-image outputs (text strings, MCP `{isError,content}` objects, hosted computer_call
3966
- * data-URL strings) pass through unchanged.
4649
+ * Normalize a tool-call output for the lossy `agent.toolCall.output` audit event.
4650
+ * Inline image bytes/data URLs become a compact `media_preview` with exact byte
4651
+ * length where knowable and `fullOutputAvailable:false`. The model-facing output
4652
+ * is not changed here, and mixed arrays retain their non-image text/error facts.
3967
4653
  */
3968
4654
  export function normalizeToolOutputForEvent(output: unknown): unknown {
3969
- const single = structuredImageToDataUrl(output);
4655
+ const single = toolOutputMediaPreview(output);
3970
4656
  if (single !== null) {
3971
4657
  return single;
3972
4658
  }
3973
4659
  if (Array.isArray(output)) {
3974
- const normalized = output.map((el) => structuredImageToDataUrl(el) ?? el);
3975
- // A lone image content item unwraps to the bare data-URL string the timeline
3976
- // image renderers expect; a mixed/multi array keeps its (now-compact) shape.
3977
- if (normalized.length === 1 && typeof normalized[0] === "string") {
4660
+ const normalized = output.map((el) => toolOutputMediaPreview(el) ?? el);
4661
+ if (normalized.length === 1 && normalized[0]?.type === "media_preview") {
3978
4662
  return normalized[0];
3979
4663
  }
3980
4664
  return normalized;
@@ -4002,7 +4686,10 @@ export function normalizeSdkEvent(event: RunStreamEvent): NormalizedRuntimeEvent
4002
4686
  return out;
4003
4687
  }
4004
4688
  if (event.type === "agent_updated_stream_event") {
4005
- out.push({ type: "agent.updated", payload: { agent: (event as any).agent?.name ?? null } });
4689
+ out.push({
4690
+ type: "agent.updated",
4691
+ payload: { agent: (event as any).agent?.name ?? null },
4692
+ });
4006
4693
  return out;
4007
4694
  }
4008
4695
  if (event.type !== "run_item_stream_event") {
@@ -4028,8 +4715,8 @@ export function normalizeSdkEvent(event: RunStreamEvent): NormalizedRuntimeEvent
4028
4715
  type: "agent.toolCall.output",
4029
4716
  payload: {
4030
4717
  id: item.rawItem?.callId ?? item.id ?? null,
4031
- // Compact any structured/binary image output to a data-URL string so a
4032
- // screenshot never bloats session_events ~10x as an object-of-numbers.
4718
+ // Inline media becomes a content-free audit fact. Model history keeps
4719
+ // the provider's real structured image output on its separate path.
4033
4720
  output: normalizeToolOutputForEvent(item.output),
4034
4721
  },
4035
4722
  });
@@ -4164,7 +4851,9 @@ function inputTokenDetailsProp(raw: Record<string, unknown>): Partial<ModelRespo
4164
4851
  if (!details || typeof details !== "object") {
4165
4852
  return {};
4166
4853
  }
4167
- return { inputTokensDetails: details as Record<string, number> | Array<Record<string, number>> };
4854
+ return {
4855
+ inputTokensDetails: details as Record<string, number> | Array<Record<string, number>>,
4856
+ };
4168
4857
  }
4169
4858
 
4170
4859
  function outputTokenDetailsProp(
@@ -4181,17 +4870,45 @@ function outputTokenDetailsProp(
4181
4870
  }
4182
4871
 
4183
4872
  export function serializeApprovals(interruptions: unknown[]): unknown[] {
4184
- return interruptions.map((item: any) => {
4185
- if (typeof item?.toJSON === "function") {
4186
- return item.toJSON();
4187
- }
4188
- return {
4189
- id: approvalIdentifier(item),
4190
- name: item?.name ?? item?.rawItem?.name ?? "tool",
4191
- arguments: item?.arguments ?? item?.rawItem?.arguments ?? null,
4192
- raw: item,
4193
- };
4194
- });
4873
+ return interruptions
4874
+ .filter((item) => interruptionToolName(item) !== HUMAN_INPUT_TOOL_NAME)
4875
+ .map((item: any) => {
4876
+ if (typeof item?.toJSON === "function") {
4877
+ return item.toJSON();
4878
+ }
4879
+ return {
4880
+ id: approvalIdentifier(item) ?? "approval",
4881
+ name: item?.name ?? item?.rawItem?.name ?? "tool",
4882
+ arguments: item?.arguments ?? item?.rawItem?.arguments ?? null,
4883
+ raw: item,
4884
+ };
4885
+ });
4886
+ }
4887
+
4888
+ export function serializeHumanInputRequests(
4889
+ interruptions: unknown[],
4890
+ ): SerializedHumanInputInterruption[] {
4891
+ return interruptions
4892
+ .filter((item) => interruptionToolName(item) === HUMAN_INPUT_TOOL_NAME)
4893
+ .map((item: any) => {
4894
+ const rawArguments = item?.arguments ?? item?.rawItem?.arguments;
4895
+ let parsedArguments: unknown = rawArguments;
4896
+ if (typeof rawArguments === "string") {
4897
+ try {
4898
+ parsedArguments = JSON.parse(rawArguments);
4899
+ } catch {
4900
+ throw new Error("Human-input interruption contains invalid JSON arguments");
4901
+ }
4902
+ }
4903
+ const toolCallId = approvalIdentifier(item);
4904
+ if (!toolCallId) {
4905
+ throw new Error("Human-input interruption is missing a stable tool-call identity");
4906
+ }
4907
+ return {
4908
+ toolCallId,
4909
+ input: RequestHumanInputToolInput.parse(parsedArguments),
4910
+ };
4911
+ });
4195
4912
  }
4196
4913
 
4197
4914
  export function buildManifest(
@@ -4200,6 +4917,7 @@ export function buildManifest(
4200
4917
  environment = collectSandboxEnvironment(settings),
4201
4918
  fileResourceDownloads: SandboxFileDownload[] = [],
4202
4919
  ): Manifest {
4920
+ assertUniqueResourceMountPaths(resources);
4203
4921
  const entries: Record<string, any> = {};
4204
4922
  const downloadsByFileId = new Map(
4205
4923
  normalizeSandboxFileDownloads(fileResourceDownloads).map((download) => [
@@ -4210,9 +4928,9 @@ export function buildManifest(
4210
4928
  for (const resource of resources) {
4211
4929
  if (resource.kind === "repository") {
4212
4930
  const url = new URL(resource.uri);
4213
- const host = url.hostname.toLowerCase();
4931
+ const host = url.host.toLowerCase();
4214
4932
  const repo = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
4215
- const mountPath = normalizeManifestPath(resource.mountPath ?? `repos/${repo}`);
4933
+ const mountPath = resourceMountPath(resource);
4216
4934
  if (repositoryUsesSandboxClone(settings, resource)) {
4217
4935
  entries[mountPath] = dir();
4218
4936
  continue;
@@ -4221,12 +4939,12 @@ export function buildManifest(
4221
4939
  host,
4222
4940
  repo,
4223
4941
  ref: resource.ref,
4224
- ...(resource.subpath ? { subpath: normalizeManifestPath(resource.subpath) } : {}),
4942
+ ...(resource.subpath ? { subpath: normalizeRepositorySubpath(resource.subpath) } : {}),
4225
4943
  });
4226
4944
  continue;
4227
4945
  }
4228
4946
  if (resource.kind === "file") {
4229
- const mountPath = normalizeManifestPath(resource.mountPath ?? `files/${resource.fileId}`);
4947
+ const mountPath = resourceMountPath(resource);
4230
4948
  const download = downloadsByFileId.get(resource.fileId);
4231
4949
  entries[mountPath] = download
4232
4950
  ? sandboxDownloadDirectory(download, mountPath)
@@ -4282,7 +5000,9 @@ function objectStorageFileMount(settings: Settings, prefix: string): any {
4282
5000
  accountKey: config.accountKey,
4283
5001
  endpointUrl: config.endpointUrl,
4284
5002
  readOnly: true,
4285
- mountStrategy: inContainerMountStrategy({ pattern: { type: "rclone", mode: "fuse" } }),
5003
+ mountStrategy: inContainerMountStrategy({
5004
+ pattern: { type: "rclone", mode: "fuse" },
5005
+ }),
4286
5006
  });
4287
5007
  }
4288
5008
  if (settings.objectStorageBackend === "aws-s3" || settings.objectStorageBackend === "gcs") {
@@ -4384,11 +5104,7 @@ function parseAzureConnectionString(value: string): Record<string, string> {
4384
5104
  }
4385
5105
 
4386
5106
  function normalizeManifestPath(path: string): string {
4387
- const normalized = path.replace(/^\/+|\/+$/g, "");
4388
- if (!normalized || normalized.includes("..")) {
4389
- throw new Error(`Invalid sandbox resource path: ${path}`);
4390
- }
4391
- return normalized;
5107
+ return normalizeResourceMountPath(path);
4392
5108
  }
4393
5109
 
4394
5110
  function normalizeSandboxFileDownloads(downloads: SandboxFileDownload[]): SandboxFileDownload[] {
@@ -4451,6 +5167,11 @@ function shellQuote(value: string): string {
4451
5167
 
4452
5168
  export type SandboxLifecycleHookPhase = "beforeAgentStart";
4453
5169
 
5170
+ export type SandboxLifecycleCommandRunner = (
5171
+ session: TurnSandboxCommandSession,
5172
+ args: TurnSandboxCommandArgs,
5173
+ ) => Promise<unknown>;
5174
+
4454
5175
  export type SandboxLifecycleHookContext = {
4455
5176
  environment: Record<string, string>;
4456
5177
  onRuntimeEvent?: (event: NormalizedRuntimeEvent) => Promise<void> | void;
@@ -4462,12 +5183,28 @@ export type SandboxLifecycleHookContext = {
4462
5183
  // NEVER the box/agent manifest env (validateNoEnvironmentDelta must never see
4463
5184
  // rotating values).
4464
5185
  gitTokenSeeds?: GitTokenSeeds;
5186
+ gitCredentialBindings?: GitCredentialBindingSeed[];
4465
5187
  toolspaceTokenSeed?: string;
5188
+ toolspaceTokenFile?: string;
4466
5189
  // M3: the rig setup descriptor for the rig-setup hook (the script + marker
4467
5190
  // version id + the rig's own timeout). Present only on a rig-bound turn.
4468
5191
  rigSetup?: RigSetupDescriptor;
5192
+ commandRunner?: SandboxLifecycleCommandRunner;
4469
5193
  };
4470
5194
 
5195
+ async function runSandboxLifecycleCommand(
5196
+ session: SandboxSessionLike,
5197
+ args: TurnSandboxCommandArgs,
5198
+ commandRunner?: SandboxLifecycleCommandRunner,
5199
+ ): Promise<unknown> {
5200
+ if (commandRunner) {
5201
+ return await commandRunner(session as TurnSandboxCommandSession, args);
5202
+ }
5203
+ if (session.exec) return await session.exec(args);
5204
+ if (session.execCommand) return await session.execCommand(args);
5205
+ throw new Error("Sandbox session does not support command execution");
5206
+ }
5207
+
4471
5208
  // M3: everything the rig-setup hook needs to run the frozen rig version's setup
4472
5209
  // script exactly once per box. `versionId` keys the idempotence marker
4473
5210
  // (/var/opengeni/rig-setup-<versionId>.done); `timeoutMs` is the rig-specific
@@ -4574,7 +5311,9 @@ export function withSandboxLifecycleHooks(
4574
5311
  }
4575
5312
  : {}),
4576
5313
  ...(client.delete
4577
- ? { delete: async (state: SandboxSessionState) => await client.delete!(state) }
5314
+ ? {
5315
+ delete: async (state: SandboxSessionState) => await client.delete!(state),
5316
+ }
4578
5317
  : {}),
4579
5318
  ...(client.serializeSessionState
4580
5319
  ? {
@@ -4615,10 +5354,32 @@ function gitTokenSeedsForAgent(agent: Agent<any, any>): GitTokenSeeds | undefine
4615
5354
  return agentGitTokenSeeds.get(agent);
4616
5355
  }
4617
5356
 
5357
+ function gitCredentialBindingsForAgent(
5358
+ agent: Agent<any, any>,
5359
+ ): GitCredentialBindingSeed[] | undefined {
5360
+ return agentGitCredentialBindings.get(agent);
5361
+ }
5362
+
4618
5363
  function toolspaceTokenSeedForAgent(agent: Agent<any, any>): string | undefined {
4619
5364
  return agentToolspaceTokenSeed.get(agent);
4620
5365
  }
4621
5366
 
5367
+ function toolspaceTokenSessionIdForAgent(agent: Agent<any, any>): string | undefined {
5368
+ return agentToolspaceTokenSessionId.get(agent);
5369
+ }
5370
+
5371
+ function toolspaceTokenFileForAgent(
5372
+ agent: Agent<any, any>,
5373
+ environment: Readonly<Record<string, string>>,
5374
+ ): string | undefined {
5375
+ if (!toolspaceTokenSeedForAgent(agent)) return undefined;
5376
+ const sessionId = toolspaceTokenSessionIdForAgent(agent);
5377
+ if (!sessionId) {
5378
+ throw new Error("Toolspace token seed is missing its session identity");
5379
+ }
5380
+ return toolspaceTokenFileFromEnvironment(environment, sessionId);
5381
+ }
5382
+
4622
5383
  function sandboxToolspaceTokenHooksForAgent(agent: Agent<any, any>): SandboxLifecycleHook[] {
4623
5384
  return toolspaceTokenSeedForAgent(agent)
4624
5385
  ? [
@@ -4688,6 +5449,22 @@ function gitCredentialSessionRegistrationHooks(
4688
5449
  : [];
4689
5450
  }
4690
5451
 
5452
+ function toolspaceTokenSessionRegistrationHooks(
5453
+ callback: RunAgentStreamOptions["onToolspaceTokenSessionReady"],
5454
+ ): SandboxLifecycleHook[] {
5455
+ return callback
5456
+ ? [
5457
+ {
5458
+ id: "toolspace-token-renewal-registration",
5459
+ phase: "beforeAgentStart",
5460
+ run: async (session) => {
5461
+ await callback(session);
5462
+ },
5463
+ },
5464
+ ]
5465
+ : [];
5466
+ }
5467
+
4691
5468
  function sandboxRepositoryCloneHooks(
4692
5469
  settings: Settings,
4693
5470
  resources: ResourceRef[],
@@ -4755,6 +5532,20 @@ function gitProviderSeedEnv(provider: GitCredentialProvider): string {
4755
5532
  return `OPENGENI_GIT_${provider.toUpperCase()}_TOKEN_SEED`;
4756
5533
  }
4757
5534
 
5535
+ export function gitCredentialBindingHash(credentialBindingId: string): string {
5536
+ return createHash("sha256").update(credentialBindingId, "utf8").digest("hex").slice(0, 32);
5537
+ }
5538
+
5539
+ function gitBindingSeedEnv(binding: GitCredentialBindingSeed): string {
5540
+ return `OPENGENI_GIT_BINDING_${gitCredentialBindingHash(binding.credentialBindingId).toUpperCase()}_TOKEN_SEED`;
5541
+ }
5542
+
5543
+ function gitCredentialBindingSeedExportPrefix(bindings: GitCredentialBindingSeed[]): string {
5544
+ return bindings
5545
+ .map((binding) => `export ${gitBindingSeedEnv(binding)}=${shellQuote(binding.token)}`)
5546
+ .join("\n");
5547
+ }
5548
+
4758
5549
  function gitTokenSeedExportPrefix(seeds: GitTokenSeeds): string {
4759
5550
  const lines: string[] = [];
4760
5551
  for (const provider of GIT_CREDENTIAL_PROVIDERS) {
@@ -4770,37 +5561,116 @@ function gitTokenSeedExportPrefix(seeds: GitTokenSeeds): string {
4770
5561
  return lines.join("\n");
4771
5562
  }
4772
5563
 
4773
- function repositoryCredentialProvider(
4774
- resource: Extract<ResourceRef, { kind: "repository" }>,
4775
- ): GitCredentialProvider {
4776
- return resource.provider ?? "github";
5564
+ type RuntimeGitBindingDescriptor = {
5565
+ provider: GitCredentialProvider;
5566
+ credentialBindingId: string;
5567
+ bindingHash: string;
5568
+ protocol: string;
5569
+ host: string;
5570
+ path: string;
5571
+ uri: string;
5572
+ };
5573
+
5574
+ function runtimeGitBindingDescriptors(
5575
+ resources: Extract<ResourceRef, { kind: "repository" }>[],
5576
+ ): RuntimeGitBindingDescriptor[] {
5577
+ const remoteBindings = new Map<string, string>();
5578
+ const bindingProviders = new Map<string, GitCredentialProvider>();
5579
+ return resources.map((resource) => {
5580
+ const url = new URL(resource.uri);
5581
+ const credentialProvider = gitCredentialProviderForRepository(resource);
5582
+ // Provider-less public/legacy resources retain the historical GitHub
5583
+ // askpass fallback, but credential-bound resources derive through the
5584
+ // shared contracts helper used by the worker and core.
5585
+ const provider = credentialProvider ?? "github";
5586
+ const credentialBindingId =
5587
+ gitCredentialBindingIdForRepository(resource, credentialProvider) ?? provider;
5588
+ const path = url.pathname.replace(/^\/+|\/+$/g, "");
5589
+ const remote = `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}/${path.replace(/\.git$/, "")}`;
5590
+ const bindingKey = `${provider}\u0000${credentialBindingId}`;
5591
+ const boundProvider = bindingProviders.get(credentialBindingId);
5592
+ if (boundProvider && boundProvider !== provider) {
5593
+ throw new Error(
5594
+ `credential binding ${credentialBindingId} is assigned to multiple Git providers`,
5595
+ );
5596
+ }
5597
+ bindingProviders.set(credentialBindingId, provider);
5598
+ const claimed = remoteBindings.get(remote);
5599
+ if (claimed && claimed !== bindingKey) {
5600
+ throw new Error(
5601
+ `repository remote ${resource.uri} is claimed by multiple credential bindings`,
5602
+ );
5603
+ }
5604
+ remoteBindings.set(remote, bindingKey);
5605
+ return {
5606
+ provider,
5607
+ credentialBindingId,
5608
+ bindingHash: gitCredentialBindingHash(credentialBindingId),
5609
+ protocol: url.protocol.replace(/:$/, "").toLowerCase(),
5610
+ host: url.host.toLowerCase(),
5611
+ path,
5612
+ uri: resource.uri,
5613
+ };
5614
+ });
5615
+ }
5616
+
5617
+ function gitUsernameForProvider(provider: GitCredentialProvider): string {
5618
+ if (provider === "github") return "x-access-token";
5619
+ if (provider === "gitlab") return "oauth2";
5620
+ return "opengeni";
5621
+ }
5622
+
5623
+ function gitCredentialHelperBindingCaseLines(
5624
+ resources: Extract<ResourceRef, { kind: "repository" }>[],
5625
+ ): string[] {
5626
+ return runtimeGitBindingDescriptors(resources).flatMap((descriptor) => {
5627
+ const paths = new Set([
5628
+ descriptor.path,
5629
+ descriptor.path.replace(/\.git$/, ""),
5630
+ `${descriptor.path.replace(/\.git$/, "")}.git`,
5631
+ ]);
5632
+ return [...paths].map(
5633
+ (path) =>
5634
+ ` ${shellQuote(`${descriptor.protocol}|${descriptor.host}|${path}`)}) username=${shellQuote(gitUsernameForProvider(descriptor.provider))}; token_file="$credential_dir/${descriptor.bindingHash}-token" ;;`,
5635
+ );
5636
+ });
4777
5637
  }
4778
5638
 
4779
5639
  function gitAskpassHostProviderCaseLines(
4780
5640
  resources: Extract<ResourceRef, { kind: "repository" }>[],
4781
5641
  ): string[] {
4782
- const hosts = new Map<string, GitCredentialProvider>();
4783
- for (const resource of resources) {
4784
- try {
4785
- const hostname = new URL(resource.uri).hostname.toLowerCase();
4786
- if (!hostname) {
4787
- continue;
4788
- }
4789
- hosts.set(hostname, repositoryCredentialProvider(resource));
4790
- } catch {
4791
- // Resource validation catches invalid URIs before normal runtime use. Keep
4792
- // helper generation tolerant so tests for clone failure can still build.
4793
- }
5642
+ const hosts = new Map<string, { provider: GitCredentialProvider; bindings: Set<string> }>();
5643
+ for (const descriptor of runtimeGitBindingDescriptors(resources)) {
5644
+ const entry = hosts.get(descriptor.host) ?? {
5645
+ provider: descriptor.provider,
5646
+ bindings: new Set<string>(),
5647
+ };
5648
+ entry.bindings.add(`${descriptor.provider}\u0000${descriptor.credentialBindingId}`);
5649
+ hosts.set(descriptor.host, entry);
4794
5650
  }
4795
5651
  return [...hosts.entries()]
5652
+ .filter(([, entry]) => entry.bindings.size === 1)
4796
5653
  .sort(([a], [b]) => a.localeCompare(b))
4797
5654
  .map(
4798
- ([hostname, provider]) =>
4799
- ` ${shellQuote(hostname)}) printf '%s\\n' ${provider}; return 0 ;;`,
5655
+ ([hostname, entry]) =>
5656
+ ` ${shellQuote(hostname)}) printf '%s\\n' ${entry.provider}; return 0 ;;`,
4800
5657
  );
4801
5658
  }
4802
5659
 
4803
- function gitCredentialTokenWriterCommandLines(): string[] {
5660
+ function gitCredentialTokenWriterCommandLines(bindings: GitCredentialBindingSeed[] = []): string[] {
5661
+ const bindingWrites = bindings.flatMap((binding) => {
5662
+ const hash = gitCredentialBindingHash(binding.credentialBindingId);
5663
+ const seedEnv = gitBindingSeedEnv(binding);
5664
+ const count = Math.max(1, binding.providerBindingCount ?? 1);
5665
+ const provider = binding.provider;
5666
+ const lines = [`write_git_binding_token ${shellQuote(hash)} "\${${seedEnv}:-}"`];
5667
+ if (count === 1) {
5668
+ lines.push(`write_git_provider_token ${shellQuote(provider)} "\${${seedEnv}:-}"`);
5669
+ } else {
5670
+ lines.push(`remove_git_provider_token ${shellQuote(provider)}`);
5671
+ }
5672
+ return lines;
5673
+ });
4804
5674
  return [
4805
5675
  // TOKEN-BROKER (B1/B2): seed run-scoped provider tokens into stable files and
4806
5676
  // atomically replace each provider file. Token VALUES are supplied only by
@@ -4814,6 +5684,23 @@ function gitCredentialTokenWriterCommandLines(): string[] {
4814
5684
  " *) printf '%s\\n' \"${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/$provider-token\" ;;",
4815
5685
  " esac",
4816
5686
  "}",
5687
+ "write_git_binding_token() {",
5688
+ ' binding_hash="$1"',
5689
+ ' token="$2"',
5690
+ ' [ -n "$token" ] || return 0',
5691
+ ' credential_dir="${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}"',
5692
+ ' mkdir -p "$credential_dir"',
5693
+ ' token_file="$credential_dir/$binding_hash-token"',
5694
+ ' printf \'%s\' "$token" > "$token_file.tmp.$$"',
5695
+ ' mv -f "$token_file.tmp.$$" "$token_file"',
5696
+ "}",
5697
+ "remove_git_provider_token() {",
5698
+ ' provider="$1"',
5699
+ ' rm -f "$(git_provider_token_file "$provider")"',
5700
+ ' if [ "$provider" = github ]; then',
5701
+ ' rm -f "${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/github-token"',
5702
+ " fi",
5703
+ "}",
4817
5704
  "write_git_provider_token() {",
4818
5705
  ' provider="$1"',
4819
5706
  ' token="$2"',
@@ -4834,16 +5721,53 @@ function gitCredentialTokenWriterCommandLines(): string[] {
4834
5721
  'write_git_provider_token github "${OPENGENI_GIT_GITHUB_TOKEN_SEED:-${OPENGENI_GIT_TOKEN_SEED:-}}"',
4835
5722
  'write_git_provider_token gitlab "${OPENGENI_GIT_GITLAB_TOKEN_SEED:-}"',
4836
5723
  'write_git_provider_token azure_devops "${OPENGENI_GIT_AZURE_DEVOPS_TOKEN_SEED:-}"',
5724
+ ...bindingWrites,
4837
5725
  'umask "$seed_umask"',
4838
5726
  ];
4839
5727
  }
4840
5728
 
4841
5729
  function gitCredentialHelperCommandLines(
4842
5730
  resources: Extract<ResourceRef, { kind: "repository" }>[] = [],
5731
+ bindings: GitCredentialBindingSeed[] = [],
4843
5732
  ): string[] {
4844
5733
  const hostProviderCases = gitAskpassHostProviderCaseLines(resources);
5734
+ const bindingCases = gitCredentialHelperBindingCaseLines(resources);
5735
+ const descriptors = runtimeGitBindingDescriptors(resources);
5736
+ const wrapperDescriptors = bindings.length > 0 ? descriptors : [];
5737
+ const bindingProviders = new Map<GitCredentialProvider, Set<string>>();
5738
+ for (const descriptor of runtimeGitBindingDescriptors(resources)) {
5739
+ const ids = bindingProviders.get(descriptor.provider) ?? new Set<string>();
5740
+ ids.add(descriptor.credentialBindingId);
5741
+ bindingProviders.set(descriptor.provider, ids);
5742
+ }
5743
+ const strictAskpass = [...bindingProviders.values()].some((ids) => ids.size > 1);
5744
+ const allowedWrapperHashes = [
5745
+ ...new Set(wrapperDescriptors.map((item) => `${item.provider}|${item.bindingHash}`)),
5746
+ ].map((key) => ` ${shellQuote(key)}) return 0 ;;`);
5747
+ const originWrapperHashes = wrapperDescriptors.flatMap((item) => {
5748
+ const base = item.uri.replace(/\.git$/, "");
5749
+ return [...new Set([item.uri, base, `${base}.git`])].map(
5750
+ (uri) =>
5751
+ ` ${shellQuote(`${item.provider}|${uri}`)}) printf '%s\\n' ${shellQuote(item.bindingHash)}; return 0 ;;`,
5752
+ );
5753
+ });
5754
+ const soleWrapperHashes = [...bindingProviders.entries()].flatMap(([provider, ids]) => {
5755
+ if (ids.size !== 1) return [];
5756
+ const descriptor = wrapperDescriptors.find((item) => item.provider === provider);
5757
+ return descriptor
5758
+ ? [
5759
+ ` ${shellQuote(provider)}) printf '%s\\n' ${shellQuote(descriptor.bindingHash)}; return 0 ;;`,
5760
+ ]
5761
+ : [];
5762
+ });
5763
+ const multiWrapperProviders =
5764
+ bindings.length > 0
5765
+ ? [...bindingProviders.entries()]
5766
+ .filter(([, ids]) => ids.size > 1)
5767
+ .map(([provider]) => provider)
5768
+ : [];
4845
5769
  return [
4846
- ...gitCredentialTokenWriterCommandLines(),
5770
+ ...gitCredentialTokenWriterCommandLines(bindings),
4847
5771
  // Provision git/provider-CLI helpers at SETUP (runtime) before any clone
4848
5772
  // runs. Renewal updates only token files and deliberately leaves these
4849
5773
  // repository-specific host mappings intact.
@@ -4860,8 +5784,8 @@ function gitCredentialHelperCommandLines(
4860
5784
  ' rest="${prompt_lower#*://}"',
4861
5785
  ' rest="${rest#*@}"',
4862
5786
  ' host="${rest%%/*}"',
4863
- ' host="${host%%:*}"',
4864
5787
  ' host="$(printf \'%s\\n\' "$host" | tr -d "\'")"',
5788
+ ' host="${host%:}"',
4865
5789
  " printf '%s\\n' \"$host\"",
4866
5790
  "}",
4867
5791
  "provider_for_prompt() {",
@@ -4873,7 +5797,7 @@ function gitCredentialHelperCommandLines(
4873
5797
  " *github.com*|*githubusercontent.com*) printf '%s\\n' github ;;",
4874
5798
  " *gitlab*) printf '%s\\n' gitlab ;;",
4875
5799
  " *dev.azure.com*|*.visualstudio.com*) printf '%s\\n' azure_devops ;;",
4876
- " *) printf '%s\\n' github ;;",
5800
+ strictAskpass ? " *) printf '\\n' ;;" : " *) printf '%s\\n' github ;;",
4877
5801
  " esac",
4878
5802
  "}",
4879
5803
  "token_file_for_provider() {",
@@ -4899,6 +5823,40 @@ function gitCredentialHelperCommandLines(
4899
5823
  "ASKPASS_EOF",
4900
5824
  'chmod 0755 "$git_askpass.tmp.$$"',
4901
5825
  'mv -f "$git_askpass.tmp.$$" "$git_askpass"',
5826
+ 'git_credential_helper="${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/helper"',
5827
+ 'mkdir -p "$(dirname "$git_credential_helper")"',
5828
+ "cat > \"$git_credential_helper.tmp.$$\" <<'GIT_CREDENTIAL_HELPER_EOF'",
5829
+ "#!/usr/bin/env sh",
5830
+ "set -eu",
5831
+ '[ "${1:-get}" = get ] || exit 0',
5832
+ "protocol= host= path=",
5833
+ "while IFS='=' read -r key value; do",
5834
+ ' case "$key" in',
5835
+ " protocol) protocol=\"$(printf '%s' \"$value\" | tr '[:upper:]' '[:lower:]')\" ;;",
5836
+ " host) host=\"$(printf '%s' \"$value\" | tr '[:upper:]' '[:lower:]')\" ;;",
5837
+ ' path) path="${value#/}" ;;',
5838
+ " esac",
5839
+ "done",
5840
+ 'credential_dir="${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}"',
5841
+ "username= token_file=",
5842
+ 'case "$protocol|$host|$path" in',
5843
+ ...bindingCases,
5844
+ " *) exit 0 ;;",
5845
+ "esac",
5846
+ '[ -r "$token_file" ] || exit 0',
5847
+ 'password="$(cat "$token_file" 2>/dev/null || true)"',
5848
+ '[ -n "$password" ] || exit 0',
5849
+ 'printf \'username=%s\\npassword=%s\\n\' "$username" "$password"',
5850
+ "GIT_CREDENTIAL_HELPER_EOF",
5851
+ 'chmod 0755 "$git_credential_helper.tmp.$$"',
5852
+ 'mv -f "$git_credential_helper.tmp.$$" "$git_credential_helper"',
5853
+ // Empty helper resets lower-priority/system helpers; our exact path-aware
5854
+ // helper returns no credential for an unbound remote, so multi-binding
5855
+ // sessions fail closed instead of falling through to ambient credentials.
5856
+ "git config --global --unset-all credential.helper >/dev/null 2>&1 || true",
5857
+ "git config --global --add credential.helper ''",
5858
+ 'git config --global --add credential.helper "$git_credential_helper"',
5859
+ "git config --global credential.useHttpPath true",
4902
5860
  'wrapper_dir="${OPENGENI_GIT_CLI_WRAPPER_DIR:-$HOME/.opengeni/bin}"',
4903
5861
  'mkdir -p "$wrapper_dir"',
4904
5862
  "for opengeni_git_cli_tool in gh glab az; do",
@@ -4913,11 +5871,53 @@ function gitCredentialHelperCommandLines(
4913
5871
  " az) provider=azure_devops; token_env=AZURE_DEVOPS_EXT_PAT ;;",
4914
5872
  " *) provider=; token_env= ;;",
4915
5873
  "esac",
4916
- 'if [ -n "$provider" ]; then',
5874
+ "hash_binding_id() {",
5875
+ " if command -v sha256sum >/dev/null 2>&1; then printf '%s' \"$1\" | sha256sum | cut -c1-32; return; fi",
5876
+ " if command -v shasum >/dev/null 2>&1; then printf '%s' \"$1\" | shasum -a 256 | cut -c1-32; return; fi",
5877
+ " if command -v openssl >/dev/null 2>&1; then printf '%s' \"$1\" | openssl dgst -sha256 | sed 's/^.*= //' | cut -c1-32; return; fi",
5878
+ " printf '%s\\n' 'No SHA-256 utility is available to select OPENGENI_GIT_BINDING' >&2",
5879
+ " return 127",
5880
+ "}",
5881
+ "binding_hash_allowed() {",
5882
+ ' case "$provider|$1" in',
5883
+ ...allowedWrapperHashes,
5884
+ " *) return 1 ;;",
5885
+ " esac",
5886
+ "}",
5887
+ "binding_hash_for_origin() {",
5888
+ ' case "$provider|$1" in',
5889
+ ...originWrapperHashes,
5890
+ " *) return 1 ;;",
5891
+ " esac",
5892
+ "}",
5893
+ "sole_binding_hash() {",
4917
5894
  ' case "$provider" in',
4918
- ' github) token_file="${OPENGENI_GIT_TOKEN_FILE:-$HOME/.opengeni/git-token}" ;;',
4919
- ' *) token_file="${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/$provider-token" ;;',
5895
+ ...soleWrapperHashes,
5896
+ " *) return 1 ;;",
4920
5897
  " esac",
5898
+ "}",
5899
+ `multi_binding_providers=${shellQuote(multiWrapperProviders.join(" "))}`,
5900
+ 'if [ -n "$provider" ]; then',
5901
+ " binding_hash=",
5902
+ ' if [ -n "${OPENGENI_GIT_BINDING:-}" ]; then',
5903
+ ' binding_hash="$(hash_binding_id "$OPENGENI_GIT_BINDING")"',
5904
+ ' binding_hash_allowed "$binding_hash" || { printf \'%s\\n\' "OPENGENI_GIT_BINDING does not select a $provider credential attached to this session" >&2; exit 2; }',
5905
+ " elif command -v git >/dev/null 2>&1; then",
5906
+ ' origin="$(git config --get remote.origin.url 2>/dev/null || true)"',
5907
+ ' [ -z "$origin" ] || binding_hash="$(binding_hash_for_origin "$origin" 2>/dev/null || true)"',
5908
+ " fi",
5909
+ ' [ -n "$binding_hash" ] || binding_hash="$(sole_binding_hash 2>/dev/null || true)"',
5910
+ ' if [ -n "$binding_hash" ]; then',
5911
+ ' token_file="${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/$binding_hash-token"',
5912
+ " else",
5913
+ ' case " $multi_binding_providers " in',
5914
+ ' *" $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 ;;',
5915
+ " esac",
5916
+ ' case "$provider" in',
5917
+ ' github) token_file="${OPENGENI_GIT_TOKEN_FILE:-$HOME/.opengeni/git-token}" ;;',
5918
+ ' *) token_file="${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/$provider-token" ;;',
5919
+ " esac",
5920
+ " fi",
4921
5921
  ' if [ -f "$token_file" ]; then',
4922
5922
  ' token="$(cat "$token_file" 2>/dev/null || true)"',
4923
5923
  ' if [ -n "$token" ]; then',
@@ -4972,10 +5972,23 @@ export function gitProviderTokenRefreshCommand(seeds: GitTokenSeeds): string {
4972
5972
  ].join("\n");
4973
5973
  }
4974
5974
 
5975
+ export function gitCredentialBindingTokenRefreshCommand(
5976
+ bindings: GitCredentialBindingSeed[],
5977
+ ): string {
5978
+ const seedPrefix = gitCredentialBindingSeedExportPrefix(bindings);
5979
+ if (!seedPrefix) return "";
5980
+ return [
5981
+ seedPrefix,
5982
+ "set -eu",
5983
+ 'export HOME="${HOME:-/workspace}"',
5984
+ ...gitCredentialTokenWriterCommandLines(bindings),
5985
+ ].join("\n");
5986
+ }
5987
+
4975
5988
  export async function refreshGitProviderTokenFiles(
4976
5989
  session: GitCredentialTokenWriterSession,
4977
5990
  seeds: GitTokenSeeds,
4978
- options: { runAs?: string } = {},
5991
+ options: { runAs?: string; commandRunner?: SandboxLifecycleCommandRunner } = {},
4979
5992
  ): Promise<void> {
4980
5993
  const command = gitProviderTokenRefreshCommand(seeds);
4981
5994
  if (!command) {
@@ -4988,23 +6001,41 @@ export async function refreshGitProviderTokenFiles(
4988
6001
  yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
4989
6002
  maxOutputTokens: 4_000,
4990
6003
  };
4991
- if (session.exec) {
4992
- assertSandboxCommandSucceeded(await session.exec(args), "Git credential refresh");
4993
- } else if (session.execCommand) {
4994
- assertSandboxCommandSucceeded(await session.execCommand(args), "Git credential refresh");
4995
- } else {
4996
- throw new Error("Sandbox session does not support command execution");
4997
- }
6004
+ assertSandboxCommandSucceeded(
6005
+ await runSandboxLifecycleCommand(session, args, options.commandRunner),
6006
+ "Git credential refresh",
6007
+ );
6008
+ }
6009
+
6010
+ export async function refreshGitCredentialBindingTokenFiles(
6011
+ session: GitCredentialTokenWriterSession,
6012
+ bindings: GitCredentialBindingSeed[],
6013
+ options: { runAs?: string; commandRunner?: SandboxLifecycleCommandRunner } = {},
6014
+ ): Promise<void> {
6015
+ const command = gitCredentialBindingTokenRefreshCommand(bindings);
6016
+ if (!command) return;
6017
+ const args = {
6018
+ cmd: command,
6019
+ workdir: "/workspace",
6020
+ ...(options.runAs ? { runAs: options.runAs } : {}),
6021
+ yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
6022
+ maxOutputTokens: 4_000,
6023
+ };
6024
+ assertSandboxCommandSucceeded(
6025
+ await runSandboxLifecycleCommand(session, args, options.commandRunner),
6026
+ "Git credential binding refresh",
6027
+ );
4998
6028
  }
4999
6029
 
5000
6030
  export function repositoryCloneCommand(
5001
6031
  resources: Extract<ResourceRef, { kind: "repository" }>[],
6032
+ bindings: GitCredentialBindingSeed[] = [],
5002
6033
  ): string {
6034
+ assertUniqueResourceMountPaths(resources);
5003
6035
  const commands = [
5004
6036
  "set -eu",
5005
6037
  'export HOME="${HOME:-/workspace}"',
5006
6038
  'export GIT_TERMINAL_PROMPT="${GIT_TERMINAL_PROMPT:-0}"',
5007
- ...gitCredentialHelperCommandLines(resources),
5008
6039
  "ensure_git() {",
5009
6040
  " if command -v git >/dev/null 2>&1; then",
5010
6041
  " return 0",
@@ -5020,6 +6051,7 @@ export function repositoryCloneCommand(
5020
6051
  " exit 127",
5021
6052
  "}",
5022
6053
  "ensure_git",
6054
+ ...gitCredentialHelperCommandLines(resources, bindings),
5023
6055
  "clone_repository() {",
5024
6056
  ' target="$1"',
5025
6057
  ' uri="$2"',
@@ -5092,33 +6124,41 @@ export function repositoryCloneCommand(
5092
6124
  "}",
5093
6125
  ];
5094
6126
  for (const resource of resources) {
5095
- const url = new URL(resource.uri);
5096
- const repo = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
5097
- const mountPath = normalizeManifestPath(resource.mountPath ?? `repos/${repo}`);
6127
+ const mountPath = resourceMountPath(resource);
5098
6128
  commands.push(
5099
6129
  [
5100
6130
  "clone_repository",
5101
6131
  shellQuote(posixPath.join("/workspace", mountPath)),
5102
6132
  shellQuote(resource.uri),
5103
6133
  shellQuote(resource.ref),
5104
- shellQuote(resource.subpath ? normalizeManifestPath(resource.subpath) : ""),
6134
+ shellQuote(resource.subpath ? normalizeRepositorySubpath(resource.subpath) : ""),
5105
6135
  ].join(" "),
5106
6136
  );
5107
6137
  }
5108
6138
  return commands.join("\n");
5109
6139
  }
5110
6140
 
5111
- export function toolspaceTokenSeedCommand(): string {
6141
+ export function toolspaceTokenSeedCommand(
6142
+ options: { tokenFile?: string; legacyTokenFile?: string } = {},
6143
+ ): string {
5112
6144
  return [
5113
6145
  "set -eu",
5114
6146
  'export HOME="${HOME:-/workspace}"',
5115
6147
  'if [ -n "${OPENGENI_TOOLSPACE_TOKEN_SEED:-}" ]; then',
5116
6148
  ' seed_umask="$(umask)"',
5117
6149
  " umask 077",
5118
- ' token_file="${OPENGENI_TOOLSPACE_TOKEN_FILE:-$HOME/.opengeni/toolspace-token}"',
6150
+ options.tokenFile
6151
+ ? ` token_file=${shellQuote(options.tokenFile)}`
6152
+ : ' token_file="${OPENGENI_TOOLSPACE_TOKEN_FILE:-$HOME/.opengeni/toolspace-token}"',
6153
+ options.legacyTokenFile
6154
+ ? ` legacy_token_file=${shellQuote(options.legacyTokenFile)}`
6155
+ : ' legacy_token_file=""',
5119
6156
  ' mkdir -p "$(dirname "$token_file")"',
5120
6157
  ' printf \'%s\' "$OPENGENI_TOOLSPACE_TOKEN_SEED" > "$token_file.tmp.$$"',
5121
6158
  ' mv -f "$token_file.tmp.$$" "$token_file"',
6159
+ ' if [ -n "$legacy_token_file" ] && [ "$legacy_token_file" != "$token_file" ]; then',
6160
+ ' rm -f -- "$legacy_token_file"',
6161
+ " fi",
5122
6162
  ' umask "$seed_umask"',
5123
6163
  "fi",
5124
6164
  ].join("\n");
@@ -5131,28 +6171,56 @@ export async function runToolspaceTokenSeedHook(
5131
6171
  if (!context.toolspaceTokenSeed) {
5132
6172
  return;
5133
6173
  }
5134
- const command = `export OPENGENI_TOOLSPACE_TOKEN_SEED=${shellQuote(context.toolspaceTokenSeed)}\n${toolspaceTokenSeedCommand()}`;
5135
- if (session.exec) {
5136
- const result = await session.exec({
6174
+ const command = `export OPENGENI_TOOLSPACE_TOKEN_SEED=${shellQuote(context.toolspaceTokenSeed)}\n${toolspaceTokenSeedCommand(
6175
+ {
6176
+ ...(context.toolspaceTokenFile ? { tokenFile: context.toolspaceTokenFile } : {}),
6177
+ ...(context.toolspaceTokenFile && context.environment.OPENGENI_TOOLSPACE_TOKEN_FILE
6178
+ ? { legacyTokenFile: context.environment.OPENGENI_TOOLSPACE_TOKEN_FILE }
6179
+ : {}),
6180
+ },
6181
+ )}`;
6182
+ const result = await runSandboxLifecycleCommand(
6183
+ session,
6184
+ {
5137
6185
  cmd: command,
5138
6186
  workdir: "/workspace",
5139
6187
  ...(context.runAs ? { runAs: context.runAs } : {}),
5140
6188
  yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
5141
6189
  maxOutputTokens: 4_000,
5142
- });
5143
- assertSandboxCommandSucceeded(result, "Toolspace token seed hook");
5144
- } else if (session.execCommand) {
5145
- const result = await session.execCommand({
6190
+ },
6191
+ context.commandRunner,
6192
+ );
6193
+ assertSandboxCommandSucceeded(result, "Toolspace token seed hook");
6194
+ }
6195
+
6196
+ export async function refreshToolspaceTokenFile(
6197
+ session: ToolspaceTokenWriterSession,
6198
+ token: string,
6199
+ options: {
6200
+ runAs?: string;
6201
+ commandRunner?: SandboxLifecycleCommandRunner;
6202
+ tokenFile?: string;
6203
+ legacyTokenFile?: string;
6204
+ } = {},
6205
+ ): Promise<void> {
6206
+ const command = `export OPENGENI_TOOLSPACE_TOKEN_SEED=${shellQuote(token)}\n${toolspaceTokenSeedCommand(
6207
+ {
6208
+ ...(options.tokenFile ? { tokenFile: options.tokenFile } : {}),
6209
+ ...(options.legacyTokenFile ? { legacyTokenFile: options.legacyTokenFile } : {}),
6210
+ },
6211
+ )}`;
6212
+ const result = await runSandboxLifecycleCommand(
6213
+ session,
6214
+ {
5146
6215
  cmd: command,
5147
6216
  workdir: "/workspace",
5148
- ...(context.runAs ? { runAs: context.runAs } : {}),
6217
+ ...(options.runAs ? { runAs: options.runAs } : {}),
5149
6218
  yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
5150
6219
  maxOutputTokens: 4_000,
5151
- });
5152
- assertSandboxCommandSucceeded(result, "Toolspace token seed hook");
5153
- } else {
5154
- throw new Error("Sandbox session does not support command execution");
5155
- }
6220
+ },
6221
+ options.commandRunner,
6222
+ );
6223
+ assertSandboxCommandSucceeded(result, "Toolspace token refresh");
5156
6224
  }
5157
6225
 
5158
6226
  // Bounds the setup output tail carried on a rig.setup failure event/error so a
@@ -5261,18 +6329,15 @@ export async function runRigSetupHook(
5261
6329
  };
5262
6330
  let result: unknown;
5263
6331
  try {
5264
- if (session.exec) {
5265
- result = await session.exec(execArgs);
5266
- } else if (session.execCommand) {
5267
- result = await session.execCommand(execArgs);
5268
- } else {
5269
- throw new Error("Sandbox session does not support command execution");
5270
- }
6332
+ result = await runSandboxLifecycleCommand(session, execArgs, context.commandRunner);
5271
6333
  } catch (error) {
5272
6334
  const message = error instanceof Error ? error.message : String(error);
5273
6335
  await context.onRuntimeEvent?.({
5274
6336
  type: "rig.setup.failed",
5275
- payload: { ...payload, error: message.slice(-RIG_SETUP_OUTPUT_TAIL_LIMIT) },
6337
+ payload: {
6338
+ ...payload,
6339
+ error: message.slice(-RIG_SETUP_OUTPUT_TAIL_LIMIT),
6340
+ },
5276
6341
  });
5277
6342
  throw new Error(
5278
6343
  `Rig setup failed for rig "${rigSetup.rigName}" (version ${rigSetup.versionId}): ${message}`,
@@ -5304,7 +6369,10 @@ export async function runRigSetupHook(
5304
6369
  );
5305
6370
  await context.onRuntimeEvent?.({
5306
6371
  type: "rig.setup.failed",
5307
- payload: { ...payload, error: failure.message.slice(-RIG_SETUP_OUTPUT_TAIL_LIMIT) },
6372
+ payload: {
6373
+ ...payload,
6374
+ error: failure.message.slice(-RIG_SETUP_OUTPUT_TAIL_LIMIT),
6375
+ },
5308
6376
  });
5309
6377
  throw failure;
5310
6378
  }
@@ -5319,8 +6387,14 @@ export async function runRepositoryCloneHook(
5319
6387
  resources: Extract<ResourceRef, { kind: "repository" }>[],
5320
6388
  context: SandboxLifecycleHookContext = { environment: {} },
5321
6389
  ): Promise<void> {
5322
- const payload = { name: "repository-clone", repositoryCount: resources.length };
5323
- await context.onRuntimeEvent?.({ type: "sandbox.operation.started", payload });
6390
+ const payload = {
6391
+ name: "repository-clone",
6392
+ repositoryCount: resources.length,
6393
+ };
6394
+ await context.onRuntimeEvent?.({
6395
+ type: "sandbox.operation.started",
6396
+ payload,
6397
+ });
5324
6398
  try {
5325
6399
  // TOKEN-BROKER (B1): thread run-scoped provider tokens PER-EXEC, never on
5326
6400
  // the manifest. The SDK's ExecCommandArgs has no `environment` field (exec
@@ -5334,32 +6408,31 @@ export async function runRepositoryCloneHook(
5334
6408
  ...(context.gitTokenSeeds ?? {}),
5335
6409
  ...(context.gitTokenSeed ? { github: context.gitTokenSeed } : {}),
5336
6410
  } satisfies GitTokenSeeds;
5337
- const seedPrefix = gitTokenSeedExportPrefix(gitTokenSeeds);
5338
- const command = seedPrefix
5339
- ? `${seedPrefix}\n${repositoryCloneCommand(resources)}`
5340
- : repositoryCloneCommand(resources);
5341
- if (session.exec) {
5342
- const result = await session.exec({
5343
- cmd: command,
5344
- workdir: "/workspace",
5345
- ...(context.runAs ? { runAs: context.runAs } : {}),
5346
- yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
5347
- maxOutputTokens: 20_000,
5348
- });
5349
- assertSandboxCommandSucceeded(result, "Repository clone hook");
5350
- } else if (session.execCommand) {
5351
- const result = await session.execCommand({
6411
+ const gitCredentialBindings = context.gitCredentialBindings ?? [];
6412
+ const seedPrefix = [
6413
+ gitTokenSeedExportPrefix(gitTokenSeeds),
6414
+ gitCredentialBindingSeedExportPrefix(gitCredentialBindings),
6415
+ ]
6416
+ .filter(Boolean)
6417
+ .join("\n");
6418
+ const cloneCommand = repositoryCloneCommand(resources, gitCredentialBindings);
6419
+ const command = seedPrefix ? `${seedPrefix}\n${cloneCommand}` : cloneCommand;
6420
+ const result = await runSandboxLifecycleCommand(
6421
+ session,
6422
+ {
5352
6423
  cmd: command,
5353
6424
  workdir: "/workspace",
5354
6425
  ...(context.runAs ? { runAs: context.runAs } : {}),
5355
6426
  yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
5356
6427
  maxOutputTokens: 20_000,
5357
- });
5358
- assertSandboxCommandSucceeded(result, "Repository clone hook");
5359
- } else {
5360
- throw new Error("Sandbox session does not support command execution");
5361
- }
5362
- await context.onRuntimeEvent?.({ type: "sandbox.operation.completed", payload });
6428
+ },
6429
+ context.commandRunner,
6430
+ );
6431
+ assertSandboxCommandSucceeded(result, "Repository clone hook");
6432
+ await context.onRuntimeEvent?.({
6433
+ type: "sandbox.operation.completed",
6434
+ payload,
6435
+ });
5363
6436
  } catch (error) {
5364
6437
  await context.onRuntimeEvent?.({
5365
6438
  type: "sandbox.operation.failed",
@@ -5472,31 +6545,31 @@ export async function runAzureCliLoginHook(
5472
6545
  session: SandboxSessionLike,
5473
6546
  context: SandboxLifecycleHookContext = { environment: {} },
5474
6547
  ): Promise<void> {
5475
- const payload = { name: "azure-cli-login", command: "az login --service-principal" };
5476
- await context.onRuntimeEvent?.({ type: "sandbox.operation.started", payload });
6548
+ const payload = {
6549
+ name: "azure-cli-login",
6550
+ command: "az login --service-principal",
6551
+ };
6552
+ await context.onRuntimeEvent?.({
6553
+ type: "sandbox.operation.started",
6554
+ payload,
6555
+ });
5477
6556
  try {
5478
- if (session.exec) {
5479
- const result = await session.exec({
5480
- cmd: azureCliLoginCommand(),
5481
- workdir: "/workspace",
5482
- ...(context.runAs ? { runAs: context.runAs } : {}),
5483
- yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
5484
- maxOutputTokens: 20_000,
5485
- });
5486
- assertSandboxCommandSucceeded(result, "Azure CLI login hook");
5487
- } else if (session.execCommand) {
5488
- const result = await session.execCommand({
6557
+ const result = await runSandboxLifecycleCommand(
6558
+ session,
6559
+ {
5489
6560
  cmd: azureCliLoginCommand(),
5490
6561
  workdir: "/workspace",
5491
6562
  ...(context.runAs ? { runAs: context.runAs } : {}),
5492
6563
  yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
5493
6564
  maxOutputTokens: 20_000,
5494
- });
5495
- assertSandboxCommandSucceeded(result, "Azure CLI login hook");
5496
- } else {
5497
- throw new Error("Sandbox session does not support command execution");
5498
- }
5499
- await context.onRuntimeEvent?.({ type: "sandbox.operation.completed", payload });
6565
+ },
6566
+ context.commandRunner,
6567
+ );
6568
+ assertSandboxCommandSucceeded(result, "Azure CLI login hook");
6569
+ await context.onRuntimeEvent?.({
6570
+ type: "sandbox.operation.completed",
6571
+ payload,
6572
+ });
5500
6573
  } catch (error) {
5501
6574
  await context.onRuntimeEvent?.({
5502
6575
  type: "sandbox.operation.failed",
@@ -5582,26 +6655,46 @@ function isPathWithin(root: string, candidate: string): boolean {
5582
6655
  }
5583
6656
 
5584
6657
  /**
5585
- * The skill source fed to the SDK Skills capability. Without pack skills this
5586
- * is the plain bundled local-dir source, byte-for-byte the pre-pack behavior.
5587
- * With pack skills it becomes a single in-memory dir source combining bundled
5588
- * skill directories (as local_dir entries the SDK materializes lazily) with
5589
- * pack skill directories built from manifest-carried file content — one skill
5590
- * index, one `## Skills` instruction section, lazy `load_skill` for all of
5591
- * them. A pack skill shadows a bundled skill with the same directory name.
6658
+ * The skill source fed to the SDK Skills capability. Without pack or curated
6659
+ * skills this is the plain bundled local-dir source, byte-for-byte the
6660
+ * pre-pack behavior. With either selected source it becomes a single
6661
+ * in-memory dir source combining bundled skill directories (as local_dir
6662
+ * entries the SDK materializes lazily) with selected in-memory skill
6663
+ * directories — one skill index, one `## Skills` instruction section, lazy
6664
+ * `load_skill` for all of them. A pack skill shadows a bundled or curated
6665
+ * skill with the same directory name, case-insensitively.
5592
6666
  */
5593
- export function lazySkillSourceWithPackSkills(packSkills: PackSkill[]): LocalDirLazySkillSource {
6667
+ export function lazySkillSourceWithPackSkills(
6668
+ packSkills: PackSkill[],
6669
+ skillLibrarySkills: PackSkill[] = [],
6670
+ ): LocalDirLazySkillSource {
5594
6671
  const bundledDir = bundledSkillsDir();
5595
6672
  const bundled = localDirLazySkillSource({ src: bundledDir });
5596
- if (packSkills.length === 0) {
6673
+ if (packSkills.length === 0 && skillLibrarySkills.length === 0) {
5597
6674
  return bundled;
5598
6675
  }
5599
6676
  const children: Record<string, Entry> = {};
5600
6677
  for (const name of bundledSkillDirNames(bundledDir)) {
5601
6678
  children[name] = localDir({ src: join(bundledDir, name) });
5602
6679
  }
6680
+ const libraryIndex: SkillIndexEntry[] = [];
6681
+ const libraryNameKeys = new Set<string>();
6682
+ for (const skill of skillLibrarySkills) {
6683
+ assertSafePackSkillName(skill.name);
6684
+ const key = skill.name.toLowerCase();
6685
+ if (libraryNameKeys.has(key)) {
6686
+ throw new Error(`Duplicate curated skill name: ${skill.name}`);
6687
+ }
6688
+ libraryNameKeys.add(key);
6689
+ removeSkillChildByNameKey(children, key);
6690
+ children[skill.name] = packSkillDirEntry(skill);
6691
+ libraryIndex.push({
6692
+ name: skill.name,
6693
+ description: packSkillDescription(skill),
6694
+ path: skill.name,
6695
+ });
6696
+ }
5603
6697
  const packIndex: SkillIndexEntry[] = [];
5604
- const packNames = new Set<string>();
5605
6698
  const packNameKeys = new Set<string>();
5606
6699
  for (const skill of packSkills) {
5607
6700
  assertSafePackSkillName(skill.name);
@@ -5609,7 +6702,7 @@ export function lazySkillSourceWithPackSkills(packSkills: PackSkill[]): LocalDir
5609
6702
  throw new Error(`Duplicate pack skill name: ${skill.name}`);
5610
6703
  }
5611
6704
  packNameKeys.add(skill.name.toLowerCase());
5612
- packNames.add(skill.name);
6705
+ removeSkillChildByNameKey(children, skill.name.toLowerCase());
5613
6706
  children[skill.name] = packSkillDirEntry(skill);
5614
6707
  packIndex.push({
5615
6708
  name: skill.name,
@@ -5621,13 +6714,62 @@ export function lazySkillSourceWithPackSkills(packSkills: PackSkill[]): LocalDir
5621
6714
  source: dir({ children }),
5622
6715
  getIndex: (manifest, skillsPath) => [
5623
6716
  ...(bundled.getIndex?.(manifest, skillsPath) ?? []).filter(
5624
- (entry) => !packNames.has(entry.path ?? entry.name),
6717
+ (entry) =>
6718
+ !packNameKeys.has((entry.path ?? entry.name).toLowerCase()) &&
6719
+ !libraryNameKeys.has((entry.path ?? entry.name).toLowerCase()),
6720
+ ),
6721
+ ...libraryIndex.filter(
6722
+ (entry) => !packNameKeys.has((entry.path ?? entry.name).toLowerCase()),
5625
6723
  ),
5626
6724
  ...packIndex,
5627
6725
  ],
5628
6726
  };
5629
6727
  }
5630
6728
 
6729
+ function effectiveSkillSelections(
6730
+ librarySelections: readonly EffectiveSkillSelection[],
6731
+ librarySkills: readonly PackSkill[],
6732
+ packSkills: readonly PackSkill[],
6733
+ ): readonly EffectiveSkillSelection[] {
6734
+ const defaultSelections = bundledSkillDirNames(bundledSkillsDir()).map((name) => ({
6735
+ id: `bundled:${name}`,
6736
+ name,
6737
+ source: "bundled" as const,
6738
+ version: null,
6739
+ contentSha256: null,
6740
+ reason: "deployment default skill bundle",
6741
+ }));
6742
+ const libraryNameKeys = new Set(librarySkills.map((skill) => skill.name.toLowerCase()));
6743
+ const packNameKeys = new Set(packSkills.map((skill) => skill.name.toLowerCase()));
6744
+ const selected = librarySelections.filter((selection) =>
6745
+ libraryNameKeys.has(selection.name.toLowerCase()),
6746
+ );
6747
+ return [
6748
+ ...defaultSelections.filter(
6749
+ (selection) =>
6750
+ !libraryNameKeys.has(selection.name.toLowerCase()) &&
6751
+ !packNameKeys.has(selection.name.toLowerCase()),
6752
+ ),
6753
+ ...selected.filter((selection) => !packNameKeys.has(selection.name.toLowerCase())),
6754
+ ...packSkills.map((skill) => ({
6755
+ id: `pack:${skill.name}`,
6756
+ name: skill.name,
6757
+ source: "pack" as const,
6758
+ version: null,
6759
+ contentSha256: null,
6760
+ reason: "enabled capability pack",
6761
+ })),
6762
+ ];
6763
+ }
6764
+
6765
+ function removeSkillChildByNameKey(children: Record<string, Entry>, nameKey: string): void {
6766
+ for (const name of Object.keys(children)) {
6767
+ if (name.toLowerCase() === nameKey) {
6768
+ delete children[name];
6769
+ }
6770
+ }
6771
+ }
6772
+
5631
6773
  function bundledSkillDirNames(root: string): string[] {
5632
6774
  return readdirSync(root, { withFileTypes: true })
5633
6775
  .filter((entry) => entry.isDirectory() && existsSync(join(root, entry.name, "SKILL.md")))
@@ -5771,7 +6913,12 @@ function sortJson(value: unknown): unknown {
5771
6913
  }
5772
6914
  return value;
5773
6915
  }
5774
-
5775
- function approvalIdentifier(item: any): string {
5776
- return String(item?.rawItem?.callId ?? item?.rawItem?.id ?? item?.id ?? item?.name ?? "approval");
6916
+ function interruptionToolName(item: unknown): string {
6917
+ const candidate = item as {
6918
+ toolName?: unknown;
6919
+ name?: unknown;
6920
+ rawItem?: { name?: unknown };
6921
+ };
6922
+ const name = candidate?.toolName ?? candidate?.name ?? candidate?.rawItem?.name;
6923
+ return typeof name === "string" ? name : "";
5777
6924
  }