@opengeni/runtime 0.7.1 → 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 +329 -121
  16. package/dist/index.js +8149 -307
  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 +1335 -280
  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
 
@@ -259,9 +316,11 @@ export type ResolveConnectionCredentialResult =
259
316
  status: "auth_needed";
260
317
  reason: ToolAuthNeededPayload["reason"];
261
318
  providerDomain: string;
319
+ provider?: string;
262
320
  connectionId?: string;
263
321
  scopes?: string[];
264
322
  resource?: string;
323
+ selectedResources?: McpServerConnectionRef["selectedResources"];
265
324
  authorizationUrl?: string;
266
325
  };
267
326
 
@@ -316,6 +375,11 @@ export type AgentSegmentInput =
316
375
  approvalId: string;
317
376
  decision: "approve" | "reject";
318
377
  message?: string;
378
+ }
379
+ | {
380
+ kind: "human_input";
381
+ serializedRunState: string;
382
+ toolCallId: string;
319
383
  };
320
384
 
321
385
  export type PreparedAgentInput = {
@@ -323,6 +387,13 @@ export type PreparedAgentInput = {
323
387
  sandboxSessionState?: SandboxSessionState;
324
388
  };
325
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
+
326
397
  export type SandboxFileDownload = {
327
398
  fileId: string;
328
399
  mountPath: string;
@@ -381,6 +452,7 @@ export type OpenGeniRuntime = {
381
452
  options?: RunAgentStreamOptions,
382
453
  ) => Promise<Awaited<ReturnType<typeof runAgentStream>>>;
383
454
  serializeApprovals: (interruptions: unknown[]) => unknown[];
455
+ serializeHumanInputRequests?: (interruptions: unknown[]) => SerializedHumanInputInterruption[];
384
456
  };
385
457
 
386
458
  export type ProductionRuntimeOverrides = {
@@ -416,6 +488,7 @@ export function createProductionAgentRuntime(
416
488
  sandboxClient: overrides.sandboxClient,
417
489
  }),
418
490
  serializeApprovals,
491
+ serializeHumanInputRequests,
419
492
  };
420
493
  }
421
494
 
@@ -804,19 +877,28 @@ export async function summarizeForCompaction(
804
877
  const maxTokens = options.maxOutputTokens ?? SUMMARY_BUFFER_TOKENS;
805
878
  if (api === "chat") {
806
879
  const transcript = renderCompactionPromptInputForChat(input);
807
- const completion = await client.chat.completions.create({
808
- model,
809
- max_tokens: maxTokens,
810
- messages: [{ role: "user", content: transcript }],
811
- ...(options.promptCacheKey ? { prompt_cache_key: options.promptCacheKey } : {}),
812
- } as any);
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
+ }
813
891
  const usage = modelResponseUsageFromResponse(completion);
814
892
  if (usage) {
815
893
  await options.onUsage?.(usage);
816
894
  }
817
895
  const text = (completion as { choices?: Array<{ message?: { content?: unknown } }> })
818
896
  .choices?.[0]?.message?.content;
819
- 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;
820
902
  }
821
903
  // Use the same SDK Responses adapter as the real agent call. It converts the
822
904
  // structured AgentInputItems (callId/providerData/etc.) to provider wire
@@ -839,12 +921,205 @@ export async function summarizeForCompaction(
839
921
  handoffs: [],
840
922
  tracing: false,
841
923
  };
842
- 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
+ }
843
930
  const usage = modelResponseUsageFromResponse(response);
844
931
  if (usage) {
845
932
  await options.onUsage?.(usage);
846
933
  }
847
- 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
+ };
848
1123
  }
849
1124
 
850
1125
  /**
@@ -909,10 +1184,49 @@ class CompactionResponsesModel extends OpenAIResponsesModel {
909
1184
  }
910
1185
 
911
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
+ };
912
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
+ }
913
1221
 
914
1222
  export type BuildAgentOptions = {
915
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
+ };
916
1230
  reasoningEffort?: ReasoningEffort;
917
1231
  // Per-turn gating overrides for the multi-provider path. Each defaults to
918
1232
  // today's settings-derived behaviour when omitted, so the legacy
@@ -1008,11 +1322,18 @@ export type BuildAgentOptions = {
1008
1322
  // repository-clone hooks; runStream forwards them into the hook context, which
1009
1323
  // seeds provider token FILES before the clone/setup runs.
1010
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[];
1011
1328
  // TOOLSPACE: the run-scoped delegated token to seed into
1012
1329
  // $OPENGENI_TOOLSPACE_TOKEN_FILE. Like gitTokenSeed, this stays off the
1013
1330
  // manifest/env delta and is written into the sandbox filesystem by a lifecycle
1014
1331
  // hook before the agent starts.
1015
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;
1016
1337
  // Genesis turn only: inject a one-shot instruction into the FIRST model
1017
1338
  // call telling it to title the session via opengeni__set_session_title.
1018
1339
  // Keeping this out of the persistent Agent.instructions prevents every
@@ -1040,10 +1361,32 @@ export type BuildAgentOptions = {
1040
1361
  // timeline message. Omitted ⇒ the composed instructions are byte-identical to
1041
1362
  // a workspace-only persona.
1042
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;
1043
1367
  // Skills delivered by enabled capability packs. They join the bundled
1044
1368
  // skills in the sandbox skill index (mounted under .agents/) so
1045
1369
  // skills/<name> references resolve like any other indexed skill.
1046
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;
1047
1390
  };
1048
1391
 
1049
1392
  export type PackSkillFile = {
@@ -1171,6 +1514,12 @@ export function appendSessionInstructions(composed: string, sessionInstructions?
1171
1514
  return trimmed ? `${composed} ${trimmed}` : composed;
1172
1515
  }
1173
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
+
1174
1523
  /**
1175
1524
  * Append the durable session metadata the model otherwise cannot observe.
1176
1525
  * This is deliberately declarative and excludes the title text: the title is
@@ -1239,7 +1588,9 @@ const agentRepositoryCloneHooks = new WeakMap<object, SandboxLifecycleHook[]>();
1239
1588
  // provided-session env; runStream reads them to build the clone hook context.
1240
1589
  // Absent when no brokered repo is attached / on the selfhosted path.
1241
1590
  const agentGitTokenSeeds = new WeakMap<object, GitTokenSeeds>();
1591
+ const agentGitCredentialBindings = new WeakMap<object, GitCredentialBindingSeed[]>();
1242
1592
  const agentToolspaceTokenSeed = new WeakMap<object, string>();
1593
+ const agentToolspaceTokenSessionId = new WeakMap<object, string>();
1243
1594
  // A genesis directive is consumed by runAgentStream exactly once for the
1244
1595
  // freshly-built agent. It must not remain in Agent.instructions: those
1245
1596
  // instructions are presented again on every internal model/tool loop.
@@ -1308,6 +1659,9 @@ export function buildOpenGeniAgent(
1308
1659
  resources: ResourceRef[],
1309
1660
  options: BuildAgentOptions = {},
1310
1661
  ): Agent<any, any> {
1662
+ if (Boolean(options.toolspaceTokenSeed) !== Boolean(options.toolspaceTokenSessionId)) {
1663
+ throw new Error("toolspaceTokenSeed and toolspaceTokenSessionId must be supplied together");
1664
+ }
1311
1665
  // Resolved per-turn gating. Each override defaults to today's settings-derived
1312
1666
  // behaviour, so the legacy global-client callers (no resolved model) build the
1313
1667
  // exact same agent as before; the multi-provider worker path passes the
@@ -1329,6 +1683,28 @@ export function buildOpenGeniAgent(
1329
1683
  // [...agent.tools, ...capability.tools()]), so hosted web_search coexists with
1330
1684
  // both rather than overriding them.
1331
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];
1332
1708
  const baseConfig = {
1333
1709
  name: "OpenGeni Agent",
1334
1710
  model: options.model ?? settings.openaiModel,
@@ -1351,24 +1727,28 @@ export function buildOpenGeniAgent(
1351
1727
  // and the worker resolved a nonblank block — appendWorkspaceMemory,
1352
1728
  // 4. + the per-session persona instructions (session-specific, so it
1353
1729
  // refines both the workspace persona and the substrate note),
1354
- // 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
1355
1732
  // mode), when supplied by the worker,
1356
1733
  // The genesis title directive is deliberately NOT part of this persistent
1357
1734
  // string. runAgentStream injects it into the first model call only.
1358
1735
  instructions: appendPersistentSessionSettings(
1359
- appendSessionInstructions(
1360
- appendWorkspaceMemory(
1361
- appendToolspaceInstructions(
1362
- composeAgentInstructions(
1363
- options.instructionsTemplate ?? settings.agentInstructionsTemplate,
1364
- options.workspaceEnvironment,
1365
- 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),
1366
1746
  ),
1367
- settings.toolspaceEnabled && Boolean(options.toolspaceTokenSeed),
1747
+ options.workspaceMemory,
1368
1748
  ),
1369
- options.workspaceMemory,
1749
+ options.sessionInstructions,
1370
1750
  ),
1371
- options.sessionInstructions,
1751
+ options.turnInstructions,
1372
1752
  ),
1373
1753
  options.persistentSessionSettings,
1374
1754
  ),
@@ -1391,7 +1771,7 @@ export function buildOpenGeniAgent(
1391
1771
  // `new Agent(baseConfig)` path (sandboxBackend === "none") and the
1392
1772
  // `new SandboxAgent({ ...baseConfig, ... })` path via the shared baseConfig
1393
1773
  // spread; the SDK concatenates these with MCP and sandbox capability tools.
1394
- ...(hostedTools.length ? { tools: hostedTools } : {}),
1774
+ tools: agentTools,
1395
1775
  ...(options.mcpServers?.length ? { mcpServers: options.mcpServers } : {}),
1396
1776
  // Surface FAILED MCP tool calls as `{ isError: true }` tool output (see
1397
1777
  // mcpToolErrorFunction / mcpToolErrorOutput) instead of the SDK's default
@@ -1423,6 +1803,9 @@ export function buildOpenGeniAgent(
1423
1803
  ),
1424
1804
  ...(runAs ? { runAs } : {}),
1425
1805
  capabilities: buildAgentCapabilities(settings, options.packSkills ?? [], {
1806
+ ...(options.skillLibrarySkills?.length
1807
+ ? { skillLibrarySkills: options.skillLibrarySkills }
1808
+ : {}),
1426
1809
  ...(options.structuredToolTransport !== undefined
1427
1810
  ? { structuredToolTransport: options.structuredToolTransport }
1428
1811
  : {}),
@@ -1430,8 +1813,24 @@ export function buildOpenGeniAgent(
1430
1813
  ? { computerToolMode: options.computerToolMode }
1431
1814
  : {}),
1432
1815
  ...(options.onComputerUseReady ? { onComputerUseReady: options.onComputerUseReady } : {}),
1816
+ ...(options.turnCancellationSignal
1817
+ ? { turnCancellationSignal: options.turnCancellationSignal }
1818
+ : {}),
1819
+ ...(options.onToolCancellationFence
1820
+ ? { onToolCancellationFence: options.onToolCancellationFence }
1821
+ : {}),
1433
1822
  }),
1434
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
+ );
1435
1834
  if (options.genesisTitleHint) {
1436
1835
  agentsNeedingGenesisTitleDirective.add(agent);
1437
1836
  }
@@ -1463,8 +1862,12 @@ export function buildOpenGeniAgent(
1463
1862
  if (Object.keys(gitTokenSeeds).length > 0) {
1464
1863
  agentGitTokenSeeds.set(agent, gitTokenSeeds);
1465
1864
  }
1865
+ if (options.gitCredentialBindings && options.gitCredentialBindings.length > 0) {
1866
+ agentGitCredentialBindings.set(agent, options.gitCredentialBindings);
1867
+ }
1466
1868
  if (options.toolspaceTokenSeed) {
1467
1869
  agentToolspaceTokenSeed.set(agent, options.toolspaceTokenSeed);
1870
+ agentToolspaceTokenSessionId.set(agent, options.toolspaceTokenSessionId!);
1468
1871
  }
1469
1872
  // M3: stash the rig setup descriptor + RESOLVE the rig credential hooks now.
1470
1873
  // sandboxLifecycleHooksForIds throws on an unknown hook name, so a typo'd rig
@@ -1721,6 +2124,7 @@ export function buildAgentCapabilities(
1721
2124
  settings: Settings,
1722
2125
  packSkills: PackSkill[],
1723
2126
  options: {
2127
+ skillLibrarySkills?: PackSkill[];
1724
2128
  structuredToolTransport?: boolean;
1725
2129
  // EXPLICIT computer-use transport (see BuildAgentOptions.computerToolMode). When
1726
2130
  // present, computerUse() is handed the mode directly and its tools() obeys it
@@ -1728,8 +2132,15 @@ export function buildAgentCapabilities(
1728
2132
  // imageFunctionResults path (driven by structuredToolTransport) is unchanged.
1729
2133
  computerToolMode?: ComputerToolMode;
1730
2134
  onComputerUseReady?: (session: SandboxSessionLike) => Promise<void>;
2135
+ turnCancellationSignal?: AbortSignal;
2136
+ onToolCancellationFence?: (fence: TurnToolCancellationFence) => void;
1731
2137
  } = {},
1732
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);
1733
2144
  // The `filesystem()` capability picks hosted-vs-function tool variants from the
1734
2145
  // bound model instance (supportsApplyPatchTransport / structured tool output).
1735
2146
  // When the caller declares the backend does NOT support that structured/hosted
@@ -1748,9 +2159,13 @@ export function buildAgentCapabilities(
1748
2159
  }
1749
2160
  const caps: ReturnType<typeof Capabilities.default> = [
1750
2161
  filesystemCapability,
1751
- shell({ configureTools: withExecOpCorrelation }),
2162
+ shell({ ...(toolCancellation ? {} : { configureTools: withExecOpCorrelation }) }),
1752
2163
  ];
1753
- caps.push(skills({ lazyFrom: lazySkillSourceWithPackSkills(packSkills) }));
2164
+ caps.push(
2165
+ skills({
2166
+ lazyFrom: lazySkillSourceWithPackSkills(packSkills, options.skillLibrarySkills ?? []),
2167
+ }),
2168
+ );
1754
2169
  // P4.3 computer-use: the agent drives the SAME :0 humans watch (xdotool/XTEST +
1755
2170
  // scrot), but only when the desktop tier is ON, computer-use is enabled, and the
1756
2171
  // backend is one whose image carries the X stack (descriptorgate — honest about
@@ -1802,6 +2217,14 @@ export function buildAgentCapabilities(
1802
2217
  }
1803
2218
  caps.push(computerCapability as unknown as ReturnType<typeof Capabilities.default>[number]);
1804
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
+ }
1805
2228
  return caps;
1806
2229
  }
1807
2230
 
@@ -2023,9 +2446,13 @@ function connectionBrokerFetch(
2023
2446
  status: "auth_needed",
2024
2447
  reason: "expired",
2025
2448
  providerDomain: connectionRef.providerDomain,
2449
+ ...(connectionRef.provider ? { provider: connectionRef.provider } : {}),
2026
2450
  connectionId: refreshed.connectionId,
2027
2451
  ...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
2028
2452
  ...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
2453
+ ...(connectionRef.selectedResources
2454
+ ? { selectedResources: connectionRef.selectedResources }
2455
+ : {}),
2029
2456
  },
2030
2457
  connectionRef,
2031
2458
  );
@@ -2054,9 +2481,13 @@ async function resolveConnectionForRequest(
2054
2481
  status: "auth_needed",
2055
2482
  reason: "missing_connection",
2056
2483
  providerDomain: connectionRef.providerDomain,
2484
+ ...(connectionRef.provider ? { provider: connectionRef.provider } : {}),
2057
2485
  ...(connectionRef.connectionId ? { connectionId: connectionRef.connectionId } : {}),
2058
2486
  ...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
2059
2487
  ...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
2488
+ ...(connectionRef.selectedResources
2489
+ ? { selectedResources: connectionRef.selectedResources }
2490
+ : {}),
2060
2491
  };
2061
2492
  }
2062
2493
  const request: ResolveConnectionCredentialInput = {
@@ -2074,9 +2505,13 @@ async function resolveConnectionForRequest(
2074
2505
  status: "auth_needed",
2075
2506
  reason: "refresh_failed",
2076
2507
  providerDomain: connectionRef.providerDomain,
2508
+ ...(connectionRef.provider ? { provider: connectionRef.provider } : {}),
2077
2509
  ...(connectionRef.connectionId ? { connectionId: connectionRef.connectionId } : {}),
2078
2510
  ...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
2079
2511
  ...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
2512
+ ...(connectionRef.selectedResources
2513
+ ? { selectedResources: connectionRef.selectedResources }
2514
+ : {}),
2080
2515
  };
2081
2516
  }
2082
2517
  }
@@ -2094,6 +2529,7 @@ function insufficientScopeAuth(
2094
2529
  status: "auth_needed",
2095
2530
  reason: "insufficient_scope",
2096
2531
  providerDomain: connectionRef.providerDomain,
2532
+ ...(connectionRef.provider ? { provider: connectionRef.provider } : {}),
2097
2533
  connectionId,
2098
2534
  ...(challenge.scope?.length
2099
2535
  ? { scopes: challenge.scope }
@@ -2105,6 +2541,9 @@ function insufficientScopeAuth(
2105
2541
  : connectionRef.resource
2106
2542
  ? { resource: connectionRef.resource }
2107
2543
  : {}),
2544
+ ...(connectionRef.selectedResources
2545
+ ? { selectedResources: connectionRef.selectedResources }
2546
+ : {}),
2108
2547
  };
2109
2548
  }
2110
2549
 
@@ -2120,6 +2559,11 @@ async function authNeededFetchResponse(
2120
2559
  serverId,
2121
2560
  toolName: request.toolName ?? null,
2122
2561
  providerDomain: auth.providerDomain,
2562
+ ...(auth.provider
2563
+ ? { provider: auth.provider }
2564
+ : connectionRef.provider
2565
+ ? { provider: connectionRef.provider }
2566
+ : {}),
2123
2567
  reason: auth.reason,
2124
2568
  ...(connectionId ? { connectionId } : {}),
2125
2569
  ...(auth.scopes
@@ -2132,6 +2576,11 @@ async function authNeededFetchResponse(
2132
2576
  : connectionRef.resource
2133
2577
  ? { resource: connectionRef.resource }
2134
2578
  : {}),
2579
+ ...(auth.selectedResources
2580
+ ? { selectedResources: auth.selectedResources }
2581
+ : connectionRef.selectedResources
2582
+ ? { selectedResources: connectionRef.selectedResources }
2583
+ : {}),
2135
2584
  ...(auth.authorizationUrl ? { authorizationUrl: auth.authorizationUrl } : {}),
2136
2585
  ...(options.subjectId ? { subjectId: options.subjectId } : {}),
2137
2586
  });
@@ -2588,6 +3037,7 @@ class PrefixedMcpServer implements MCPServer {
2588
3037
  // doesn't spam the log when the SDK re-lists across model turns.
2589
3038
  private readonly bestEffort: boolean;
2590
3039
  private loggedListToolsFailure = false;
3040
+ private listedToolSchemaTokens = 0;
2591
3041
 
2592
3042
  constructor(
2593
3043
  private readonly inner: MCPServer,
@@ -2644,12 +3094,19 @@ class PrefixedMcpServer implements MCPServer {
2644
3094
  }
2645
3095
  return [];
2646
3096
  }
2647
- return tools
3097
+ const exposed = tools
2648
3098
  .filter((tool) => this.isAllowed(tool.name))
2649
3099
  .map((tool) => ({
2650
3100
  ...tool,
2651
3101
  name: prefixedMcpToolName(this.name, tool.name),
2652
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;
2653
3110
  }
2654
3111
 
2655
3112
  async callTool(
@@ -2813,22 +3270,25 @@ export async function prepareRunInput(
2813
3270
  ...(sandboxSessionState ? { sandboxSessionState } : {}),
2814
3271
  };
2815
3272
  }
2816
- // 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
2817
3274
  // latest blob is the cleared sentinel the awaiting turn was wiped (the API
2818
3275
  // refuses clear in requires_action, so this is a defensive guard) — fail with
2819
3276
  // an honest message instead of the cryptic SDK "missing schema version".
2820
3277
  if (isClearedRunStateBlob(input.serializedRunState)) {
2821
3278
  throw new Error(
2822
- "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.",
2823
3280
  );
2824
3281
  }
2825
3282
  const state = await RunState.fromString(agent, input.serializedRunState);
2826
3283
  const interruptions = state.getInterruptions();
2827
- 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);
2828
3286
  if (!target) {
2829
- 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}`);
2830
3288
  }
2831
- if (input.decision === "approve") {
3289
+ if (input.kind === "human_input") {
3290
+ state.approve(target as any);
3291
+ } else if (input.decision === "approve") {
2832
3292
  state.approve(target as any);
2833
3293
  } else {
2834
3294
  state.reject(target as any, input.message ? { message: input.message } : undefined);
@@ -2842,13 +3302,21 @@ export type RunAgentStreamOptions = {
2842
3302
  sandboxClient?: unknown;
2843
3303
  sandboxEnvironment?: Record<string, string>;
2844
3304
  onRuntimeEvent?: (event: NormalizedRuntimeEvent) => Promise<void> | void;
2845
- contextCompactionSignalTokens?: () => number | null | undefined;
3305
+ contextCompactionSignal?: () => ProviderContextTokenSignal | null | undefined;
2846
3306
  contextCompactionRequested?: () => boolean | Promise<boolean>;
2847
3307
  // Host-managed git credential renewal registration. Called only after the
2848
3308
  // initial token-file seed completed on a real provisioned box. The worker
2849
3309
  // owns the multi-day timer and uses this pinned, un-proxied session to
2850
3310
  // atomically replace token files; runtime never mints credentials itself.
2851
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;
2852
3320
  // OWNERSHIP INVERSION (P1.2): an externally-owned, already-live sandbox
2853
3321
  // session resolved by the per-turn resume-by-id path. When present,
2854
3322
  // runAgentStream does NOT build (or resume, or discard) a client — it threads
@@ -2879,6 +3347,13 @@ export type RunAgentStreamOptions = {
2879
3347
  // after establish, so runAgentStream must not run it eagerly here.
2880
3348
  deferredSetup?: boolean;
2881
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;
2882
3357
  // A per-turn model-input filter chained AFTER the provider-item-id strip.
2883
3358
  // Used by the genesis-title injection to prepend a hidden, NON-PERSISTED
2884
3359
  // directive: a callModelInputFilter mutates only `modelData.input` for each
@@ -2888,7 +3363,7 @@ export type RunAgentStreamOptions = {
2888
3363
  };
2889
3364
 
2890
3365
  export type ContextRobustnessFilterOptions = {
2891
- contextCompactionSignalTokens?: () => number | null | undefined;
3366
+ contextCompactionSignal?: () => ProviderContextTokenSignal | null | undefined;
2892
3367
  contextCompactionRequested?: () => boolean | Promise<boolean>;
2893
3368
  throwOnCompactionNeeded?: boolean;
2894
3369
  };
@@ -2934,9 +3409,10 @@ function takeGenesisTitleInputFilter(agent: Agent<any, any>): CallModelInputFilt
2934
3409
  // Generic substrate prompting for programmatic tool calling (toolspace). Same
2935
3410
  // text for every host; gated per-turn by appendToolspaceInstructions on the
2936
3411
  // presence of a minted toolspace token, so it only appears when the sandbox
2937
- // 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.
2938
3414
  export const TOOLSPACE_PROGRAMMATIC_DIRECTIVE =
2939
- "Every tool on your MCP surface is also callable programmatically from the sandbox shell, so scripts can invoke tools without a model round trip per call. Run `ogtool list` to see the available tools and their input schemas (from tools/list), then `ogtool call <tool-name> '<json-args>'`; equivalently, POST MCP JSON-RPC to $OPENGENI_TOOLSPACE_URL with the bearer token read from $OPENGENI_TOOLSPACE_TOKEN_FILE. Prefer programmatic calls for loops, polling, and bulk filtering: their results stay in the sandbox and do not consume your context window. Tools that require human approval must still be invoked normally — called programmatically they return a typed error.";
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.";
2940
3416
 
2941
3417
  /**
2942
3418
  * callModelInputFilter that removes provider-assigned item ids (rs_/msg_/fc_…)
@@ -2995,24 +3471,89 @@ export const elideSupersededViewImagesFilter: CallModelInputFilter = ({ modelDat
2995
3471
  ) as unknown as AgentInputItem[],
2996
3472
  });
2997
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
+
2998
3517
  export function contextRobustnessFilterForSettings(
2999
3518
  settings: Settings,
3000
3519
  options: ContextRobustnessFilterOptions = {},
3001
3520
  ): CallModelInputFilter {
3002
3521
  const thresholdTokens = compactionThresholdTokens(settings);
3003
- return async ({ modelData }) => {
3522
+ let previousRequest: { revision: number; footprint: CompleteModelInputFootprint } | null = null;
3523
+ let requestRevision = 0;
3524
+ return async ({ modelData, agent }) => {
3004
3525
  const input = modelData.input;
3005
3526
  if (options.throwOnCompactionNeeded) {
3006
- const reported = options.contextCompactionSignalTokens?.();
3007
- const hasReported = typeof reported === "number" && reported > 0;
3008
- const signalTokens = hasReported
3009
- ? reported
3010
- : estimateTokens(input as unknown as Array<Record<string, unknown>>);
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 };
3011
3552
  if (await options.contextCompactionRequested?.()) {
3012
3553
  throw new CompactionNeededError({
3013
3554
  signalTokens,
3014
3555
  thresholdTokens,
3015
- signalSource: hasReported ? "provider" : "estimate",
3556
+ signalSource: boundProvider ? "provider" : "estimate",
3016
3557
  trigger: "operator",
3017
3558
  });
3018
3559
  }
@@ -3020,7 +3561,7 @@ export function contextRobustnessFilterForSettings(
3020
3561
  throw new CompactionNeededError({
3021
3562
  signalTokens,
3022
3563
  thresholdTokens,
3023
- signalSource: hasReported ? "provider" : "estimate",
3564
+ signalSource: boundProvider ? "provider" : "estimate",
3024
3565
  });
3025
3566
  }
3026
3567
  }
@@ -3057,6 +3598,7 @@ export function callModelInputFilterForSettings(
3057
3598
  const filters: CallModelInputFilter[] = [
3058
3599
  normalizeComputerCallsFilter,
3059
3600
  elideSupersededViewImagesFilter,
3601
+ boundModelToolOutputsFilterForSettings(settings),
3060
3602
  ];
3061
3603
  if (settings.openaiProviderItemIds === "strip") {
3062
3604
  filters.push(stripProviderItemIdsFilter);
@@ -3074,7 +3616,11 @@ export async function runAgentStream(
3074
3616
  const prepared: PreparedAgentInput =
3075
3617
  typeof input === "string" || input instanceof RunState ? { input } : input;
3076
3618
  const environment = overrides.sandboxEnvironment ?? collectSandboxEnvironment(settings);
3619
+ const toolspaceTokenFile = toolspaceTokenFileForAgent(agent, environment);
3077
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
+ }
3078
3624
 
3079
3625
  // OWNED PATH (P1.2 ownership inversion): the per-turn resume path injected a
3080
3626
  // live, externally-owned box. We thread the live `session` straight into
@@ -3093,11 +3639,24 @@ export async function runAgentStream(
3093
3639
  // whose per-op pointer re-read could land these execs on a machine swapped in
3094
3640
  // mid-turn.
3095
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;
3096
3654
  // Platform setup (manifest-env pin + beforeAgentStart hooks + file downloads)
3097
3655
  // against the UN-proxied established box — the ONE-TRUTH helper shared with the
3098
3656
  // lazy provisioner. Eager path: runs here, before the run starts (unchanged).
3099
3657
  if (!overrides.ownedSandbox.deferredSetup) {
3100
- await runOwnedSandboxSetup(agent, session as SandboxSessionLike, setupSession, {
3658
+ await overrides.onRunCredentialSessionReady?.(session as SandboxSessionLike);
3659
+ await runOwnedSandboxSetup(agent, session as SandboxSessionLike, decoratedSetupSession, {
3101
3660
  settings,
3102
3661
  environment,
3103
3662
  preparedInput: prepared,
@@ -3105,7 +3664,17 @@ export async function runAgentStream(
3105
3664
  ? { fileDownloadsMaterialized: true }
3106
3665
  : {}),
3107
3666
  ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
3667
+ ...(overrides.turnToolCancellationFence
3668
+ ? {
3669
+ commandRunner: overrides.turnToolCancellationFence.runSandboxCommand.bind(
3670
+ overrides.turnToolCancellationFence,
3671
+ ),
3672
+ }
3673
+ : {}),
3108
3674
  });
3675
+ if (toolspaceTokenSeedForAgent(agent)) {
3676
+ await overrides.onToolspaceTokenSessionReady?.(agentSession);
3677
+ }
3109
3678
  await overrides.onGitCredentialSessionReady?.(setupSession);
3110
3679
  }
3111
3680
  const runAs = sandboxRunAs(settings);
@@ -3115,11 +3684,19 @@ export async function runAgentStream(
3115
3684
  ? withSandboxFileDownloads(ownedClient as SandboxClient, fileDownloads, {
3116
3685
  ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
3117
3686
  ...(runAs ? { runAs } : {}),
3687
+ ...(overrides.turnToolCancellationFence
3688
+ ? {
3689
+ commandRunner: overrides.turnToolCancellationFence.runSandboxCommand.bind(
3690
+ overrides.turnToolCancellationFence,
3691
+ ),
3692
+ }
3693
+ : {}),
3118
3694
  })
3119
3695
  : (ownedClient as SandboxClient);
3120
3696
  // TOKEN-BROKER (B1): the per-turn git token seed, forwarded OFF-MANIFEST so the
3121
3697
  // repository-clone hook seeds it to the box's token file before the clone.
3122
3698
  const ownedGitTokenSeeds = gitTokenSeedsForAgent(agent);
3699
+ const ownedGitCredentialBindings = gitCredentialBindingsForAgent(agent);
3123
3700
  const ownedToolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
3124
3701
  const ownedRigSetup = rigSetupDescriptorForAgent(agent);
3125
3702
  const ownedHooks = [
@@ -3132,6 +3709,9 @@ export async function runAgentStream(
3132
3709
  rigCredentialHooksForAgent(agent),
3133
3710
  ),
3134
3711
  ...sandboxToolspaceTokenHooksForAgent(agent),
3712
+ ...toolspaceTokenSessionRegistrationHooks(
3713
+ ownedToolspaceTokenSeed ? overrides.onToolspaceTokenSessionReady : undefined,
3714
+ ),
3135
3715
  ...sandboxRepositoryCloneHooksForAgent(agent),
3136
3716
  ...gitCredentialSessionRegistrationHooks(overrides.onGitCredentialSessionReady),
3137
3717
  ];
@@ -3140,31 +3720,49 @@ export async function runAgentStream(
3140
3720
  ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
3141
3721
  ...(runAs ? { runAs } : {}),
3142
3722
  ...(ownedGitTokenSeeds ? { gitTokenSeeds: ownedGitTokenSeeds } : {}),
3723
+ ...(ownedGitCredentialBindings ? { gitCredentialBindings: ownedGitCredentialBindings } : {}),
3143
3724
  ...(ownedToolspaceTokenSeed ? { toolspaceTokenSeed: ownedToolspaceTokenSeed } : {}),
3725
+ ...(toolspaceTokenFile ? { toolspaceTokenFile } : {}),
3144
3726
  ...(ownedRigSetup ? { rigSetup: ownedRigSetup } : {}),
3145
3727
  };
3146
- // Keep the decoration as a safety net for any session the SDK does create/resume
3147
- // through the client during this run (it is inert for the provided session).
3148
- const decoratedClient = withSandboxLifecycleHooks(resourceClient, ownedHooks, ownedHookContext);
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
+ );
3149
3746
  const ownedFilter = composeCallModelInputFilters(
3150
3747
  [
3151
- 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, {
3152
3756
  throwOnCompactionNeeded: Boolean(
3153
- overrides.contextCompactionSignalTokens || overrides.contextCompactionRequested,
3757
+ overrides.contextCompactionSignal || overrides.contextCompactionRequested,
3154
3758
  ),
3155
- ...(overrides.contextCompactionSignalTokens
3156
- ? {
3157
- contextCompactionSignalTokens: overrides.contextCompactionSignalTokens,
3158
- }
3759
+ ...(overrides.contextCompactionSignal
3760
+ ? { contextCompactionSignal: overrides.contextCompactionSignal }
3159
3761
  : {}),
3160
3762
  ...(overrides.contextCompactionRequested
3161
- ? {
3162
- contextCompactionRequested: overrides.contextCompactionRequested,
3163
- }
3763
+ ? { contextCompactionRequested: overrides.contextCompactionRequested }
3164
3764
  : {}),
3165
3765
  }),
3166
- genesisTitleInputFilter,
3167
- overrides.callModelInputFilter,
3168
3766
  ].filter((f): f is CallModelInputFilter => Boolean(f)),
3169
3767
  );
3170
3768
  const ownedRunOptions: Parameters<typeof run>[2] = {
@@ -3175,7 +3773,7 @@ export async function runAgentStream(
3175
3773
  };
3176
3774
  ownedRunOptions.sandbox = {
3177
3775
  client: decoratedClient,
3178
- session,
3776
+ session: agentSession,
3179
3777
  ...(sessionState ? { sessionState } : {}),
3180
3778
  } as SandboxRunConfig;
3181
3779
  return await runScopedRunner(settings).run(agent, prepared.input, ownedRunOptions);
@@ -3200,14 +3798,27 @@ export async function runAgentStream(
3200
3798
  ...(runAs ? { runAs } : {}),
3201
3799
  })
3202
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;
3203
3813
  // TOKEN-BROKER (B1): the per-turn git token seed, forwarded OFF-MANIFEST so the
3204
3814
  // repository-clone hook seeds it to the box's token file before the clone.
3205
3815
  const gitTokenSeeds = gitTokenSeedsForAgent(agent);
3816
+ const gitCredentialBindings = gitCredentialBindingsForAgent(agent);
3206
3817
  const toolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
3207
3818
  const legacyRigSetup = rigSetupDescriptorForAgent(agent);
3208
- const client = resourceClient
3819
+ const client = toolspaceClient
3209
3820
  ? withSandboxLifecycleHooks(
3210
- resourceClient,
3821
+ toolspaceClient,
3211
3822
  [
3212
3823
  // M3: same rig-setup-first ordering + credential-hook union as the owned
3213
3824
  // path (this legacy create/resume decoration path is byte-for-byte today
@@ -3218,6 +3829,9 @@ export async function runAgentStream(
3218
3829
  rigCredentialHooksForAgent(agent),
3219
3830
  ),
3220
3831
  ...sandboxToolspaceTokenHooksForAgent(agent),
3832
+ ...toolspaceTokenSessionRegistrationHooks(
3833
+ toolspaceTokenSeed ? overrides.onToolspaceTokenSessionReady : undefined,
3834
+ ),
3221
3835
  ...sandboxRepositoryCloneHooksForAgent(agent),
3222
3836
  ...gitCredentialSessionRegistrationHooks(overrides.onGitCredentialSessionReady),
3223
3837
  ],
@@ -3226,7 +3840,9 @@ export async function runAgentStream(
3226
3840
  ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
3227
3841
  ...(runAs ? { runAs } : {}),
3228
3842
  ...(gitTokenSeeds ? { gitTokenSeeds } : {}),
3843
+ ...(gitCredentialBindings ? { gitCredentialBindings } : {}),
3229
3844
  ...(toolspaceTokenSeed ? { toolspaceTokenSeed } : {}),
3845
+ ...(toolspaceTokenFile ? { toolspaceTokenFile } : {}),
3230
3846
  ...(legacyRigSetup ? { rigSetup: legacyRigSetup } : {}),
3231
3847
  },
3232
3848
  )
@@ -3239,21 +3855,21 @@ export async function runAgentStream(
3239
3855
  // OpenGeni's durable conversation truth is still reconciled explicitly below.
3240
3856
  const callModelInputFilter = composeCallModelInputFilters(
3241
3857
  [
3242
- callModelInputFilterForSettings(settings, {
3858
+ callModelInputFilterForSettings(settings),
3859
+ genesisTitleInputFilter,
3860
+ overrides.callModelInputFilter,
3861
+ boundModelToolOutputsFilterForSettings(settings),
3862
+ contextRobustnessFilterForSettings(settings, {
3243
3863
  throwOnCompactionNeeded: Boolean(
3244
- overrides.contextCompactionSignalTokens || overrides.contextCompactionRequested,
3864
+ overrides.contextCompactionSignal || overrides.contextCompactionRequested,
3245
3865
  ),
3246
- ...(overrides.contextCompactionSignalTokens
3247
- ? {
3248
- contextCompactionSignalTokens: overrides.contextCompactionSignalTokens,
3249
- }
3866
+ ...(overrides.contextCompactionSignal
3867
+ ? { contextCompactionSignal: overrides.contextCompactionSignal }
3250
3868
  : {}),
3251
3869
  ...(overrides.contextCompactionRequested
3252
3870
  ? { contextCompactionRequested: overrides.contextCompactionRequested }
3253
3871
  : {}),
3254
3872
  }),
3255
- genesisTitleInputFilter,
3256
- overrides.callModelInputFilter,
3257
3873
  ].filter((f): f is CallModelInputFilter => Boolean(f)),
3258
3874
  );
3259
3875
  const runOptions: Parameters<typeof run>[2] = {
@@ -3656,6 +4272,9 @@ export async function runOwnedSandboxSetup(
3656
4272
  onRuntimeEvent?: SandboxLifecycleHookContext["onRuntimeEvent"];
3657
4273
  gitTokenSeedsOverride?: GitTokenSeeds;
3658
4274
  gitTokenSeedOverride?: string;
4275
+ gitCredentialBindingsOverride?: GitCredentialBindingSeed[];
4276
+ toolspaceTokenSeedOverride?: string;
4277
+ commandRunner?: SandboxLifecycleCommandRunner;
3659
4278
  },
3660
4279
  ): Promise<void> {
3661
4280
  const { settings, environment } = opts;
@@ -3677,7 +4296,11 @@ export async function runOwnedSandboxSetup(
3677
4296
  ...(opts.gitTokenSeedOverride ? { github: opts.gitTokenSeedOverride } : {}),
3678
4297
  ...(opts.gitTokenSeedsOverride ?? {}),
3679
4298
  } satisfies GitTokenSeeds;
3680
- 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);
3681
4304
  const ownedRigSetup = rigSetupDescriptorForAgent(agent);
3682
4305
  const ownedHooks = [
3683
4306
  // M3: rig setup runs FIRST so any tooling it installs is present for the
@@ -3699,8 +4322,11 @@ export async function runOwnedSandboxSetup(
3699
4322
  ...(opts.onRuntimeEvent ? { onRuntimeEvent: opts.onRuntimeEvent } : {}),
3700
4323
  ...(runAs ? { runAs } : {}),
3701
4324
  ...(Object.keys(ownedGitTokenSeeds).length > 0 ? { gitTokenSeeds: ownedGitTokenSeeds } : {}),
4325
+ ...(ownedGitCredentialBindings ? { gitCredentialBindings: ownedGitCredentialBindings } : {}),
3702
4326
  ...(ownedToolspaceTokenSeed ? { toolspaceTokenSeed: ownedToolspaceTokenSeed } : {}),
4327
+ ...(ownedToolspaceTokenFile ? { toolspaceTokenFile: ownedToolspaceTokenFile } : {}),
3703
4328
  ...(ownedRigSetup ? { rigSetup: ownedRigSetup } : {}),
4329
+ ...(opts.commandRunner ? { commandRunner: opts.commandRunner } : {}),
3704
4330
  };
3705
4331
  // OWNED-PATH HOOKS: run the beforeAgentStart hooks directly against the provided
3706
4332
  // box, once per turn, BEFORE the run starts (repository-clone hook seeds the B1
@@ -3720,6 +4346,7 @@ export async function runOwnedSandboxSetup(
3720
4346
  const materialized = await materializeSandboxFileDownloads(setupSession, fileDownloads, {
3721
4347
  ...(opts.onRuntimeEvent ? { onRuntimeEvent: opts.onRuntimeEvent } : {}),
3722
4348
  ...(runAs ? { runAs } : {}),
4349
+ ...(opts.commandRunner ? { commandRunner: opts.commandRunner } : {}),
3723
4350
  });
3724
4351
  if (opts.preparedInput) {
3725
4352
  appendSandboxFileDownloadFailureNote(opts.preparedInput, materialized.failures);
@@ -3753,7 +4380,7 @@ function mergePathGrants(
3753
4380
  export function withSandboxFileDownloads(
3754
4381
  client: SandboxClient,
3755
4382
  downloads: SandboxFileDownload[],
3756
- context: Pick<SandboxLifecycleHookContext, "onRuntimeEvent" | "runAs"> = {},
4383
+ context: Pick<SandboxLifecycleHookContext, "onRuntimeEvent" | "runAs" | "commandRunner"> = {},
3757
4384
  ): SandboxClient {
3758
4385
  const normalizedDownloads = normalizeSandboxFileDownloads(downloads);
3759
4386
  if (normalizedDownloads.length === 0) {
@@ -3819,7 +4446,7 @@ export function withSandboxFileDownloads(
3819
4446
  export async function materializeSandboxFileDownloads(
3820
4447
  session: SandboxSessionLike,
3821
4448
  downloads: SandboxFileDownload[],
3822
- context: Pick<SandboxLifecycleHookContext, "onRuntimeEvent" | "runAs"> = {},
4449
+ context: Pick<SandboxLifecycleHookContext, "onRuntimeEvent" | "runAs" | "commandRunner"> = {},
3823
4450
  ): Promise<SandboxFileDownloadMaterializationResult> {
3824
4451
  const normalizedDownloads = normalizeSandboxFileDownloads(downloads);
3825
4452
  if (normalizedDownloads.length === 0) {
@@ -3857,27 +4484,24 @@ export async function materializeSandboxFileDownloads(
3857
4484
  }
3858
4485
  let result: unknown;
3859
4486
  try {
3860
- result = session.exec
3861
- ? await session.exec({
3862
- cmd: sandboxFileDownloadCommand(download, targetPath),
3863
- workdir: "/workspace",
3864
- ...(context.runAs ? { runAs: context.runAs } : {}),
3865
- yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
3866
- maxOutputTokens: 20_000,
3867
- })
3868
- : await session.execCommand!({
3869
- cmd: sandboxFileDownloadCommand(download, targetPath),
3870
- workdir: "/workspace",
3871
- ...(context.runAs ? { runAs: context.runAs } : {}),
3872
- yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
3873
- maxOutputTokens: 20_000,
3874
- });
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
+ );
3875
4498
  assertSandboxCommandSucceeded(result, `Sandbox file resource download ${download.fileId}`);
3876
4499
  await context.onRuntimeEvent?.({
3877
4500
  type: "sandbox.operation.completed",
3878
4501
  payload: { name: "file-resource-download", ...payload },
3879
4502
  });
3880
4503
  } catch (error) {
4504
+ if (error instanceof TurnSandboxCommandCancelledError) throw error;
3881
4505
  const failure = sandboxFileDownloadFailure(download, targetPath, error, result);
3882
4506
  failures.push(failure);
3883
4507
  await context.onRuntimeEvent?.({
@@ -3954,86 +4578,87 @@ function ensureManifest(
3954
4578
  });
3955
4579
  }
3956
4580
 
3957
- /** Coerce the various binary shapes a tool-output image `data` field can take into
3958
- * a Uint8Array. Handles a live `Uint8Array`, a plain number[] , and the
3959
- * object-of-numbers (`{"0":137,"1":80,…}`) that a `Uint8Array` degrades into after
3960
- * a JSON round-trip — the exact 10x-bloat shape this normalizer exists to kill. */
3961
- function toImageBytes(data: unknown): Uint8Array | null {
3962
- if (data instanceof Uint8Array) {
3963
- return data;
3964
- }
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;
3965
4590
  if (Array.isArray(data)) {
3966
- return data.every((n) => typeof n === "number") ? Uint8Array.from(data as number[]) : null;
4591
+ return data.every((value) => typeof value === "number") ? data.length : null;
3967
4592
  }
3968
- if (data && typeof data === "object") {
3969
- const values = Object.values(data as Record<string, unknown>);
3970
- if (values.length > 0 && values.every((n) => typeof n === "number")) {
3971
- return Uint8Array.from(values as number[]);
3972
- }
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;
3973
4597
  }
3974
- 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;
3975
4603
  }
3976
4604
 
3977
- /** Compact a structured image tool output — the SDK's `{type:'image', image:{data,mediaType}}`
3978
- * shape (produced by the codex-path `computer_screenshot` function tool) OR the already-
3979
- * normalized protocol `{type:'input_image', image:'data:…'}` item — into a `data:<mt>;base64,…`
3980
- * string. Returns null when `value` is not an image output. */
3981
- function structuredImageToDataUrl(value: unknown): string | null {
3982
- if (!value || typeof value !== "object" || Array.isArray(value)) {
3983
- return null;
3984
- }
3985
- const v = value as { type?: unknown; image?: unknown };
3986
- if (v.type === "input_image") {
3987
- // Protocol item: `image` is already a `data:…` (or plain URL) string.
3988
- return typeof v.image === "string" && v.image.length > 0 ? v.image : null;
3989
- }
3990
- if (v.type !== "image" || !v.image || typeof v.image !== "object") {
3991
- return null;
3992
- }
3993
- const image = v.image as {
3994
- data?: unknown;
3995
- mediaType?: unknown;
3996
- url?: unknown;
3997
- };
3998
- if (typeof image.url === "string" && image.url.length > 0) {
3999
- return image.url;
4000
- }
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>;
4001
4629
  const mediaType =
4002
4630
  typeof image.mediaType === "string" && image.mediaType.length > 0
4003
4631
  ? image.mediaType
4004
4632
  : "image/png";
4633
+ if (typeof image.url === "string" && image.url.length > 0) {
4634
+ return (
4635
+ sessionEventMediaPreviewFromDataUrl(image.url) ?? sessionEventMediaPreview(mediaType, null)
4636
+ );
4637
+ }
4005
4638
  if (typeof image.data === "string") {
4006
- 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
+ );
4007
4643
  }
4008
- const bytes = toImageBytes(image.data);
4009
- 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);
4010
4646
  }
4011
4647
 
4012
4648
  /**
4013
- * Compact a tool-call output for the `agent.toolCall.output` SESSION EVENT so it
4014
- * never carries a raw binary payload. The codex-path `computer_screenshot` function
4015
- * tool returns a structured `{type:'image', image:{data: Uint8Array, mediaType}}`;
4016
- * captured verbatim its `Uint8Array` JSON-serializes as an object-of-numbers (~12.7MB
4017
- * per screenshot in session_events — ~10x the base64 form). This mirrors the desktop
4018
- * screenshot to the SAME compact `data:<mediaType>;base64,…` STRING the HOSTED
4019
- * `computer_call` event already carries (agents-core sets its output to that data-URL),
4020
- * so both computer-use transports emit one representation. The full data-URL is kept
4021
- * (not truncated) because the web timeline RENDERS the screenshot from this event
4022
- * payload — packages/react/src/timeline/tool-renderers.tsx ComputerCallRenderer
4023
- * (`out.startsWith("data:image")` → <ScreenshotFigure src={out}/>) and ViewImageRenderer.
4024
- * Non-image outputs (text strings, MCP `{isError,content}` objects, hosted computer_call
4025
- * data-URL strings) pass through unchanged.
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.
4026
4653
  */
4027
4654
  export function normalizeToolOutputForEvent(output: unknown): unknown {
4028
- const single = structuredImageToDataUrl(output);
4655
+ const single = toolOutputMediaPreview(output);
4029
4656
  if (single !== null) {
4030
4657
  return single;
4031
4658
  }
4032
4659
  if (Array.isArray(output)) {
4033
- const normalized = output.map((el) => structuredImageToDataUrl(el) ?? el);
4034
- // A lone image content item unwraps to the bare data-URL string the timeline
4035
- // image renderers expect; a mixed/multi array keeps its (now-compact) shape.
4036
- if (normalized.length === 1 && typeof normalized[0] === "string") {
4660
+ const normalized = output.map((el) => toolOutputMediaPreview(el) ?? el);
4661
+ if (normalized.length === 1 && normalized[0]?.type === "media_preview") {
4037
4662
  return normalized[0];
4038
4663
  }
4039
4664
  return normalized;
@@ -4090,8 +4715,8 @@ export function normalizeSdkEvent(event: RunStreamEvent): NormalizedRuntimeEvent
4090
4715
  type: "agent.toolCall.output",
4091
4716
  payload: {
4092
4717
  id: item.rawItem?.callId ?? item.id ?? null,
4093
- // Compact any structured/binary image output to a data-URL string so a
4094
- // screenshot never bloats session_events ~10x as an object-of-numbers.
4718
+ // Inline media becomes a content-free audit fact. Model history keeps
4719
+ // the provider's real structured image output on its separate path.
4095
4720
  output: normalizeToolOutputForEvent(item.output),
4096
4721
  },
4097
4722
  });
@@ -4245,17 +4870,45 @@ function outputTokenDetailsProp(
4245
4870
  }
4246
4871
 
4247
4872
  export function serializeApprovals(interruptions: unknown[]): unknown[] {
4248
- return interruptions.map((item: any) => {
4249
- if (typeof item?.toJSON === "function") {
4250
- return item.toJSON();
4251
- }
4252
- return {
4253
- id: approvalIdentifier(item),
4254
- name: item?.name ?? item?.rawItem?.name ?? "tool",
4255
- arguments: item?.arguments ?? item?.rawItem?.arguments ?? null,
4256
- raw: item,
4257
- };
4258
- });
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
+ });
4259
4912
  }
4260
4913
 
4261
4914
  export function buildManifest(
@@ -4264,6 +4917,7 @@ export function buildManifest(
4264
4917
  environment = collectSandboxEnvironment(settings),
4265
4918
  fileResourceDownloads: SandboxFileDownload[] = [],
4266
4919
  ): Manifest {
4920
+ assertUniqueResourceMountPaths(resources);
4267
4921
  const entries: Record<string, any> = {};
4268
4922
  const downloadsByFileId = new Map(
4269
4923
  normalizeSandboxFileDownloads(fileResourceDownloads).map((download) => [
@@ -4274,9 +4928,9 @@ export function buildManifest(
4274
4928
  for (const resource of resources) {
4275
4929
  if (resource.kind === "repository") {
4276
4930
  const url = new URL(resource.uri);
4277
- const host = url.hostname.toLowerCase();
4931
+ const host = url.host.toLowerCase();
4278
4932
  const repo = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
4279
- const mountPath = normalizeManifestPath(resource.mountPath ?? `repos/${repo}`);
4933
+ const mountPath = resourceMountPath(resource);
4280
4934
  if (repositoryUsesSandboxClone(settings, resource)) {
4281
4935
  entries[mountPath] = dir();
4282
4936
  continue;
@@ -4285,12 +4939,12 @@ export function buildManifest(
4285
4939
  host,
4286
4940
  repo,
4287
4941
  ref: resource.ref,
4288
- ...(resource.subpath ? { subpath: normalizeManifestPath(resource.subpath) } : {}),
4942
+ ...(resource.subpath ? { subpath: normalizeRepositorySubpath(resource.subpath) } : {}),
4289
4943
  });
4290
4944
  continue;
4291
4945
  }
4292
4946
  if (resource.kind === "file") {
4293
- const mountPath = normalizeManifestPath(resource.mountPath ?? `files/${resource.fileId}`);
4947
+ const mountPath = resourceMountPath(resource);
4294
4948
  const download = downloadsByFileId.get(resource.fileId);
4295
4949
  entries[mountPath] = download
4296
4950
  ? sandboxDownloadDirectory(download, mountPath)
@@ -4450,11 +5104,7 @@ function parseAzureConnectionString(value: string): Record<string, string> {
4450
5104
  }
4451
5105
 
4452
5106
  function normalizeManifestPath(path: string): string {
4453
- const normalized = path.replace(/^\/+|\/+$/g, "");
4454
- if (!normalized || normalized.includes("..")) {
4455
- throw new Error(`Invalid sandbox resource path: ${path}`);
4456
- }
4457
- return normalized;
5107
+ return normalizeResourceMountPath(path);
4458
5108
  }
4459
5109
 
4460
5110
  function normalizeSandboxFileDownloads(downloads: SandboxFileDownload[]): SandboxFileDownload[] {
@@ -4517,6 +5167,11 @@ function shellQuote(value: string): string {
4517
5167
 
4518
5168
  export type SandboxLifecycleHookPhase = "beforeAgentStart";
4519
5169
 
5170
+ export type SandboxLifecycleCommandRunner = (
5171
+ session: TurnSandboxCommandSession,
5172
+ args: TurnSandboxCommandArgs,
5173
+ ) => Promise<unknown>;
5174
+
4520
5175
  export type SandboxLifecycleHookContext = {
4521
5176
  environment: Record<string, string>;
4522
5177
  onRuntimeEvent?: (event: NormalizedRuntimeEvent) => Promise<void> | void;
@@ -4528,12 +5183,28 @@ export type SandboxLifecycleHookContext = {
4528
5183
  // NEVER the box/agent manifest env (validateNoEnvironmentDelta must never see
4529
5184
  // rotating values).
4530
5185
  gitTokenSeeds?: GitTokenSeeds;
5186
+ gitCredentialBindings?: GitCredentialBindingSeed[];
4531
5187
  toolspaceTokenSeed?: string;
5188
+ toolspaceTokenFile?: string;
4532
5189
  // M3: the rig setup descriptor for the rig-setup hook (the script + marker
4533
5190
  // version id + the rig's own timeout). Present only on a rig-bound turn.
4534
5191
  rigSetup?: RigSetupDescriptor;
5192
+ commandRunner?: SandboxLifecycleCommandRunner;
4535
5193
  };
4536
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
+
4537
5208
  // M3: everything the rig-setup hook needs to run the frozen rig version's setup
4538
5209
  // script exactly once per box. `versionId` keys the idempotence marker
4539
5210
  // (/var/opengeni/rig-setup-<versionId>.done); `timeoutMs` is the rig-specific
@@ -4683,10 +5354,32 @@ function gitTokenSeedsForAgent(agent: Agent<any, any>): GitTokenSeeds | undefine
4683
5354
  return agentGitTokenSeeds.get(agent);
4684
5355
  }
4685
5356
 
5357
+ function gitCredentialBindingsForAgent(
5358
+ agent: Agent<any, any>,
5359
+ ): GitCredentialBindingSeed[] | undefined {
5360
+ return agentGitCredentialBindings.get(agent);
5361
+ }
5362
+
4686
5363
  function toolspaceTokenSeedForAgent(agent: Agent<any, any>): string | undefined {
4687
5364
  return agentToolspaceTokenSeed.get(agent);
4688
5365
  }
4689
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
+
4690
5383
  function sandboxToolspaceTokenHooksForAgent(agent: Agent<any, any>): SandboxLifecycleHook[] {
4691
5384
  return toolspaceTokenSeedForAgent(agent)
4692
5385
  ? [
@@ -4756,6 +5449,22 @@ function gitCredentialSessionRegistrationHooks(
4756
5449
  : [];
4757
5450
  }
4758
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
+
4759
5468
  function sandboxRepositoryCloneHooks(
4760
5469
  settings: Settings,
4761
5470
  resources: ResourceRef[],
@@ -4823,6 +5532,20 @@ function gitProviderSeedEnv(provider: GitCredentialProvider): string {
4823
5532
  return `OPENGENI_GIT_${provider.toUpperCase()}_TOKEN_SEED`;
4824
5533
  }
4825
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
+
4826
5549
  function gitTokenSeedExportPrefix(seeds: GitTokenSeeds): string {
4827
5550
  const lines: string[] = [];
4828
5551
  for (const provider of GIT_CREDENTIAL_PROVIDERS) {
@@ -4838,37 +5561,116 @@ function gitTokenSeedExportPrefix(seeds: GitTokenSeeds): string {
4838
5561
  return lines.join("\n");
4839
5562
  }
4840
5563
 
4841
- function repositoryCredentialProvider(
4842
- resource: Extract<ResourceRef, { kind: "repository" }>,
4843
- ): GitCredentialProvider {
4844
- 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
+ });
4845
5637
  }
4846
5638
 
4847
5639
  function gitAskpassHostProviderCaseLines(
4848
5640
  resources: Extract<ResourceRef, { kind: "repository" }>[],
4849
5641
  ): string[] {
4850
- const hosts = new Map<string, GitCredentialProvider>();
4851
- for (const resource of resources) {
4852
- try {
4853
- const hostname = new URL(resource.uri).hostname.toLowerCase();
4854
- if (!hostname) {
4855
- continue;
4856
- }
4857
- hosts.set(hostname, repositoryCredentialProvider(resource));
4858
- } catch {
4859
- // Resource validation catches invalid URIs before normal runtime use. Keep
4860
- // helper generation tolerant so tests for clone failure can still build.
4861
- }
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);
4862
5650
  }
4863
5651
  return [...hosts.entries()]
5652
+ .filter(([, entry]) => entry.bindings.size === 1)
4864
5653
  .sort(([a], [b]) => a.localeCompare(b))
4865
5654
  .map(
4866
- ([hostname, provider]) =>
4867
- ` ${shellQuote(hostname)}) printf '%s\\n' ${provider}; return 0 ;;`,
5655
+ ([hostname, entry]) =>
5656
+ ` ${shellQuote(hostname)}) printf '%s\\n' ${entry.provider}; return 0 ;;`,
4868
5657
  );
4869
5658
  }
4870
5659
 
4871
- 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
+ });
4872
5674
  return [
4873
5675
  // TOKEN-BROKER (B1/B2): seed run-scoped provider tokens into stable files and
4874
5676
  // atomically replace each provider file. Token VALUES are supplied only by
@@ -4882,6 +5684,23 @@ function gitCredentialTokenWriterCommandLines(): string[] {
4882
5684
  " *) printf '%s\\n' \"${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/$provider-token\" ;;",
4883
5685
  " esac",
4884
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
+ "}",
4885
5704
  "write_git_provider_token() {",
4886
5705
  ' provider="$1"',
4887
5706
  ' token="$2"',
@@ -4902,16 +5721,53 @@ function gitCredentialTokenWriterCommandLines(): string[] {
4902
5721
  'write_git_provider_token github "${OPENGENI_GIT_GITHUB_TOKEN_SEED:-${OPENGENI_GIT_TOKEN_SEED:-}}"',
4903
5722
  'write_git_provider_token gitlab "${OPENGENI_GIT_GITLAB_TOKEN_SEED:-}"',
4904
5723
  'write_git_provider_token azure_devops "${OPENGENI_GIT_AZURE_DEVOPS_TOKEN_SEED:-}"',
5724
+ ...bindingWrites,
4905
5725
  'umask "$seed_umask"',
4906
5726
  ];
4907
5727
  }
4908
5728
 
4909
5729
  function gitCredentialHelperCommandLines(
4910
5730
  resources: Extract<ResourceRef, { kind: "repository" }>[] = [],
5731
+ bindings: GitCredentialBindingSeed[] = [],
4911
5732
  ): string[] {
4912
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
+ : [];
4913
5769
  return [
4914
- ...gitCredentialTokenWriterCommandLines(),
5770
+ ...gitCredentialTokenWriterCommandLines(bindings),
4915
5771
  // Provision git/provider-CLI helpers at SETUP (runtime) before any clone
4916
5772
  // runs. Renewal updates only token files and deliberately leaves these
4917
5773
  // repository-specific host mappings intact.
@@ -4928,8 +5784,8 @@ function gitCredentialHelperCommandLines(
4928
5784
  ' rest="${prompt_lower#*://}"',
4929
5785
  ' rest="${rest#*@}"',
4930
5786
  ' host="${rest%%/*}"',
4931
- ' host="${host%%:*}"',
4932
5787
  ' host="$(printf \'%s\\n\' "$host" | tr -d "\'")"',
5788
+ ' host="${host%:}"',
4933
5789
  " printf '%s\\n' \"$host\"",
4934
5790
  "}",
4935
5791
  "provider_for_prompt() {",
@@ -4941,7 +5797,7 @@ function gitCredentialHelperCommandLines(
4941
5797
  " *github.com*|*githubusercontent.com*) printf '%s\\n' github ;;",
4942
5798
  " *gitlab*) printf '%s\\n' gitlab ;;",
4943
5799
  " *dev.azure.com*|*.visualstudio.com*) printf '%s\\n' azure_devops ;;",
4944
- " *) printf '%s\\n' github ;;",
5800
+ strictAskpass ? " *) printf '\\n' ;;" : " *) printf '%s\\n' github ;;",
4945
5801
  " esac",
4946
5802
  "}",
4947
5803
  "token_file_for_provider() {",
@@ -4967,6 +5823,40 @@ function gitCredentialHelperCommandLines(
4967
5823
  "ASKPASS_EOF",
4968
5824
  'chmod 0755 "$git_askpass.tmp.$$"',
4969
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",
4970
5860
  'wrapper_dir="${OPENGENI_GIT_CLI_WRAPPER_DIR:-$HOME/.opengeni/bin}"',
4971
5861
  'mkdir -p "$wrapper_dir"',
4972
5862
  "for opengeni_git_cli_tool in gh glab az; do",
@@ -4981,11 +5871,53 @@ function gitCredentialHelperCommandLines(
4981
5871
  " az) provider=azure_devops; token_env=AZURE_DEVOPS_EXT_PAT ;;",
4982
5872
  " *) provider=; token_env= ;;",
4983
5873
  "esac",
4984
- '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() {",
4985
5894
  ' case "$provider" in',
4986
- ' github) token_file="${OPENGENI_GIT_TOKEN_FILE:-$HOME/.opengeni/git-token}" ;;',
4987
- ' *) token_file="${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/$provider-token" ;;',
5895
+ ...soleWrapperHashes,
5896
+ " *) return 1 ;;",
4988
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",
4989
5921
  ' if [ -f "$token_file" ]; then',
4990
5922
  ' token="$(cat "$token_file" 2>/dev/null || true)"',
4991
5923
  ' if [ -n "$token" ]; then',
@@ -5040,10 +5972,23 @@ export function gitProviderTokenRefreshCommand(seeds: GitTokenSeeds): string {
5040
5972
  ].join("\n");
5041
5973
  }
5042
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
+
5043
5988
  export async function refreshGitProviderTokenFiles(
5044
5989
  session: GitCredentialTokenWriterSession,
5045
5990
  seeds: GitTokenSeeds,
5046
- options: { runAs?: string } = {},
5991
+ options: { runAs?: string; commandRunner?: SandboxLifecycleCommandRunner } = {},
5047
5992
  ): Promise<void> {
5048
5993
  const command = gitProviderTokenRefreshCommand(seeds);
5049
5994
  if (!command) {
@@ -5056,23 +6001,41 @@ export async function refreshGitProviderTokenFiles(
5056
6001
  yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
5057
6002
  maxOutputTokens: 4_000,
5058
6003
  };
5059
- if (session.exec) {
5060
- assertSandboxCommandSucceeded(await session.exec(args), "Git credential refresh");
5061
- } else if (session.execCommand) {
5062
- assertSandboxCommandSucceeded(await session.execCommand(args), "Git credential refresh");
5063
- } else {
5064
- throw new Error("Sandbox session does not support command execution");
5065
- }
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
+ );
5066
6028
  }
5067
6029
 
5068
6030
  export function repositoryCloneCommand(
5069
6031
  resources: Extract<ResourceRef, { kind: "repository" }>[],
6032
+ bindings: GitCredentialBindingSeed[] = [],
5070
6033
  ): string {
6034
+ assertUniqueResourceMountPaths(resources);
5071
6035
  const commands = [
5072
6036
  "set -eu",
5073
6037
  'export HOME="${HOME:-/workspace}"',
5074
6038
  'export GIT_TERMINAL_PROMPT="${GIT_TERMINAL_PROMPT:-0}"',
5075
- ...gitCredentialHelperCommandLines(resources),
5076
6039
  "ensure_git() {",
5077
6040
  " if command -v git >/dev/null 2>&1; then",
5078
6041
  " return 0",
@@ -5088,6 +6051,7 @@ export function repositoryCloneCommand(
5088
6051
  " exit 127",
5089
6052
  "}",
5090
6053
  "ensure_git",
6054
+ ...gitCredentialHelperCommandLines(resources, bindings),
5091
6055
  "clone_repository() {",
5092
6056
  ' target="$1"',
5093
6057
  ' uri="$2"',
@@ -5160,33 +6124,41 @@ export function repositoryCloneCommand(
5160
6124
  "}",
5161
6125
  ];
5162
6126
  for (const resource of resources) {
5163
- const url = new URL(resource.uri);
5164
- const repo = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
5165
- const mountPath = normalizeManifestPath(resource.mountPath ?? `repos/${repo}`);
6127
+ const mountPath = resourceMountPath(resource);
5166
6128
  commands.push(
5167
6129
  [
5168
6130
  "clone_repository",
5169
6131
  shellQuote(posixPath.join("/workspace", mountPath)),
5170
6132
  shellQuote(resource.uri),
5171
6133
  shellQuote(resource.ref),
5172
- shellQuote(resource.subpath ? normalizeManifestPath(resource.subpath) : ""),
6134
+ shellQuote(resource.subpath ? normalizeRepositorySubpath(resource.subpath) : ""),
5173
6135
  ].join(" "),
5174
6136
  );
5175
6137
  }
5176
6138
  return commands.join("\n");
5177
6139
  }
5178
6140
 
5179
- export function toolspaceTokenSeedCommand(): string {
6141
+ export function toolspaceTokenSeedCommand(
6142
+ options: { tokenFile?: string; legacyTokenFile?: string } = {},
6143
+ ): string {
5180
6144
  return [
5181
6145
  "set -eu",
5182
6146
  'export HOME="${HOME:-/workspace}"',
5183
6147
  'if [ -n "${OPENGENI_TOOLSPACE_TOKEN_SEED:-}" ]; then',
5184
6148
  ' seed_umask="$(umask)"',
5185
6149
  " umask 077",
5186
- ' 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=""',
5187
6156
  ' mkdir -p "$(dirname "$token_file")"',
5188
6157
  ' printf \'%s\' "$OPENGENI_TOOLSPACE_TOKEN_SEED" > "$token_file.tmp.$$"',
5189
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",
5190
6162
  ' umask "$seed_umask"',
5191
6163
  "fi",
5192
6164
  ].join("\n");
@@ -5199,28 +6171,56 @@ export async function runToolspaceTokenSeedHook(
5199
6171
  if (!context.toolspaceTokenSeed) {
5200
6172
  return;
5201
6173
  }
5202
- const command = `export OPENGENI_TOOLSPACE_TOKEN_SEED=${shellQuote(context.toolspaceTokenSeed)}\n${toolspaceTokenSeedCommand()}`;
5203
- if (session.exec) {
5204
- 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
+ {
5205
6185
  cmd: command,
5206
6186
  workdir: "/workspace",
5207
6187
  ...(context.runAs ? { runAs: context.runAs } : {}),
5208
6188
  yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
5209
6189
  maxOutputTokens: 4_000,
5210
- });
5211
- assertSandboxCommandSucceeded(result, "Toolspace token seed hook");
5212
- } else if (session.execCommand) {
5213
- 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
+ {
5214
6215
  cmd: command,
5215
6216
  workdir: "/workspace",
5216
- ...(context.runAs ? { runAs: context.runAs } : {}),
6217
+ ...(options.runAs ? { runAs: options.runAs } : {}),
5217
6218
  yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
5218
6219
  maxOutputTokens: 4_000,
5219
- });
5220
- assertSandboxCommandSucceeded(result, "Toolspace token seed hook");
5221
- } else {
5222
- throw new Error("Sandbox session does not support command execution");
5223
- }
6220
+ },
6221
+ options.commandRunner,
6222
+ );
6223
+ assertSandboxCommandSucceeded(result, "Toolspace token refresh");
5224
6224
  }
5225
6225
 
5226
6226
  // Bounds the setup output tail carried on a rig.setup failure event/error so a
@@ -5329,13 +6329,7 @@ export async function runRigSetupHook(
5329
6329
  };
5330
6330
  let result: unknown;
5331
6331
  try {
5332
- if (session.exec) {
5333
- result = await session.exec(execArgs);
5334
- } else if (session.execCommand) {
5335
- result = await session.execCommand(execArgs);
5336
- } else {
5337
- throw new Error("Sandbox session does not support command execution");
5338
- }
6332
+ result = await runSandboxLifecycleCommand(session, execArgs, context.commandRunner);
5339
6333
  } catch (error) {
5340
6334
  const message = error instanceof Error ? error.message : String(error);
5341
6335
  await context.onRuntimeEvent?.({
@@ -5414,31 +6408,27 @@ export async function runRepositoryCloneHook(
5414
6408
  ...(context.gitTokenSeeds ?? {}),
5415
6409
  ...(context.gitTokenSeed ? { github: context.gitTokenSeed } : {}),
5416
6410
  } satisfies GitTokenSeeds;
5417
- const seedPrefix = gitTokenSeedExportPrefix(gitTokenSeeds);
5418
- const command = seedPrefix
5419
- ? `${seedPrefix}\n${repositoryCloneCommand(resources)}`
5420
- : repositoryCloneCommand(resources);
5421
- if (session.exec) {
5422
- const result = await session.exec({
5423
- cmd: command,
5424
- workdir: "/workspace",
5425
- ...(context.runAs ? { runAs: context.runAs } : {}),
5426
- yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
5427
- maxOutputTokens: 20_000,
5428
- });
5429
- assertSandboxCommandSucceeded(result, "Repository clone hook");
5430
- } else if (session.execCommand) {
5431
- const result = await session.execCommand({
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
+ {
5432
6423
  cmd: command,
5433
6424
  workdir: "/workspace",
5434
6425
  ...(context.runAs ? { runAs: context.runAs } : {}),
5435
6426
  yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
5436
6427
  maxOutputTokens: 20_000,
5437
- });
5438
- assertSandboxCommandSucceeded(result, "Repository clone hook");
5439
- } else {
5440
- throw new Error("Sandbox session does not support command execution");
5441
- }
6428
+ },
6429
+ context.commandRunner,
6430
+ );
6431
+ assertSandboxCommandSucceeded(result, "Repository clone hook");
5442
6432
  await context.onRuntimeEvent?.({
5443
6433
  type: "sandbox.operation.completed",
5444
6434
  payload,
@@ -5564,27 +6554,18 @@ export async function runAzureCliLoginHook(
5564
6554
  payload,
5565
6555
  });
5566
6556
  try {
5567
- if (session.exec) {
5568
- const result = await session.exec({
5569
- cmd: azureCliLoginCommand(),
5570
- workdir: "/workspace",
5571
- ...(context.runAs ? { runAs: context.runAs } : {}),
5572
- yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
5573
- maxOutputTokens: 20_000,
5574
- });
5575
- assertSandboxCommandSucceeded(result, "Azure CLI login hook");
5576
- } else if (session.execCommand) {
5577
- const result = await session.execCommand({
6557
+ const result = await runSandboxLifecycleCommand(
6558
+ session,
6559
+ {
5578
6560
  cmd: azureCliLoginCommand(),
5579
6561
  workdir: "/workspace",
5580
6562
  ...(context.runAs ? { runAs: context.runAs } : {}),
5581
6563
  yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
5582
6564
  maxOutputTokens: 20_000,
5583
- });
5584
- assertSandboxCommandSucceeded(result, "Azure CLI login hook");
5585
- } else {
5586
- throw new Error("Sandbox session does not support command execution");
5587
- }
6565
+ },
6566
+ context.commandRunner,
6567
+ );
6568
+ assertSandboxCommandSucceeded(result, "Azure CLI login hook");
5588
6569
  await context.onRuntimeEvent?.({
5589
6570
  type: "sandbox.operation.completed",
5590
6571
  payload,
@@ -5674,26 +6655,46 @@ function isPathWithin(root: string, candidate: string): boolean {
5674
6655
  }
5675
6656
 
5676
6657
  /**
5677
- * The skill source fed to the SDK Skills capability. Without pack skills this
5678
- * is the plain bundled local-dir source, byte-for-byte the pre-pack behavior.
5679
- * With pack skills it becomes a single in-memory dir source combining bundled
5680
- * skill directories (as local_dir entries the SDK materializes lazily) with
5681
- * pack skill directories built from manifest-carried file content — one skill
5682
- * index, one `## Skills` instruction section, lazy `load_skill` for all of
5683
- * them. A pack skill shadows a bundled skill with the same directory name.
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.
5684
6666
  */
5685
- export function lazySkillSourceWithPackSkills(packSkills: PackSkill[]): LocalDirLazySkillSource {
6667
+ export function lazySkillSourceWithPackSkills(
6668
+ packSkills: PackSkill[],
6669
+ skillLibrarySkills: PackSkill[] = [],
6670
+ ): LocalDirLazySkillSource {
5686
6671
  const bundledDir = bundledSkillsDir();
5687
6672
  const bundled = localDirLazySkillSource({ src: bundledDir });
5688
- if (packSkills.length === 0) {
6673
+ if (packSkills.length === 0 && skillLibrarySkills.length === 0) {
5689
6674
  return bundled;
5690
6675
  }
5691
6676
  const children: Record<string, Entry> = {};
5692
6677
  for (const name of bundledSkillDirNames(bundledDir)) {
5693
6678
  children[name] = localDir({ src: join(bundledDir, name) });
5694
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
+ }
5695
6697
  const packIndex: SkillIndexEntry[] = [];
5696
- const packNames = new Set<string>();
5697
6698
  const packNameKeys = new Set<string>();
5698
6699
  for (const skill of packSkills) {
5699
6700
  assertSafePackSkillName(skill.name);
@@ -5701,7 +6702,7 @@ export function lazySkillSourceWithPackSkills(packSkills: PackSkill[]): LocalDir
5701
6702
  throw new Error(`Duplicate pack skill name: ${skill.name}`);
5702
6703
  }
5703
6704
  packNameKeys.add(skill.name.toLowerCase());
5704
- packNames.add(skill.name);
6705
+ removeSkillChildByNameKey(children, skill.name.toLowerCase());
5705
6706
  children[skill.name] = packSkillDirEntry(skill);
5706
6707
  packIndex.push({
5707
6708
  name: skill.name,
@@ -5713,13 +6714,62 @@ export function lazySkillSourceWithPackSkills(packSkills: PackSkill[]): LocalDir
5713
6714
  source: dir({ children }),
5714
6715
  getIndex: (manifest, skillsPath) => [
5715
6716
  ...(bundled.getIndex?.(manifest, skillsPath) ?? []).filter(
5716
- (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()),
5717
6723
  ),
5718
6724
  ...packIndex,
5719
6725
  ],
5720
6726
  };
5721
6727
  }
5722
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
+
5723
6773
  function bundledSkillDirNames(root: string): string[] {
5724
6774
  return readdirSync(root, { withFileTypes: true })
5725
6775
  .filter((entry) => entry.isDirectory() && existsSync(join(root, entry.name, "SKILL.md")))
@@ -5863,7 +6913,12 @@ function sortJson(value: unknown): unknown {
5863
6913
  }
5864
6914
  return value;
5865
6915
  }
5866
-
5867
- function approvalIdentifier(item: any): string {
5868
- 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 : "";
5869
6924
  }