@opengeni/runtime 0.6.1 → 0.7.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 (45) hide show
  1. package/dist/{chunk-DYBF7ZEB.js → chunk-RYDCVTMW.js} +1958 -207
  2. package/dist/chunk-RYDCVTMW.js.map +1 -0
  3. package/dist/{index-BTo_bZvN.d.ts → index-DIiiwQY2.d.ts} +689 -52
  4. package/dist/index.d.ts +293 -123
  5. package/dist/index.js +1819 -790
  6. package/dist/index.js.map +1 -1
  7. package/dist/sandbox/index.d.ts +1 -1
  8. package/dist/sandbox/index.js +79 -3
  9. package/package.json +21 -20
  10. package/src/codex-tool-search.ts +185 -31
  11. package/src/context-compaction.ts +78 -243
  12. package/src/history-sanitizer.ts +172 -20
  13. package/src/index.ts +2573 -898
  14. package/src/metrics.ts +28 -2
  15. package/src/sandbox/capabilities.ts +12 -3
  16. package/src/sandbox/channel-a.ts +420 -72
  17. package/src/sandbox/display-stack.ts +18 -4
  18. package/src/sandbox/errors.ts +13 -0
  19. package/src/sandbox/index.ts +398 -112
  20. package/src/sandbox/op-correlation.ts +70 -0
  21. package/src/sandbox/providers/index.ts +9 -3
  22. package/src/sandbox/providers/modal.ts +84 -14
  23. package/src/sandbox/providers/selfhosted.ts +1 -4
  24. package/src/sandbox/recording.ts +146 -90
  25. package/src/sandbox/routing/backend-resolver.ts +144 -10
  26. package/src/sandbox/routing/routing-session.ts +129 -25
  27. package/src/sandbox/select.ts +60 -8
  28. package/src/sandbox/selfhosted/capabilities.ts +59 -25
  29. package/src/sandbox/selfhosted/control-rpc.ts +160 -28
  30. package/src/sandbox/selfhosted/fault-rendering.ts +226 -0
  31. package/src/sandbox/selfhosted/op-observer.ts +54 -0
  32. package/src/sandbox/selfhosted/op-stream.ts +801 -0
  33. package/src/sandbox/selfhosted/op-testing.ts +468 -0
  34. package/src/sandbox/selfhosted/op-transport.ts +151 -0
  35. package/src/sandbox/selfhosted/retry-policy.ts +177 -0
  36. package/src/sandbox/selfhosted/session.ts +498 -55
  37. package/src/sandbox/selfhosted/testing.ts +60 -19
  38. package/src/sandbox/stream-port.ts +17 -5
  39. package/src/sandbox/stream-token.ts +8 -2
  40. package/src/sandbox/terminal-server.ts +18 -4
  41. package/src/sandbox-computer.ts +332 -70
  42. package/src/usage-telemetry.ts +73 -0
  43. package/dist/chunk-DYBF7ZEB.js.map +0 -1
  44. package/src/image-history.ts +0 -149
  45. package/src/screenshot-omitted-card.ts +0 -15
package/src/index.ts CHANGED
@@ -1,6 +1,30 @@
1
- import type { ConfiguredModel, ContextCompactionMode, ModelProviderApi, ResolvedModelProvider, Settings } from "@opengeni/config";
2
- import { AGENT_INSTRUCTIONS_CORE_PLACEHOLDER, collectSandboxEnvironment, contextInputBudgetTokens, contextServerCompactThreshold, firstPartyMcpBaseUrl, parseExposedPorts, resolveContextCompactionMode, resolveModelProvider, sandboxLifecycleHookIds } from "@opengeni/config";
3
- import { CAPABILITY_DESCRIPTORS, isClearedRunStateBlob, prefixedMcpToolName as sharedPrefixedMcpToolName, signDelegatedAccessToken, type McpServerConnectionRef, type Permission, type ReasoningEffort, type ResourceRef, type SessionEventType, type ToolAuthNeededPayload, type ToolRef } from "@opengeni/contracts";
1
+ import type {
2
+ ConfiguredModel,
3
+ ModelProviderApi,
4
+ ResolvedModelProvider,
5
+ Settings,
6
+ } from "@opengeni/config";
7
+ import {
8
+ AGENT_INSTRUCTIONS_CORE_PLACEHOLDER,
9
+ collectSandboxEnvironment,
10
+ firstPartyMcpBaseUrl,
11
+ resolveModelProvider,
12
+ sandboxLifecycleHookIds,
13
+ } from "@opengeni/config";
14
+ import {
15
+ CAPABILITY_DESCRIPTORS,
16
+ isClearedRunStateBlob,
17
+ prefixedMcpToolName as sharedPrefixedMcpToolName,
18
+ signDelegatedAccessToken,
19
+ type GitCredentialProvider,
20
+ type McpServerConnectionRef,
21
+ type Permission,
22
+ type ReasoningEffort,
23
+ type ResourceRef,
24
+ type SessionEventType,
25
+ type ToolAuthNeededPayload,
26
+ type ToolRef,
27
+ } from "@opengeni/contracts";
4
28
  import {
5
29
  Agent,
6
30
  AgentsError,
@@ -45,20 +69,17 @@ import {
45
69
  type MCPServer,
46
70
  type MCPToolErrorFunction,
47
71
  type Model,
72
+ type ModelRequest,
48
73
  type ModelProvider,
49
74
  type RunStreamEvent,
50
75
  type Tool,
51
76
  } from "@openai/agents";
52
- import {
53
- localDirLazySkillSource,
54
- } from "@openai/agents/sandbox/local";
77
+ import { localDirLazySkillSource } from "@openai/agents/sandbox/local";
55
78
  import {
56
79
  Capabilities,
57
80
  Manifest,
58
81
  SandboxAgent,
59
- StaticCompactionPolicy,
60
82
  azureBlobMount,
61
- compaction,
62
83
  dir,
63
84
  file,
64
85
  filesystem,
@@ -79,30 +100,39 @@ import {
79
100
  } from "@openai/agents/sandbox";
80
101
  import { ModalCloudBucketMountStrategy } from "@openai/agents-extensions/sandbox/modal";
81
102
  import OpenAI from "openai";
82
- import { CODEX_APPS_MCP_SERVER_ID, CODEX_MODEL_ID_PREFIX, CODEX_ORIGINATOR, codexAppsSanitizingFetch, codexRequestStorage, codexSubscriptionFetch } from "@opengeni/codex";
103
+ import {
104
+ CODEX_APPS_MCP_SERVER_ID,
105
+ CODEX_MODEL_ID_PREFIX,
106
+ CODEX_ORIGINATOR,
107
+ codexAppsSanitizingFetch,
108
+ codexRequestStorage,
109
+ codexSubscriptionFetch,
110
+ } from "@opengeni/codex";
83
111
  import { cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
84
112
  import { dirname, isAbsolute, join, posix as posixPath, relative } from "node:path";
85
113
  import { fileURLToPath } from "node:url";
86
114
 
87
- import { computerCallNormalizingFetch, normalizeComputerCallActions, sanitizeHistoryItemsForModel } from "./history-sanitizer";
88
- import { elideStaleScreenshotImages } from "./image-history";
115
+ import {
116
+ computerCallNormalizingFetch,
117
+ elideSupersededViewImagePairs,
118
+ normalizeComputerCallActions,
119
+ sanitizeHistoryItemsForModel,
120
+ } from "./history-sanitizer";
89
121
  import { installCodexToolSearch } from "./codex-tool-search";
90
122
  import {
91
123
  CompactionNeededError,
92
124
  SUMMARY_BUFFER_TOKENS,
93
- clientCompactionThresholdTokens,
94
- enforceInputBudget,
95
- estimateItemTokens,
125
+ compactionThresholdTokens,
96
126
  estimateTokens,
97
127
  renderCompactionPromptInputForChat,
98
128
  } from "./context-compaction";
99
129
  import {
100
130
  createSandboxClient,
101
- deserializeSandboxSessionStateEnvelope,
102
131
  desktopCapableBackend,
103
132
  restoredSandboxSessionStateFromEntry,
104
133
  setSelfhostedApplyDiff,
105
134
  } from "./sandbox";
135
+ import { runWithToolCallCorrelation } from "./sandbox/op-correlation";
106
136
  import { computerUse, type ComputerToolMode } from "./sandbox-computer";
107
137
  import type { RuntimeMetricsHooks } from "./metrics";
108
138
 
@@ -136,9 +166,17 @@ export * from "./sandbox";
136
166
  // active backend can now apply file edits over its NATS fs ops with the SDK's exact
137
167
  // diff semantics; without this, `createEditor()` throws a clear "not injected" error
138
168
  // rather than mis-editing. Runs at import time, before any turn binds a capability.
139
- setSelfhostedApplyDiff(applyDiff as unknown as (input: string, diff: string, mode?: "default" | "create") => string);
169
+ setSelfhostedApplyDiff(
170
+ applyDiff as unknown as (input: string, diff: string, mode?: "default" | "create") => string,
171
+ );
140
172
 
141
- export { sanitizeHistoryItemsForModel, stripReasoningEncryptedContent, stripReasoningIdentityFromSerializedRunState, neutralizeToolSearchItemsInSerializedRunState } from "./history-sanitizer";
173
+ export {
174
+ elideSupersededViewImagePairs,
175
+ sanitizeHistoryItemsForModel,
176
+ stripReasoningEncryptedContent,
177
+ stripReasoningIdentityFromSerializedRunState,
178
+ neutralizeToolSearchItemsInSerializedRunState,
179
+ } from "./history-sanitizer";
142
180
  export type { HistoryItem } from "./history-sanitizer";
143
181
 
144
182
  // The provider-bound Model classes used by buildModelInstance/resolveTurnModel.
@@ -151,17 +189,14 @@ export { OpenAIChatCompletionsModel, OpenAIResponsesModel } from "@openai/agents
151
189
  export {
152
190
  CompactionNeededError,
153
191
  buildCompactionPromptInput,
154
- buildDeterministicFallbackCompactionHistory,
155
192
  buildCompactionReplacementHistory,
156
- clientCompactionThresholdTokens,
193
+ compactionThresholdTokens,
157
194
  clampCompactionThresholdRatio,
158
- decideClientCompaction,
159
- enforceInputBudget,
195
+ decideCompaction,
160
196
  buildSummaryItem,
161
197
  findCompactionNeededError,
162
198
  isCompactionSummary,
163
199
  isUserMessage,
164
- findKeepBoundary,
165
200
  estimateTokens,
166
201
  estimateItemTokens,
167
202
  renderCompactionPromptInputForChat,
@@ -173,15 +208,12 @@ export {
173
208
  MAX_COMPACTION_THRESHOLD_RATIO,
174
209
  SUMMARY_BUFFER_TOKENS,
175
210
  SUMMARY_PREFIX,
211
+ isEphemeralInternalContext,
176
212
  USER_MESSAGE_TRUNCATION_MARKER,
177
213
  } from "./context-compaction";
178
- export type { ClientCompactionDecision, CompactionItem } from "./context-compaction";
179
- export {
180
- elideStaleScreenshotImages,
181
- SCREENSHOT_OMITTED_PLACEHOLDER,
182
- } from "./image-history";
183
- export { SCREENSHOT_OMITTED_IMAGE_DATA_URL } from "./screenshot-omitted-card";
184
- export type { ElideStaleScreenshotsOptions, ElideStaleScreenshotsResult } from "./image-history";
214
+ export type { CompactionDecision, CompactionItem } from "./context-compaction";
215
+ export { modelCallUsageTelemetry } from "./usage-telemetry";
216
+ export type { ModelCallUsageTelemetry } from "./usage-telemetry";
185
217
 
186
218
  ensureReadableStreamFrom();
187
219
 
@@ -199,6 +231,7 @@ export type ModelResponseUsage = {
199
231
  outputTokens?: number;
200
232
  totalTokens?: number;
201
233
  inputTokensDetails?: Record<string, number> | Array<Record<string, number>>;
234
+ outputTokensDetails?: Record<string, number> | Array<Record<string, number>>;
202
235
  };
203
236
  };
204
237
 
@@ -218,19 +251,21 @@ export type ResolveConnectionCredentialInput = {
218
251
  export type ResolveConnectionCredentialResult =
219
252
  | { status: "ok"; headers: Record<string, string>; connectionId: string; expiresAt?: Date | null }
220
253
  | {
221
- status: "auth_needed";
222
- reason: ToolAuthNeededPayload["reason"];
223
- providerDomain: string;
224
- connectionId?: string;
225
- scopes?: string[];
226
- resource?: string;
227
- authorizationUrl?: string;
228
- };
254
+ status: "auth_needed";
255
+ reason: ToolAuthNeededPayload["reason"];
256
+ providerDomain: string;
257
+ connectionId?: string;
258
+ scopes?: string[];
259
+ resource?: string;
260
+ authorizationUrl?: string;
261
+ };
229
262
 
230
263
  export function ensureReadableStreamFrom(): void {
231
- const ctor = globalThis.ReadableStream as (typeof ReadableStream & {
232
- from?: <T>(source: Iterable<T> | AsyncIterable<T>) => ReadableStream<T>;
233
- }) | undefined;
264
+ const ctor = globalThis.ReadableStream as
265
+ | (typeof ReadableStream & {
266
+ from?: <T>(source: Iterable<T> | AsyncIterable<T>) => ReadableStream<T>;
267
+ })
268
+ | undefined;
234
269
  if (!ctor || typeof ctor.from === "function") {
235
270
  return;
236
271
  }
@@ -260,21 +295,27 @@ export function ensureReadableStreamFrom(): void {
260
295
 
261
296
  export type AgentSegmentInput =
262
297
  | {
263
- kind: "message";
264
- text: string;
265
- serializedRunState?: string | null;
266
- // Items-mode conversation truth (issue #35): when provided, turn input is
267
- // built from these verbatim AgentInputItems and the stored sandbox
268
- // envelope — no RunState deserialization, no SDK-version coupling.
269
- historyItems?: AgentInputItem[] | null;
270
- sandboxEnvelope?: Record<string, unknown> | null;
271
- }
272
- | { kind: "approval"; serializedRunState: string; approvalId: string; decision: "approve" | "reject"; message?: string };
298
+ kind: "message";
299
+ /** A real human/API prompt. Omitted when resuming the same inference. */
300
+ text?: string;
301
+ /** Ephemeral platform context: system role, never conversation history. */
302
+ internalContext?: string;
303
+ // Canonical conversation truth. When omitted, this is a fresh first
304
+ // message; ordinary messages never deserialize an SDK RunState.
305
+ historyItems?: AgentInputItem[] | null;
306
+ sandboxEnvelope?: Record<string, unknown> | null;
307
+ }
308
+ | {
309
+ kind: "approval";
310
+ serializedRunState: string;
311
+ approvalId: string;
312
+ decision: "approve" | "reject";
313
+ message?: string;
314
+ };
273
315
 
274
316
  export type PreparedAgentInput = {
275
317
  input: string | AgentInputItem[] | RunState<any, any>;
276
318
  sandboxSessionState?: SandboxSessionState;
277
- serializedRunStateForSandbox?: string;
278
319
  };
279
320
 
280
321
  export type SandboxFileDownload = {
@@ -287,6 +328,19 @@ export type SandboxFileDownload = {
287
328
  sizeBytes?: number;
288
329
  };
289
330
 
331
+ export type SandboxFileDownloadFailure = {
332
+ fileId: string;
333
+ filename: string;
334
+ path: string;
335
+ reason: string;
336
+ exitCode?: number;
337
+ output?: string;
338
+ };
339
+
340
+ export type SandboxFileDownloadMaterializationResult = {
341
+ failures: SandboxFileDownloadFailure[];
342
+ };
343
+
290
344
  let runtimeMetricsHooks: RuntimeMetricsHooks | null = null;
291
345
 
292
346
  export function configureRuntimeMetricsHooks(hooks: RuntimeMetricsHooks | null | undefined): void {
@@ -300,10 +354,27 @@ export type OpenGeniRuntime = {
300
354
  // shape; null when the turn's model is not in the registry, so the caller
301
355
  // falls back to the legacy global-client path (settings.openaiModel).
302
356
  resolveTurnModel: (settings: Settings, modelId: string) => ReturnType<typeof resolveTurnModel>;
303
- buildAgent: (settings: Settings, resources: ResourceRef[], options?: BuildAgentOptions) => Agent<any, any>;
304
- prepareTools: (settings: Settings, tools: ToolRef[], options?: PrepareToolsOptions) => Promise<PreparedAgentTools>;
305
- prepareInput: (agent: Agent<any, any>, input: AgentSegmentInput, options?: PrepareInputOptions) => Promise<PreparedAgentInput>;
306
- runStream: (agent: Agent<any, any>, input: PreparedAgentInput, settings: Settings, options?: RunAgentStreamOptions) => Promise<Awaited<ReturnType<typeof runAgentStream>>>;
357
+ buildAgent: (
358
+ settings: Settings,
359
+ resources: ResourceRef[],
360
+ options?: BuildAgentOptions,
361
+ ) => Agent<any, any>;
362
+ prepareTools: (
363
+ settings: Settings,
364
+ tools: ToolRef[],
365
+ options?: PrepareToolsOptions,
366
+ ) => Promise<PreparedAgentTools>;
367
+ prepareInput: (
368
+ agent: Agent<any, any>,
369
+ input: AgentSegmentInput,
370
+ options?: PrepareInputOptions,
371
+ ) => Promise<PreparedAgentInput>;
372
+ runStream: (
373
+ agent: Agent<any, any>,
374
+ input: PreparedAgentInput,
375
+ settings: Settings,
376
+ options?: RunAgentStreamOptions,
377
+ ) => Promise<Awaited<ReturnType<typeof runAgentStream>>>;
307
378
  serializeApprovals: (interruptions: unknown[]) => unknown[];
308
379
  };
309
380
 
@@ -313,7 +384,9 @@ export type ProductionRuntimeOverrides = {
313
384
  metrics?: RuntimeMetricsHooks;
314
385
  };
315
386
 
316
- export function createProductionAgentRuntime(overrides: ProductionRuntimeOverrides = {}): OpenGeniRuntime {
387
+ export function createProductionAgentRuntime(
388
+ overrides: ProductionRuntimeOverrides = {},
389
+ ): OpenGeniRuntime {
317
390
  return {
318
391
  configure: (settings) => {
319
392
  configureRuntimeMetricsHooks(overrides.metrics);
@@ -323,17 +396,20 @@ export function createProductionAgentRuntime(overrides: ProductionRuntimeOverrid
323
396
  // model used in worker tests is not in any provider's allow-list), so when
324
397
  // one is supplied resolveTurnModel reports "no resolution" and the caller
325
398
  // keeps the legacy global-client path with the override model.
326
- resolveTurnModel: (settings, modelId) => (overrides.model ? null : resolveTurnModel(settings, modelId)),
327
- buildAgent: (settings, resources, options) => buildOpenGeniAgent(settings, resources, {
328
- ...options,
329
- ...(overrides.model ? { model: overrides.model } : {}),
330
- }),
399
+ resolveTurnModel: (settings, modelId) =>
400
+ overrides.model ? null : resolveTurnModel(settings, modelId),
401
+ buildAgent: (settings, resources, options) =>
402
+ buildOpenGeniAgent(settings, resources, {
403
+ ...options,
404
+ ...(overrides.model ? { model: overrides.model } : {}),
405
+ }),
331
406
  prepareTools: prepareAgentTools,
332
407
  prepareInput: prepareRunInput,
333
- runStream: async (agent, input, settings, options) => await runAgentStream(agent, input, settings, {
334
- ...options,
335
- sandboxClient: overrides.sandboxClient,
336
- }),
408
+ runStream: async (agent, input, settings, options) =>
409
+ await runAgentStream(agent, input, settings, {
410
+ ...options,
411
+ sandboxClient: overrides.sandboxClient,
412
+ }),
337
413
  serializeApprovals,
338
414
  };
339
415
  }
@@ -345,7 +421,10 @@ export function createProductionAgentRuntime(overrides: ProductionRuntimeOverrid
345
421
  * the OpenAI-platform path has only a key (the SDK default client is used via
346
422
  * setDefaultOpenAIKey there); the caller then constructs a key-only client.
347
423
  */
348
- export function buildOpenAIClientFromSettings(settings: Settings, providerId: string = settings.openaiProvider): OpenAI {
424
+ export function buildOpenAIClientFromSettings(
425
+ settings: Settings,
426
+ providerId: string = settings.openaiProvider,
427
+ ): OpenAI {
349
428
  if (settings.openaiProvider === "azure") {
350
429
  const baseURL = settings.azureOpenaiBaseUrl ?? azureDeploymentBaseUrl(settings);
351
430
  const apiKey = settings.azureOpenaiApiKey ?? settings.azureOpenaiAdToken ?? "azure-ad-token";
@@ -354,11 +433,12 @@ export function buildOpenAIClientFromSettings(settings: Settings, providerId: st
354
433
  baseURL,
355
434
  maxRetries: settings.openaiMaxRetries,
356
435
  defaultQuery: azureOpenAIDefaultQuery(settings, baseURL),
357
- defaultHeaders: settings.azureOpenaiAdToken && !settings.azureOpenaiApiKey
358
- ? { Authorization: `Bearer ${settings.azureOpenaiAdToken}` }
359
- : undefined,
436
+ defaultHeaders:
437
+ settings.azureOpenaiAdToken && !settings.azureOpenaiApiKey
438
+ ? { Authorization: `Bearer ${settings.azureOpenaiAdToken}` }
439
+ : undefined,
360
440
  // Rewrite every outbound /responses computer_call to the ACTIONS-ONLY shape
361
- // the GA Azure computer tool (gpt-5.5) accepts. This is the lowest reachable
441
+ // the GA Azure computer tool accepts. This is the lowest reachable
362
442
  // seam — below the SDK responses converter, which always re-synthesizes BOTH
363
443
  // `action` and `actions` (rejected 400 "exactly one of action or actions").
364
444
  // See computerCallNormalizingFetch / rewriteComputerCallsToActionsOnly.
@@ -393,29 +473,32 @@ export function buildProviderClient(provider: ResolvedModelProvider, settings: S
393
473
  const client = provider.builtin
394
474
  ? buildOpenAIClientFromSettings(settings, provider.id)
395
475
  : provider.kind === "codex-subscription"
396
- // Codex subscription: the static apiKey is a placeholder — the real per-request
397
- // bearer + ChatGPT-Account-ID, the /responses->/codex/responses rewrite, and the
398
- // body normalization are all injected by codexSubscriptionFetch, which reads the
399
- // per-workspace token from codexRequestStorage (AsyncLocalStorage) at call time.
400
- // The provider id is constant ("codex-subscription"), so one cached client serves
401
- // every workspace without baking a token into it.
402
- ? new OpenAI({
403
- apiKey: provider.apiKey ?? "codex-subscription",
404
- ...(provider.baseUrl ? { baseURL: provider.baseUrl } : {}),
405
- maxRetries: settings.openaiMaxRetries,
406
- fetch: codexSubscriptionFetch(instrumentedModelFetch(provider.id, globalThis.fetch)),
407
- })
408
- // ResolvedModelProvider.apiKey is already the resolved key (configuredProviders
409
- // ran resolveProviderApiKey at config time, collapsing apiKey/apiKeyEnv), so it
410
- // is passed straight through here rather than re-resolved.
411
- : new OpenAI({
412
- ...(provider.apiKey ? { apiKey: provider.apiKey } : {}),
413
- ...(provider.baseUrl ? { baseURL: provider.baseUrl } : {}),
414
- maxRetries: settings.openaiMaxRetries,
415
- ...(provider.defaultQuery ? { defaultQuery: provider.defaultQuery } : {}),
416
- ...(provider.defaultHeaders ? { defaultHeaders: provider.defaultHeaders } : {}),
417
- fetch: instrumentedModelFetch(provider.id, globalThis.fetch),
418
- });
476
+ ? // Codex subscription: the static apiKey is a placeholder — the real per-request
477
+ // bearer + ChatGPT-Account-ID, the /responses->/codex/responses rewrite, and the
478
+ // body normalization are all injected by codexSubscriptionFetch, which reads the
479
+ // per-workspace token from codexRequestStorage (AsyncLocalStorage) at call time.
480
+ // The provider id is constant ("codex-subscription"), so one cached client serves
481
+ // every workspace without baking a token into it.
482
+ new OpenAI({
483
+ apiKey: provider.apiKey ?? "codex-subscription",
484
+ ...(provider.baseUrl ? { baseURL: provider.baseUrl } : {}),
485
+ // Codex transport owns exactly one explicit 401 refresh/retry. Blind
486
+ // SDK retries on network/5xx/partial streams can replay provider work
487
+ // or external tool side effects without a durable checkpoint.
488
+ maxRetries: 0,
489
+ fetch: codexSubscriptionFetch(instrumentedModelFetch(provider.id, globalThis.fetch)),
490
+ })
491
+ : // ResolvedModelProvider.apiKey is already the resolved key (configuredProviders
492
+ // ran resolveProviderApiKey at config time, collapsing apiKey/apiKeyEnv), so it
493
+ // is passed straight through here rather than re-resolved.
494
+ new OpenAI({
495
+ ...(provider.apiKey ? { apiKey: provider.apiKey } : {}),
496
+ ...(provider.baseUrl ? { baseURL: provider.baseUrl } : {}),
497
+ maxRetries: settings.openaiMaxRetries,
498
+ ...(provider.defaultQuery ? { defaultQuery: provider.defaultQuery } : {}),
499
+ ...(provider.defaultHeaders ? { defaultHeaders: provider.defaultHeaders } : {}),
500
+ fetch: instrumentedModelFetch(provider.id, globalThis.fetch),
501
+ });
419
502
  providerClientCache.set(provider.id, client);
420
503
  return client;
421
504
  }
@@ -429,7 +512,11 @@ export function buildProviderClient(provider: ResolvedModelProvider, settings: S
429
512
  * the agent is what routes a turn to its provider without mutating the global
430
513
  * default client.
431
514
  */
432
- export function buildModelInstance(provider: ResolvedModelProvider, client: OpenAI, modelId: string): Model {
515
+ export function buildModelInstance(
516
+ provider: ResolvedModelProvider,
517
+ client: OpenAI,
518
+ modelId: string,
519
+ ): Model {
433
520
  return provider.api === "chat"
434
521
  ? new OpenAIChatCompletionsModel(client, modelId)
435
522
  : new OpenAIResponsesModel(client, modelId);
@@ -446,7 +533,12 @@ export function buildModelInstance(provider: ResolvedModelProvider, client: Open
446
533
  export function resolveTurnModel(
447
534
  settings: Settings,
448
535
  modelId: string,
449
- ): { provider: ResolvedModelProvider; client: OpenAI; model: Model; configured: ConfiguredModel } | null {
536
+ ): {
537
+ provider: ResolvedModelProvider;
538
+ client: OpenAI;
539
+ model: Model;
540
+ configured: ConfiguredModel;
541
+ } | null {
450
542
  const resolved = resolveModelProvider(settings, modelId);
451
543
  if (!resolved) {
452
544
  return null;
@@ -487,7 +579,10 @@ export class MultiProviderModelProvider implements ModelProvider {
487
579
 
488
580
  async getModel(modelName?: string): Promise<Model> {
489
581
  if (modelName) {
490
- const resolved = resolveTurnModel(settingsForRunScopedModelResolution(this.settings, modelName), modelName);
582
+ const resolved = resolveTurnModel(
583
+ settingsForRunScopedModelResolution(this.settings, modelName),
584
+ modelName,
585
+ );
491
586
  if (resolved) {
492
587
  // Fail-loud floor (defense in depth): a `codex/<slug>` id must only ever
493
588
  // resolve through the synthetic codex-subscription provider (which installs
@@ -497,7 +592,10 @@ export class MultiProviderModelProvider implements ModelProvider {
497
592
  // to Azure/OpenAI as a deployment name (DeploymentNotFound 404). Refuse it
498
593
  // here so codex can never reach a non-codex client on ANY backend; the
499
594
  // primary fix (config configuredModels) keeps this a no-op in practice.
500
- if (modelName.startsWith(CODEX_MODEL_ID_PREFIX) && resolved.provider.kind !== "codex-subscription") {
595
+ if (
596
+ modelName.startsWith(CODEX_MODEL_ID_PREFIX) &&
597
+ resolved.provider.kind !== "codex-subscription"
598
+ ) {
501
599
  throw new CodexSubscriptionUnavailableError(modelName);
502
600
  }
503
601
  return resolved.model;
@@ -539,11 +637,16 @@ function settingsForRunScopedModelResolution(settings: Settings, modelName: stri
539
637
  // bare registry id would otherwise be claimed by the built-in only because of
540
638
  // that per-turn override. Resolve the run-scoped router against the deployment
541
639
  // allow-list head instead; real built-in models stay in the allow-list.
542
- return builtinAllowed.includes(modelName) ? settings : { ...settings, openaiModel: fallbackBuiltin };
640
+ return builtinAllowed.includes(modelName)
641
+ ? settings
642
+ : { ...settings, openaiModel: fallbackBuiltin };
543
643
  }
544
644
 
545
645
  function splitOpenaiAllowedModels(value: string): string[] {
546
- return value.split(",").map((item) => item.trim()).filter(Boolean);
646
+ return value
647
+ .split(",")
648
+ .map((item) => item.trim())
649
+ .filter(Boolean);
547
650
  }
548
651
 
549
652
  /**
@@ -558,13 +661,34 @@ function splitOpenaiAllowedModels(value: string): string[] {
558
661
  export class CodexSubscriptionUnavailableError extends Error {
559
662
  constructor(modelName: string) {
560
663
  super(
561
- `Codex subscription model "${modelName}" is unavailable: no active Codex subscription is connected for this workspace. `
562
- + `Connect (or reconnect) your ChatGPT/Codex subscription in Settings, then retry.`,
664
+ `Codex subscription model "${modelName}" is unavailable: no active Codex subscription is connected for this workspace. ` +
665
+ `Connect (or reconnect) your ChatGPT/Codex subscription in Settings, then retry.`,
563
666
  );
564
667
  this.name = "CodexSubscriptionUnavailableError";
565
668
  }
566
669
  }
567
670
 
671
+ /**
672
+ * The workspace's model policy blocks the provider/model this turn resolved
673
+ * to. Thrown at the worker's post-resolution gate INSTEAD of running the turn
674
+ * on the blocked provider — a policy-restricted workspace (e.g. fail-closed to
675
+ * the Codex subscription) must never silently remap to, or fall through to,
676
+ * the paid built-in client. Like CodexSubscriptionUnavailableError the message
677
+ * is user-actionable and surfaces verbatim as a non-retryable turn.failed.
678
+ */
679
+ export class WorkspaceModelPolicyBlockedError extends Error {
680
+ constructor(modelName: string, providerId: string, reason: "provider" | "model") {
681
+ super(
682
+ reason === "provider"
683
+ ? `Model "${modelName}" is not available in this workspace: its provider ("${providerId}") is not in the workspace's allowed providers. ` +
684
+ `Pick an allowed model, or ask a workspace admin to change the workspace model policy.`
685
+ : `Model "${modelName}" is not in this workspace's allowed models. ` +
686
+ `Pick an allowed model, or ask a workspace admin to change the workspace model policy.`,
687
+ );
688
+ this.name = "WorkspaceModelPolicyBlockedError";
689
+ }
690
+ }
691
+
568
692
  export function configureOpenAI(settings: Settings): void {
569
693
  setOpenAIResponsesTransport(settings.openaiResponsesTransport);
570
694
  setTracingDisabled(settings.disableOpenaiTracing || !settings.observabilityOtlpEndpoint);
@@ -605,25 +729,32 @@ function instrumentedModelFetch(provider: string, inner: typeof fetch): typeof f
605
729
  }
606
730
 
607
731
  function isModelCallFetch(input: Parameters<typeof fetch>[0]): boolean {
608
- const rawUrl = typeof input === "string"
609
- ? input
610
- : input instanceof URL
611
- ? input.toString()
612
- : (input as { url?: unknown }).url;
732
+ const rawUrl =
733
+ typeof input === "string"
734
+ ? input
735
+ : input instanceof URL
736
+ ? input.toString()
737
+ : (input as { url?: unknown }).url;
613
738
  if (typeof rawUrl !== "string" || rawUrl.length === 0) {
614
739
  return false;
615
740
  }
616
741
  try {
617
742
  const pathname = new URL(rawUrl, "http://opengeni.local").pathname;
618
- return pathname.endsWith("/responses")
619
- || pathname.endsWith("/chat/completions")
620
- || pathname.endsWith("/codex/responses");
743
+ return (
744
+ pathname.endsWith("/responses") ||
745
+ pathname.endsWith("/chat/completions") ||
746
+ pathname.endsWith("/codex/responses")
747
+ );
621
748
  } catch {
622
749
  return /\/(?:codex\/)?responses(?:\?|$)|\/chat\/completions(?:\?|$)/.test(rawUrl);
623
750
  }
624
751
  }
625
752
 
626
- function recordModelCallMetric(provider: string, outcome: "completed" | "failed", started: number): void {
753
+ function recordModelCallMetric(
754
+ provider: string,
755
+ outcome: "completed" | "failed",
756
+ started: number,
757
+ ): void {
627
758
  const durationSeconds = Math.max(0, (performance.now() - started) / 1000);
628
759
  try {
629
760
  runtimeMetricsHooks?.onModelCall?.({ provider, outcome, durationSeconds });
@@ -635,10 +766,10 @@ function recordModelCallMetric(provider: string, outcome: "completed" | "failed"
635
766
  /**
636
767
  * Run the compaction summarizer as one plain, tool-less, non-streaming model
637
768
  * call against the resolved provider. `input` is the active history plus
638
- * Codex's checkpoint prompt. Returns the trimmed summary text, or null on any
639
- * failure (the caller can retry with a harder-trimmed transcript, then fall
640
- * back to deterministic non-LLM compaction). The call deliberately does NOT
641
- * request reasoning encryption, tools, or server-side compaction; it is a
769
+ * Codex's checkpoint prompt. Provider failures propagate to the compaction
770
+ * lifecycle; there is no non-model fallback that silently discards history.
771
+ * The call deliberately does NOT
772
+ * request reasoning encryption, tools, or inline provider compaction; it is a
642
773
  * self-contained summarize.
643
774
  *
644
775
  * Provider-aware: the summary always runs on the SAME provider that serves the
@@ -646,52 +777,69 @@ function recordModelCallMetric(provider: string, outcome: "completed" | "failed"
646
777
  * versa). `api: "chat"` providers (Fireworks) speak /v1/chat/completions, where
647
778
  * the summary is choices[0].message.content; `api: "responses"` (the default,
648
779
  * built-in OpenAI/Azure) speaks /v1/responses as before. When no client/api is
649
- * supplied it falls back to the built-in OpenAI/Azure Responses path so the
650
- * legacy global-client callers are byte-for-byte unchanged. store:false is set
780
+ * supplied it uses the built-in OpenAI/Azure Responses path. store:false is set
651
781
  * only on the OpenAI-platform Responses path (Azure rejects it; chat ignores it).
652
782
  */
653
783
  export async function summarizeForCompaction(
654
784
  settings: Settings,
655
785
  input: Array<Record<string, unknown>>,
656
- options: { client?: OpenAI; api?: ModelProviderApi; maxOutputTokens?: number; model?: string; maxTranscriptTokens?: number } = {},
657
- ): Promise<string | null> {
786
+ options: {
787
+ client?: OpenAI;
788
+ api?: ModelProviderApi;
789
+ maxOutputTokens?: number;
790
+ model?: string;
791
+ promptCacheKey?: string;
792
+ systemInstructions?: string;
793
+ onUsage?: (usage: ModelResponseUsage) => void | Promise<void>;
794
+ } = {},
795
+ ): Promise<string> {
658
796
  const client = options.client ?? buildOpenAIClientFromSettings(settings);
659
797
  const api = options.api ?? "responses";
660
798
  const model = options.model ?? settings.openaiModel;
661
799
  const maxTokens = options.maxOutputTokens ?? SUMMARY_BUFFER_TOKENS;
662
- const transcript = renderCompactionPromptInputForChat(
663
- input,
664
- typeof options.maxTranscriptTokens === "number" && options.maxTranscriptTokens > 0
665
- ? { maxEstimatedTokens: options.maxTranscriptTokens }
666
- : {},
667
- );
668
- try {
669
- if (api === "chat") {
670
- const completion = await client.chat.completions.create({
671
- model,
672
- max_tokens: maxTokens,
673
- messages: [{ role: "user", content: transcript }],
674
- } as any);
675
- const text = (completion as { choices?: Array<{ message?: { content?: unknown } }> }).choices?.[0]?.message?.content;
676
- const trimmed = typeof text === "string" ? text.trim() : "";
677
- return trimmed.length > 0 ? trimmed : null;
678
- }
679
- const response = await client.responses.create({
800
+ if (api === "chat") {
801
+ const transcript = renderCompactionPromptInputForChat(input);
802
+ const completion = await client.chat.completions.create({
680
803
  model,
681
- // store:false is the OpenAI-platform-only storeless precondition; Azure
682
- // rejects it. The summarizer's resolved client is OpenAI/Azure on the
683
- // built-in path (api "responses"), so gate it on the built-in provider.
684
- ...(settings.openaiProvider === "azure" ? {} : { store: false }),
685
- max_output_tokens: maxTokens,
686
- input: transcript,
804
+ max_tokens: maxTokens,
805
+ messages: [{ role: "user", content: transcript }],
806
+ ...(options.promptCacheKey ? { prompt_cache_key: options.promptCacheKey } : {}),
687
807
  } as any);
688
- const text = extractResponseOutputText(response);
689
- const trimmed = text.trim();
690
- return trimmed.length > 0 ? trimmed : null;
691
- } catch (error) {
692
- console.error("context compaction summarize failed", error);
693
- return null;
808
+ const usage = modelResponseUsageFromResponse(completion);
809
+ if (usage) {
810
+ await options.onUsage?.(usage);
811
+ }
812
+ const text = (completion as { choices?: Array<{ message?: { content?: unknown } }> })
813
+ .choices?.[0]?.message?.content;
814
+ return typeof text === "string" ? text.trim() : "";
815
+ }
816
+ // Use the same SDK Responses adapter as the real agent call. It converts the
817
+ // structured AgentInputItems (callId/providerData/etc.) to provider wire
818
+ // items without flattening tool history into a fake user transcript.
819
+ const request: ModelRequest = {
820
+ systemInstructions: options.systemInstructions ?? "",
821
+ input: input as AgentInputItem[],
822
+ modelSettings: {
823
+ maxTokens,
824
+ // Azure rejects store:false; the Codex subscription transport enforces
825
+ // it independently. The OpenAI platform path remains explicitly storeless.
826
+ ...(settings.openaiProvider === "azure" ? {} : { store: false }),
827
+ ...(options.promptCacheKey
828
+ ? { providerData: { prompt_cache_key: options.promptCacheKey } }
829
+ : {}),
830
+ },
831
+ tools: [],
832
+ toolsExplicitlyProvided: true,
833
+ outputType: "text",
834
+ handoffs: [],
835
+ tracing: false,
836
+ };
837
+ const response = await new CompactionResponsesModel(client, model).fetchResponse(request);
838
+ const usage = modelResponseUsageFromResponse(response);
839
+ if (usage) {
840
+ await options.onUsage?.(usage);
694
841
  }
842
+ return extractResponseOutputText(response).trim();
695
843
  }
696
844
 
697
845
  /**
@@ -731,7 +879,11 @@ export function extractResponseOutputText(response: unknown): string {
731
879
  continue;
732
880
  }
733
881
  for (const part of content) {
734
- if (part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string") {
882
+ if (
883
+ part &&
884
+ typeof part === "object" &&
885
+ typeof (part as { text?: unknown }).text === "string"
886
+ ) {
735
887
  parts.push((part as { text: string }).text);
736
888
  }
737
889
  }
@@ -739,6 +891,21 @@ export function extractResponseOutputText(response: unknown): string {
739
891
  return parts.join("");
740
892
  }
741
893
 
894
+ /**
895
+ * The public SDK getResponse() method is runner-facing and always opens a
896
+ * tracing span. Compaction is a standalone model call, so use the same SDK
897
+ * request conversion and Responses transport without manufacturing a runner
898
+ * trace solely to satisfy that wrapper.
899
+ */
900
+ class CompactionResponsesModel extends OpenAIResponsesModel {
901
+ fetchResponse(request: ModelRequest) {
902
+ return this._fetchResponse(request, false);
903
+ }
904
+ }
905
+
906
+ export type GitTokenSeeds = Partial<Record<GitCredentialProvider, string>>;
907
+ export type GitCredentialTokenWriterSession = SandboxSessionLike;
908
+
742
909
  export type BuildAgentOptions = {
743
910
  model?: Model;
744
911
  reasoningEffort?: ReasoningEffort;
@@ -746,11 +913,6 @@ export type BuildAgentOptions = {
746
913
  // today's settings-derived behaviour when omitted, so the legacy
747
914
  // global-client callers (no model resolution) are byte-for-byte unchanged.
748
915
  //
749
- // - compactionMode: the resolved context-compaction path. Drives whether the
750
- // sandbox `compaction()` capability is attached AND whether `store: false`
751
- // is set (the OpenAI-platform-only storeless precondition). Registry
752
- // providers resolve to "client", so neither is applied to them.
753
- // Default: resolveContextCompactionMode(settings).
754
916
  // - hostedWebSearch: attach the hosted web_search tool. Only the providers
755
917
  // that actually execute it (built-in OpenAI/Azure; a registry model that
756
918
  // opts in) should get it — Fireworks accepts the param but no-ops it, which
@@ -759,9 +921,6 @@ export type BuildAgentOptions = {
759
921
  // providerData.include. Only the Responses API carries it; the chat wire
760
922
  // API has no such field, so registry "chat" providers turn it off.
761
923
  // Default: settings.openaiReasoningEncryptedContent.
762
- // - contextWindowTokens: the model's effective window, used to derive the
763
- // server-path compaction threshold. A registry model can declare its own
764
- // (e.g. GLM 5.2's 1,048,576). Default: settings.contextWindowTokens.
765
924
  // - structuredToolTransport: whether the backend supports the Responses
766
925
  // STRUCTURED/HOSTED sandbox-tool transport — the hosted `apply_patch` tool
767
926
  // type and structured `view_image` output. The SDK's sandbox capabilities
@@ -774,10 +933,8 @@ export type BuildAgentOptions = {
774
933
  // function `apply_patch` + text `view_image` variants it accepts. Default
775
934
  // true (let the SDK decide from the model instance) — non-codex paths are
776
935
  // byte-for-byte unchanged.
777
- compactionMode?: ContextCompactionMode;
778
936
  hostedWebSearch?: boolean;
779
937
  encryptedReasoning?: boolean;
780
- contextWindowTokens?: number;
781
938
  structuredToolTransport?: boolean;
782
939
  // EXPLICIT computer-use tool transport, decided where provider identity is
783
940
  // authoritative (the worker's model resolution — agent-turn.ts). Threaded into
@@ -785,12 +942,24 @@ export type BuildAgentOptions = {
785
942
  // on the SDK's constructor-name sniff. When omitted, the legacy sniff +
786
943
  // `structuredToolTransport` neutralize path is preserved byte-for-byte.
787
944
  computerToolMode?: ComputerToolMode;
945
+ /**
946
+ * Invoked once, immediately before the first real computer action and after
947
+ * the display is ready. Merely advertising computer-use does not call it.
948
+ * The worker uses this seam for computer-use-only recording; ordinary
949
+ * shell/filesystem turns never pay that cost.
950
+ */
951
+ onComputerUseReady?: (session: SandboxSessionLike) => Promise<void>;
788
952
  // The LIVE, by-reference connector-namespace Set from prepareAgentTools
789
953
  // (codexConnectorNamespaces): fills during each turn's codex_apps tools/list,
790
954
  // read per model call by the codex tool_search description so the model sees
791
955
  // the account's ACTUALLY-connected sources (codex-rs parity). Only meaningful
792
956
  // on the codex tool-search path.
793
957
  codexConnectorNamespaces?: ReadonlySet<string>;
958
+ // Stable per-session routing key for provider-side prompt prefix caches. The
959
+ // worker passes this only for transports whose docs/API surface accept
960
+ // prompt_cache_key; registry providers that use a different affinity field stay
961
+ // unset to avoid unknown-parameter 400s.
962
+ promptCacheKey?: string;
794
963
  sandboxEnvironment?: Record<string, string>;
795
964
  // The EFFECTIVE/active compute backend for this turn. `settings.sandboxBackend`
796
965
  // is the session's HOME backend (the default cloud group box it was created
@@ -807,27 +976,49 @@ export type BuildAgentOptions = {
807
976
  activeSandboxBackend?: Settings["sandboxBackend"];
808
977
  fileResourceDownloads?: SandboxFileDownload[];
809
978
  mcpServers?: MCPServer[];
979
+ // Workspace Memory V1 working-set block, resolved by the worker per turn.
980
+ // Composed after the workspace persona/CORE/toolspace substrate and before
981
+ // per-session instructions. Omitted/blank ⇒ byte-identical instructions.
982
+ workspaceMemory?: string;
810
983
  workspaceEnvironment?: WorkspaceEnvironmentContext;
811
- // TOKEN-BROKER (B1): the run-scoped GitHub App installation token, minted ONCE
812
- // per turn by the worker (sandboxEnvironmentForRun's `gitToken`). Threaded here
813
- // OFF-MANIFEST — it is NOT part of sandboxEnvironment (the manifest env), so the
814
- // token VALUE never triggers the SDK's provided-session env-delta guard even
815
- // though it rotates every turn. buildAgent stashes it alongside the agent's
816
- // repository-clone hooks; runStream forwards it into the clone hook context, which
817
- // seeds it to the box's token FILE before the clone runs. Omitted on the
818
- // selfhosted path (the machine uses its own git creds) — a NO-OP there.
984
+ // M3 rig runtime binding (all absent ⇒ a rig-less turn, byte-for-byte today).
985
+ // - `rig`: renders the non-bypassable rig doctrine block in the CORE.
986
+ // - `rigSetup`: the rig version's setup script, run ONCE per box (marker-
987
+ // guarded) as the FIRST beforeAgentStart hook so later hooks see its tooling.
988
+ // - `rigCredentialHookIds`: the rig version's credential_hooks, unioned with
989
+ // the deployment preparation-profile hooks. Resolved (and VALIDATED — an
990
+ // unknown name throws here, at build = per-turn resolution time) so a typo'd
991
+ // hook fails the turn instead of being silently ignored.
992
+ rig?: RigInstructionsContext;
993
+ rigSetup?: RigSetupDescriptor;
994
+ rigCredentialHookIds?: string[];
995
+ // TOKEN-BROKER (B1): the run-scoped GitHub App installation token alias,
996
+ // minted ONCE per turn by the worker (sandboxEnvironmentForRun's `gitToken`).
997
+ // Kept for back-compat; internally it maps to gitTokenSeeds.github.
819
998
  gitTokenSeed?: string;
999
+ // Provider-token map for GitHub/GitLab/Azure DevOps. Threaded here OFF-
1000
+ // MANIFEST — it is NOT part of sandboxEnvironment (the manifest env), so token
1001
+ // VALUES never trigger the SDK's provided-session env-delta guard even though
1002
+ // they rotate every turn. buildAgent stashes them alongside the agent's
1003
+ // repository-clone hooks; runStream forwards them into the hook context, which
1004
+ // seeds provider token FILES before the clone/setup runs.
1005
+ gitTokenSeeds?: GitTokenSeeds;
820
1006
  // TOOLSPACE: the run-scoped delegated token to seed into
821
1007
  // $OPENGENI_TOOLSPACE_TOKEN_FILE. Like gitTokenSeed, this stays off the
822
1008
  // manifest/env delta and is written into the sandbox filesystem by a lifecycle
823
1009
  // hook before the agent starts.
824
1010
  toolspaceTokenSeed?: string;
825
- // Genesis turn only: append a one-shot instruction to the agent's system
826
- // prompt telling it to title the session via opengeni__set_session_title
827
- // before responding. Delivered through the instructions channel (where the
828
- // model actually obeys), appended AFTER the non-bypassable core so a
829
- // white-label persona template can't drop it.
1011
+ // Genesis turn only: inject a one-shot instruction into the FIRST model
1012
+ // call telling it to title the session via opengeni__set_session_title.
1013
+ // Keeping this out of the persistent Agent.instructions prevents every
1014
+ // tool-follow-up model call in a long first turn from re-running setup.
830
1015
  genesisTitleHint?: boolean;
1016
+ // Persistent session settings, resolved by the worker from the durable
1017
+ // session row at turn start. Tool schemas are rebuilt on every turn, so
1018
+ // without this factual state the model can mistake persistent metadata tools
1019
+ // for per-turn initialization and re-apply the same title/notification mode.
1020
+ // Values only — never the user-controlled title text — enter instructions.
1021
+ persistentSessionSettings?: PersistentSessionSettings;
831
1022
  // Per-call agent persona override (the white-label surface). Resolved by the
832
1023
  // caller as session > workspace > deployment default; when omitted the
833
1024
  // runtime falls back to settings.agentInstructionsTemplate. The runtime
@@ -877,7 +1068,35 @@ export type WorkspaceEnvironmentContext = {
877
1068
  variableNames?: string[];
878
1069
  };
879
1070
 
880
- export function workspaceEnvironmentInstructions(environment: WorkspaceEnvironmentContext): string[] {
1071
+ export type PersistentSessionSettings = {
1072
+ titleIsSet: boolean;
1073
+ childNotificationsMode?: "digest" | "passive";
1074
+ };
1075
+
1076
+ /**
1077
+ * The rig a session rides (M3): its name + the active version pinned onto the
1078
+ * session. Surfaced verbatim in the non-bypassable CORE instructions so the
1079
+ * agent understands its sandbox is a disposable fork of a shared, versioned
1080
+ * machine definition and how to promote a durable change. Absent for rig-less
1081
+ * sessions (the block never renders).
1082
+ */
1083
+ export type RigInstructionsContext = {
1084
+ name: string;
1085
+ version: number;
1086
+ };
1087
+
1088
+ export function rigInstructions(rig: RigInstructionsContext): string[] {
1089
+ return [
1090
+ `This session runs on rig "${rig.name}" (active version v${rig.version}) — a shared, versioned sandbox machine definition for your workspace.`,
1091
+ "Your sandbox is an EPHEMERAL FORK of that rig: you have root and may install anything freely, but everything you change here is junk that dies with the box and never reaches the rig or other sessions.",
1092
+ "For a DURABLE, team-wide change (tooling every future session on this rig should have), propose it with rig_propose_change, passing the EXACT command that already worked in this box — never assume an unverified change propagates.",
1093
+ "If tooling you expect is missing, consult rig_get to see the rig's current setup and checks before reinstalling.",
1094
+ ];
1095
+ }
1096
+
1097
+ export function workspaceEnvironmentInstructions(
1098
+ environment: WorkspaceEnvironmentContext,
1099
+ ): string[] {
881
1100
  const lines = [
882
1101
  `A workspace environment named "${environment.name}" is attached to this session; its variables are exported in the sandbox shell environment.`,
883
1102
  ];
@@ -903,10 +1122,16 @@ export function workspaceEnvironmentInstructions(environment: WorkspaceEnvironme
903
1122
  * drop: composeAgentInstructions() substitutes it at the persona template's
904
1123
  * {{core}} marker, and appends it when the marker is absent.
905
1124
  */
906
- export function coreInstructions(workspaceEnvironment?: WorkspaceEnvironmentContext): string[] {
1125
+ export function coreInstructions(
1126
+ workspaceEnvironment?: WorkspaceEnvironmentContext,
1127
+ rig?: RigInstructionsContext,
1128
+ ): string[] {
907
1129
  return [
908
1130
  "If the session has a goal, you own it: keep working until you call opengeni__goal_complete with concrete evidence or opengeni__goal_pause with a rationale; revise it with opengeni__goal_update; create one with opengeni__goal_set when given a long-running objective.",
909
1131
  ...(workspaceEnvironment ? workspaceEnvironmentInstructions(workspaceEnvironment) : []),
1132
+ // Rig doctrine (M3): data-conditional, inside the non-bypassable CORE so a
1133
+ // white-label persona template can never drop it. Absent for rig-less sessions.
1134
+ ...(rig ? rigInstructions(rig) : []),
910
1135
  ];
911
1136
  }
912
1137
 
@@ -918,8 +1143,12 @@ export function coreInstructions(workspaceEnvironment?: WorkspaceEnvironmentCont
918
1143
  * and the append both join by " ", so the DEFAULT_AGENT_INSTRUCTIONS template
919
1144
  * with an empty environment reproduces the historical preamble byte-for-byte.
920
1145
  */
921
- export function composeAgentInstructions(template: string, workspaceEnvironment?: WorkspaceEnvironmentContext): string {
922
- const core = coreInstructions(workspaceEnvironment).join(" ");
1146
+ export function composeAgentInstructions(
1147
+ template: string,
1148
+ workspaceEnvironment?: WorkspaceEnvironmentContext,
1149
+ rig?: RigInstructionsContext,
1150
+ ): string {
1151
+ const core = coreInstructions(workspaceEnvironment, rig).join(" ");
923
1152
  if (template.includes(AGENT_INSTRUCTIONS_CORE_PLACEHOLDER)) {
924
1153
  return template.split(AGENT_INSTRUCTIONS_CORE_PLACEHOLDER).join(core);
925
1154
  }
@@ -938,6 +1167,41 @@ export function appendSessionInstructions(composed: string, sessionInstructions?
938
1167
  return trimmed ? `${composed} ${trimmed}` : composed;
939
1168
  }
940
1169
 
1170
+ /**
1171
+ * Append the durable session metadata the model otherwise cannot observe.
1172
+ * This is deliberately declarative and excludes the title text: the title is
1173
+ * user-controlled display metadata, while the agent only needs to know whether
1174
+ * initialization has already happened. The block is present on every turn when
1175
+ * supplied so compaction cannot erase knowledge that these settings persist.
1176
+ */
1177
+ export function appendPersistentSessionSettings(
1178
+ composed: string,
1179
+ settings?: PersistentSessionSettings,
1180
+ ): string {
1181
+ if (!settings) {
1182
+ return composed;
1183
+ }
1184
+ const childState = settings.childNotificationsMode
1185
+ ? `; spawned-worker completion notifications are ${settings.childNotificationsMode}`
1186
+ : "";
1187
+ const persistentTools = settings.childNotificationsMode
1188
+ ? "opengeni__set_session_title or opengeni__set_child_notifications_mode"
1189
+ : "opengeni__set_session_title";
1190
+ return `${composed} Persistent session settings already in effect: the display title is ${settings.titleIsSet ? "set" : "not set"}${childState}. These settings persist across turns, continuations, and interruptions. Do not call ${persistentTools} merely to reassert an unchanged value; call them only when the current value actually needs to change.`;
1191
+ }
1192
+
1193
+ /**
1194
+ * Appends the workspace memory working-set block to the already-composed
1195
+ * (workspace + CORE + generic substrate) instructions, joined by " ". The
1196
+ * memory slice is workspace-ground and intentionally lands before
1197
+ * per-session instructions. An absent/blank value is a no-op that returns the
1198
+ * composed string byte-for-byte.
1199
+ */
1200
+ export function appendWorkspaceMemory(composed: string, workspaceMemory?: string): string {
1201
+ const trimmed = workspaceMemory?.trim();
1202
+ return trimmed ? `${composed} ${trimmed}` : composed;
1203
+ }
1204
+
941
1205
  /**
942
1206
  * Appends the generic programmatic-tool-calling (toolspace) directive to the
943
1207
  * composed workspace + CORE instructions, joined by " ". This is GENERIC
@@ -962,23 +1226,36 @@ export function appendToolspaceInstructions(composed: string, toolspaceAvailable
962
1226
  * " " and always LAST so a white-label persona template or a per-session
963
1227
  * instruction can't drop it. A no-op when the hint is absent.
964
1228
  */
965
- export function appendGenesisTitleDirective(instructions: string, genesisTitleHint?: boolean): string {
1229
+ export function appendGenesisTitleDirective(
1230
+ instructions: string,
1231
+ genesisTitleHint?: boolean,
1232
+ ): string {
966
1233
  return genesisTitleHint ? `${instructions} ${GENESIS_TITLE_DIRECTIVE}` : instructions;
967
1234
  }
968
1235
 
969
1236
  const agentFileDownloads = new WeakMap<object, SandboxFileDownload[]>();
970
1237
  const agentRepositoryCloneHooks = new WeakMap<object, SandboxLifecycleHook[]>();
971
- // TOKEN-BROKER (B1): the per-turn git token seed, stashed alongside the agent's
972
- // repository-clone hooks (a parallel map keyed by the agent). Kept OFF the
973
- // manifest/defaultManifest so the rotating value never rides the SDK's provided-
974
- // session env; runStream reads it to build the clone hook context. Absent when
975
- // no repo is attached / on the selfhosted path.
976
- const agentGitTokenSeed = new WeakMap<object, string>();
1238
+ // TOKEN-BROKER (B1): the per-turn provider git token seeds, stashed alongside
1239
+ // the agent's repository-clone hooks (a parallel map keyed by the agent). Kept
1240
+ // OFF the manifest/defaultManifest so rotating values never ride the SDK's
1241
+ // provided-session env; runStream reads them to build the clone hook context.
1242
+ // Absent when no brokered repo is attached / on the selfhosted path.
1243
+ const agentGitTokenSeeds = new WeakMap<object, GitTokenSeeds>();
977
1244
  const agentToolspaceTokenSeed = new WeakMap<object, string>();
1245
+ // A genesis directive is consumed by runAgentStream exactly once for the
1246
+ // freshly-built agent. It must not remain in Agent.instructions: those
1247
+ // instructions are presented again on every internal model/tool loop.
1248
+ const agentsNeedingGenesisTitleDirective = new WeakSet<object>();
978
1249
  // The EFFECTIVE backend the turn resolved for this agent (undefined -> the home
979
1250
  // backend). Read by runStream's owned branch to keep platform box-setup hooks off
980
1251
  // connected machines (a user's real computer).
981
1252
  const agentActiveSandboxBackend = new WeakMap<object, Settings["sandboxBackend"]>();
1253
+ // M3: the rig-setup descriptor + resolved rig credential hooks for this agent's
1254
+ // turn (kept off the manifest like the clone hooks). Read by runStream to build
1255
+ // the rig-setup hook and to union the rig credential hooks with the deployment
1256
+ // preparation-profile hooks. Absent for a rig-less turn.
1257
+ const agentRigSetup = new WeakMap<object, RigSetupDescriptor>();
1258
+ const agentRigCredentialHooks = new WeakMap<object, SandboxLifecycleHook[]>();
982
1259
 
983
1260
  /**
984
1261
  * The tool output emitted for an MCP tool call that FAILED with a THROWN error
@@ -1004,9 +1281,20 @@ const agentActiveSandboxBackend = new WeakMap<object, Settings["sandboxBackend"]
1004
1281
  * requires an SDK-level change (preserve `isError`) or reading the raw
1005
1282
  * CallToolResult — tracked separately.
1006
1283
  */
1007
- export function mcpToolErrorOutput(error: unknown): { isError: true; content: [{ type: "text"; text: string }] } {
1284
+ export function mcpToolErrorOutput(error: unknown): {
1285
+ isError: true;
1286
+ content: [{ type: "text"; text: string }];
1287
+ } {
1008
1288
  const details = error instanceof Error ? error.message : String(error);
1009
- return { isError: true, content: [{ type: "text", text: `An error occurred while running the tool. Please try again. Error: ${details}` }] };
1289
+ return {
1290
+ isError: true,
1291
+ content: [
1292
+ {
1293
+ type: "text",
1294
+ text: `An error occurred while running the tool. Please try again. Error: ${details}`,
1295
+ },
1296
+ ],
1297
+ };
1010
1298
  }
1011
1299
 
1012
1300
  // Applied to EVERY MCP server via the agent's `mcpConfig.errorFunction`
@@ -1014,17 +1302,24 @@ export function mcpToolErrorOutput(error: unknown): { isError: true; content: [{
1014
1302
  // SDK types the return as `string`; the runtime actually stores the raw return
1015
1303
  // as the tool output, so we return the MCP-shaped error object and cast — this
1016
1304
  // is the only way to attach an `isError` flag to a failed MCP tool call's output.
1017
- const mcpToolErrorFunction: MCPToolErrorFunction = ({ error }) => mcpToolErrorOutput(error) as unknown as string;
1305
+ const mcpToolErrorFunction: MCPToolErrorFunction = ({ error }) =>
1306
+ mcpToolErrorOutput(error) as unknown as string;
1018
1307
 
1019
- export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[], options: BuildAgentOptions = {}): Agent<any, any> {
1308
+ export function buildOpenGeniAgent(
1309
+ settings: Settings,
1310
+ resources: ResourceRef[],
1311
+ options: BuildAgentOptions = {},
1312
+ ): Agent<any, any> {
1020
1313
  // Resolved per-turn gating. Each override defaults to today's settings-derived
1021
1314
  // behaviour, so the legacy global-client callers (no resolved model) build the
1022
1315
  // exact same agent as before; the multi-provider worker path passes the
1023
- // resolved provider's mode/api/window/web-search instead.
1024
- const compactionMode = options.compactionMode ?? resolveContextCompactionMode(settings);
1316
+ // resolved provider's api/window/web-search instead.
1025
1317
  const hostedWebSearch = options.hostedWebSearch ?? settings.webSearchEnabled;
1026
1318
  const encryptedReasoning = options.encryptedReasoning ?? settings.openaiReasoningEncryptedContent;
1027
- const contextWindowTokens = options.contextWindowTokens ?? settings.contextWindowTokens;
1319
+ const providerData = {
1320
+ ...(encryptedReasoning ? { include: ["reasoning.encrypted_content"] } : {}),
1321
+ ...(options.promptCacheKey ? { prompt_cache_key: options.promptCacheKey } : {}),
1322
+ };
1028
1323
  // Native hosted tools attached to every constructed agent. webSearchEnabled
1029
1324
  // is ON by default and provider-unconditional on the built-in path (the live
1030
1325
  // Azure Responses path executes the hosted web_search tool); a registry model
@@ -1054,32 +1349,36 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
1054
1349
  // when a toolspace token was minted for this turn (feature enabled + a
1055
1350
  // per-turn seed — the mint gate, which includes selfhosted turns since
1056
1351
  // they now receive the token too) — appendToolspaceInstructions,
1057
- // 3. + the per-session persona instructions (session-specific, so it
1352
+ // 3. + workspace memory working set, ONLY when the workspace setting is on
1353
+ // and the worker resolved a nonblank block — appendWorkspaceMemory,
1354
+ // 4. + the per-session persona instructions (session-specific, so it
1058
1355
  // refines both the workspace persona and the substrate note),
1059
- // 4. + the one-shot genesis title directive (genesis turn only).
1060
- // With no toolspace token, no session instructions, and no genesis hint this
1061
- // is byte-identical to the historical composed instructions.
1062
- instructions: appendGenesisTitleDirective(
1356
+ // 5. + durable session-setting state (title present + child notification
1357
+ // mode), when supplied by the worker,
1358
+ // The genesis title directive is deliberately NOT part of this persistent
1359
+ // string. runAgentStream injects it into the first model call only.
1360
+ instructions: appendPersistentSessionSettings(
1063
1361
  appendSessionInstructions(
1064
- appendToolspaceInstructions(
1065
- composeAgentInstructions(options.instructionsTemplate ?? settings.agentInstructionsTemplate, options.workspaceEnvironment),
1066
- settings.toolspaceEnabled && Boolean(options.toolspaceTokenSeed),
1362
+ appendWorkspaceMemory(
1363
+ appendToolspaceInstructions(
1364
+ composeAgentInstructions(
1365
+ options.instructionsTemplate ?? settings.agentInstructionsTemplate,
1366
+ options.workspaceEnvironment,
1367
+ options.rig,
1368
+ ),
1369
+ settings.toolspaceEnabled && Boolean(options.toolspaceTokenSeed),
1370
+ ),
1371
+ options.workspaceMemory,
1067
1372
  ),
1068
1373
  options.sessionInstructions,
1069
1374
  ),
1070
- options.genesisTitleHint,
1375
+ options.persistentSessionSettings,
1071
1376
  ),
1072
1377
  modelSettings: {
1073
- reasoning: { effort: options.reasoningEffort ?? settings.openaiReasoningEffort, summary: "detailed" },
1074
- // Server-side compaction (OpenAI platform) requires store=false: the
1075
- // server emits an opaque ENCRYPTED 'compaction' item that round-trips in
1076
- // the request rather than being anchored to a stored response. OpenGeni
1077
- // already runs storeless (provider item ids stripped, encrypted reasoning
1078
- // round-tripped), so this is consistent with the existing design and
1079
- // only set where the server compaction capability is attached. Gated on
1080
- // the RESOLVED compaction mode (registry providers resolve to "client",
1081
- // so they never carry store:false).
1082
- ...(compactionMode === "server" ? { store: false } : {}),
1378
+ reasoning: {
1379
+ effort: options.reasoningEffort ?? settings.openaiReasoningEffort,
1380
+ summary: "detailed",
1381
+ },
1083
1382
  // Round-trip the encrypted reasoning payload with every call so chains
1084
1383
  // of thought survive without provider-side response storage (which is
1085
1384
  // what stripped provider item ids opt us out of — see
@@ -1088,9 +1387,7 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
1088
1387
  // function tools, which contribute none. Gated on the resolved
1089
1388
  // encryptedReasoning flag: the chat wire API has no encrypted_content
1090
1389
  // field, so registry "chat" providers turn it off.
1091
- ...(encryptedReasoning
1092
- ? { providerData: { include: ["reasoning.encrypted_content"] } }
1093
- : {}),
1390
+ ...(Object.keys(providerData).length > 0 ? { providerData } : {}),
1094
1391
  },
1095
1392
  // Explicit hosted tools (web_search when enabled). Threaded into BOTH the
1096
1393
  // `new Agent(baseConfig)` path (sandboxBackend === "none") and the
@@ -1109,6 +1406,9 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
1109
1406
 
1110
1407
  if (settings.sandboxBackend === "none") {
1111
1408
  const agent = new Agent(baseConfig);
1409
+ if (options.genesisTitleHint) {
1410
+ agentsNeedingGenesisTitleDirective.add(agent);
1411
+ }
1112
1412
  maybeInstallCodexToolSearch(agent, settings, options);
1113
1413
  applyMcpApprovalPolicy(agent, settings);
1114
1414
  return agent;
@@ -1117,17 +1417,36 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
1117
1417
  const runAs = sandboxRunAs(settings);
1118
1418
  const agent = new SandboxAgent({
1119
1419
  ...baseConfig,
1120
- defaultManifest: buildManifest(settings, resources, options.sandboxEnvironment, options.fileResourceDownloads),
1420
+ defaultManifest: buildManifest(
1421
+ settings,
1422
+ resources,
1423
+ options.sandboxEnvironment,
1424
+ options.fileResourceDownloads,
1425
+ ),
1121
1426
  ...(runAs ? { runAs } : {}),
1122
1427
  capabilities: buildAgentCapabilities(settings, options.packSkills ?? [], {
1123
- compactionMode,
1124
- contextWindowTokens,
1125
- ...(options.structuredToolTransport !== undefined ? { structuredToolTransport: options.structuredToolTransport } : {}),
1126
- ...(options.computerToolMode !== undefined ? { computerToolMode: options.computerToolMode } : {}),
1428
+ ...(options.structuredToolTransport !== undefined
1429
+ ? { structuredToolTransport: options.structuredToolTransport }
1430
+ : {}),
1431
+ ...(options.computerToolMode !== undefined
1432
+ ? { computerToolMode: options.computerToolMode }
1433
+ : {}),
1434
+ ...(options.onComputerUseReady ? { onComputerUseReady: options.onComputerUseReady } : {}),
1127
1435
  }),
1128
1436
  });
1129
- agentFileDownloads.set(agent, normalizeSandboxFileDownloads(options.fileResourceDownloads ?? []).filter((download) => !download.content));
1130
- agentRepositoryCloneHooks.set(agent, sandboxRepositoryCloneHooks(settings, resources, options.activeSandboxBackend));
1437
+ if (options.genesisTitleHint) {
1438
+ agentsNeedingGenesisTitleDirective.add(agent);
1439
+ }
1440
+ agentFileDownloads.set(
1441
+ agent,
1442
+ normalizeSandboxFileDownloads(options.fileResourceDownloads ?? []).filter(
1443
+ (download) => !download.content,
1444
+ ),
1445
+ );
1446
+ agentRepositoryCloneHooks.set(
1447
+ agent,
1448
+ sandboxRepositoryCloneHooks(settings, resources, options.activeSandboxBackend),
1449
+ );
1131
1450
  // Stash the EFFECTIVE backend so runStream's owned branch can skip the direct
1132
1451
  // beforeAgentStart hook run on a connected machine: the box there is the user's
1133
1452
  // REAL computer — the platform must not run setup (az login) against it. The
@@ -1136,14 +1455,29 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
1136
1455
  if (options.activeSandboxBackend) {
1137
1456
  agentActiveSandboxBackend.set(agent, options.activeSandboxBackend);
1138
1457
  }
1139
- // TOKEN-BROKER (B1): stash the per-turn seed off-manifest so runStream can seed the
1140
- // clone hook without the token ever touching defaultManifest / sandboxEnvironment.
1141
- if (options.gitTokenSeed) {
1142
- agentGitTokenSeed.set(agent, options.gitTokenSeed);
1458
+ // TOKEN-BROKER (B1): stash per-turn provider seeds off-manifest so runStream
1459
+ // can seed the setup hook without tokens ever touching defaultManifest /
1460
+ // sandboxEnvironment. `gitTokenSeed` remains the GitHub alias.
1461
+ const gitTokenSeeds = {
1462
+ ...(options.gitTokenSeeds ?? {}),
1463
+ ...(options.gitTokenSeed ? { github: options.gitTokenSeed } : {}),
1464
+ } satisfies GitTokenSeeds;
1465
+ if (Object.keys(gitTokenSeeds).length > 0) {
1466
+ agentGitTokenSeeds.set(agent, gitTokenSeeds);
1143
1467
  }
1144
1468
  if (options.toolspaceTokenSeed) {
1145
1469
  agentToolspaceTokenSeed.set(agent, options.toolspaceTokenSeed);
1146
1470
  }
1471
+ // M3: stash the rig setup descriptor + RESOLVE the rig credential hooks now.
1472
+ // sandboxLifecycleHooksForIds throws on an unknown hook name, so a typo'd rig
1473
+ // credential_hook fails the turn HERE (per-turn resolution) rather than being
1474
+ // silently dropped — the fail-visible contract the brief requires.
1475
+ if (options.rigSetup) {
1476
+ agentRigSetup.set(agent, options.rigSetup);
1477
+ }
1478
+ if (options.rigCredentialHookIds && options.rigCredentialHookIds.length > 0) {
1479
+ agentRigCredentialHooks.set(agent, sandboxLifecycleHooksForIds(options.rigCredentialHookIds));
1480
+ }
1147
1481
  maybeInstallCodexToolSearch(agent, settings, options);
1148
1482
  applyMcpApprovalPolicy(agent, settings);
1149
1483
  return agent;
@@ -1158,7 +1492,11 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
1158
1492
  * codex_apps schemas + add the client tool_search tool, whose description renders
1159
1493
  * the live connector namespaces threaded from prepareAgentTools.
1160
1494
  */
1161
- function maybeInstallCodexToolSearch(agent: Agent<any, any>, settings: Settings, options: BuildAgentOptions): void {
1495
+ function maybeInstallCodexToolSearch(
1496
+ agent: Agent<any, any>,
1497
+ settings: Settings,
1498
+ options: BuildAgentOptions,
1499
+ ): void {
1162
1500
  if (settings.codexToolSearchEnabled && options.structuredToolTransport === false) {
1163
1501
  installCodexToolSearch(
1164
1502
  agent as unknown as Parameters<typeof installCodexToolSearch>[0],
@@ -1198,7 +1536,10 @@ type ApprovalCapableAgent = {
1198
1536
  * paths. The base (non-sandbox) `Agent.clone` spreads `...this` and would carry
1199
1537
  * the override, but re-installing is idempotent there and keeps one code path.
1200
1538
  */
1201
- function installMcpApprovalPolicy(agent: ApprovalCapableAgent, policies: McpApprovalPolicy[]): void {
1539
+ function installMcpApprovalPolicy(
1540
+ agent: ApprovalCapableAgent,
1541
+ policies: McpApprovalPolicy[],
1542
+ ): void {
1202
1543
  const listMcpTools = agent.getMcpTools.bind(agent);
1203
1544
  agent.getMcpTools = async (runContext: unknown) => {
1204
1545
  const tools = await listMcpTools(runContext);
@@ -1253,8 +1594,15 @@ function installMcpApprovalPolicy(agent: ApprovalCapableAgent, policies: McpAppr
1253
1594
  */
1254
1595
  function applyMcpApprovalPolicy(agent: Agent<any, any>, settings: Settings): void {
1255
1596
  const policies: McpApprovalPolicy[] = settings.mcpServers
1256
- .filter((server) => server.requireApproval === true || (Array.isArray(server.requireApproval) && server.requireApproval.length > 0))
1257
- .map((server) => ({ prefix: prefixedMcpToolName(server.id, ""), requireApproval: server.requireApproval as boolean | string[] }))
1597
+ .filter(
1598
+ (server) =>
1599
+ server.requireApproval === true ||
1600
+ (Array.isArray(server.requireApproval) && server.requireApproval.length > 0),
1601
+ )
1602
+ .map((server) => ({
1603
+ prefix: prefixedMcpToolName(server.id, ""),
1604
+ requireApproval: server.requireApproval as boolean | string[],
1605
+ }))
1258
1606
  .sort((a, b) => b.prefix.length - a.prefix.length);
1259
1607
  if (policies.length === 0) {
1260
1608
  return;
@@ -1276,7 +1624,9 @@ function applyMcpApprovalPolicy(agent: Agent<any, any>, settings: Settings): voi
1276
1624
  * `computer_screenshot` for computer-use. `bindModel` still returns the capability
1277
1625
  * so the SDK's bind chain (`.bind().bindRunAs().bindModel()`) is preserved.
1278
1626
  */
1279
- function neutralizeStructuredToolTransport(capability: ReturnType<typeof filesystem> | ReturnType<typeof computerUse>): void {
1627
+ function neutralizeStructuredToolTransport(
1628
+ capability: ReturnType<typeof filesystem> | ReturnType<typeof computerUse>,
1629
+ ): void {
1280
1630
  // Use `this` (NOT a captured reference to `capability`): the SandboxAgent binds
1281
1631
  // via `cap.clone().bind(session).bindRunAs(runAs).bindModel(model, instance)` and
1282
1632
  // runs tools() on the object the CHAIN returns. Capability.clone() copies this
@@ -1290,49 +1640,95 @@ function neutralizeStructuredToolTransport(capability: ReturnType<typeof filesys
1290
1640
  this._modelInstance = undefined;
1291
1641
  return this;
1292
1642
  };
1293
- (capability as unknown as { bindModel: typeof forceFunctionTransport }).bindModel = forceFunctionTransport;
1643
+ (capability as unknown as { bindModel: typeof forceFunctionTransport }).bindModel =
1644
+ forceFunctionTransport;
1294
1645
  }
1295
1646
 
1296
1647
  /**
1297
- * Build the SandboxAgent capability set provider-aware.
1298
- *
1299
- * The SDK's `Capabilities.default()` force-includes `compaction()`, whose
1300
- * sampling params emit `context_management:[{type:'compaction', …}]` to the
1301
- * Responses transport. The OpenAI platform honors that (server-side compaction);
1302
- * AZURE rejects it with `400 unsupported_parameter` — which is exactly the live
1303
- * production failure on Azure today. So we MUST NOT attach the compaction
1304
- * capability on the Azure / client / off paths.
1305
- *
1306
- * We rebuild the base set explicitly (`filesystem()`, `shell()`, the same
1307
- * factories the SDK default uses) and add `compaction()` ONLY on the server
1308
- * path, with an explicit `StaticCompactionPolicy(threshold)` so gpt-5.5 — which
1309
- * is absent from the SDK's hardcoded context-window map and would otherwise hit
1310
- * the wrong 240k fallback — gets the correct threshold. The SDK has no
1311
- * window-registration API, so an explicit threshold is the only way to fix it.
1312
- *
1313
- * The resolved compaction mode and the effective context window are now passed
1314
- * IN (the multi-provider caller resolves them per provider/model) rather than
1315
- * re-derived from settings here. Both default to the settings-derived value so
1316
- * callers that don't route per-model (and the existing tests) keep today's exact
1317
- * behaviour; the effective window only changes the server-path threshold when a
1318
- * resolved model declares its own contextWindowTokens.
1648
+ * Build the SandboxAgent capability set explicitly. The SDK default includes
1649
+ * its inline provider compaction capability; OpenGeni deliberately omits it
1650
+ * because durable portable compaction owns the full history transition.
1651
+ */
1652
+ /**
1653
+ * Wrap the shell capability's `exec_command` so its execution runs inside a
1654
+ * tool-call correlation context (op-correlation.ts): the SDK's tool machinery
1655
+ * passes `details.toolCall` (the model's function_call, with its durable
1656
+ * `callId`) into every function-tool `invoke`, and binding an
1657
+ * AsyncLocalStorage around the invocation makes that id visible to the sandbox
1658
+ * transport underneath — which mints the DURABLE op id `{callId}:{ordinal}`
1659
+ * (op-stream ruling B1). A re-dispatched turn re-executes the same
1660
+ * function_call with the same callId, so the transport's idempotent OpStart
1661
+ * ATTACHES to the already-running/completed op instead of re-running it.
1662
+ * Without a callId (no details on the invocation) the tool runs unwrapped and
1663
+ * the transport falls back to a random unique id — today's semantics.
1664
+ */
1665
+ function withExecOpCorrelation(tools: Tool<unknown>[]): Tool<unknown>[] {
1666
+ return tools.map((capabilityTool) => {
1667
+ if (capabilityTool.type !== "function" || capabilityTool.name !== "exec_command") {
1668
+ return capabilityTool;
1669
+ }
1670
+ const invoke = capabilityTool.invoke;
1671
+ return {
1672
+ ...capabilityTool,
1673
+ invoke: (runContext, input, details) => {
1674
+ const callId = details?.toolCall?.callId;
1675
+ if (!callId) {
1676
+ return invoke(runContext, input, details);
1677
+ }
1678
+ return runWithToolCallCorrelation(callId, () => invoke(runContext, input, details));
1679
+ },
1680
+ };
1681
+ });
1682
+ }
1683
+
1684
+ /**
1685
+ * Codex accepts ordinary FUNCTION tools but also accepts `input_image` content
1686
+ * inside a function_call_output (the same transport used by codex-rs
1687
+ * view_image). The SDK filesystem capability unnecessarily couples this choice
1688
+ * to hosted apply_patch support; when hosted tools are disabled it degrades
1689
+ * view_image to a giant text data URL, charging roughly one token per four
1690
+ * base64 characters. Re-wrap only successful data-URL results as a structured
1691
+ * image. Text errors remain text, and the tool itself remains a normal function.
1319
1692
  */
1693
+ export function withStructuredViewImageFunctionResults(tools: Tool<unknown>[]): Tool<unknown>[] {
1694
+ return tools.map((capabilityTool) => {
1695
+ if (capabilityTool.type !== "function" || capabilityTool.name !== "view_image") {
1696
+ return capabilityTool;
1697
+ }
1698
+ const invoke = capabilityTool.invoke;
1699
+ return {
1700
+ ...capabilityTool,
1701
+ invoke: async (runContext, input, details) => {
1702
+ const output = await invoke(runContext, input, details);
1703
+ const dataUrl =
1704
+ typeof output === "string"
1705
+ ? output
1706
+ : output &&
1707
+ typeof output === "object" &&
1708
+ typeof (output as { text?: unknown }).text === "string"
1709
+ ? (output as { text: string }).text
1710
+ : null;
1711
+ return dataUrl?.startsWith("data:image/")
1712
+ ? { type: "image" as const, image: { url: dataUrl } }
1713
+ : output;
1714
+ },
1715
+ };
1716
+ });
1717
+ }
1718
+
1320
1719
  export function buildAgentCapabilities(
1321
1720
  settings: Settings,
1322
1721
  packSkills: PackSkill[],
1323
1722
  options: {
1324
- compactionMode?: ContextCompactionMode;
1325
- contextWindowTokens?: number;
1326
1723
  structuredToolTransport?: boolean;
1327
1724
  // EXPLICIT computer-use transport (see BuildAgentOptions.computerToolMode). When
1328
1725
  // present, computerUse() is handed the mode directly and its tools() obeys it
1329
1726
  // without the constructor-name sniff. When absent, the legacy neutralize +
1330
1727
  // imageFunctionResults path (driven by structuredToolTransport) is unchanged.
1331
1728
  computerToolMode?: ComputerToolMode;
1729
+ onComputerUseReady?: (session: SandboxSessionLike) => Promise<void>;
1332
1730
  } = {},
1333
1731
  ): ReturnType<typeof Capabilities.default> {
1334
- const mode = options.compactionMode ?? resolveContextCompactionMode(settings);
1335
- const contextWindowTokens = options.contextWindowTokens ?? settings.contextWindowTokens;
1336
1732
  // The `filesystem()` capability picks hosted-vs-function tool variants from the
1337
1733
  // bound model instance (supportsApplyPatchTransport / structured tool output).
1338
1734
  // When the caller declares the backend does NOT support that structured/hosted
@@ -1340,16 +1736,19 @@ export function buildAgentCapabilities(
1340
1736
  // neutralize this capability's model binding so tools() falls to the function
1341
1737
  // `apply_patch` + text `view_image` variants the backend accepts — the SDK
1342
1738
  // handles their function_call round-trip natively, so no reimplementation.
1343
- // Scoped to filesystem: shell() (always function tools) and compaction() (a
1344
- // sampling param, dropped by the codex normalizer) are untouched.
1345
- const filesystemCapability = filesystem();
1739
+ // Scoped to filesystem: shell() is always a function-tool transport.
1740
+ const filesystemCapability = filesystem({
1741
+ ...(options.structuredToolTransport === false
1742
+ ? { configureTools: withStructuredViewImageFunctionResults }
1743
+ : {}),
1744
+ });
1346
1745
  if (options.structuredToolTransport === false) {
1347
1746
  neutralizeStructuredToolTransport(filesystemCapability);
1348
1747
  }
1349
- const caps: ReturnType<typeof Capabilities.default> = [filesystemCapability, shell()];
1350
- if (mode === "server") {
1351
- caps.push(compaction({ policy: new StaticCompactionPolicy(contextServerCompactThreshold({ ...settings, contextWindowTokens })) }));
1352
- }
1748
+ const caps: ReturnType<typeof Capabilities.default> = [
1749
+ filesystemCapability,
1750
+ shell({ configureTools: withExecOpCorrelation }),
1751
+ ];
1353
1752
  caps.push(skills({ lazyFrom: lazySkillSourceWithPackSkills(packSkills) }));
1354
1753
  // P4.3 computer-use: the agent drives the SAME :0 humans watch (xdotool/XTEST +
1355
1754
  // scrot), but only when the desktop tier is ON, computer-use is enabled, and the
@@ -1360,9 +1759,9 @@ export function buildAgentCapabilities(
1360
1759
  // run time (the SandboxAgent merge); xdotool drives :0 regardless of whether any
1361
1760
  // viewer is attached, so no pixel-tunnel dependency.
1362
1761
  if (
1363
- settings.computerUseEnabled
1364
- && settings.sandboxDesktopEnabled
1365
- && desktopCapableBackend(settings.sandboxBackend)
1762
+ settings.computerUseEnabled &&
1763
+ settings.sandboxDesktopEnabled &&
1764
+ desktopCapableBackend(settings.sandboxBackend)
1366
1765
  ) {
1367
1766
  // computer-use is transport-aware, exactly like filesystem: `tools()` emits the
1368
1767
  // HOSTED `computer_use_preview` tool on the structured transport and a set of
@@ -1382,15 +1781,18 @@ export function buildAgentCapabilities(
1382
1781
  const computerCapability = computerUse({
1383
1782
  dimensions: [settings.streamResolutionWidth, settings.streamResolutionHeight],
1384
1783
  readOnly: settings.computerUseReadOnly,
1784
+ ...(options.onComputerUseReady ? { onReady: options.onComputerUseReady } : {}),
1385
1785
  ...(explicitMode
1386
1786
  ? { toolMode: explicitMode }
1387
- // Legacy (no explicit mode): on the codex path the function tools deliver
1388
- // screenshots as a real image the model can see. The ChatGPT/Codex backend
1389
- // rejects HOSTED tool types but DOES accept `input_image` content items inside a
1390
- // `function_call_output` (proven by openai/codex codex-rs, whose view_image tool
1391
- // ships exactly that shape) — so a structured image tool result is seen, where a
1392
- // text data-URL would be unreadable.
1393
- : options.structuredToolTransport === false ? { imageFunctionResults: true } : {}),
1787
+ : // Legacy (no explicit mode): on the codex path the function tools deliver
1788
+ // screenshots as a real image the model can see. The ChatGPT/Codex backend
1789
+ // rejects HOSTED tool types but DOES accept `input_image` content items inside a
1790
+ // `function_call_output` (proven by openai/codex codex-rs, whose view_image tool
1791
+ // ships exactly that shape) — so a structured image tool result is seen, where a
1792
+ // text data-URL would be unreadable.
1793
+ options.structuredToolTransport === false
1794
+ ? { imageFunctionResults: true }
1795
+ : {}),
1394
1796
  });
1395
1797
  // Neutralize ONLY on the legacy sniff path. With an explicit toolMode the mode
1396
1798
  // already forces the function tools, so the constructor-name override is moot.
@@ -1424,17 +1826,26 @@ export type PrepareToolsOptions = {
1424
1826
  // Worker-asserted session scope for first-party MCP calls; enables
1425
1827
  // session-scoped tools such as goal management on the API side.
1426
1828
  sessionId?: string;
1829
+ // The calling turn's id, signed into the token so tools can classify the
1830
+ // caller from its own identity instead of the session's live active pointer.
1831
+ turnId?: string;
1427
1832
  subjectId?: string;
1428
1833
  subjectLabel?: string;
1429
1834
  // Overrides the fixed first-party MCP permission set for this session's
1430
1835
  // delegated token (manager-style sessions). The caller is responsible for
1431
1836
  // having validated the set against the session creator's grant.
1432
1837
  firstPartyPermissions?: Permission[];
1433
- resolveCredential?: (input: ResolveConnectionCredentialInput) => Promise<ResolveConnectionCredentialResult>;
1838
+ resolveCredential?: (
1839
+ input: ResolveConnectionCredentialInput,
1840
+ ) => Promise<ResolveConnectionCredentialResult>;
1434
1841
  onAuthNeeded?: (payload: ToolAuthNeededPayload) => Promise<void> | void;
1435
1842
  };
1436
1843
 
1437
- export async function prepareAgentTools(settings: Settings, tools: ToolRef[], options: PrepareToolsOptions = {}): Promise<PreparedAgentTools> {
1844
+ export async function prepareAgentTools(
1845
+ settings: Settings,
1846
+ tools: ToolRef[],
1847
+ options: PrepareToolsOptions = {},
1848
+ ): Promise<PreparedAgentTools> {
1438
1849
  // P4 (Part B.1): one Set per prepareTools call, shared by reference into the
1439
1850
  // codex_apps sanitizing fetch so every tools/list this turn accumulates the
1440
1851
  // account's connector namespaces. Surfaced on PreparedAgentTools for the worker.
@@ -1443,49 +1854,76 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
1443
1854
  return { mcpServers: [], close: async () => {}, codexConnectorNamespaces };
1444
1855
  }
1445
1856
  const registry = new Map(settings.mcpServers.map((server) => [server.id, server]));
1446
- const servers = await Promise.all(tools.map(async (tool) => {
1447
- const config = registry.get(tool.id);
1448
- if (!config) {
1449
- throw new Error(`Unknown MCP server id: ${tool.id}`);
1450
- }
1451
- const url = firstPartyMcpServerUrlForRun(settings, config, options.workspaceId) ?? config.url;
1452
- const baseFetch = isCodexAppsMcpServer(config)
1453
- ? codexAppsSanitizingFetch(globalThis.fetch, codexConnectorNamespaces)
1454
- : globalThis.fetch;
1455
- const fetchImpl = config.connectionRef
1456
- ? connectionBrokerFetch(baseFetch, config, options)
1457
- : baseFetch;
1458
- const server = new PrefixedMcpServer(new MCPServerStreamableHttp({
1459
- url,
1460
- name: config.name ?? config.id,
1461
- cacheToolsList: config.cacheToolsList,
1462
- // codex_apps returns connector tools with empty `outputSchema: {}` that the
1463
- // MCP SDK's strict Tool schema rejects (fails the turn during tools/list);
1464
- // sanitize the response on the wire before validation. The namespace Set
1465
- // also captures each tool's original connector namespace (P4 Part B.1).
1466
- ...(fetchImpl !== globalThis.fetch ? { fetch: fetchImpl } : {}),
1467
- ...await mcpServerRequestInit(settings, config, options),
1468
- ...(config.timeoutMs ? {
1469
- timeout: config.timeoutMs,
1470
- clientSessionTimeoutSeconds: Math.ceil(config.timeoutMs / 1000),
1471
- } : {}),
1472
- }), config.id, config.allowedTools);
1473
- // A server is connected BEST-EFFORT (a connect / tools-list failure drops
1474
- // it instead of failing the turn) in two cases:
1475
- // - codex_apps: connector availability is RUNTIME-DISCOVERED — the
1476
- // device-code login may lack the connector scopes, and the backend can
1477
- // reject the bearer at the initialize/tools-list handshake, so a 401/403
1478
- // (or a missing/failed token) drops the server.
1479
- // - an optional ToolRef: either an auto-attached workspace-default
1480
- // capability MCP or a client/pack-selected portable ref. A
1481
- // broken/expired credential or unavailable endpoint skips the server
1482
- // with a warning, never killing the turn before the model runs. Bare
1483
- // refs stay strict (below), preserving the fail-loud default.
1484
- const optional = tool.optional === true;
1485
- return { server, bestEffort: isCodexAppsMcpServer(config) || optional || !!config.connectionRef, optional };
1486
- }));
1857
+ const servers = await Promise.all(
1858
+ tools.map(async (tool) => {
1859
+ const config = registry.get(tool.id);
1860
+ if (!config) {
1861
+ throw new Error(`Unknown MCP server id: ${tool.id}`);
1862
+ }
1863
+ const url = firstPartyMcpServerUrlForRun(settings, config, options.workspaceId) ?? config.url;
1864
+ const baseFetch = isCodexAppsMcpServer(config)
1865
+ ? codexAppsSanitizingFetch(globalThis.fetch, codexConnectorNamespaces)
1866
+ : globalThis.fetch;
1867
+ const fetchImpl = config.connectionRef
1868
+ ? connectionBrokerFetch(baseFetch, config, options)
1869
+ : isFirstPartyMcpServer(settings, config)
1870
+ ? firstPartyAuthFetch(baseFetch, settings, options)
1871
+ : baseFetch;
1872
+ // A server is connected BEST-EFFORT (a connect OR tools-list failure drops
1873
+ // it — its tools go unavailable for the turn — instead of failing the turn)
1874
+ // in two cases:
1875
+ // - codex_apps: connector availability is RUNTIME-DISCOVERED — the
1876
+ // device-code login may lack the connector scopes, and the backend can
1877
+ // reject the bearer at the initialize/tools-list handshake, so a 401/403
1878
+ // (or a missing/failed token) drops the server.
1879
+ // - an optional ToolRef: either an auto-attached workspace-default
1880
+ // capability MCP or a client/pack-selected portable ref. A
1881
+ // broken/expired credential or unavailable endpoint skips the server
1882
+ // with a warning, never killing the turn before the model runs. Bare
1883
+ // refs stay strict (below), preserving the fail-loud default.
1884
+ // The connect-time drop is handled by connectMcpServers({ strict: false });
1885
+ // the tools-list-time drop is enforced inside PrefixedMcpServer.listTools —
1886
+ // a best-effort server whose tools/list throws (e.g. an expired connection
1887
+ // credential surfacing as a StreamableHTTP "authentication required" 401)
1888
+ // degrades to zero tools rather than throwing out of the SDK's run-time
1889
+ // getAllMcpTools and failing an unrelated turn. The actionable
1890
+ // tool.auth_needed signal is preserved: the connection-broker fetch
1891
+ // publishes it before returning the 401 that provokes the throw.
1892
+ const optional = tool.optional === true;
1893
+ const bestEffort = isCodexAppsMcpServer(config) || optional || !!config.connectionRef;
1894
+ const server = new PrefixedMcpServer(
1895
+ new MCPServerStreamableHttp({
1896
+ url,
1897
+ name: config.name ?? config.id,
1898
+ cacheToolsList: config.cacheToolsList,
1899
+ // codex_apps returns connector tools with empty `outputSchema: {}` that the
1900
+ // MCP SDK's strict Tool schema rejects (fails the turn during tools/list);
1901
+ // sanitize the response on the wire before validation. The namespace Set
1902
+ // also captures each tool's original connector namespace (P4 Part B.1).
1903
+ ...(fetchImpl !== globalThis.fetch ? { fetch: fetchImpl } : {}),
1904
+ ...(await mcpServerRequestInit(settings, config)),
1905
+ ...(config.timeoutMs
1906
+ ? {
1907
+ timeout: config.timeoutMs,
1908
+ clientSessionTimeoutSeconds: Math.ceil(config.timeoutMs / 1000),
1909
+ }
1910
+ : {}),
1911
+ }),
1912
+ config.id,
1913
+ config.allowedTools,
1914
+ bestEffort,
1915
+ );
1916
+ return {
1917
+ server,
1918
+ bestEffort,
1919
+ optional,
1920
+ };
1921
+ }),
1922
+ );
1487
1923
  const requiredServers = servers.filter((entry) => !entry.bestEffort).map((entry) => entry.server);
1488
- const bestEffortServers = servers.filter((entry) => entry.bestEffort).map((entry) => entry.server);
1924
+ const bestEffortServers = servers
1925
+ .filter((entry) => entry.bestEffort)
1926
+ .map((entry) => entry.server);
1489
1927
  // Names of the OPTIONAL servers (not codex_apps) so a drop is surfaced as a
1490
1928
  // warning; codex_apps keeps its historically-quiet drop (a not-logged-in
1491
1929
  // ChatGPT plan is a normal, non-noteworthy state).
@@ -1537,36 +1975,64 @@ function connectionBrokerFetch(
1537
1975
  }
1538
1976
  return async (input, init) => {
1539
1977
  const request = await mcpRequestInfo(input, init);
1540
- const first = await resolveConnectionForRequest(options, config.id, connectionRef, request.toolName, false);
1978
+ const first = await resolveConnectionForRequest(
1979
+ options,
1980
+ config.id,
1981
+ connectionRef,
1982
+ request.toolName,
1983
+ false,
1984
+ );
1541
1985
  if (first.status === "auth_needed") {
1542
1986
  return await authNeededFetchResponse(options, config.id, request, first, connectionRef);
1543
1987
  }
1544
- const response = await baseFetch(fetchInputForAttempt(input), withConnectionHeaders(input, init, first.headers));
1988
+ const response = await baseFetch(
1989
+ fetchInputForAttempt(input),
1990
+ withConnectionHeaders(input, init, first.headers),
1991
+ );
1545
1992
  if (response.status === 401) {
1546
- const refreshed = await resolveConnectionForRequest(options, config.id, connectionRef, request.toolName, true);
1993
+ const refreshed = await resolveConnectionForRequest(
1994
+ options,
1995
+ config.id,
1996
+ connectionRef,
1997
+ request.toolName,
1998
+ true,
1999
+ );
1547
2000
  if (refreshed.status === "auth_needed") {
1548
2001
  return await authNeededFetchResponse(options, config.id, request, refreshed, connectionRef);
1549
2002
  }
1550
- const retry = await baseFetch(fetchInputForAttempt(input), withConnectionHeaders(input, init, refreshed.headers));
2003
+ const retry = await baseFetch(
2004
+ fetchInputForAttempt(input),
2005
+ withConnectionHeaders(input, init, refreshed.headers),
2006
+ );
1551
2007
  if (retry.status === 403) {
1552
2008
  const auth = insufficientScopeAuth(retry.headers, connectionRef, refreshed.connectionId);
1553
- return auth ? await authNeededFetchResponse(options, config.id, request, auth, connectionRef) : retry;
2009
+ return auth
2010
+ ? await authNeededFetchResponse(options, config.id, request, auth, connectionRef)
2011
+ : retry;
1554
2012
  }
1555
2013
  if (retry.status === 401) {
1556
- return await authNeededFetchResponse(options, config.id, request, {
1557
- status: "auth_needed",
1558
- reason: "expired",
1559
- providerDomain: connectionRef.providerDomain,
1560
- connectionId: refreshed.connectionId,
1561
- ...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
1562
- ...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
1563
- }, connectionRef);
2014
+ return await authNeededFetchResponse(
2015
+ options,
2016
+ config.id,
2017
+ request,
2018
+ {
2019
+ status: "auth_needed",
2020
+ reason: "expired",
2021
+ providerDomain: connectionRef.providerDomain,
2022
+ connectionId: refreshed.connectionId,
2023
+ ...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
2024
+ ...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
2025
+ },
2026
+ connectionRef,
2027
+ );
1564
2028
  }
1565
2029
  return retry;
1566
2030
  }
1567
2031
  if (response.status === 403) {
1568
2032
  const auth = insufficientScopeAuth(response.headers, connectionRef, first.connectionId);
1569
- return auth ? await authNeededFetchResponse(options, config.id, request, auth, connectionRef) : response;
2033
+ return auth
2034
+ ? await authNeededFetchResponse(options, config.id, request, auth, connectionRef)
2035
+ : response;
1570
2036
  }
1571
2037
  return response;
1572
2038
  };
@@ -1625,8 +2091,16 @@ function insufficientScopeAuth(
1625
2091
  reason: "insufficient_scope",
1626
2092
  providerDomain: connectionRef.providerDomain,
1627
2093
  connectionId,
1628
- ...(challenge.scope?.length ? { scopes: challenge.scope } : connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
1629
- ...(challenge.resource ? { resource: challenge.resource } : connectionRef.resource ? { resource: connectionRef.resource } : {}),
2094
+ ...(challenge.scope?.length
2095
+ ? { scopes: challenge.scope }
2096
+ : connectionRef.scopes
2097
+ ? { scopes: connectionRef.scopes }
2098
+ : {}),
2099
+ ...(challenge.resource
2100
+ ? { resource: challenge.resource }
2101
+ : connectionRef.resource
2102
+ ? { resource: connectionRef.resource }
2103
+ : {}),
1630
2104
  };
1631
2105
  }
1632
2106
 
@@ -1644,8 +2118,16 @@ async function authNeededFetchResponse(
1644
2118
  providerDomain: auth.providerDomain,
1645
2119
  reason: auth.reason,
1646
2120
  ...(connectionId ? { connectionId } : {}),
1647
- ...(auth.scopes ? { scopes: auth.scopes } : connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
1648
- ...(auth.resource ? { resource: auth.resource } : connectionRef.resource ? { resource: connectionRef.resource } : {}),
2121
+ ...(auth.scopes
2122
+ ? { scopes: auth.scopes }
2123
+ : connectionRef.scopes
2124
+ ? { scopes: connectionRef.scopes }
2125
+ : {}),
2126
+ ...(auth.resource
2127
+ ? { resource: auth.resource }
2128
+ : connectionRef.resource
2129
+ ? { resource: connectionRef.resource }
2130
+ : {}),
1649
2131
  ...(auth.authorizationUrl ? { authorizationUrl: auth.authorizationUrl } : {}),
1650
2132
  ...(options.subjectId ? { subjectId: options.subjectId } : {}),
1651
2133
  });
@@ -1655,7 +2137,10 @@ async function authNeededFetchResponse(
1655
2137
  return new Response("Authentication required for MCP server connection", { status: 401 });
1656
2138
  }
1657
2139
 
1658
- async function publishAuthNeeded(options: PrepareToolsOptions, payload: ToolAuthNeededPayload): Promise<void> {
2140
+ async function publishAuthNeeded(
2141
+ options: PrepareToolsOptions,
2142
+ payload: ToolAuthNeededPayload,
2143
+ ): Promise<void> {
1659
2144
  try {
1660
2145
  await options.onAuthNeeded?.(payload);
1661
2146
  } catch {
@@ -1670,20 +2155,37 @@ type McpRequestInfo = {
1670
2155
  toolName?: string;
1671
2156
  };
1672
2157
 
1673
- async function mcpRequestInfo(input: string | URL | Request, init?: RequestInit): Promise<McpRequestInfo> {
1674
- const body = typeof init?.body === "string"
1675
- ? init.body
1676
- : input instanceof Request && (init?.method ?? input.method).toUpperCase() === "POST"
1677
- ? await input.clone().text().catch(() => "")
1678
- : "";
2158
+ async function mcpRequestInfo(
2159
+ input: string | URL | Request,
2160
+ init?: RequestInit,
2161
+ ): Promise<McpRequestInfo> {
2162
+ const body =
2163
+ typeof init?.body === "string"
2164
+ ? init.body
2165
+ : input instanceof Request && (init?.method ?? input.method).toUpperCase() === "POST"
2166
+ ? await input
2167
+ .clone()
2168
+ .text()
2169
+ .catch(() => "")
2170
+ : "";
1679
2171
  if (!body) {
1680
2172
  return {};
1681
2173
  }
1682
2174
  try {
1683
- const parsed = JSON.parse(body) as { id?: unknown; method?: unknown; params?: { name?: unknown } };
2175
+ const parsed = JSON.parse(body) as {
2176
+ id?: unknown;
2177
+ method?: unknown;
2178
+ params?: { name?: unknown };
2179
+ };
1684
2180
  const method = typeof parsed.method === "string" ? parsed.method : undefined;
1685
- const id = typeof parsed.id === "string" || typeof parsed.id === "number" || parsed.id === null ? parsed.id : undefined;
1686
- const toolName = method === "tools/call" && typeof parsed.params?.name === "string" ? parsed.params.name : undefined;
2181
+ const id =
2182
+ typeof parsed.id === "string" || typeof parsed.id === "number" || parsed.id === null
2183
+ ? parsed.id
2184
+ : undefined;
2185
+ const toolName =
2186
+ method === "tools/call" && typeof parsed.params?.name === "string"
2187
+ ? parsed.params.name
2188
+ : undefined;
1687
2189
  return {
1688
2190
  ...(method ? { method } : {}),
1689
2191
  ...(id !== undefined ? { id } : {}),
@@ -1694,8 +2196,14 @@ async function mcpRequestInfo(input: string | URL | Request, init?: RequestInit)
1694
2196
  }
1695
2197
  }
1696
2198
 
1697
- function withConnectionHeaders(input: string | URL | Request, init: RequestInit | undefined, authHeaders: Record<string, string>): RequestInit {
1698
- const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
2199
+ function withConnectionHeaders(
2200
+ input: string | URL | Request,
2201
+ init: RequestInit | undefined,
2202
+ authHeaders: Record<string, string>,
2203
+ ): RequestInit {
2204
+ const headers = new Headers(
2205
+ init?.headers ?? (input instanceof Request ? input.headers : undefined),
2206
+ );
1699
2207
  for (const [name, value] of Object.entries(authHeaders)) {
1700
2208
  headers.set(name, value);
1701
2209
  }
@@ -1706,7 +2214,11 @@ function fetchInputForAttempt(input: string | URL | Request): string | URL | Req
1706
2214
  return input instanceof Request ? input.clone() : input;
1707
2215
  }
1708
2216
 
1709
- function parseWwwAuthenticate(header: string | null): { error?: string; scope?: string[]; resource?: string } {
2217
+ function parseWwwAuthenticate(header: string | null): {
2218
+ error?: string;
2219
+ scope?: string[];
2220
+ resource?: string;
2221
+ } {
1710
2222
  if (!header) {
1711
2223
  return {};
1712
2224
  }
@@ -1720,7 +2232,9 @@ function parseWwwAuthenticate(header: string | null): { error?: string; scope?:
1720
2232
  let match: RegExpExecArray | null;
1721
2233
  while ((match = re.exec(paramsText)) !== null) {
1722
2234
  const raw = match[2]!;
1723
- params[match[1]!.toLowerCase()] = raw.startsWith("\"") ? raw.slice(1, -1).replace(/\\"/g, "\"") : raw;
2235
+ params[match[1]!.toLowerCase()] = raw.startsWith('"')
2236
+ ? raw.slice(1, -1).replace(/\\"/g, '"')
2237
+ : raw;
1724
2238
  }
1725
2239
  return {
1726
2240
  ...(params.error ? { error: params.error } : {}),
@@ -1736,20 +2250,24 @@ function parseWwwAuthenticate(header: string | null): { error?: string; scope?:
1736
2250
  // McpError, which PrefixedMcpServer.callTool converts back into an MCP-shaped
1737
2251
  // `{ isError: true }` output for the model.
1738
2252
  const MCP_AUTH_NEEDED_ERROR_CODE = -32001;
1739
- const MCP_AUTH_NEEDED_MESSAGE = "Authentication required - a connection link was posted to the session.";
2253
+ const MCP_AUTH_NEEDED_MESSAGE =
2254
+ "Authentication required - a connection link was posted to the session.";
1740
2255
 
1741
2256
  function mcpToolAuthNeededResponse(id: string | number | null | undefined): Response {
1742
- return new Response(JSON.stringify({
1743
- jsonrpc: "2.0",
1744
- id: id ?? null,
1745
- error: {
1746
- code: MCP_AUTH_NEEDED_ERROR_CODE,
1747
- message: MCP_AUTH_NEEDED_MESSAGE,
2257
+ return new Response(
2258
+ JSON.stringify({
2259
+ jsonrpc: "2.0",
2260
+ id: id ?? null,
2261
+ error: {
2262
+ code: MCP_AUTH_NEEDED_ERROR_CODE,
2263
+ message: MCP_AUTH_NEEDED_MESSAGE,
2264
+ },
2265
+ }),
2266
+ {
2267
+ status: 200,
2268
+ headers: { "content-type": "application/json" },
1748
2269
  },
1749
- }), {
1750
- status: 200,
1751
- headers: { "content-type": "application/json" },
1752
- });
2270
+ );
1753
2271
  }
1754
2272
 
1755
2273
  function isAuthNeededMcpError(error: unknown): boolean {
@@ -1760,7 +2278,44 @@ function isAuthNeededMcpError(error: unknown): boolean {
1760
2278
  return code === MCP_AUTH_NEEDED_ERROR_CODE || error.message.includes(MCP_AUTH_NEEDED_MESSAGE);
1761
2279
  }
1762
2280
 
1763
- async function mcpServerRequestInit(settings: Settings, config: Settings["mcpServers"][number], options: PrepareToolsOptions): Promise<{ requestInit: { headers: Record<string, string> } } | {}> {
2281
+ // Model-facing text for a best-effort server whose tool call failed for a
2282
+ // non-auth reason (transport 401/403 that never became the broker's JSON-RPC
2283
+ // short-circuit, a provider 5xx, a network blip). The copy is LOOP-SAFE: it
2284
+ // tells the model the tool is dead for the REST OF THIS TURN and to NOT retry
2285
+ // it, so a model that would otherwise burn the turn re-calling the same broken
2286
+ // optional tool moves on instead. Only the safe error surface (JS error class +
2287
+ // numeric HTTP status) is interpolated — NEVER the raw error message/response
2288
+ // body, which for a broker 401/403 can echo request URLs/headers/credentials.
2289
+ function mcpToolUnavailableMessage(reason: string): string {
2290
+ return `This tool is unavailable for the rest of this turn (${reason}). Do not retry it — continue without it or use another approach.`;
2291
+ }
2292
+
2293
+ // The only error detail safe to surface to the model or the logs: the JS error
2294
+ // constructor name and, when present, a numeric HTTP status. A StreamableHTTP
2295
+ // transport error carries the raw response BODY in its `.message` (a broker
2296
+ // 401/403 body can echo request detail), so `.message` is never included; the
2297
+ // numeric `.code`/`.status` (e.g. 401) is safe and useful.
2298
+ function safeMcpErrorFields(error: unknown): { errorClass: string; status?: number } {
2299
+ const errorClass = error instanceof Error ? error.constructor.name : typeof error;
2300
+ const raw = (error as { code?: unknown; status?: unknown } | null)?.code;
2301
+ const status = typeof raw === "number" ? raw : undefined;
2302
+ if (status === undefined) {
2303
+ const altRaw = (error as { status?: unknown } | null)?.status;
2304
+ return typeof altRaw === "number" ? { errorClass, status: altRaw } : { errorClass };
2305
+ }
2306
+ return { errorClass, status };
2307
+ }
2308
+
2309
+ // Compose the safe model/log reason string ("StreamableHTTPError 401", or just
2310
+ // the class when no numeric status is available). Never carries the raw body.
2311
+ function mcpErrorReason(fields: { errorClass: string; status?: number }): string {
2312
+ return fields.status === undefined ? fields.errorClass : `${fields.errorClass} ${fields.status}`;
2313
+ }
2314
+
2315
+ async function mcpServerRequestInit(
2316
+ settings: Settings,
2317
+ config: Settings["mcpServers"][number],
2318
+ ): Promise<{ requestInit: { headers: Record<string, string> } } | {}> {
1764
2319
  // codex_apps is checked FIRST so the static-headers path can never apply to
1765
2320
  // it: its refreshing ChatGPT/Codex bearer is resolved per-connect from the
1766
2321
  // codex ALS, never from a baked `config.headers` value.
@@ -1768,7 +2323,7 @@ async function mcpServerRequestInit(settings: Settings, config: Settings["mcpSer
1768
2323
  return await codexAppsMcpRequestInit(settings);
1769
2324
  }
1770
2325
  if (isFirstPartyMcpServer(settings, config)) {
1771
- return await firstPartyMcpRequestInit(settings, config, options);
2326
+ return await firstPartyMcpRequestInit(settings, config);
1772
2327
  }
1773
2328
  // Third-party MCP servers get their configured credential headers (for
1774
2329
  // example workspace-enabled capability MCP credentials) and nothing else —
@@ -1779,7 +2334,10 @@ async function mcpServerRequestInit(settings: Settings, config: Settings["mcpSer
1779
2334
  return {};
1780
2335
  }
1781
2336
 
1782
- async function firstPartyMcpRequestInit(settings: Settings, config: Settings["mcpServers"][number], options: PrepareToolsOptions): Promise<{ requestInit: { headers: Record<string, string> } } | {}> {
2337
+ async function firstPartyMcpRequestInit(
2338
+ settings: Settings,
2339
+ config: Settings["mcpServers"][number],
2340
+ ): Promise<{ requestInit: { headers: Record<string, string> } } | {}> {
1783
2341
  if (!isFirstPartyMcpServer(settings, config)) {
1784
2342
  return {};
1785
2343
  }
@@ -1787,17 +2345,14 @@ async function firstPartyMcpRequestInit(settings: Settings, config: Settings["mc
1787
2345
  if (settings.authRequired && settings.accessKey) {
1788
2346
  headers["x-opengeni-access-key"] = settings.accessKey;
1789
2347
  }
1790
- if (settings.delegationSecret && options.accountId && options.workspaceId) {
1791
- headers.authorization = `Bearer ${await signDelegatedAccessToken(settings.delegationSecret, {
1792
- accountId: options.accountId,
1793
- workspaceId: options.workspaceId,
1794
- subjectId: options.subjectId ?? "worker:first-party-mcp",
1795
- ...(options.subjectLabel ? { subjectLabel: options.subjectLabel } : {}),
1796
- permissions: options.firstPartyPermissions ?? firstPartyMcpPermissions,
1797
- ...(options.sessionId ? { sessionId: options.sessionId } : {}),
1798
- exp: Math.floor(Date.now() / 1000) + 60 * 60,
1799
- })}`;
1800
- }
2348
+ // The delegated bearer is deliberately NOT baked here. It is re-signed PER
2349
+ // REQUEST by firstPartyAuthFetch (wired in prepareAgentTools) so a turn or
2350
+ // persistent MCP connection that outlives the token's 1h TTL never sends a
2351
+ // stale bearer — an expired first-party bearer 401s ("authentication
2352
+ // required"), and because the first-party server is REQUIRED that killed the
2353
+ // turn on any run past ~1h. This function keeps only NON-expiring static
2354
+ // headers (the access key); the per-request fetch wrapper is the single source
2355
+ // of truth for the ever-refreshed Authorization header.
1801
2356
  if (Object.keys(headers).length === 0) {
1802
2357
  return {};
1803
2358
  }
@@ -1808,6 +2363,55 @@ async function firstPartyMcpRequestInit(settings: Settings, config: Settings["mc
1808
2363
  };
1809
2364
  }
1810
2365
 
2366
+ // Sign a FRESH first-party delegated bearer for a single request. Returns null
2367
+ // when the run lacks the inputs to mint one (no delegation secret / account /
2368
+ // workspace), in which case the request proceeds with whatever static headers
2369
+ // requestInit already set. The 1h TTL is safe precisely because this runs per
2370
+ // request: the token on the wire is always seconds old, never near expiry.
2371
+ async function signFirstPartyDelegatedBearer(
2372
+ settings: Settings,
2373
+ options: PrepareToolsOptions,
2374
+ ): Promise<string | null> {
2375
+ if (!settings.delegationSecret || !options.accountId || !options.workspaceId) {
2376
+ return null;
2377
+ }
2378
+ return await signDelegatedAccessToken(settings.delegationSecret, {
2379
+ accountId: options.accountId,
2380
+ workspaceId: options.workspaceId,
2381
+ subjectId: options.subjectId ?? "worker:first-party-mcp",
2382
+ ...(options.subjectLabel ? { subjectLabel: options.subjectLabel } : {}),
2383
+ permissions: options.firstPartyPermissions ?? firstPartyMcpPermissions,
2384
+ ...(options.sessionId ? { sessionId: options.sessionId } : {}),
2385
+ ...(options.turnId ? { turnId: options.turnId } : {}),
2386
+ exp: Math.floor(Date.now() / 1000) + 60 * 60,
2387
+ });
2388
+ }
2389
+
2390
+ // Per-request auth for the FIRST-PARTY MCP server: re-sign the delegated bearer
2391
+ // on EVERY request (connect/initialize, every tools/list re-list, every
2392
+ // tools/call) and set it as the Authorization header, so a turn or connection
2393
+ // outliving the 1h token TTL never sends an expired bearer. This is the fix for
2394
+ // the prod grind where a >1h turn's next re-list/tool-call 401'd on the stale
2395
+ // first-party bearer and — the first-party server being required — failed the
2396
+ // whole turn. Scoped STRICTLY to the token WE mint; external OAuth (connectionRef
2397
+ // servers) go through connectionBrokerFetch and are untouched.
2398
+ function firstPartyAuthFetch(
2399
+ baseFetch: FetchLike,
2400
+ settings: Settings,
2401
+ options: PrepareToolsOptions,
2402
+ ): FetchLike {
2403
+ return async (input, init) => {
2404
+ const bearer = await signFirstPartyDelegatedBearer(settings, options);
2405
+ if (!bearer) {
2406
+ return await baseFetch(input, init);
2407
+ }
2408
+ return await baseFetch(
2409
+ fetchInputForAttempt(input),
2410
+ withConnectionHeaders(input, init, { authorization: `Bearer ${bearer}` }),
2411
+ );
2412
+ };
2413
+ }
2414
+
1811
2415
  /**
1812
2416
  * Builds the connect-time auth headers for the codex_apps connectors MCP. The
1813
2417
  * bearer is resolved from codexRequestStorage — the SAME refreshing token source
@@ -1816,7 +2420,9 @@ async function firstPartyMcpRequestInit(settings: Settings, config: Settings["mc
1816
2420
  * ran outside the ALS) or a token failure (needs_relogin) returns {} so the
1817
2421
  * best-effort connect drops the server rather than crashing the turn.
1818
2422
  */
1819
- async function codexAppsMcpRequestInit(settings: Settings): Promise<{ requestInit: { headers: Record<string, string> } } | {}> {
2423
+ async function codexAppsMcpRequestInit(
2424
+ settings: Settings,
2425
+ ): Promise<{ requestInit: { headers: Record<string, string> } } | {}> {
1820
2426
  const ctx = codexRequestStorage.getStore();
1821
2427
  if (!ctx) {
1822
2428
  return {};
@@ -1849,8 +2455,8 @@ async function codexAppsMcpRequestInit(settings: Settings): Promise<{ requestIni
1849
2455
  // The first-party MCP permission set signed into a worker's delegated token
1850
2456
  // when the session does not specify its own. POWERFUL BY DEFAULT: it carries
1851
2457
  // every permission that unlocks a first-party tool — session orchestration
1852
- // (sessions:*), workspace environments (environments:*), and GitHub
1853
- // (github:use) — so agents are fully capable out of the box. A user DEMOTES a
2458
+ // (sessions:*), workspace variable sets (variable-sets:*), rigs:use
2459
+ // (list/get/propose/verify only), and GitHub (github:use) — so agents are fully capable out of the box. A user DEMOTES a
1854
2460
  // specific session by setting a narrower session.firstPartyMcpPermissions (the
1855
2461
  // create-session permission picker), which the worker uses instead. Account-
1856
2462
  // level scopes (billing/account/members/api_keys/workspace:admin) are
@@ -1866,8 +2472,9 @@ const firstPartyMcpPermissions: Permission[] = [
1866
2472
  "sessions:read",
1867
2473
  "sessions:create",
1868
2474
  "sessions:control",
1869
- "environments:use",
1870
- "environments:manage",
2475
+ "variable-sets:use",
2476
+ "variable-sets:manage",
2477
+ "rigs:use",
1871
2478
  "github:use",
1872
2479
  ];
1873
2480
 
@@ -1879,7 +2486,10 @@ function isCodexAppsMcpServer(config: Settings["mcpServers"][number]): boolean {
1879
2486
  return config.id === CODEX_APPS_MCP_SERVER_ID;
1880
2487
  }
1881
2488
 
1882
- function isFirstPartyMcpServer(settings: Settings, config: Settings["mcpServers"][number]): boolean {
2489
+ function isFirstPartyMcpServer(
2490
+ settings: Settings,
2491
+ config: Settings["mcpServers"][number],
2492
+ ): boolean {
1883
2493
  if (!["opengeni", "files", "docs"].includes(config.id)) {
1884
2494
  return false;
1885
2495
  }
@@ -1893,7 +2503,11 @@ function isFirstPartyMcpServer(settings: Settings, config: Settings["mcpServers"
1893
2503
  return firstPartyMcpUrls(settings).some((candidate) => candidate === url);
1894
2504
  }
1895
2505
 
1896
- function firstPartyMcpServerUrlForRun(settings: Settings, config: Settings["mcpServers"][number], workspaceId: string | undefined): string | null {
2506
+ function firstPartyMcpServerUrlForRun(
2507
+ settings: Settings,
2508
+ config: Settings["mcpServers"][number],
2509
+ workspaceId: string | undefined,
2510
+ ): string | null {
1897
2511
  if (!workspaceId || !["opengeni", "files", "docs"].includes(config.id)) {
1898
2512
  return null;
1899
2513
  }
@@ -1907,9 +2521,9 @@ function firstPartyMcpServerUrlForRun(settings: Settings, config: Settings["mcpS
1907
2521
  ? settings.opengeniMcpUrl.replaceAll("{workspaceId}", workspaceId)
1908
2522
  : settings.opengeniMcpUrl
1909
2523
  ? scopedMcpUrlFromConfiguredBase(settings.opengeniMcpUrl, workspaceId)
1910
- // unset → the shared loopback default (a `{workspaceId}` template owned by
1911
- // @opengeni/config's firstPartyMcpBaseUrl), scoped to this run's workspace.
1912
- : firstPartyMcpBaseUrl(settings).replaceAll("{workspaceId}", workspaceId);
2524
+ : // unset → the shared loopback default (a `{workspaceId}` template owned by
2525
+ // @opengeni/config's firstPartyMcpBaseUrl), scoped to this run's workspace.
2526
+ firstPartyMcpBaseUrl(settings).replaceAll("{workspaceId}", workspaceId);
1913
2527
  const url = new URL(rawBase);
1914
2528
  if (config.id === "docs") {
1915
2529
  url.pathname = `${url.pathname.replace(/\/+$/, "")}/docs`;
@@ -1957,12 +2571,24 @@ class PrefixedMcpServer implements MCPServer {
1957
2571
  readonly name: string;
1958
2572
  readonly prefix: string;
1959
2573
  private readonly allowedTools: Set<string> | undefined;
1960
-
1961
- constructor(private readonly inner: MCPServer, registryId: string, allowedTools?: string[]) {
2574
+ // Best-effort servers (optional refs, connectionRef-backed capability MCPs,
2575
+ // codex_apps) must never fail a turn: a tools/list throw degrades to zero
2576
+ // tools instead of propagating. Deduplicate the warn so one degraded server
2577
+ // doesn't spam the log when the SDK re-lists across model turns.
2578
+ private readonly bestEffort: boolean;
2579
+ private loggedListToolsFailure = false;
2580
+
2581
+ constructor(
2582
+ private readonly inner: MCPServer,
2583
+ registryId: string,
2584
+ allowedTools?: string[],
2585
+ bestEffort = false,
2586
+ ) {
1962
2587
  this.name = registryId;
1963
2588
  this.prefix = prefixedMcpToolName(registryId, "");
1964
2589
  this.cacheToolsList = inner.cacheToolsList;
1965
2590
  this.allowedTools = allowedTools ? new Set(allowedTools) : undefined;
2591
+ this.bestEffort = bestEffort;
1966
2592
  }
1967
2593
 
1968
2594
  connect(): Promise<void> {
@@ -1974,13 +2600,49 @@ class PrefixedMcpServer implements MCPServer {
1974
2600
  }
1975
2601
 
1976
2602
  async listTools(): Promise<RuntimeMcpTool[]> {
1977
- const tools = await this.inner.listTools();
2603
+ let tools: RuntimeMcpTool[];
2604
+ try {
2605
+ tools = await this.inner.listTools();
2606
+ } catch (error) {
2607
+ // A REQUIRED server's tools/list failure is fatal (fail-loud default): the
2608
+ // caller explicitly requested it, so its absence must fail the turn.
2609
+ if (!this.bestEffort) {
2610
+ throw error;
2611
+ }
2612
+ // Best-effort isolation. The SDK's run-time getAllMcpTools calls listTools
2613
+ // OUTSIDE the connect-time connectMcpServers({ strict: false }) guard, so a
2614
+ // best-effort server whose tools/list throws here — for ANY reason (an
2615
+ // expired/failed connection credential surfacing as a StreamableHTTP
2616
+ // "authentication required" 401, a provider 5xx, a network blip) — would
2617
+ // otherwise take down an unrelated turn. Drop this server's tools for the
2618
+ // turn instead. An auth failure additionally published tool.auth_needed via
2619
+ // the connection-broker fetch before the throw (so that actionable signal
2620
+ // is preserved), but a non-auth failure has NO such signal — the structured
2621
+ // warn below is its only visibility, so a chronically-dead optional
2622
+ // integration surfaces in logs/metrics rather than being silently swallowed.
2623
+ // Warn once per degraded server per turn (instances are per-turn), so a
2624
+ // re-list across model turns does not spam the log.
2625
+ if (!this.loggedListToolsFailure) {
2626
+ this.loggedListToolsFailure = true;
2627
+ console.warn(
2628
+ "[mcp] best-effort server tools/list failed; its tools are unavailable this turn",
2629
+ // Safe surface only (class + numeric status), never the raw error
2630
+ // message/response body — a broker 401/403 body can echo request detail.
2631
+ { serverId: this.name, ...safeMcpErrorFields(error) },
2632
+ );
2633
+ }
2634
+ return [];
2635
+ }
1978
2636
  return tools
1979
2637
  .filter((tool) => this.isAllowed(tool.name))
1980
2638
  .map((tool) => ({ ...tool, name: prefixedMcpToolName(this.name, tool.name) }));
1981
2639
  }
1982
2640
 
1983
- async callTool(toolName: string, args: Record<string, unknown> | null, meta?: Record<string, unknown> | null): Promise<any> {
2641
+ async callTool(
2642
+ toolName: string,
2643
+ args: Record<string, unknown> | null,
2644
+ meta?: Record<string, unknown> | null,
2645
+ ): Promise<any> {
1984
2646
  const unprefixed = this.unprefixToolName(toolName);
1985
2647
  if (!this.isAllowed(unprefixed)) {
1986
2648
  throw new Error(`MCP tool ${unprefixed} is not allowed for server ${this.name}`);
@@ -1992,9 +2654,34 @@ class PrefixedMcpServer implements MCPServer {
1992
2654
  // JSON-RPC error (an inline isError result would be stripped by the SDK
1993
2655
  // shim). Surface it to the model as a failed-but-recoverable tool result
1994
2656
  // instead of failing the turn; the timeline chip was already published.
2657
+ // This applies to ANY server — an auth-needed is recoverable once the user
2658
+ // re-links, so even a required tool degrades gracefully here.
1995
2659
  if (isAuthNeededMcpError(error)) {
1996
2660
  return { isError: true, content: [{ type: "text", text: MCP_AUTH_NEEDED_MESSAGE }] };
1997
2661
  }
2662
+ // Best-effort INVOCATION isolation (sibling to the listTools guard). When
2663
+ // the model calls a best-effort server's tool and the call throws for ANY
2664
+ // other reason — a raw transport 401/403 that never became the broker's
2665
+ // JSON-RPC short-circuit (e.g. a codex_apps bearer that expired mid-turn,
2666
+ // or a 403 with no insufficient_scope challenge), a provider 5xx, or a
2667
+ // network blip — the whole turn would otherwise die. Return a tool-error
2668
+ // RESULT the model sees instead, so it adapts (tries another approach,
2669
+ // tells the user) and the turn survives. Required servers keep the
2670
+ // fail-loud default: the caller depends on them, so their tool failure
2671
+ // still fails the turn. For auth cases the actionable tool.auth_needed was
2672
+ // already published upstream by the connection-broker fetch before the
2673
+ // throw, so degrading here never silences it.
2674
+ if (this.bestEffort) {
2675
+ const fields = safeMcpErrorFields(error);
2676
+ console.warn(
2677
+ "[mcp] best-effort server tool call failed; returning an unavailable result for this turn",
2678
+ { serverId: this.name, toolName: unprefixed, ...fields },
2679
+ );
2680
+ return {
2681
+ isError: true,
2682
+ content: [{ type: "text", text: mcpToolUnavailableMessage(mcpErrorReason(fields)) }],
2683
+ };
2684
+ }
1998
2685
  throw error;
1999
2686
  }
2000
2687
  }
@@ -2004,7 +2691,9 @@ class PrefixedMcpServer implements MCPServer {
2004
2691
  }
2005
2692
 
2006
2693
  async listResources(params?: Record<string, unknown>): Promise<any> {
2007
- const resourcesServer = this.inner as MCPServer & { listResources?: (params?: Record<string, unknown>) => Promise<any> };
2694
+ const resourcesServer = this.inner as MCPServer & {
2695
+ listResources?: (params?: Record<string, unknown>) => Promise<any>;
2696
+ };
2008
2697
  if (!resourcesServer.listResources) {
2009
2698
  throw new Error(`MCP server ${this.name} does not support resources`);
2010
2699
  }
@@ -2012,7 +2701,9 @@ class PrefixedMcpServer implements MCPServer {
2012
2701
  }
2013
2702
 
2014
2703
  async listResourceTemplates(params?: Record<string, unknown>): Promise<any> {
2015
- const resourcesServer = this.inner as MCPServer & { listResourceTemplates?: (params?: Record<string, unknown>) => Promise<any> };
2704
+ const resourcesServer = this.inner as MCPServer & {
2705
+ listResourceTemplates?: (params?: Record<string, unknown>) => Promise<any>;
2706
+ };
2016
2707
  if (!resourcesServer.listResourceTemplates) {
2017
2708
  throw new Error(`MCP server ${this.name} does not support resource templates`);
2018
2709
  }
@@ -2020,7 +2711,9 @@ class PrefixedMcpServer implements MCPServer {
2020
2711
  }
2021
2712
 
2022
2713
  async readResource(uri: string): Promise<any> {
2023
- const resourcesServer = this.inner as MCPServer & { readResource?: (uri: string) => Promise<any> };
2714
+ const resourcesServer = this.inner as MCPServer & {
2715
+ readResource?: (uri: string) => Promise<any>;
2716
+ };
2024
2717
  if (!resourcesServer.readResource) {
2025
2718
  throw new Error(`MCP server ${this.name} does not support resource reads`);
2026
2719
  }
@@ -2044,112 +2737,58 @@ class PrefixedMcpServer implements MCPServer {
2044
2737
 
2045
2738
  export type PrepareInputOptions = {
2046
2739
  sandboxClient?: unknown;
2047
- /**
2048
- * Usable input-token budget B (window - reserved output). When set, the
2049
- * assembled history is passed through `enforceInputBudget` so a single
2050
- * over-budget input can never be sent — the last-resort backstop behind the
2051
- * best-effort pre-turn compaction. Omitted (undefined) disables the guard
2052
- * (no behaviour change for callers that don't opt in).
2053
- */
2054
- inputBudgetTokens?: number;
2055
2740
  };
2056
2741
 
2057
- /**
2058
- * Apply the read-path budget guard to an assembled model input: drop the oldest
2059
- * history at a clean turn boundary until the request fits B. Orphan-safe (only
2060
- * cuts at user-message boundaries) and only active when a budget is supplied.
2061
- * The trailing user message is counted against the budget but never dropped.
2062
- */
2063
- function guardAssembledInput(
2064
- history: AgentInputItem[],
2065
- trailing: AgentInputItem,
2066
- inputBudgetTokens: number | undefined,
2067
- ): AgentInputItem[] {
2068
- if (typeof inputBudgetTokens !== "number" || inputBudgetTokens <= 0) {
2069
- return [...history, trailing];
2070
- }
2071
- const trailingTokens = estimateItemTokens(trailing as unknown as Record<string, unknown>);
2072
- const guarded = enforceInputBudget(
2073
- history as unknown as Array<Record<string, unknown>>,
2074
- inputBudgetTokens,
2075
- trailingTokens,
2076
- );
2077
- if (guarded.trimmed) {
2078
- console.warn(
2079
- `read-path budget guard trimmed ${guarded.droppedCount} oldest history item(s) to fit input budget (${inputBudgetTokens} tokens); the over-budget input was NOT sent`,
2080
- );
2081
- }
2082
- return [...(guarded.items as unknown as AgentInputItem[]), trailing];
2083
- }
2084
-
2085
- export async function prepareRunInput(agent: Agent<any, any>, input: AgentSegmentInput, options: PrepareInputOptions = {}): Promise<PreparedAgentInput> {
2742
+ export async function prepareRunInput(
2743
+ agent: Agent<any, any>,
2744
+ input: AgentSegmentInput,
2745
+ options: PrepareInputOptions = {},
2746
+ ): Promise<PreparedAgentInput> {
2086
2747
  if (input.kind === "message") {
2087
- if (input.historyItems && input.historyItems.length > 0) {
2088
- // Items mode: conversation truth comes from the database, the sandbox
2089
- // recovery descriptor from its own store. The RunState blob is not
2090
- // touched at all on this path.
2091
- const sandboxSessionState = input.sandboxEnvelope
2092
- ? await restoredSandboxSessionStateFromEntry(input.sandboxEnvelope, options.sandboxClient)
2093
- : undefined;
2094
- // Replayed conversation truth is reloaded verbatim from the database, so
2095
- // it can contain a tool-call pairing the Responses API rejects (most
2096
- // destructively an orphaned function_call_result with no matching
2097
- // function_call — which 400s every turn and bricks the session until the
2098
- // row is hand-deleted). Sanitize the in-memory copy before it reaches the
2099
- // model so existing corruption self-heals and a future write-path race is
2100
- // non-fatal; the stored rows are never touched.
2101
- const sanitizedHistory = sanitizeHistoryItemsForModel(
2102
- input.historyItems as unknown as Array<Record<string, unknown>>,
2103
- ) as unknown as AgentInputItem[];
2104
- return {
2105
- // Read-path budget guard: even after the orphan sanitizer, an assembled
2106
- // input can exceed the model window (pre-turn compaction is best-effort
2107
- // and can no-op). Trim the oldest history at a clean turn boundary so an
2108
- // over-budget request is never sent. No-op when no budget is supplied.
2109
- input: guardAssembledInput(
2110
- sanitizedHistory,
2111
- {
2112
- type: "message",
2113
- role: "user",
2114
- content: input.text,
2115
- } as AgentInputItem,
2116
- options.inputBudgetTokens,
2117
- ),
2118
- ...(sandboxSessionState ? { sandboxSessionState } : {}),
2119
- };
2748
+ const trailingMessages: AgentInputItem[] = [];
2749
+ if (input.internalContext?.trim()) {
2750
+ trailingMessages.push({
2751
+ type: "message",
2752
+ role: "system",
2753
+ content: input.internalContext,
2754
+ } as AgentInputItem);
2755
+ }
2756
+ if (input.text?.trim()) {
2757
+ trailingMessages.push({
2758
+ type: "message",
2759
+ role: "user",
2760
+ content: input.text,
2761
+ } as AgentInputItem);
2120
2762
  }
2121
- // No prior state, or a cleared sentinel: start fresh. The clear sentinel
2122
- // ({@link CLEARED_RUN_STATE_BLOB}) is not a real serialized run state — it
2123
- // carries no $schemaVersion, so RunState.fromString would throw on it. In
2124
- // run_state history mode this message path is the one that reads the blob
2125
- // after a /clear, so recognizing the sentinel here is what keeps the next
2126
- // turn working (a fresh, empty context) instead of bricking on deserialize.
2127
- if (!input.serializedRunState || isClearedRunStateBlob(input.serializedRunState)) {
2128
- return { input: input.text };
2763
+ if (
2764
+ trailingMessages.length === 0 &&
2765
+ (input.historyItems === undefined ||
2766
+ input.historyItems === null ||
2767
+ input.historyItems.length === 0)
2768
+ ) {
2769
+ throw new Error("Message input requires a user prompt or internal context");
2129
2770
  }
2130
- const state = await RunState.fromString(agent, input.serializedRunState);
2131
- const sandboxSessionState = await restoredSandboxSessionState(state, options.sandboxClient);
2132
- // state.history already runs the SDK's own orphan-tool-call pruning, but
2133
- // applying the same sanitizer keeps the legacy run-state resume path under
2134
- // one invariant with the items path and is defensive against a corrupt blob.
2771
+ // Conversation truth comes only from durable history items. Sanitize the
2772
+ // in-memory copy before it reaches the model so a corrupt tool-call pair is
2773
+ // non-fatal; stored audit rows remain untouched.
2135
2774
  const sanitizedHistory = sanitizeHistoryItemsForModel(
2136
- state.history as unknown as Array<Record<string, unknown>>,
2775
+ (input.historyItems ?? []) as unknown as Array<Record<string, unknown>>,
2137
2776
  ) as unknown as AgentInputItem[];
2777
+ const sandboxSessionState = input.sandboxEnvelope
2778
+ ? await restoredSandboxSessionStateFromEntry(input.sandboxEnvelope, options.sandboxClient)
2779
+ : undefined;
2780
+ const assembled = [...sanitizedHistory, ...trailingMessages];
2781
+ if (assembled.length === 0) {
2782
+ throw new Error("Message input requires durable history or internal context");
2783
+ }
2138
2784
  return {
2139
- // Read-path budget guard (see the items path above): keep an over-budget
2140
- // resumed history off the wire by trimming the oldest turns when a budget
2141
- // is supplied.
2142
- input: guardAssembledInput(
2143
- sanitizedHistory,
2144
- {
2145
- type: "message",
2146
- role: "user",
2147
- content: input.text,
2148
- } as AgentInputItem,
2149
- options.inputBudgetTokens,
2150
- ),
2785
+ // Preserve the SDK's simple first-message form without creating a second
2786
+ // history path: this is merely the zero-history representation.
2787
+ input:
2788
+ sanitizedHistory.length === 0 && !input.internalContext?.trim() && input.text?.trim()
2789
+ ? input.text
2790
+ : assembled,
2151
2791
  ...(sandboxSessionState ? { sandboxSessionState } : {}),
2152
- serializedRunStateForSandbox: input.serializedRunState,
2153
2792
  };
2154
2793
  }
2155
2794
  // An approval can only be resumed against a real saved run state. If the
@@ -2157,7 +2796,9 @@ export async function prepareRunInput(agent: Agent<any, any>, input: AgentSegmen
2157
2796
  // refuses clear in requires_action, so this is a defensive guard) — fail with
2158
2797
  // an honest message instead of the cryptic SDK "missing schema version".
2159
2798
  if (isClearedRunStateBlob(input.serializedRunState)) {
2160
- throw new Error("Cannot resume an approval: the session context was cleared, so the awaiting run state no longer exists.");
2799
+ throw new Error(
2800
+ "Cannot resume an approval: the session context was cleared, so the awaiting run state no longer exists.",
2801
+ );
2161
2802
  }
2162
2803
  const state = await RunState.fromString(agent, input.serializedRunState);
2163
2804
  const interruptions = state.getInterruptions();
@@ -2178,6 +2819,12 @@ export type RunAgentStreamOptions = {
2178
2819
  sandboxEnvironment?: Record<string, string>;
2179
2820
  onRuntimeEvent?: (event: NormalizedRuntimeEvent) => Promise<void> | void;
2180
2821
  contextCompactionSignalTokens?: () => number | null | undefined;
2822
+ contextCompactionRequested?: () => boolean | Promise<boolean>;
2823
+ // Host-managed git credential renewal registration. Called only after the
2824
+ // initial token-file seed completed on a real provisioned box. The worker
2825
+ // owns the multi-day timer and uses this pinned, un-proxied session to
2826
+ // atomically replace token files; runtime never mints credentials itself.
2827
+ onGitCredentialSessionReady?: (session: GitCredentialTokenWriterSession) => Promise<void> | void;
2181
2828
  // OWNERSHIP INVERSION (P1.2): an externally-owned, already-live sandbox
2182
2829
  // session resolved by the per-turn resume-by-id path. When present,
2183
2830
  // runAgentStream does NOT build (or resume, or discard) a client — it threads
@@ -2190,15 +2837,23 @@ export type RunAgentStreamOptions = {
2190
2837
  // re-applied around the resumed client here; the live `session`/`sessionState`
2191
2838
  // carry the box, so no create()/resume() is re-invoked inside run().
2192
2839
  ownedSandbox?: {
2193
- client: unknown; // built by the per-turn resume path (the raw provider client)
2194
- session: unknown; // SandboxSessionLike — the live, NON-OWNED handle (never reaped)
2195
- sessionState?: unknown; // SandboxSessionState the box was resumed from
2840
+ client: unknown; // built by the per-turn resume path (the raw provider client)
2841
+ session: unknown; // SandboxSessionLike — the live, NON-OWNED handle (never reaped)
2842
+ sessionState?: unknown; // SandboxSessionState the box was resumed from
2196
2843
  // The UN-PROXIED established box for platform setup (lifecycle hooks + file
2197
2844
  // resource materialization). `session` may be the mid-turn routing proxy whose
2198
2845
  // every exec re-reads the active pointer — platform-initiated setup must NOT
2199
2846
  // follow a swap onto a connected machine (the user's real computer), so it
2200
2847
  // runs against this pinned handle instead. Absent -> falls back to `session`.
2201
2848
  setupSession?: unknown;
2849
+ // True when the caller already ran file-resource materialization for this
2850
+ // provided session and threaded any failures into the model input.
2851
+ fileDownloadsMaterialized?: boolean;
2852
+ // Lazy sandbox provisioning injects a synthetic provided session at run start;
2853
+ // the real box does not exist until the first sandbox op. In that path the
2854
+ // worker provisioner runs runOwnedSandboxSetup against the un-proxied real box
2855
+ // after establish, so runAgentStream must not run it eagerly here.
2856
+ deferredSetup?: boolean;
2202
2857
  };
2203
2858
  // A per-turn model-input filter chained AFTER the provider-item-id strip.
2204
2859
  // Used by the genesis-title injection to prepend a hidden, NON-PERSISTED
@@ -2210,17 +2865,48 @@ export type RunAgentStreamOptions = {
2210
2865
 
2211
2866
  export type ContextRobustnessFilterOptions = {
2212
2867
  contextCompactionSignalTokens?: () => number | null | undefined;
2868
+ contextCompactionRequested?: () => boolean | Promise<boolean>;
2213
2869
  throwOnCompactionNeeded?: boolean;
2214
2870
  };
2215
2871
 
2216
- // One-shot directive appended to the agent's system prompt on the genesis turn
2872
+ // One-shot directive injected into the FIRST model call on the genesis turn
2217
2873
  // (see buildOpenGeniAgent's genesisTitleHint). Delivered through the
2218
2874
  // authoritative instructions channel so the model reliably obeys; references
2219
2875
  // the prefixed tool name the agent actually sees (opengeni__set_session_title).
2220
- // Appended after the non-bypassable core so a white-label persona can't drop it.
2221
2876
  export const GENESIS_TITLE_DIRECTIVE =
2222
2877
  "This is the first turn of a new session. Before responding to the user, call the opengeni__set_session_title tool with a concise 3-7 word title that summarizes what this session is about, then address the user's request normally.";
2223
2878
 
2879
+ /**
2880
+ * Inject the genesis-title directive into exactly one model request. Agent
2881
+ * instructions are reused for every model call in a tool loop, so placing this
2882
+ * directive there turned a nominally one-shot setup action into repeated title
2883
+ * calls. The closure is intentionally consumed even when the first request
2884
+ * fails: a retry/recovery must continue the task, not restart setup.
2885
+ */
2886
+ export function oneShotGenesisTitleInputFilter(): CallModelInputFilter {
2887
+ let pending = true;
2888
+ return ({ modelData }) => {
2889
+ if (!pending) {
2890
+ return modelData;
2891
+ }
2892
+ pending = false;
2893
+ return {
2894
+ ...modelData,
2895
+ instructions: modelData.instructions
2896
+ ? `${modelData.instructions} ${GENESIS_TITLE_DIRECTIVE}`
2897
+ : GENESIS_TITLE_DIRECTIVE,
2898
+ };
2899
+ };
2900
+ }
2901
+
2902
+ function takeGenesisTitleInputFilter(agent: Agent<any, any>): CallModelInputFilter | undefined {
2903
+ if (!agentsNeedingGenesisTitleDirective.has(agent)) {
2904
+ return undefined;
2905
+ }
2906
+ agentsNeedingGenesisTitleDirective.delete(agent);
2907
+ return oneShotGenesisTitleInputFilter();
2908
+ }
2909
+
2224
2910
  // Generic substrate prompting for programmatic tool calling (toolspace). Same
2225
2911
  // text for every host; gated per-turn by appendToolspaceInstructions on the
2226
2912
  // presence of a minted toolspace token, so it only appears when the sandbox
@@ -2257,7 +2943,7 @@ export const stripProviderItemIdsFilter: CallModelInputFilter = ({ modelData })
2257
2943
  * `action` and `actions` down to EXACTLY ONE (keeps `actions`, drops `action`).
2258
2944
  * The Azure computer-use endpoint rejects a request whose computer_call has
2259
2945
  * both with `400 Computer call input must include exactly one of `action` or
2260
- * `actions``; and (live-proven against gpt-5.5's GA computer tool) it also
2946
+ * `actions``; and (live-proven against gpt-5.6-sol's GA computer tool) it also
2261
2947
  * rejects the `action`-only form, accepting ONLY the batched plural `actions`.
2262
2948
  * The SDK 0.11.6 schema allows both, so a freshly-emitted
2263
2949
  * screenshot call carries the redundant pair. This filter runs before EVERY
@@ -2273,43 +2959,43 @@ export const normalizeComputerCallsFilter: CallModelInputFilter = ({ modelData }
2273
2959
  ) as unknown as AgentInputItem[],
2274
2960
  });
2275
2961
 
2962
+ /**
2963
+ * Per-call state compaction for local image inspection. Re-opening the same
2964
+ * path supersedes its prior base64 result; carrying both provides no newer
2965
+ * information and can otherwise balloon every following model request.
2966
+ */
2967
+ export const elideSupersededViewImagesFilter: CallModelInputFilter = ({ modelData }) => ({
2968
+ ...modelData,
2969
+ input: elideSupersededViewImagePairs(
2970
+ modelData.input as unknown as Array<Record<string, unknown>>,
2971
+ ) as unknown as AgentInputItem[],
2972
+ });
2973
+
2276
2974
  export function contextRobustnessFilterForSettings(
2277
2975
  settings: Settings,
2278
2976
  options: ContextRobustnessFilterOptions = {},
2279
2977
  ): CallModelInputFilter {
2280
- const inputBudgetTokens = modelCallBudgetTokens(settings);
2281
- const clientCompactionMode = resolveContextCompactionMode(settings) === "client";
2282
- const compactionThresholdTokens = clientCompactionThresholdTokens(settings);
2283
- return ({ modelData }) => {
2284
- const images = elideStaleScreenshotImages(modelData.input);
2285
- if (images.elidedCount > 0) {
2286
- console.warn(
2287
- `per-call image history policy elided ${images.elidedCount} older screenshot image(s), keeping the last ${Math.min(3, images.imageCount)} full image(s)`,
2288
- );
2289
- }
2290
- let input = images.items;
2291
- if (inputBudgetTokens !== undefined) {
2292
- const guarded = enforceInputBudget(
2293
- input as unknown as Array<Record<string, unknown>>,
2294
- inputBudgetTokens,
2295
- );
2296
- if (guarded.trimmed) {
2297
- console.warn(
2298
- `per-call budget guard trimmed ${guarded.droppedCount} oldest history item(s) to fit input budget (${inputBudgetTokens} tokens); the over-budget model call was NOT sent`,
2299
- );
2300
- input = guarded.items as unknown as AgentInputItem[];
2301
- }
2302
- }
2303
- if (clientCompactionMode && options.throwOnCompactionNeeded) {
2978
+ const thresholdTokens = compactionThresholdTokens(settings);
2979
+ return async ({ modelData }) => {
2980
+ const input = modelData.input;
2981
+ if (options.throwOnCompactionNeeded) {
2304
2982
  const reported = options.contextCompactionSignalTokens?.();
2305
2983
  const hasReported = typeof reported === "number" && reported > 0;
2306
2984
  const signalTokens = hasReported
2307
2985
  ? reported
2308
2986
  : estimateTokens(input as unknown as Array<Record<string, unknown>>);
2309
- if (signalTokens > compactionThresholdTokens) {
2987
+ if (await options.contextCompactionRequested?.()) {
2988
+ throw new CompactionNeededError({
2989
+ signalTokens,
2990
+ thresholdTokens,
2991
+ signalSource: hasReported ? "provider" : "estimate",
2992
+ trigger: "operator",
2993
+ });
2994
+ }
2995
+ if (signalTokens >= thresholdTokens) {
2310
2996
  throw new CompactionNeededError({
2311
2997
  signalTokens,
2312
- thresholdTokens: compactionThresholdTokens,
2998
+ thresholdTokens,
2313
2999
  signalSource: hasReported ? "provider" : "estimate",
2314
3000
  });
2315
3001
  }
@@ -2318,14 +3004,6 @@ export function contextRobustnessFilterForSettings(
2318
3004
  };
2319
3005
  }
2320
3006
 
2321
- function modelCallBudgetTokens(settings: Settings): number | undefined {
2322
- if (resolveContextCompactionMode(settings) !== "client") {
2323
- return undefined;
2324
- }
2325
- const budget = contextInputBudgetTokens(settings);
2326
- return budget > 0 ? budget : undefined;
2327
- }
2328
-
2329
3007
  /**
2330
3008
  * Compose a list of callModelInputFilters into one, applied left-to-right so
2331
3009
  * each sees the prior filter's output.
@@ -2344,14 +3022,18 @@ function composeCallModelInputFilters(filters: CallModelInputFilter[]): CallMode
2344
3022
  * The model-input filter applied before every model call. The computer_call
2345
3023
  * action/actions normalizer is ALWAYS on (the Azure endpoint 400s without it);
2346
3024
  * the provider-item-id strip is layered on top when the configured policy
2347
- * selects it; the context-robustness guard then elides stale screenshots on
2348
- * every mode and applies hard budget trimming only on the client-compaction path.
3025
+ * selects it; the context-robustness guard then raises the proactive durable
3026
+ * compaction signal on the client-compaction path. It never trims history from
3027
+ * an individual request.
2349
3028
  */
2350
3029
  export function callModelInputFilterForSettings(
2351
3030
  settings: Settings,
2352
3031
  options: ContextRobustnessFilterOptions = {},
2353
3032
  ): CallModelInputFilter | undefined {
2354
- const filters: CallModelInputFilter[] = [normalizeComputerCallsFilter];
3033
+ const filters: CallModelInputFilter[] = [
3034
+ normalizeComputerCallsFilter,
3035
+ elideSupersededViewImagesFilter,
3036
+ ];
2355
3037
  if (settings.openaiProviderItemIds === "strip") {
2356
3038
  filters.push(stripProviderItemIdsFilter);
2357
3039
  }
@@ -2359,9 +3041,16 @@ export function callModelInputFilterForSettings(
2359
3041
  return composeCallModelInputFilters(filters);
2360
3042
  }
2361
3043
 
2362
- export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgentInput | string | RunState<any, any>, settings: Settings, overrides: RunAgentStreamOptions = {}) {
2363
- const prepared: PreparedAgentInput = typeof input === "string" || input instanceof RunState ? { input } : input;
3044
+ export async function runAgentStream(
3045
+ agent: Agent<any, any>,
3046
+ input: PreparedAgentInput | string | RunState<any, any>,
3047
+ settings: Settings,
3048
+ overrides: RunAgentStreamOptions = {},
3049
+ ) {
3050
+ const prepared: PreparedAgentInput =
3051
+ typeof input === "string" || input instanceof RunState ? { input } : input;
2364
3052
  const environment = overrides.sandboxEnvironment ?? collectSandboxEnvironment(settings);
3053
+ const genesisTitleInputFilter = takeGenesisTitleInputFilter(agent);
2365
3054
 
2366
3055
  // OWNED PATH (P1.2 ownership inversion): the per-turn resume path injected a
2367
3056
  // live, externally-owned box. We thread the live `session` straight into
@@ -2380,90 +3069,75 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
2380
3069
  // whose per-op pointer re-read could land these execs on a machine swapped in
2381
3070
  // mid-turn.
2382
3071
  const setupSession = (overrides.ownedSandbox.setupSession ?? session) as SandboxSessionLike;
3072
+ // Platform setup (manifest-env pin + beforeAgentStart hooks + file downloads)
3073
+ // against the UN-proxied established box — the ONE-TRUTH helper shared with the
3074
+ // lazy provisioner. Eager path: runs here, before the run starts (unchanged).
3075
+ if (!overrides.ownedSandbox.deferredSetup) {
3076
+ await runOwnedSandboxSetup(agent, session as SandboxSessionLike, setupSession, {
3077
+ settings,
3078
+ environment,
3079
+ preparedInput: prepared,
3080
+ ...(overrides.ownedSandbox.fileDownloadsMaterialized
3081
+ ? { fileDownloadsMaterialized: true }
3082
+ : {}),
3083
+ ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
3084
+ });
3085
+ await overrides.onGitCredentialSessionReady?.(setupSession);
3086
+ }
2383
3087
  const runAs = sandboxRunAs(settings);
2384
3088
  const fileDownloads = sandboxFileDownloadsForAgent(agent);
2385
- const resourceClient = fileDownloads.length > 0
2386
- ? withSandboxFileDownloads(ownedClient as SandboxClient, fileDownloads, {
2387
- ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
2388
- ...(runAs ? { runAs } : {}),
2389
- })
2390
- : (ownedClient as SandboxClient);
3089
+ const resourceClient =
3090
+ fileDownloads.length > 0
3091
+ ? withSandboxFileDownloads(ownedClient as SandboxClient, fileDownloads, {
3092
+ ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
3093
+ ...(runAs ? { runAs } : {}),
3094
+ })
3095
+ : (ownedClient as SandboxClient);
2391
3096
  // TOKEN-BROKER (B1): the per-turn git token seed, forwarded OFF-MANIFEST so the
2392
3097
  // repository-clone hook seeds it to the box's token file before the clone.
2393
- const ownedGitTokenSeed = gitTokenSeedForAgent(agent);
3098
+ const ownedGitTokenSeeds = gitTokenSeedsForAgent(agent);
2394
3099
  const ownedToolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
3100
+ const ownedRigSetup = rigSetupDescriptorForAgent(agent);
2395
3101
  const ownedHooks = [
2396
- ...sandboxLifecycleHooksForIds(sandboxLifecycleHookIds(settings)),
3102
+ // M3: rig setup runs FIRST so any tooling it installs is present for the
3103
+ // credential / repository-clone hooks below. The rig's credential hooks are
3104
+ // unioned into the deployment preparation-profile hooks (deduped by id).
3105
+ ...sandboxRigSetupHooksForAgent(agent),
3106
+ ...unionCredentialHooks(
3107
+ sandboxLifecycleHooksForIds(sandboxLifecycleHookIds(settings)),
3108
+ rigCredentialHooksForAgent(agent),
3109
+ ),
2397
3110
  ...sandboxToolspaceTokenHooksForAgent(agent),
2398
3111
  ...sandboxRepositoryCloneHooksForAgent(agent),
3112
+ ...gitCredentialSessionRegistrationHooks(overrides.onGitCredentialSessionReady),
2399
3113
  ];
2400
3114
  const ownedHookContext: SandboxLifecycleHookContext = {
2401
3115
  environment,
2402
3116
  ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
2403
3117
  ...(runAs ? { runAs } : {}),
2404
- ...(ownedGitTokenSeed ? { gitTokenSeed: ownedGitTokenSeed } : {}),
3118
+ ...(ownedGitTokenSeeds ? { gitTokenSeeds: ownedGitTokenSeeds } : {}),
2405
3119
  ...(ownedToolspaceTokenSeed ? { toolspaceTokenSeed: ownedToolspaceTokenSeed } : {}),
3120
+ ...(ownedRigSetup ? { rigSetup: ownedRigSetup } : {}),
2406
3121
  };
2407
- // OWNED-PATH HOOKS: the SDK NEVER calls client.create/resume when handed a live
2408
- // provided session (SandboxRuntimeManager uses `sandboxConfig.session` directly),
2409
- // so the withSandboxLifecycleHooks decoration below can never fire on this branch —
2410
- // it only wraps create/resume. Run the beforeAgentStart hooks directly against the
2411
- // provided box, once per turn, BEFORE the run starts: this is what executes the
2412
- // repository-clone hook (which also seeds the B1 askpass + token file) and the
2413
- // azure-cli-login hook on lease-owned boxes. Re-running on a warm box is safe by
2414
- // construction: clone skips when the target is already materialized, the token
2415
- // seed OVERWRITES the file (the desired per-turn refresh), and az login is
2416
- // idempotent. A turn resumed after preemption re-enters here and re-seeds the
2417
- // freshly minted token — which is exactly what a >1h-old warm box needs.
2418
- // EXCEPT on a connected machine (effective backend "selfhosted"): the box is the
2419
- // user's REAL computer — the platform must not run setup against it (the clone
2420
- // hooks are already empty there; this keeps az login off it too).
2421
- if (agentActiveSandboxBackend.get(agent) !== "selfhosted") {
2422
- await runBeforeAgentStartHooks(setupSession, ownedHooks, ownedHookContext);
2423
- // FILE RESOURCES: withSandboxFileDownloads below has the IDENTICAL provided-
2424
- // session blind spot (it too wraps only create/resume), so signed-URL file
2425
- // materialization must also run directly against the pinned box. The download
2426
- // command is idempotent (skips an existing file) and atomic (tmp + rename),
2427
- // so the per-turn re-run is safe; the turn re-signs URLs each run, so a
2428
- // re-warmed box always gets fresh links.
2429
- if (fileDownloads.length > 0) {
2430
- await materializeSandboxFileDownloads(setupSession, fileDownloads, {
2431
- ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
2432
- ...(runAs ? { runAs } : {}),
2433
- });
2434
- }
2435
- } else {
2436
- // SELFHOSTED TOOLSPACE (parity): the platform setup hooks (repository clone,
2437
- // az login) and file materialization stay OFF the user's real machine — but
2438
- // the toolspace token seed is the ONE piece of per-turn material that must
2439
- // reach it. It writes a scoped, own-session-bound token to
2440
- // $OPENGENI_TOOLSPACE_TOKEN_FILE over the SAME exec channel the clone-seed
2441
- // uses (off-manifest, value never on the manifest), and it grants no more
2442
- // than the machine owner's own authority (toolspace:call, this session, turn
2443
- // TTL, budgeted, approval-tools excluded) — the invariant that makes it safe
2444
- // to cross to a user machine when the git token is not. It is also the
2445
- // machine's only path to programmatic tool calling. Seed it (only) here; the
2446
- // hook list is empty when no toolspace token was minted for this turn.
2447
- const toolspaceHooks = sandboxToolspaceTokenHooksForAgent(agent);
2448
- if (toolspaceHooks.length > 0) {
2449
- await runBeforeAgentStartHooks(setupSession, toolspaceHooks, ownedHookContext);
2450
- }
2451
- }
2452
3122
  // Keep the decoration as a safety net for any session the SDK does create/resume
2453
3123
  // through the client during this run (it is inert for the provided session).
2454
3124
  const decoratedClient = withSandboxLifecycleHooks(resourceClient, ownedHooks, ownedHookContext);
2455
3125
  const ownedFilter = composeCallModelInputFilters(
2456
3126
  [
2457
3127
  callModelInputFilterForSettings(settings, {
2458
- throwOnCompactionNeeded: Boolean(overrides.contextCompactionSignalTokens),
3128
+ throwOnCompactionNeeded: Boolean(
3129
+ overrides.contextCompactionSignalTokens || overrides.contextCompactionRequested,
3130
+ ),
2459
3131
  ...(overrides.contextCompactionSignalTokens
2460
3132
  ? { contextCompactionSignalTokens: overrides.contextCompactionSignalTokens }
2461
3133
  : {}),
3134
+ ...(overrides.contextCompactionRequested
3135
+ ? { contextCompactionRequested: overrides.contextCompactionRequested }
3136
+ : {}),
2462
3137
  }),
3138
+ genesisTitleInputFilter,
2463
3139
  overrides.callModelInputFilter,
2464
- ].filter(
2465
- (f): f is CallModelInputFilter => Boolean(f),
2466
- ),
3140
+ ].filter((f): f is CallModelInputFilter => Boolean(f)),
2467
3141
  );
2468
3142
  const ownedRunOptions: Parameters<typeof run>[2] = {
2469
3143
  stream: true,
@@ -2480,37 +3154,55 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
2480
3154
 
2481
3155
  const rawClient = overrides.sandboxClient ?? createSandboxClient(settings, environment);
2482
3156
  const refreshedClient = rawClient
2483
- ? withManifestRefreshOnResume(rawClient as SandboxClient, (agent as { defaultManifest?: Manifest }).defaultManifest)
3157
+ ? withManifestRefreshOnResume(
3158
+ rawClient as SandboxClient,
3159
+ (agent as { defaultManifest?: Manifest }).defaultManifest,
3160
+ {
3161
+ ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
3162
+ },
3163
+ )
2484
3164
  : undefined;
2485
3165
  const runAs = sandboxRunAs(settings);
2486
3166
  const fileDownloads = sandboxFileDownloadsForAgent(agent);
2487
- const resourceClient = refreshedClient && fileDownloads.length > 0
2488
- ? withSandboxFileDownloads(refreshedClient, fileDownloads, {
2489
- ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
2490
- ...(runAs ? { runAs } : {}),
2491
- })
2492
- : refreshedClient;
3167
+ const resourceClient =
3168
+ refreshedClient && fileDownloads.length > 0
3169
+ ? withSandboxFileDownloads(refreshedClient, fileDownloads, {
3170
+ ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
3171
+ ...(runAs ? { runAs } : {}),
3172
+ })
3173
+ : refreshedClient;
2493
3174
  // TOKEN-BROKER (B1): the per-turn git token seed, forwarded OFF-MANIFEST so the
2494
3175
  // repository-clone hook seeds it to the box's token file before the clone.
2495
- const gitTokenSeed = gitTokenSeedForAgent(agent);
3176
+ const gitTokenSeeds = gitTokenSeedsForAgent(agent);
2496
3177
  const toolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
3178
+ const legacyRigSetup = rigSetupDescriptorForAgent(agent);
2497
3179
  const client = resourceClient
2498
- ? withSandboxLifecycleHooks(resourceClient, [
2499
- ...sandboxLifecycleHooksForIds(sandboxLifecycleHookIds(settings)),
2500
- ...sandboxToolspaceTokenHooksForAgent(agent),
2501
- ...sandboxRepositoryCloneHooksForAgent(agent),
2502
- ], {
2503
- environment,
2504
- ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
2505
- ...(runAs ? { runAs } : {}),
2506
- ...(gitTokenSeed ? { gitTokenSeed } : {}),
2507
- ...(toolspaceTokenSeed ? { toolspaceTokenSeed } : {}),
2508
- })
3180
+ ? withSandboxLifecycleHooks(
3181
+ resourceClient,
3182
+ [
3183
+ // M3: same rig-setup-first ordering + credential-hook union as the owned
3184
+ // path (this legacy create/resume decoration path is byte-for-byte today
3185
+ // for a rig-less turn — the rig hooks are empty then).
3186
+ ...sandboxRigSetupHooksForAgent(agent),
3187
+ ...unionCredentialHooks(
3188
+ sandboxLifecycleHooksForIds(sandboxLifecycleHookIds(settings)),
3189
+ rigCredentialHooksForAgent(agent),
3190
+ ),
3191
+ ...sandboxToolspaceTokenHooksForAgent(agent),
3192
+ ...sandboxRepositoryCloneHooksForAgent(agent),
3193
+ ...gitCredentialSessionRegistrationHooks(overrides.onGitCredentialSessionReady),
3194
+ ],
3195
+ {
3196
+ environment,
3197
+ ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
3198
+ ...(runAs ? { runAs } : {}),
3199
+ ...(gitTokenSeeds ? { gitTokenSeeds } : {}),
3200
+ ...(toolspaceTokenSeed ? { toolspaceTokenSeed } : {}),
3201
+ ...(legacyRigSetup ? { rigSetup: legacyRigSetup } : {}),
3202
+ },
3203
+ )
2509
3204
  : undefined;
2510
- const sandboxSessionState = prepared.sandboxSessionState
2511
- ?? (prepared.serializedRunStateForSandbox && client
2512
- ? await restoredSandboxSessionState(await RunState.fromString(agent, prepared.serializedRunStateForSandbox), client)
2513
- : undefined);
3205
+ const sandboxSessionState = prepared.sandboxSessionState;
2514
3206
  // Apply the built-in per-call filters (computer-call normalization, optional
2515
3207
  // provider-id stripping, image/budget guard), then any per-turn filter
2516
3208
  // (genesis title directive). A callModelInputFilter only shapes the per-call
@@ -2519,22 +3211,26 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
2519
3211
  const callModelInputFilter = composeCallModelInputFilters(
2520
3212
  [
2521
3213
  callModelInputFilterForSettings(settings, {
2522
- throwOnCompactionNeeded: Boolean(overrides.contextCompactionSignalTokens),
3214
+ throwOnCompactionNeeded: Boolean(
3215
+ overrides.contextCompactionSignalTokens || overrides.contextCompactionRequested,
3216
+ ),
2523
3217
  ...(overrides.contextCompactionSignalTokens
2524
3218
  ? { contextCompactionSignalTokens: overrides.contextCompactionSignalTokens }
2525
3219
  : {}),
3220
+ ...(overrides.contextCompactionRequested
3221
+ ? { contextCompactionRequested: overrides.contextCompactionRequested }
3222
+ : {}),
2526
3223
  }),
3224
+ genesisTitleInputFilter,
2527
3225
  overrides.callModelInputFilter,
2528
- ].filter(
2529
- (f): f is CallModelInputFilter => Boolean(f),
2530
- ),
3226
+ ].filter((f): f is CallModelInputFilter => Boolean(f)),
2531
3227
  );
2532
3228
  const runOptions: Parameters<typeof run>[2] = {
2533
3229
  stream: true,
2534
3230
  maxTurns: settings.agentMaxModelCallsPerTurn,
2535
3231
  // Built-in per-call guard chain: normalize computer calls, optionally strip
2536
- // provider ids, elide stale screenshots in every mode, and trim to the input
2537
- // budget on the client-compaction path. This runs for turn-start replay AND
3232
+ // provider ids, trim to the input budget on the client-compaction path, and
3233
+ // raise the proactive compaction signal. This runs for turn-start replay AND
2538
3234
  // every mid-turn follow-up.
2539
3235
  callModelInputFilter,
2540
3236
  };
@@ -2547,6 +3243,26 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
2547
3243
  return await runScopedRunner(settings).run(agent, prepared.input, runOptions);
2548
3244
  }
2549
3245
 
3246
+ function appendSandboxFileDownloadFailureNote(
3247
+ input: PreparedAgentInput,
3248
+ failures: SandboxFileDownloadFailure[],
3249
+ ): void {
3250
+ const note = sandboxFileDownloadFailureNote(failures);
3251
+ if (!note) {
3252
+ return;
3253
+ }
3254
+ if (typeof input.input === "string") {
3255
+ input.input = [input.input, "", note].join("\n");
3256
+ return;
3257
+ }
3258
+ if (Array.isArray(input.input)) {
3259
+ input.input = [
3260
+ ...input.input,
3261
+ { type: "message", role: "user", content: note } as AgentInputItem,
3262
+ ];
3263
+ }
3264
+ }
3265
+
2550
3266
  /**
2551
3267
  * A per-run `Runner` whose `modelProvider` is built from THIS turn's settings.
2552
3268
  *
@@ -2578,7 +3294,9 @@ export { MaxTurnsExceededError } from "@openai/agents";
2578
3294
  * the work. When the SDK attached the run state at the moment the cap hit,
2579
3295
  * the serialized form is returned so the resumed turn keeps full context.
2580
3296
  */
2581
- export function maxTurnsExceededRunState(error: unknown): { serializedRunState: string | null } | null {
3297
+ export function maxTurnsExceededRunState(
3298
+ error: unknown,
3299
+ ): { serializedRunState: string | null } | null {
2582
3300
  if (!(error instanceof MaxTurnsExceededError)) {
2583
3301
  return null;
2584
3302
  }
@@ -2606,42 +3324,102 @@ export function agentsErrorRunState(error: unknown): string | null {
2606
3324
  }
2607
3325
  }
2608
3326
 
2609
- export function withManifestRefreshOnResume(client: SandboxClient, targetManifest: Manifest | undefined): SandboxClient {
3327
+ export function withManifestRefreshOnResume(
3328
+ client: SandboxClient,
3329
+ targetManifest: Manifest | undefined,
3330
+ context: Pick<SandboxLifecycleHookContext, "onRuntimeEvent"> = {},
3331
+ ): SandboxClient {
2610
3332
  if (!targetManifest || !client.resume) {
2611
3333
  return client;
2612
3334
  }
2613
3335
  return {
2614
3336
  backendId: client.backendId,
2615
- ...(client.supportsDefaultOptions !== undefined ? { supportsDefaultOptions: client.supportsDefaultOptions } : {}),
2616
- ...(client.create ? { create: async (...args: any[]) => await (client.create as any)(...args) } : {}),
3337
+ ...(client.supportsDefaultOptions !== undefined
3338
+ ? { supportsDefaultOptions: client.supportsDefaultOptions }
3339
+ : {}),
3340
+ ...(client.create
3341
+ ? { create: async (...args: any[]) => await (client.create as any)(...args) }
3342
+ : {}),
2617
3343
  resume: async (state: SandboxSessionState) => {
2618
3344
  const session = await client.resume!(state);
2619
- await applyMissingManifestEntries(session, targetManifest);
3345
+ await applyMissingManifestEntries(session, targetManifest, context);
2620
3346
  return session;
2621
3347
  },
2622
- ...(client.delete ? { delete: async (state: SandboxSessionState) => await client.delete!(state) } : {}),
2623
- ...(client.serializeSessionState ? { serializeSessionState: async (state: SandboxSessionState, options) => await client.serializeSessionState!(state, options) } : {}),
2624
- ...(client.canPersistOwnedSessionState ? { canPersistOwnedSessionState: async (state: SandboxSessionState) => await client.canPersistOwnedSessionState!(state) } : {}),
2625
- ...(client.canReusePreservedOwnedSession ? { canReusePreservedOwnedSession: async (state: SandboxSessionState) => await client.canReusePreservedOwnedSession!(state) } : {}),
2626
- ...(client.deserializeSessionState ? { deserializeSessionState: async (state: Record<string, unknown>) => await client.deserializeSessionState!(state) } : {}),
3348
+ ...(client.delete
3349
+ ? { delete: async (state: SandboxSessionState) => await client.delete!(state) }
3350
+ : {}),
3351
+ ...(client.serializeSessionState
3352
+ ? {
3353
+ serializeSessionState: async (state: SandboxSessionState, options) =>
3354
+ await client.serializeSessionState!(state, options),
3355
+ }
3356
+ : {}),
3357
+ ...(client.canPersistOwnedSessionState
3358
+ ? {
3359
+ canPersistOwnedSessionState: async (state: SandboxSessionState) =>
3360
+ await client.canPersistOwnedSessionState!(state),
3361
+ }
3362
+ : {}),
3363
+ ...(client.canReusePreservedOwnedSession
3364
+ ? {
3365
+ canReusePreservedOwnedSession: async (state: SandboxSessionState) =>
3366
+ await client.canReusePreservedOwnedSession!(state),
3367
+ }
3368
+ : {}),
3369
+ ...(client.deserializeSessionState
3370
+ ? {
3371
+ deserializeSessionState: async (state: Record<string, unknown>) =>
3372
+ await client.deserializeSessionState!(state),
3373
+ }
3374
+ : {}),
2627
3375
  };
2628
3376
  }
2629
3377
 
2630
- export async function applyMissingManifestEntries(session: SandboxSessionLike, targetManifest: Manifest): Promise<void> {
2631
- const currentManifestValue = (session as { state?: { manifest?: Manifest | { root?: string; entries?: Record<string, any>; environment?: Record<string, any> } } }).state?.manifest;
3378
+ // OWNED-RESUME manifest refresh. This path runs ONLY for SDK-owned sessions
3379
+ // (withManifestRefreshOnResume wraps client.resume, which the SDK never calls
3380
+ // when handed a live provided session — the ownedSandbox branch bypasses this
3381
+ // entirely). Owned applyManifest MERGES env safely with no guard, and this
3382
+ // refresh is a FEATURE: it is how a workspace-env edit reaches a long-lived
3383
+ // owned local/docker box that rarely recycles. The provided-session env pin
3384
+ // (pinProvidedSessionManifestEnvironment below) — NOT this function — is the
3385
+ // fix for the SDK's validateNoEnvironmentDelta session-fatal guard. Drift is
3386
+ // additionally REPORTED here (key names only, never values) so any env
3387
+ // recompute change stays attributable from the DB alone.
3388
+ export async function applyMissingManifestEntries(
3389
+ session: SandboxSessionLike,
3390
+ targetManifest: Manifest,
3391
+ context: Pick<SandboxLifecycleHookContext, "onRuntimeEvent"> = {},
3392
+ ): Promise<void> {
3393
+ const currentManifestValue = (
3394
+ session as {
3395
+ state?: {
3396
+ manifest?:
3397
+ | Manifest
3398
+ | { root?: string; entries?: Record<string, any>; environment?: Record<string, any> };
3399
+ };
3400
+ }
3401
+ ).state?.manifest;
2632
3402
  const currentManifest = currentManifestValue ? ensureManifest(currentManifestValue) : undefined;
2633
3403
  const target = ensureManifest(targetManifest);
2634
3404
  if (!currentManifest) {
2635
3405
  if (Object.keys(target.entries).length === 0) {
2636
3406
  return;
2637
3407
  }
2638
- throw new Error("Resumed sandbox session cannot apply new manifest entries because current manifest state is unavailable");
3408
+ throw new Error(
3409
+ "Resumed sandbox session cannot apply new manifest entries because current manifest state is unavailable",
3410
+ );
2639
3411
  }
3412
+ // Drift detection runs on EVERY resume (even no-op ones): the durable trace
3413
+ // that makes an env-recompute regression attributable from the DB instead of
3414
+ // from rotated worker logs.
3415
+ await reportManifestEnvironmentDrift(currentManifest, target, context);
2640
3416
  if (!session.applyManifest && !session.materializeEntry) {
2641
3417
  if (Object.keys(target.entries).length === 0) {
2642
3418
  return;
2643
3419
  }
2644
- throw new Error("Resumed sandbox session cannot apply new manifest entries because it does not support applyManifest() or materializeEntry()");
3420
+ throw new Error(
3421
+ "Resumed sandbox session cannot apply new manifest entries because it does not support applyManifest() or materializeEntry()",
3422
+ );
2645
3423
  }
2646
3424
  if (Object.keys(target.entries).length === 0) {
2647
3425
  return;
@@ -2660,9 +3438,12 @@ export async function applyMissingManifestEntries(session: SandboxSessionLike, t
2660
3438
  throw new Error(`Cannot replace existing sandbox manifest entry: ${path}`);
2661
3439
  }
2662
3440
  }
2663
- const environmentChanged = stableJson(currentManifest.environment) !== stableJson(target.environment);
3441
+ const environmentChanged =
3442
+ stableJson(currentManifest.environment) !== stableJson(target.environment);
2664
3443
  if (environmentChanged && !session.applyManifest) {
2665
- throw new Error("Resumed sandbox session cannot refresh manifest environment because it does not support applyManifest()");
3444
+ throw new Error(
3445
+ "Resumed sandbox session cannot refresh manifest environment because it does not support applyManifest()",
3446
+ );
2666
3447
  }
2667
3448
  if (Object.keys(entries).length === 0 && !environmentChanged) {
2668
3449
  return;
@@ -2695,6 +3476,216 @@ export async function applyMissingManifestEntries(session: SandboxSessionLike, t
2695
3476
  });
2696
3477
  }
2697
3478
 
3479
+ /**
3480
+ * Key-level diff of the live box's baked manifest env vs the freshly recomputed
3481
+ * target env. Returns null when identical. Key NAMES only — values are secrets
3482
+ * and must never leave this function's comparison.
3483
+ */
3484
+ export function manifestEnvironmentDrift(
3485
+ current: Manifest,
3486
+ target: Manifest,
3487
+ ): { added: string[]; removed: string[]; changed: string[] } | null {
3488
+ const currentEnv = (current.environment ?? {}) as Record<string, unknown>;
3489
+ const targetEnv = (target.environment ?? {}) as Record<string, unknown>;
3490
+ const added: string[] = [];
3491
+ const removed: string[] = [];
3492
+ const changed: string[] = [];
3493
+ for (const key of Object.keys(targetEnv)) {
3494
+ if (!(key in currentEnv)) {
3495
+ added.push(key);
3496
+ } else if (stableJson(currentEnv[key]) !== stableJson(targetEnv[key])) {
3497
+ changed.push(key);
3498
+ }
3499
+ }
3500
+ for (const key of Object.keys(currentEnv)) {
3501
+ if (!(key in targetEnv)) {
3502
+ removed.push(key);
3503
+ }
3504
+ }
3505
+ if (added.length === 0 && removed.length === 0 && changed.length === 0) {
3506
+ return null;
3507
+ }
3508
+ return { added: added.sort(), removed: removed.sort(), changed: changed.sort() };
3509
+ }
3510
+
3511
+ async function reportManifestEnvironmentDrift(
3512
+ current: Manifest,
3513
+ target: Manifest,
3514
+ context: Pick<SandboxLifecycleHookContext, "onRuntimeEvent">,
3515
+ ): Promise<ReturnType<typeof manifestEnvironmentDrift>> {
3516
+ const drift = manifestEnvironmentDrift(current, target);
3517
+ if (!drift) {
3518
+ return null;
3519
+ }
3520
+ // Reporting must never break a resume: the drift itself is benign under the
3521
+ // env pin (the box keeps running on its baked env); only the SIGNAL matters.
3522
+ try {
3523
+ await context.onRuntimeEvent?.({ type: "sandbox.env.drift", payload: drift });
3524
+ } catch {
3525
+ // Swallow: a failed emit must not fail the turn.
3526
+ }
3527
+ return drift;
3528
+ }
3529
+
3530
+ /**
3531
+ * ENV PIN for provided sessions (the ownership-inversion turn path). The SDK
3532
+ * validates the FULL target manifest environment against a live provided
3533
+ * session's baked env BEFORE reducing to its entry-only delta
3534
+ * (validateNoEnvironmentDelta -> session-fatal UserError on ANY difference).
3535
+ * So a turn resuming an existing box must declare the box's OWN env,
3536
+ * byte-identical — never the fresh recompute. This replaces the agent's
3537
+ * defaultManifest environment with the baked one and reports the drift (key
3538
+ * names only) instead of letting it kill the session. Fresh env lands at the
3539
+ * next cold-create; rotating values ride OFF-manifest (TOKEN-BROKER B1/B2).
3540
+ */
3541
+ export async function pinProvidedSessionManifestEnvironment(
3542
+ agent: Agent<any, any>,
3543
+ session: SandboxSessionLike,
3544
+ context: Pick<SandboxLifecycleHookContext, "onRuntimeEvent"> = {},
3545
+ ): Promise<void> {
3546
+ const holder = agent as { defaultManifest?: Manifest };
3547
+ const currentManifestValue = (
3548
+ session as {
3549
+ state?: {
3550
+ manifest?:
3551
+ | Manifest
3552
+ | { root?: string; entries?: Record<string, any>; environment?: Record<string, any> };
3553
+ };
3554
+ }
3555
+ ).state?.manifest;
3556
+ if (!holder.defaultManifest || !currentManifestValue) {
3557
+ return;
3558
+ }
3559
+ const current = ensureManifest(currentManifestValue);
3560
+ const target = ensureManifest(holder.defaultManifest);
3561
+ const drift = await reportManifestEnvironmentDrift(current, target, context);
3562
+ if (!drift) {
3563
+ return;
3564
+ }
3565
+ holder.defaultManifest = new Manifest({
3566
+ ...(target.root ? { root: target.root } : {}),
3567
+ entries: target.entries,
3568
+ environment: current.environment,
3569
+ ...(target.extraPathGrants?.length ? { extraPathGrants: target.extraPathGrants } : {}),
3570
+ });
3571
+ }
3572
+
3573
+ /**
3574
+ * The one-truth owned-path platform setup: the manifest-env pin (align the turn's
3575
+ * manifest to the live box's baked env + report drift, NEVER die on it) plus the
3576
+ * beforeAgentStart hooks (repository clone with B1 token/askpass seed, toolspace
3577
+ * token seed, azure-cli-login) and signed-URL file materialization — all executed
3578
+ * DIRECTLY against the pinned, UN-proxied established box (the SDK never calls
3579
+ * client.create/resume for a provided session, so these decorations would never
3580
+ * fire on their own).
3581
+ *
3582
+ * Extracted verbatim from `runStream`'s owned branch so both the EAGER path
3583
+ * (runStream, before the run starts) and the LAZY path (the worker's first-op
3584
+ * provisioner, after the box is established) run the IDENTICAL setup. A pure
3585
+ * refactor for the eager path: same order, same gates, same idempotency
3586
+ * (clone skips a materialized tree, token seed overwrites — the desired per-turn
3587
+ * refresh, az login is idempotent). The connected-machine (selfhosted) branch
3588
+ * keeps platform setup OFF the user's real box and seeds ONLY the toolspace token.
3589
+ *
3590
+ * `gitTokenSeedsOverride` lets the lazy provisioner pass its own freshly-minted
3591
+ * run-scoped provider tokens (minted at establish time, not turn start);
3592
+ * unset ⇒ read the seeds off the agent exactly as the eager path does.
3593
+ */
3594
+ export async function runOwnedSandboxSetup(
3595
+ agent: Agent<any, any>,
3596
+ session: SandboxSessionLike,
3597
+ setupSession: SandboxSessionLike,
3598
+ opts: {
3599
+ settings: Settings;
3600
+ environment: Record<string, string>;
3601
+ preparedInput?: PreparedAgentInput;
3602
+ fileDownloadsMaterialized?: boolean;
3603
+ onRuntimeEvent?: SandboxLifecycleHookContext["onRuntimeEvent"];
3604
+ gitTokenSeedsOverride?: GitTokenSeeds;
3605
+ gitTokenSeedOverride?: string;
3606
+ },
3607
+ ): Promise<void> {
3608
+ const { settings, environment } = opts;
3609
+ // ENV PIN (provided sessions): the SDK validates the FULL recomputed manifest
3610
+ // env against the live box's baked env before applying its entry-only delta
3611
+ // (validateNoEnvironmentDelta — session-fatal on ANY drift; killed a 4-day
3612
+ // prod manager session 2026-07-06). Align the turn's manifest to the box's
3613
+ // own env and REPORT the drift instead of dying on it.
3614
+ await pinProvidedSessionManifestEnvironment(agent, session, {
3615
+ ...(opts.onRuntimeEvent ? { onRuntimeEvent: opts.onRuntimeEvent } : {}),
3616
+ });
3617
+ const runAs = sandboxRunAs(settings);
3618
+ const fileDownloads = sandboxFileDownloadsForAgent(agent);
3619
+ // TOKEN-BROKER (B1): per-turn provider token seeds, forwarded OFF-MANIFEST so
3620
+ // the repository-clone hook writes provider token files before the clone. The
3621
+ // lazy provisioner overrides them with its own establish-time mint.
3622
+ const ownedGitTokenSeeds = {
3623
+ ...(gitTokenSeedsForAgent(agent) ?? {}),
3624
+ ...(opts.gitTokenSeedOverride ? { github: opts.gitTokenSeedOverride } : {}),
3625
+ ...(opts.gitTokenSeedsOverride ?? {}),
3626
+ } satisfies GitTokenSeeds;
3627
+ const ownedToolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
3628
+ const ownedRigSetup = rigSetupDescriptorForAgent(agent);
3629
+ const ownedHooks = [
3630
+ // M3: rig setup runs FIRST so any tooling it installs is present for the
3631
+ // credential / repository-clone hooks below; the rig's credential hooks are
3632
+ // unioned into the deployment preparation-profile hooks (deduped by id).
3633
+ // This is the LIVE owned-path execution (the provided session skips the
3634
+ // client create/resume decoration), so the rig hooks MUST be here or a
3635
+ // rig-bound turn would start without ever running the frozen setup script.
3636
+ ...sandboxRigSetupHooksForAgent(agent),
3637
+ ...unionCredentialHooks(
3638
+ sandboxLifecycleHooksForIds(sandboxLifecycleHookIds(settings)),
3639
+ rigCredentialHooksForAgent(agent),
3640
+ ),
3641
+ ...sandboxToolspaceTokenHooksForAgent(agent),
3642
+ ...sandboxRepositoryCloneHooksForAgent(agent),
3643
+ ];
3644
+ const ownedHookContext: SandboxLifecycleHookContext = {
3645
+ environment,
3646
+ ...(opts.onRuntimeEvent ? { onRuntimeEvent: opts.onRuntimeEvent } : {}),
3647
+ ...(runAs ? { runAs } : {}),
3648
+ ...(Object.keys(ownedGitTokenSeeds).length > 0 ? { gitTokenSeeds: ownedGitTokenSeeds } : {}),
3649
+ ...(ownedToolspaceTokenSeed ? { toolspaceTokenSeed: ownedToolspaceTokenSeed } : {}),
3650
+ ...(ownedRigSetup ? { rigSetup: ownedRigSetup } : {}),
3651
+ };
3652
+ // OWNED-PATH HOOKS: run the beforeAgentStart hooks directly against the provided
3653
+ // box, once per turn, BEFORE the run starts (repository-clone hook seeds the B1
3654
+ // askpass + token file; azure-cli-login on lease-owned boxes). Re-running on a
3655
+ // warm box is safe by construction (clone skips a materialized tree, token seed
3656
+ // overwrites the file, az login is idempotent). EXCEPT on a connected machine
3657
+ // (effective backend "selfhosted"): the box is the user's REAL computer — the
3658
+ // platform must not run setup against it (clone hooks are already empty there;
3659
+ // this keeps az login off it too).
3660
+ if (agentActiveSandboxBackend.get(agent) !== "selfhosted") {
3661
+ await runBeforeAgentStartHooks(setupSession, ownedHooks, ownedHookContext);
3662
+ // FILE RESOURCES: withSandboxFileDownloads has the IDENTICAL provided-session
3663
+ // blind spot (it too wraps only create/resume), so signed-URL file
3664
+ // materialization must also run directly against the pinned box. The download
3665
+ // command is idempotent (skips an existing file) and atomic (tmp + rename).
3666
+ if (fileDownloads.length > 0 && !opts.fileDownloadsMaterialized) {
3667
+ const materialized = await materializeSandboxFileDownloads(setupSession, fileDownloads, {
3668
+ ...(opts.onRuntimeEvent ? { onRuntimeEvent: opts.onRuntimeEvent } : {}),
3669
+ ...(runAs ? { runAs } : {}),
3670
+ });
3671
+ if (opts.preparedInput) {
3672
+ appendSandboxFileDownloadFailureNote(opts.preparedInput, materialized.failures);
3673
+ }
3674
+ }
3675
+ } else {
3676
+ // SELFHOSTED TOOLSPACE (parity): the platform setup hooks and file
3677
+ // materialization stay OFF the user's real machine — but the toolspace token
3678
+ // seed is the ONE piece of per-turn material that must reach it (a scoped,
3679
+ // own-session-bound token written to $OPENGENI_TOOLSPACE_TOKEN_FILE over the
3680
+ // same off-manifest exec channel the clone-seed uses; the machine's only path
3681
+ // to programmatic tool calling). Seed it (only) here.
3682
+ const toolspaceHooks = sandboxToolspaceTokenHooksForAgent(agent);
3683
+ if (toolspaceHooks.length > 0) {
3684
+ await runBeforeAgentStartHooks(setupSession, toolspaceHooks, ownedHookContext);
3685
+ }
3686
+ }
3687
+ }
3688
+
2698
3689
  function mergePathGrants(
2699
3690
  current: Manifest["extraPathGrants"] | undefined,
2700
3691
  target: Manifest["extraPathGrants"] | undefined,
@@ -2725,14 +3716,48 @@ export function withSandboxFileDownloads(
2725
3716
  };
2726
3717
  return {
2727
3718
  backendId: client.backendId,
2728
- ...(client.supportsDefaultOptions !== undefined ? { supportsDefaultOptions: client.supportsDefaultOptions } : {}),
2729
- ...(client.create ? { create: async (...args: any[]) => await wrapSession(await (client.create as any)(...args)) } : {}),
2730
- ...(client.resume ? { resume: async (state: SandboxSessionState) => await wrapSession(await client.resume!(state)) } : {}),
2731
- ...(client.delete ? { delete: async (state: SandboxSessionState) => await client.delete!(state) } : {}),
2732
- ...(client.serializeSessionState ? { serializeSessionState: async (state: SandboxSessionState, options) => await client.serializeSessionState!(state, options) } : {}),
2733
- ...(client.canPersistOwnedSessionState ? { canPersistOwnedSessionState: async (state: SandboxSessionState) => await client.canPersistOwnedSessionState!(state) } : {}),
2734
- ...(client.canReusePreservedOwnedSession ? { canReusePreservedOwnedSession: async (state: SandboxSessionState) => await client.canReusePreservedOwnedSession!(state) } : {}),
2735
- ...(client.deserializeSessionState ? { deserializeSessionState: async (state: Record<string, unknown>) => await client.deserializeSessionState!(state) } : {}),
3719
+ ...(client.supportsDefaultOptions !== undefined
3720
+ ? { supportsDefaultOptions: client.supportsDefaultOptions }
3721
+ : {}),
3722
+ ...(client.create
3723
+ ? {
3724
+ create: async (...args: any[]) =>
3725
+ await wrapSession(await (client.create as any)(...args)),
3726
+ }
3727
+ : {}),
3728
+ ...(client.resume
3729
+ ? {
3730
+ resume: async (state: SandboxSessionState) =>
3731
+ await wrapSession(await client.resume!(state)),
3732
+ }
3733
+ : {}),
3734
+ ...(client.delete
3735
+ ? { delete: async (state: SandboxSessionState) => await client.delete!(state) }
3736
+ : {}),
3737
+ ...(client.serializeSessionState
3738
+ ? {
3739
+ serializeSessionState: async (state: SandboxSessionState, options) =>
3740
+ await client.serializeSessionState!(state, options),
3741
+ }
3742
+ : {}),
3743
+ ...(client.canPersistOwnedSessionState
3744
+ ? {
3745
+ canPersistOwnedSessionState: async (state: SandboxSessionState) =>
3746
+ await client.canPersistOwnedSessionState!(state),
3747
+ }
3748
+ : {}),
3749
+ ...(client.canReusePreservedOwnedSession
3750
+ ? {
3751
+ canReusePreservedOwnedSession: async (state: SandboxSessionState) =>
3752
+ await client.canReusePreservedOwnedSession!(state),
3753
+ }
3754
+ : {}),
3755
+ ...(client.deserializeSessionState
3756
+ ? {
3757
+ deserializeSessionState: async (state: Record<string, unknown>) =>
3758
+ await client.deserializeSessionState!(state),
3759
+ }
3760
+ : {}),
2736
3761
  };
2737
3762
  }
2738
3763
 
@@ -2740,14 +3765,12 @@ export async function materializeSandboxFileDownloads(
2740
3765
  session: SandboxSessionLike,
2741
3766
  downloads: SandboxFileDownload[],
2742
3767
  context: Pick<SandboxLifecycleHookContext, "onRuntimeEvent" | "runAs"> = {},
2743
- ): Promise<void> {
3768
+ ): Promise<SandboxFileDownloadMaterializationResult> {
2744
3769
  const normalizedDownloads = normalizeSandboxFileDownloads(downloads);
2745
3770
  if (normalizedDownloads.length === 0) {
2746
- return;
2747
- }
2748
- if (!session.exec && !session.execCommand) {
2749
- throw new Error("Sandbox file download materialization requires command execution support");
3771
+ return { failures: [] };
2750
3772
  }
3773
+ const failures: SandboxFileDownloadFailure[] = [];
2751
3774
  for (const download of normalizedDownloads) {
2752
3775
  const targetPath = sandboxDownloadTargetPath(download);
2753
3776
  const payload = {
@@ -2756,37 +3779,94 @@ export async function materializeSandboxFileDownloads(
2756
3779
  sizeBytes: download.sizeBytes ?? null,
2757
3780
  expiresAt: download.expiresAt ? new Date(download.expiresAt).toISOString() : null,
2758
3781
  };
2759
- await context.onRuntimeEvent?.({ type: "sandbox.operation.started", payload: { name: "file-resource-download", ...payload } });
3782
+ await context.onRuntimeEvent?.({
3783
+ type: "sandbox.operation.started",
3784
+ payload: { name: "file-resource-download", ...payload },
3785
+ });
3786
+ if (!session.exec && !session.execCommand) {
3787
+ const failure = sandboxFileDownloadFailure(
3788
+ download,
3789
+ targetPath,
3790
+ "Sandbox file download materialization requires command execution support",
3791
+ );
3792
+ failures.push(failure);
3793
+ await context.onRuntimeEvent?.({
3794
+ type: "sandbox.operation.failed",
3795
+ payload: {
3796
+ name: "file-resource-download",
3797
+ ...payload,
3798
+ error: failure.reason,
3799
+ },
3800
+ });
3801
+ continue;
3802
+ }
3803
+ let result: unknown;
2760
3804
  try {
2761
- const result = session.exec
3805
+ result = session.exec
2762
3806
  ? await session.exec({
2763
- cmd: sandboxFileDownloadCommand(download, targetPath),
2764
- workdir: "/workspace",
2765
- ...(context.runAs ? { runAs: context.runAs } : {}),
2766
- yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
2767
- maxOutputTokens: 20_000,
2768
- })
3807
+ cmd: sandboxFileDownloadCommand(download, targetPath),
3808
+ workdir: "/workspace",
3809
+ ...(context.runAs ? { runAs: context.runAs } : {}),
3810
+ yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
3811
+ maxOutputTokens: 20_000,
3812
+ })
2769
3813
  : await session.execCommand!({
2770
- cmd: sandboxFileDownloadCommand(download, targetPath),
2771
- workdir: "/workspace",
2772
- ...(context.runAs ? { runAs: context.runAs } : {}),
2773
- yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
2774
- maxOutputTokens: 20_000,
2775
- });
3814
+ cmd: sandboxFileDownloadCommand(download, targetPath),
3815
+ workdir: "/workspace",
3816
+ ...(context.runAs ? { runAs: context.runAs } : {}),
3817
+ yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
3818
+ maxOutputTokens: 20_000,
3819
+ });
2776
3820
  assertSandboxCommandSucceeded(result, `Sandbox file resource download ${download.fileId}`);
2777
- await context.onRuntimeEvent?.({ type: "sandbox.operation.completed", payload: { name: "file-resource-download", ...payload } });
3821
+ await context.onRuntimeEvent?.({
3822
+ type: "sandbox.operation.completed",
3823
+ payload: { name: "file-resource-download", ...payload },
3824
+ });
2778
3825
  } catch (error) {
3826
+ const failure = sandboxFileDownloadFailure(download, targetPath, error, result);
3827
+ failures.push(failure);
2779
3828
  await context.onRuntimeEvent?.({
2780
3829
  type: "sandbox.operation.failed",
2781
3830
  payload: {
2782
3831
  name: "file-resource-download",
2783
3832
  ...payload,
2784
- error: error instanceof Error ? error.message : String(error),
3833
+ error: failure.reason,
3834
+ ...(failure.exitCode !== undefined ? { exitCode: failure.exitCode } : {}),
3835
+ ...(failure.output ? { output: failure.output } : {}),
2785
3836
  },
2786
3837
  });
2787
- throw error;
2788
3838
  }
2789
3839
  }
3840
+ return { failures };
3841
+ }
3842
+
3843
+ function sandboxFileDownloadFailure(
3844
+ download: SandboxFileDownload,
3845
+ targetPath: string,
3846
+ error: unknown,
3847
+ result?: unknown,
3848
+ ): SandboxFileDownloadFailure {
3849
+ const exitCode = sandboxCommandExitCode(result);
3850
+ const output = sandboxCommandOutput(result);
3851
+ return {
3852
+ fileId: download.fileId,
3853
+ filename: download.filename,
3854
+ path: targetPath,
3855
+ reason: error instanceof Error ? error.message : String(error),
3856
+ ...(exitCode !== null ? { exitCode } : {}),
3857
+ ...(output ? { output } : {}),
3858
+ };
3859
+ }
3860
+
3861
+ export function sandboxFileDownloadFailureNote(failures: SandboxFileDownloadFailure[]): string {
3862
+ if (failures.length === 0) {
3863
+ return "";
3864
+ }
3865
+ return [
3866
+ "The following attached files could not be loaded into the sandbox and are unavailable this turn:",
3867
+ ...failures.map((failure) => `- ${failure.filename} (${failure.reason})`),
3868
+ "Continue without them or tell the user.",
3869
+ ].join("\n");
2790
3870
  }
2791
3871
 
2792
3872
  export function sandboxFileDownloadsForAgent(agent: unknown): SandboxFileDownload[] {
@@ -2795,8 +3875,20 @@ export function sandboxFileDownloadsForAgent(agent: unknown): SandboxFileDownloa
2795
3875
  : [];
2796
3876
  }
2797
3877
 
2798
- function ensureManifest(manifest: Manifest | { root?: string; entries?: Record<string, any>; environment?: Record<string, any>; extraPathGrants?: any[] }): Manifest {
2799
- if (manifest instanceof Manifest && typeof manifest.mountTargetsForMaterialization === "function") {
3878
+ function ensureManifest(
3879
+ manifest:
3880
+ | Manifest
3881
+ | {
3882
+ root?: string;
3883
+ entries?: Record<string, any>;
3884
+ environment?: Record<string, any>;
3885
+ extraPathGrants?: any[];
3886
+ },
3887
+ ): Manifest {
3888
+ if (
3889
+ manifest instanceof Manifest &&
3890
+ typeof manifest.mountTargetsForMaterialization === "function"
3891
+ ) {
2800
3892
  return manifest;
2801
3893
  }
2802
3894
  return new Manifest({
@@ -2847,7 +3939,10 @@ function structuredImageToDataUrl(value: unknown): string | null {
2847
3939
  if (typeof image.url === "string" && image.url.length > 0) {
2848
3940
  return image.url;
2849
3941
  }
2850
- const mediaType = typeof image.mediaType === "string" && image.mediaType.length > 0 ? image.mediaType : "image/png";
3942
+ const mediaType =
3943
+ typeof image.mediaType === "string" && image.mediaType.length > 0
3944
+ ? image.mediaType
3945
+ : "image/png";
2851
3946
  if (typeof image.data === "string") {
2852
3947
  return image.data.startsWith("data:") ? image.data : `data:${mediaType};base64,${image.data}`;
2853
3948
  }
@@ -2895,6 +3990,9 @@ export function normalizeSdkEvent(event: RunStreamEvent): NormalizedRuntimeEvent
2895
3990
  out.push({ type: "agent.message.delta", payload: { text: data.delta } });
2896
3991
  return out;
2897
3992
  }
3993
+ if (data?.type === "response_done") {
3994
+ return out;
3995
+ }
2898
3996
  }
2899
3997
  if (isOpenAIResponsesRawModelStreamEvent(event)) {
2900
3998
  const raw = (event as any).data?.event;
@@ -2953,13 +4051,21 @@ export function normalizeSdkEvent(event: RunStreamEvent): NormalizedRuntimeEvent
2953
4051
  } else if (item.type === "tool_search_output_item") {
2954
4052
  const raw = item.rawItem ?? {};
2955
4053
  const disclosed = Array.isArray(raw.tools)
2956
- ? raw.tools.map((tool: { name?: unknown }) => (typeof tool?.name === "string" ? tool.name : "")).filter(Boolean)
4054
+ ? raw.tools
4055
+ .map((tool: { name?: unknown }) => (typeof tool?.name === "string" ? tool.name : ""))
4056
+ .filter(Boolean)
2957
4057
  : [];
2958
4058
  out.push({
2959
4059
  type: "agent.toolCall.output",
2960
4060
  payload: {
2961
4061
  id: raw.call_id ?? raw.callId ?? item.id ?? null,
2962
- output: { type: "text", text: disclosed.length > 0 ? `Disclosed tools: ${disclosed.join(", ")}` : "No matching tools found." },
4062
+ output: {
4063
+ type: "text",
4064
+ text:
4065
+ disclosed.length > 0
4066
+ ? `Disclosed tools: ${disclosed.join(", ")}`
4067
+ : "No matching tools found.",
4068
+ },
2963
4069
  },
2964
4070
  });
2965
4071
  } else if (item.type === "message_output_item") {
@@ -2973,15 +4079,21 @@ export function normalizeSdkEvent(event: RunStreamEvent): NormalizedRuntimeEvent
2973
4079
 
2974
4080
  export function modelResponseUsageFromSdkEvent(event: RunStreamEvent): ModelResponseUsage | null {
2975
4081
  const response = modelResponseFromSdkEvent(event);
4082
+ return modelResponseUsageFromResponse(response);
4083
+ }
4084
+
4085
+ /** Normalize usage from either a Responses or Chat Completions result. */
4086
+ export function modelResponseUsageFromResponse(response: unknown): ModelResponseUsage | null {
2976
4087
  const usage = usageFromResponse(response);
2977
4088
  if (!usage) {
2978
4089
  return null;
2979
4090
  }
2980
- const responseId = typeof response?.id === "string"
2981
- ? response.id
2982
- : typeof response?.responseId === "string"
2983
- ? response.responseId
2984
- : undefined;
4091
+ const responseId =
4092
+ typeof (response as { id?: unknown } | null)?.id === "string"
4093
+ ? (response as { id: string }).id
4094
+ : typeof (response as { responseId?: unknown } | null)?.responseId === "string"
4095
+ ? (response as { responseId: string }).responseId
4096
+ : undefined;
2985
4097
  return {
2986
4098
  ...(responseId ? { responseId } : {}),
2987
4099
  usage,
@@ -3004,33 +4116,70 @@ function modelResponseFromSdkEvent(event: RunStreamEvent): any {
3004
4116
  return null;
3005
4117
  }
3006
4118
 
3007
- function usageFromResponse(response: any): ModelResponseUsage["usage"] | null {
3008
- const raw = response?.usage;
4119
+ function usageFromResponse(response: unknown): ModelResponseUsage["usage"] | null {
4120
+ const raw = (response as { usage?: unknown } | null)?.usage;
3009
4121
  if (!raw || typeof raw !== "object") {
3010
4122
  return null;
3011
4123
  }
4124
+ const record = raw as Record<string, unknown>;
3012
4125
  const usage = {
3013
- ...numberProp(raw, "inputTokens", "inputTokens", "input_tokens"),
3014
- ...numberProp(raw, "outputTokens", "outputTokens", "output_tokens"),
3015
- ...numberProp(raw, "totalTokens", "totalTokens", "total_tokens"),
3016
- ...inputTokenDetailsProp(raw),
4126
+ ...numberProp(
4127
+ record,
4128
+ "inputTokens",
4129
+ "inputTokens",
4130
+ "input_tokens",
4131
+ "promptTokens",
4132
+ "prompt_tokens",
4133
+ ),
4134
+ ...numberProp(
4135
+ record,
4136
+ "outputTokens",
4137
+ "outputTokens",
4138
+ "output_tokens",
4139
+ "completionTokens",
4140
+ "completion_tokens",
4141
+ ),
4142
+ ...numberProp(record, "totalTokens", "totalTokens", "total_tokens"),
4143
+ ...inputTokenDetailsProp(record),
4144
+ ...outputTokenDetailsProp(record),
3017
4145
  };
3018
4146
  return Object.keys(usage).length > 0 ? usage : null;
3019
4147
  }
3020
4148
 
3021
- function numberProp(raw: Record<string, unknown>, outputKey: "inputTokens" | "outputTokens" | "totalTokens", camel: string, snake: string): Partial<ModelResponseUsage["usage"]> {
3022
- const value = raw[camel] ?? raw[snake];
4149
+ function numberProp(
4150
+ raw: Record<string, unknown>,
4151
+ outputKey: "inputTokens" | "outputTokens" | "totalTokens",
4152
+ ...keys: string[]
4153
+ ): Partial<ModelResponseUsage["usage"]> {
4154
+ const value = keys.map((key) => raw[key]).find((candidate) => candidate !== undefined);
3023
4155
  return typeof value === "number" && Number.isFinite(value) ? { [outputKey]: value } : {};
3024
4156
  }
3025
4157
 
3026
4158
  function inputTokenDetailsProp(raw: Record<string, unknown>): Partial<ModelResponseUsage["usage"]> {
3027
- const details = raw.inputTokensDetails ?? raw.input_tokens_details;
4159
+ const details =
4160
+ raw.inputTokensDetails ??
4161
+ raw.input_tokens_details ??
4162
+ raw.promptTokensDetails ??
4163
+ raw.prompt_tokens_details;
3028
4164
  if (!details || typeof details !== "object") {
3029
4165
  return {};
3030
4166
  }
3031
4167
  return { inputTokensDetails: details as Record<string, number> | Array<Record<string, number>> };
3032
4168
  }
3033
4169
 
4170
+ function outputTokenDetailsProp(
4171
+ raw: Record<string, unknown>,
4172
+ ): Partial<ModelResponseUsage["usage"]> {
4173
+ const details = raw.outputTokensDetails ?? raw.output_tokens_details;
4174
+ const normalized = details ?? raw.completionTokensDetails ?? raw.completion_tokens_details;
4175
+ if (!normalized || typeof normalized !== "object") {
4176
+ return {};
4177
+ }
4178
+ return {
4179
+ outputTokensDetails: normalized as Record<string, number> | Array<Record<string, number>>,
4180
+ };
4181
+ }
4182
+
3034
4183
  export function serializeApprovals(interruptions: unknown[]): unknown[] {
3035
4184
  return interruptions.map((item: any) => {
3036
4185
  if (typeof item?.toJSON === "function") {
@@ -3052,7 +4201,12 @@ export function buildManifest(
3052
4201
  fileResourceDownloads: SandboxFileDownload[] = [],
3053
4202
  ): Manifest {
3054
4203
  const entries: Record<string, any> = {};
3055
- const downloadsByFileId = new Map(normalizeSandboxFileDownloads(fileResourceDownloads).map((download) => [download.fileId, download]));
4204
+ const downloadsByFileId = new Map(
4205
+ normalizeSandboxFileDownloads(fileResourceDownloads).map((download) => [
4206
+ download.fileId,
4207
+ download,
4208
+ ]),
4209
+ );
3056
4210
  for (const resource of resources) {
3057
4211
  if (resource.kind === "repository") {
3058
4212
  const url = new URL(resource.uri);
@@ -3093,7 +4247,9 @@ export function buildManifest(
3093
4247
 
3094
4248
  function sandboxDownloadDirectory(download: SandboxFileDownload, mountPath: string): any {
3095
4249
  if (download.mountPath !== mountPath) {
3096
- throw new Error(`File download materialization path mismatch for ${download.fileId}: expected ${mountPath}, got ${download.mountPath}`);
4250
+ throw new Error(
4251
+ `File download materialization path mismatch for ${download.fileId}: expected ${mountPath}, got ${download.mountPath}`,
4252
+ );
3097
4253
  }
3098
4254
  assertSafeSandboxFilename(download.filename, download.fileId);
3099
4255
  if (download.content) {
@@ -3114,7 +4270,9 @@ function objectStorageFileMount(settings: Settings, prefix: string): any {
3114
4270
  const nativeBucketMount = CAPABILITY_DESCRIPTORS[settings.sandboxBackend].nativeBucketMount;
3115
4271
  if (settings.objectStorageBackend === "azure-blob") {
3116
4272
  if (nativeBucketMount) {
3117
- throw new Error("Modal sandbox Azure Blob file resources require pre-signed download materialization because the current OpenAI Agents SDK Modal client does not support Azure Blob mount entries.");
4273
+ throw new Error(
4274
+ "Modal sandbox Azure Blob file resources require pre-signed download materialization because the current OpenAI Agents SDK Modal client does not support Azure Blob mount entries.",
4275
+ );
3118
4276
  }
3119
4277
  const config = azureBlobMountConfig(settings);
3120
4278
  return azureBlobMount({
@@ -3128,7 +4286,9 @@ function objectStorageFileMount(settings: Settings, prefix: string): any {
3128
4286
  });
3129
4287
  }
3130
4288
  if (settings.objectStorageBackend === "aws-s3" || settings.objectStorageBackend === "gcs") {
3131
- throw new Error(`${settings.objectStorageBackend} file resources require pre-signed download materialization`);
4289
+ throw new Error(
4290
+ `${settings.objectStorageBackend} file resources require pre-signed download materialization`,
4291
+ );
3132
4292
  }
3133
4293
  const config = s3CompatibleMountConfig(settings);
3134
4294
  return s3Mount({
@@ -3155,7 +4315,11 @@ function s3CompatibleMountConfig(settings: Settings): {
3155
4315
  secretAccessKey: string;
3156
4316
  } {
3157
4317
  const endpointUrl = settings.objectStorageSandboxEndpoint ?? settings.objectStorageEndpoint;
3158
- if (!endpointUrl || !settings.objectStorageAccessKeyId || !settings.objectStorageSecretAccessKey) {
4318
+ if (
4319
+ !endpointUrl ||
4320
+ !settings.objectStorageAccessKeyId ||
4321
+ !settings.objectStorageSecretAccessKey
4322
+ ) {
3159
4323
  throw new Error("File resources require configured S3-compatible object storage");
3160
4324
  }
3161
4325
  return {
@@ -3182,7 +4346,10 @@ function azureBlobMountConfig(settings: Settings): {
3182
4346
  if (!accountName || !accountKey) {
3183
4347
  throw new Error("File resources require Azure Blob account name and account key");
3184
4348
  }
3185
- const endpointUrl = azureBlobManifestEndpoint(settings.objectStorageAzureEndpoint ?? parsed.BlobEndpoint, accountName);
4349
+ const endpointUrl = azureBlobManifestEndpoint(
4350
+ settings.objectStorageAzureEndpoint ?? parsed.BlobEndpoint,
4351
+ accountName,
4352
+ );
3186
4353
  return {
3187
4354
  container: settings.objectStorageBucket,
3188
4355
  accountName,
@@ -3191,7 +4358,10 @@ function azureBlobMountConfig(settings: Settings): {
3191
4358
  };
3192
4359
  }
3193
4360
 
3194
- function azureBlobManifestEndpoint(endpoint: string | undefined, accountName: string): string | undefined {
4361
+ function azureBlobManifestEndpoint(
4362
+ endpoint: string | undefined,
4363
+ accountName: string,
4364
+ ): string | undefined {
3195
4365
  if (!endpoint) {
3196
4366
  return undefined;
3197
4367
  }
@@ -3201,13 +4371,16 @@ function azureBlobManifestEndpoint(endpoint: string | undefined, accountName: st
3201
4371
  }
3202
4372
 
3203
4373
  function parseAzureConnectionString(value: string): Record<string, string> {
3204
- return Object.fromEntries(value.split(";")
3205
- .map((part) => part.trim())
3206
- .filter(Boolean)
3207
- .map((part) => {
3208
- const index = part.indexOf("=");
3209
- return index === -1 ? [part, ""] : [part.slice(0, index), part.slice(index + 1)];
3210
- }));
4374
+ return Object.fromEntries(
4375
+ value
4376
+ .split(";")
4377
+ .map((part) => part.trim())
4378
+ .filter(Boolean)
4379
+ .map((part) => {
4380
+ const index = part.indexOf("=");
4381
+ return index === -1 ? [part, ""] : [part.slice(0, index), part.slice(index + 1)];
4382
+ }),
4383
+ );
3211
4384
  }
3212
4385
 
3213
4386
  function normalizeManifestPath(path: string): string {
@@ -3223,7 +4396,9 @@ function normalizeSandboxFileDownloads(downloads: SandboxFileDownload[]): Sandbo
3223
4396
  const mountPath = normalizeManifestPath(download.mountPath);
3224
4397
  assertSafeSandboxFilename(download.filename, download.fileId);
3225
4398
  if (!download.content && !download.url?.trim()) {
3226
- throw new Error(`File download materialization requires content or a URL for ${download.fileId}`);
4399
+ throw new Error(
4400
+ `File download materialization requires content or a URL for ${download.fileId}`,
4401
+ );
3227
4402
  }
3228
4403
  return {
3229
4404
  ...download,
@@ -3233,7 +4408,14 @@ function normalizeSandboxFileDownloads(downloads: SandboxFileDownload[]): Sandbo
3233
4408
  }
3234
4409
 
3235
4410
  function assertSafeSandboxFilename(filename: string, fileId: string): void {
3236
- if (!filename || filename.includes("/") || filename.includes("\\") || filename === "." || filename === ".." || filename.includes("..")) {
4411
+ if (
4412
+ !filename ||
4413
+ filename.includes("/") ||
4414
+ filename.includes("\\") ||
4415
+ filename === "." ||
4416
+ filename === ".." ||
4417
+ filename.includes("..")
4418
+ ) {
3237
4419
  throw new Error(`Invalid sandbox file name for ${fileId}: ${filename}`);
3238
4420
  }
3239
4421
  }
@@ -3249,11 +4431,11 @@ function sandboxFileDownloadCommand(download: SandboxFileDownload, targetPath: s
3249
4431
  const targetDir = posixPath.dirname(targetPath);
3250
4432
  const tmpPath = `${targetPath}.opengeni-download-$$`;
3251
4433
  return [
3252
- "set -euo pipefail",
4434
+ "set -eu",
3253
4435
  `mkdir -p -- ${shellQuote(targetDir)}`,
3254
4436
  `if [ ! -f ${shellQuote(targetPath)} ]; then`,
3255
4437
  ` tmp=${shellQuote(tmpPath)}`,
3256
- " cleanup() { rm -f -- \"$tmp\"; }",
4438
+ ' cleanup() { rm -f -- "$tmp"; }',
3257
4439
  " trap cleanup EXIT",
3258
4440
  ` curl --fail --location --silent --show-error --retry 3 --retry-delay 1 --output "$tmp" ${shellQuote(download.url)}`,
3259
4441
  ` mv -- "$tmp" ${shellQuote(targetPath)}`,
@@ -3267,47 +4449,36 @@ function shellQuote(value: string): string {
3267
4449
  return `'${value.replace(/'/g, "'\\''")}'`;
3268
4450
  }
3269
4451
 
3270
- async function restoredSandboxSessionState(state: RunState<any, any>, client: unknown): Promise<SandboxSessionState | undefined> {
3271
- if (!client) {
3272
- return undefined;
3273
- }
3274
- const sandboxState = (state as any)._sandbox;
3275
- const entry = sandboxState?.sessionsByAgent?.[sandboxState.currentAgentKey]
3276
- ?? (sandboxState?.currentAgentKey && sandboxState?.sessionState
3277
- ? {
3278
- backendId: sandboxState.backendId,
3279
- currentAgentKey: sandboxState.currentAgentKey,
3280
- currentAgentName: sandboxState.currentAgentName,
3281
- sessionState: sandboxState.sessionState,
3282
- }
3283
- : undefined);
3284
- if (!entry) {
3285
- return undefined;
3286
- }
3287
- if ((client as SandboxClient).backendId !== entry.backendId) {
3288
- throw new Error("RunState sandbox backend does not match the configured sandbox client");
3289
- }
3290
- return await deserializeSandboxSessionStateEnvelope(client as SandboxClient, entry.sessionState);
3291
- }
3292
-
3293
- // sandboxStateEntryFromRunState + restoredSandboxSessionStateFromEntry +
3294
- // deserializeSandboxSessionStateEnvelope moved to the agent-loop-free leaf
3295
- // ./sandbox; re-exported via `export * from "./sandbox"`. The private
3296
- // restoredSandboxSessionState above (which takes an agent-loop RunState) calls
3297
- // the moved deserializeSandboxSessionStateEnvelope, imported from ./sandbox.
3298
-
3299
4452
  export type SandboxLifecycleHookPhase = "beforeAgentStart";
3300
4453
 
3301
4454
  export type SandboxLifecycleHookContext = {
3302
4455
  environment: Record<string, string>;
3303
4456
  onRuntimeEvent?: (event: NormalizedRuntimeEvent) => Promise<void> | void;
3304
4457
  runAs?: string;
3305
- // TOKEN-BROKER (B1): the run-scoped GitHub token to seed into the box's token
3306
- // FILE before the repository clone runs. Threaded OFF-MANIFEST — it rides ONLY
3307
- // the clone exec's per-call env (OPENGENI_GIT_TOKEN_SEED), NEVER the box/agent
3308
- // manifest env (validateNoEnvironmentDelta must never see a rotating value).
4458
+ // TOKEN-BROKER (B1): back-compat GitHub alias for gitTokenSeeds.github.
3309
4459
  gitTokenSeed?: string;
4460
+ // Provider tokens to seed into box token FILES before repository clone/setup
4461
+ // runs. Threaded OFF-MANIFEST — they ride ONLY the setup exec command prefix,
4462
+ // NEVER the box/agent manifest env (validateNoEnvironmentDelta must never see
4463
+ // rotating values).
4464
+ gitTokenSeeds?: GitTokenSeeds;
3310
4465
  toolspaceTokenSeed?: string;
4466
+ // M3: the rig setup descriptor for the rig-setup hook (the script + marker
4467
+ // version id + the rig's own timeout). Present only on a rig-bound turn.
4468
+ rigSetup?: RigSetupDescriptor;
4469
+ };
4470
+
4471
+ // M3: everything the rig-setup hook needs to run the frozen rig version's setup
4472
+ // script exactly once per box. `versionId` keys the idempotence marker
4473
+ // (/var/opengeni/rig-setup-<versionId>.done); `timeoutMs` is the rig-specific
4474
+ // budget (settings.rigSetupTimeoutMs), NOT the 120s lifecycle default; `rigName`
4475
+ // is for human-readable events/errors only.
4476
+ export type RigSetupDescriptor = {
4477
+ rigId: string;
4478
+ versionId: string;
4479
+ rigName: string;
4480
+ script: string;
4481
+ timeoutMs: number;
3311
4482
  };
3312
4483
 
3313
4484
  export type SandboxLifecycleHook = {
@@ -3340,7 +4511,9 @@ function applicableBeforeAgentStartHooks(
3340
4511
  hooks: SandboxLifecycleHook[],
3341
4512
  context: SandboxLifecycleHookContext,
3342
4513
  ): SandboxLifecycleHook[] {
3343
- return hooks.filter((hook) => hook.phase === "beforeAgentStart" && (hook.shouldRun?.(context) ?? true));
4514
+ return hooks.filter(
4515
+ (hook) => hook.phase === "beforeAgentStart" && (hook.shouldRun?.(context) ?? true),
4516
+ );
3344
4517
  }
3345
4518
 
3346
4519
  /**
@@ -3385,14 +4558,48 @@ export function withSandboxLifecycleHooks(
3385
4558
  };
3386
4559
  const wrapped: SandboxClient = {
3387
4560
  backendId: client.backendId,
3388
- ...(client.supportsDefaultOptions !== undefined ? { supportsDefaultOptions: client.supportsDefaultOptions } : {}),
3389
- ...(client.create ? { create: async (...args: any[]) => await wrapSession(await (client.create as any)(...args)) } : {}),
3390
- ...(client.resume ? { resume: async (state: SandboxSessionState) => await wrapSession(await client.resume!(state)) } : {}),
3391
- ...(client.delete ? { delete: async (state: SandboxSessionState) => await client.delete!(state) } : {}),
3392
- ...(client.serializeSessionState ? { serializeSessionState: async (state: SandboxSessionState, options) => await client.serializeSessionState!(state, options) } : {}),
3393
- ...(client.canPersistOwnedSessionState ? { canPersistOwnedSessionState: async (state: SandboxSessionState) => await client.canPersistOwnedSessionState!(state) } : {}),
3394
- ...(client.canReusePreservedOwnedSession ? { canReusePreservedOwnedSession: async (state: SandboxSessionState) => await client.canReusePreservedOwnedSession!(state) } : {}),
3395
- ...(client.deserializeSessionState ? { deserializeSessionState: async (state: Record<string, unknown>) => await client.deserializeSessionState!(state) } : {}),
4561
+ ...(client.supportsDefaultOptions !== undefined
4562
+ ? { supportsDefaultOptions: client.supportsDefaultOptions }
4563
+ : {}),
4564
+ ...(client.create
4565
+ ? {
4566
+ create: async (...args: any[]) =>
4567
+ await wrapSession(await (client.create as any)(...args)),
4568
+ }
4569
+ : {}),
4570
+ ...(client.resume
4571
+ ? {
4572
+ resume: async (state: SandboxSessionState) =>
4573
+ await wrapSession(await client.resume!(state)),
4574
+ }
4575
+ : {}),
4576
+ ...(client.delete
4577
+ ? { delete: async (state: SandboxSessionState) => await client.delete!(state) }
4578
+ : {}),
4579
+ ...(client.serializeSessionState
4580
+ ? {
4581
+ serializeSessionState: async (state: SandboxSessionState, options) =>
4582
+ await client.serializeSessionState!(state, options),
4583
+ }
4584
+ : {}),
4585
+ ...(client.canPersistOwnedSessionState
4586
+ ? {
4587
+ canPersistOwnedSessionState: async (state: SandboxSessionState) =>
4588
+ await client.canPersistOwnedSessionState!(state),
4589
+ }
4590
+ : {}),
4591
+ ...(client.canReusePreservedOwnedSession
4592
+ ? {
4593
+ canReusePreservedOwnedSession: async (state: SandboxSessionState) =>
4594
+ await client.canReusePreservedOwnedSession!(state),
4595
+ }
4596
+ : {}),
4597
+ ...(client.deserializeSessionState
4598
+ ? {
4599
+ deserializeSessionState: async (state: Record<string, unknown>) =>
4600
+ await client.deserializeSessionState!(state),
4601
+ }
4602
+ : {}),
3396
4603
  };
3397
4604
  return wrapped;
3398
4605
  }
@@ -3404,8 +4611,8 @@ function sandboxRepositoryCloneHooksForAgent(agent: Agent<any, any>): SandboxLif
3404
4611
  // TOKEN-BROKER (B1): the per-turn git token seed stashed for this agent (undefined
3405
4612
  // when no repo is attached / on the selfhosted path). Read into the clone hook
3406
4613
  // context at runStream so the token is seeded off-manifest.
3407
- function gitTokenSeedForAgent(agent: Agent<any, any>): string | undefined {
3408
- return agentGitTokenSeed.get(agent);
4614
+ function gitTokenSeedsForAgent(agent: Agent<any, any>): GitTokenSeeds | undefined {
4615
+ return agentGitTokenSeeds.get(agent);
3409
4616
  }
3410
4617
 
3411
4618
  function toolspaceTokenSeedForAgent(agent: Agent<any, any>): string | undefined {
@@ -3414,11 +4621,70 @@ function toolspaceTokenSeedForAgent(agent: Agent<any, any>): string | undefined
3414
4621
 
3415
4622
  function sandboxToolspaceTokenHooksForAgent(agent: Agent<any, any>): SandboxLifecycleHook[] {
3416
4623
  return toolspaceTokenSeedForAgent(agent)
3417
- ? [{
3418
- id: "toolspace-token",
3419
- phase: "beforeAgentStart",
3420
- run: runToolspaceTokenSeedHook,
3421
- }]
4624
+ ? [
4625
+ {
4626
+ id: "toolspace-token",
4627
+ phase: "beforeAgentStart",
4628
+ run: runToolspaceTokenSeedHook,
4629
+ },
4630
+ ]
4631
+ : [];
4632
+ }
4633
+
4634
+ // M3: the rig-setup hook for this agent (present only on a rig-bound turn whose
4635
+ // frozen version carries a non-empty setup script). It runs FIRST among the
4636
+ // owned beforeAgentStart hooks so any tooling it installs is available to the
4637
+ // repository-clone / credential hooks that follow. The descriptor is threaded
4638
+ // through the hook context (rigSetupHookContext) so runRigSetupHook reads it.
4639
+ function sandboxRigSetupHooksForAgent(agent: Agent<any, any>): SandboxLifecycleHook[] {
4640
+ return agentRigSetup.get(agent)
4641
+ ? [
4642
+ {
4643
+ id: "rig-setup",
4644
+ phase: "beforeAgentStart",
4645
+ run: runRigSetupHook,
4646
+ },
4647
+ ]
4648
+ : [];
4649
+ }
4650
+
4651
+ function rigSetupDescriptorForAgent(agent: Agent<any, any>): RigSetupDescriptor | undefined {
4652
+ return agentRigSetup.get(agent);
4653
+ }
4654
+
4655
+ // M3: the rig version's credential hooks (already resolved + validated at build
4656
+ // time). Unioned with the deployment preparation-profile hooks by the caller.
4657
+ function rigCredentialHooksForAgent(agent: Agent<any, any>): SandboxLifecycleHook[] {
4658
+ return agentRigCredentialHooks.get(agent) ?? [];
4659
+ }
4660
+
4661
+ // M3: union the deployment preparation-profile hooks with the rig version's
4662
+ // credential hooks, deduped by id (a hook named by BOTH the deployment profile
4663
+ // and the rig runs once). Deployment hooks keep their leading position.
4664
+ function unionCredentialHooks(
4665
+ deploymentHooks: SandboxLifecycleHook[],
4666
+ rigHooks: SandboxLifecycleHook[],
4667
+ ): SandboxLifecycleHook[] {
4668
+ if (rigHooks.length === 0) {
4669
+ return deploymentHooks;
4670
+ }
4671
+ const seen = new Set(deploymentHooks.map((hook) => hook.id));
4672
+ return [...deploymentHooks, ...rigHooks.filter((hook) => !seen.has(hook.id))];
4673
+ }
4674
+
4675
+ function gitCredentialSessionRegistrationHooks(
4676
+ callback: RunAgentStreamOptions["onGitCredentialSessionReady"],
4677
+ ): SandboxLifecycleHook[] {
4678
+ return callback
4679
+ ? [
4680
+ {
4681
+ id: "git-credential-renewal-registration",
4682
+ phase: "beforeAgentStart",
4683
+ run: async (session) => {
4684
+ await callback(session);
4685
+ },
4686
+ },
4687
+ ]
3422
4688
  : [];
3423
4689
  }
3424
4690
 
@@ -3427,19 +4693,23 @@ function sandboxRepositoryCloneHooks(
3427
4693
  resources: ResourceRef[],
3428
4694
  activeSandboxBackend: Settings["sandboxBackend"] = settings.sandboxBackend,
3429
4695
  ): SandboxLifecycleHook[] {
3430
- const repositories = resources.filter((resource): resource is Extract<ResourceRef, { kind: "repository" }> => (
3431
- resource.kind === "repository" && repositoryUsesSandboxClone(settings, resource, activeSandboxBackend)
3432
- ));
4696
+ const repositories = resources.filter(
4697
+ (resource): resource is Extract<ResourceRef, { kind: "repository" }> =>
4698
+ resource.kind === "repository" &&
4699
+ repositoryUsesSandboxClone(settings, resource, activeSandboxBackend),
4700
+ );
3433
4701
  if (repositories.length === 0) {
3434
4702
  return [];
3435
4703
  }
3436
- return [{
3437
- id: "repository-clone",
3438
- phase: "beforeAgentStart",
3439
- run: async (session, context) => {
3440
- await runRepositoryCloneHook(session, repositories, context);
4704
+ return [
4705
+ {
4706
+ id: "repository-clone",
4707
+ phase: "beforeAgentStart",
4708
+ run: async (session, context) => {
4709
+ await runRepositoryCloneHook(session, repositories, context);
4710
+ },
3441
4711
  },
3442
- }];
4712
+ ];
3443
4713
  }
3444
4714
 
3445
4715
  /**
@@ -3468,60 +4738,273 @@ export function repositoryUsesSandboxClone(
3468
4738
  if (activeSandboxBackend === "selfhosted") {
3469
4739
  return false;
3470
4740
  }
3471
- return settings.sandboxBackend === "modal" || Boolean(resource.githubInstallationId && resource.githubRepositoryId);
4741
+ return (
4742
+ settings.sandboxBackend === "modal" ||
4743
+ Boolean(resource.githubInstallationId && resource.githubRepositoryId) ||
4744
+ Boolean(resource.provider)
4745
+ );
3472
4746
  }
3473
4747
 
3474
- export function repositoryCloneCommand(resources: Extract<ResourceRef, { kind: "repository" }>[]): string {
3475
- const commands = [
3476
- "set -eu",
3477
- "export HOME=\"${HOME:-/workspace}\"",
3478
- "export GIT_TERMINAL_PROMPT=\"${GIT_TERMINAL_PROMPT:-0}\"",
3479
- // TOKEN-BROKER (B1/B2): seed the run-scoped GitHub token into the STABLE token FILE
3480
- // AND provision the git-askpass helper into the box AT SETUP (runtime) BEFORE any
3481
- // clone runs, so GIT_ASKPASS points at a per-box, user-writable script that reads
3482
- // that file for the fetch below. Provisioning the askpass here (rather than relying
3483
- // on a baked image script at /usr/local/bin/opengeni-git-askpass) removes the
3484
- // image-rebuild rollout gate: the askpass is correct on ANY box image, including
3485
- // pre-existing warm boxes on their next turn's clone hook, and no product image has
3486
- // to carry it. The seed rides the per-exec env (OPENGENI_GIT_TOKEN_SEED) — NEVER the
3487
- // box/agent manifest (validateNoEnvironmentDelta must not see a rotating value), so
3488
- // this whole block is a no-op when the seed is absent (e.g. the selfhosted path,
3489
- // which uses its own git creds). The token file lives at $OPENGENI_GIT_TOKEN_FILE
3490
- // (stable, from the shared base) with a $HOME/.opengeni/git-token fallback.
3491
- // $GIT_ASKPASS is on the box manifest env (set by
3492
- // sandboxEnvironmentForRun to $HOME/.opengeni/askpass), so it is available to this
3493
- // exec; the askpass script we write is byte-identical to docker/opengeni-git-askpass
3494
- // and is written via a QUOTED heredoc (<<'ASKPASS_EOF') so NOTHING inside it expands
3495
- // ($1, $HOME, ${OPENGENI_GIT_TOKEN_FILE:-...}, and the literal \n in printf all land
3496
- // verbatim), then chmod 0755 so git can exec it.
3497
- //
3498
- // ATOMIC REWRITE: this block now re-runs at the start of EVERY turn on a warm box
3499
- // that other turn holders may be actively using — an in-flight `git fetch` from a
3500
- // concurrent turn can invoke the askpass (which cats the token file) at any moment.
3501
- // Both files are therefore written to a pid-suffixed temp under umask 077 and
3502
- // renamed into place: rename is atomic, concurrent readers keep the old inode, and
3503
- // the token is never observable world-readable (no post-hoc chmod window).
3504
- "if [ -n \"${OPENGENI_GIT_TOKEN_SEED:-}\" ]; then",
3505
- " seed_umask=\"$(umask)\"",
3506
- " umask 077",
3507
- " git_token_file=\"${OPENGENI_GIT_TOKEN_FILE:-$HOME/.opengeni/git-token}\"",
3508
- " mkdir -p \"$(dirname \"$git_token_file\")\"",
3509
- " printf '%s' \"$OPENGENI_GIT_TOKEN_SEED\" > \"$git_token_file.tmp.$$\"",
3510
- " mv -f \"$git_token_file.tmp.$$\" \"$git_token_file\"",
3511
- " git_askpass=\"${GIT_ASKPASS:-$HOME/.opengeni/askpass}\"",
3512
- " mkdir -p \"$(dirname \"$git_askpass\")\"",
3513
- " cat > \"$git_askpass.tmp.$$\" <<'ASKPASS_EOF'",
4748
+ const GIT_CREDENTIAL_PROVIDERS = [
4749
+ "github",
4750
+ "gitlab",
4751
+ "azure_devops",
4752
+ ] as const satisfies readonly GitCredentialProvider[];
4753
+
4754
+ function gitProviderSeedEnv(provider: GitCredentialProvider): string {
4755
+ return `OPENGENI_GIT_${provider.toUpperCase()}_TOKEN_SEED`;
4756
+ }
4757
+
4758
+ function gitTokenSeedExportPrefix(seeds: GitTokenSeeds): string {
4759
+ const lines: string[] = [];
4760
+ for (const provider of GIT_CREDENTIAL_PROVIDERS) {
4761
+ const token = seeds[provider];
4762
+ if (!token) {
4763
+ continue;
4764
+ }
4765
+ lines.push(`export ${gitProviderSeedEnv(provider)}=${shellQuote(token)}`);
4766
+ if (provider === "github") {
4767
+ lines.push(`export OPENGENI_GIT_TOKEN_SEED=${shellQuote(token)}`);
4768
+ }
4769
+ }
4770
+ return lines.join("\n");
4771
+ }
4772
+
4773
+ function repositoryCredentialProvider(
4774
+ resource: Extract<ResourceRef, { kind: "repository" }>,
4775
+ ): GitCredentialProvider {
4776
+ return resource.provider ?? "github";
4777
+ }
4778
+
4779
+ function gitAskpassHostProviderCaseLines(
4780
+ resources: Extract<ResourceRef, { kind: "repository" }>[],
4781
+ ): string[] {
4782
+ const hosts = new Map<string, GitCredentialProvider>();
4783
+ for (const resource of resources) {
4784
+ try {
4785
+ const hostname = new URL(resource.uri).hostname.toLowerCase();
4786
+ if (!hostname) {
4787
+ continue;
4788
+ }
4789
+ hosts.set(hostname, repositoryCredentialProvider(resource));
4790
+ } catch {
4791
+ // Resource validation catches invalid URIs before normal runtime use. Keep
4792
+ // helper generation tolerant so tests for clone failure can still build.
4793
+ }
4794
+ }
4795
+ return [...hosts.entries()]
4796
+ .sort(([a], [b]) => a.localeCompare(b))
4797
+ .map(
4798
+ ([hostname, provider]) =>
4799
+ ` ${shellQuote(hostname)}) printf '%s\\n' ${provider}; return 0 ;;`,
4800
+ );
4801
+ }
4802
+
4803
+ function gitCredentialTokenWriterCommandLines(): string[] {
4804
+ return [
4805
+ // TOKEN-BROKER (B1/B2): seed run-scoped provider tokens into stable files and
4806
+ // atomically replace each provider file. Token VALUES are supplied only by
4807
+ // the per-exec command prefix
4808
+ // (OPENGENI_GIT_*_TOKEN_SEED), never by the box/agent manifest. Helper paths
4809
+ // are stable manifest values from @opengeni/config.
4810
+ "git_provider_token_file() {",
4811
+ ' provider="$1"',
4812
+ ' case "$provider" in',
4813
+ " github) printf '%s\\n' \"${OPENGENI_GIT_TOKEN_FILE:-$HOME/.opengeni/git-token}\" ;;",
4814
+ " *) printf '%s\\n' \"${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/$provider-token\" ;;",
4815
+ " esac",
4816
+ "}",
4817
+ "write_git_provider_token() {",
4818
+ ' provider="$1"',
4819
+ ' token="$2"',
4820
+ ' [ -n "$token" ] || return 0',
4821
+ ' token_file="$(git_provider_token_file "$provider")"',
4822
+ ' mkdir -p "$(dirname "$token_file")"',
4823
+ ' printf \'%s\' "$token" > "$token_file.tmp.$$"',
4824
+ ' mv -f "$token_file.tmp.$$" "$token_file"',
4825
+ ' if [ "$provider" = github ]; then',
4826
+ ' credential_dir="${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}"',
4827
+ ' mkdir -p "$credential_dir"',
4828
+ ' printf \'%s\' "$token" > "$credential_dir/github-token.tmp.$$"',
4829
+ ' mv -f "$credential_dir/github-token.tmp.$$" "$credential_dir/github-token"',
4830
+ " fi",
4831
+ "}",
4832
+ 'seed_umask="$(umask)"',
4833
+ "umask 077",
4834
+ 'write_git_provider_token github "${OPENGENI_GIT_GITHUB_TOKEN_SEED:-${OPENGENI_GIT_TOKEN_SEED:-}}"',
4835
+ 'write_git_provider_token gitlab "${OPENGENI_GIT_GITLAB_TOKEN_SEED:-}"',
4836
+ 'write_git_provider_token azure_devops "${OPENGENI_GIT_AZURE_DEVOPS_TOKEN_SEED:-}"',
4837
+ 'umask "$seed_umask"',
4838
+ ];
4839
+ }
4840
+
4841
+ function gitCredentialHelperCommandLines(
4842
+ resources: Extract<ResourceRef, { kind: "repository" }>[] = [],
4843
+ ): string[] {
4844
+ const hostProviderCases = gitAskpassHostProviderCaseLines(resources);
4845
+ return [
4846
+ ...gitCredentialTokenWriterCommandLines(),
4847
+ // Provision git/provider-CLI helpers at SETUP (runtime) before any clone
4848
+ // runs. Renewal updates only token files and deliberately leaves these
4849
+ // repository-specific host mappings intact.
4850
+ 'git_askpass="${GIT_ASKPASS:-$HOME/.opengeni/askpass}"',
4851
+ 'mkdir -p "$(dirname "$git_askpass")"',
4852
+ "cat > \"$git_askpass.tmp.$$\" <<'ASKPASS_EOF'",
3514
4853
  "#!/usr/bin/env sh",
3515
- "case \"$1\" in",
3516
- " *Username*) printf '%s\\n' \"x-access-token\" ;;",
3517
- " *Password*) cat \"${OPENGENI_GIT_TOKEN_FILE:-$HOME/.opengeni/git-token}\" 2>/dev/null || printf '\\n' ;;",
4854
+ "prompt_host() {",
4855
+ " prompt_lower=\"$(printf '%s\\n' \"$1\" | tr '[:upper:]' '[:lower:]')\"",
4856
+ ' case "$prompt_lower" in',
4857
+ " *://*) ;;",
4858
+ " *) printf '\\n'; return 0 ;;",
4859
+ " esac",
4860
+ ' rest="${prompt_lower#*://}"',
4861
+ ' rest="${rest#*@}"',
4862
+ ' host="${rest%%/*}"',
4863
+ ' host="${host%%:*}"',
4864
+ ' host="$(printf \'%s\\n\' "$host" | tr -d "\'")"',
4865
+ " printf '%s\\n' \"$host\"",
4866
+ "}",
4867
+ "provider_for_prompt() {",
4868
+ ' host="$(prompt_host "$1")"',
4869
+ ' case "$host" in',
4870
+ ...(hostProviderCases.length > 0 ? hostProviderCases : [' "") : ;;']),
4871
+ " esac",
4872
+ " case \"$(printf '%s\\n' \"$1\" | tr '[:upper:]' '[:lower:]')\" in",
4873
+ " *github.com*|*githubusercontent.com*) printf '%s\\n' github ;;",
4874
+ " *gitlab*) printf '%s\\n' gitlab ;;",
4875
+ " *dev.azure.com*|*.visualstudio.com*) printf '%s\\n' azure_devops ;;",
4876
+ " *) printf '%s\\n' github ;;",
4877
+ " esac",
4878
+ "}",
4879
+ "token_file_for_provider() {",
4880
+ ' case "$1" in',
4881
+ " github) printf '%s\\n' \"${OPENGENI_GIT_TOKEN_FILE:-$HOME/.opengeni/git-token}\" ;;",
4882
+ " *) printf '%s\\n' \"${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/$1-token\" ;;",
4883
+ " esac",
4884
+ "}",
4885
+ "username_for_provider() {",
4886
+ ' case "$1" in',
4887
+ " github) printf '%s\\n' \"x-access-token\" ;;",
4888
+ " gitlab) printf '%s\\n' \"oauth2\" ;;",
4889
+ " azure_devops) printf '%s\\n' \"opengeni\" ;;",
4890
+ " *) printf '\\n' ;;",
4891
+ " esac",
4892
+ "}",
4893
+ 'provider="$(provider_for_prompt "$1")"',
4894
+ 'case "$1" in',
4895
+ ' *Username*) username_for_provider "$provider" ;;',
4896
+ ' *Password*) cat "$(token_file_for_provider "$provider")" 2>/dev/null || printf \'\\n\' ;;',
3518
4897
  " *) printf '\\n' ;;",
3519
4898
  "esac",
3520
4899
  "ASKPASS_EOF",
3521
- " chmod 0755 \"$git_askpass.tmp.$$\"",
3522
- " mv -f \"$git_askpass.tmp.$$\" \"$git_askpass\"",
3523
- " umask \"$seed_umask\"",
4900
+ 'chmod 0755 "$git_askpass.tmp.$$"',
4901
+ 'mv -f "$git_askpass.tmp.$$" "$git_askpass"',
4902
+ 'wrapper_dir="${OPENGENI_GIT_CLI_WRAPPER_DIR:-$HOME/.opengeni/bin}"',
4903
+ 'mkdir -p "$wrapper_dir"',
4904
+ "for opengeni_git_cli_tool in gh glab az; do",
4905
+ ' wrapper="$wrapper_dir/$opengeni_git_cli_tool"',
4906
+ " cat > \"$wrapper.tmp.$$\" <<'CLI_WRAPPER_EOF'",
4907
+ "#!/usr/bin/env sh",
4908
+ "set -eu",
4909
+ 'tool="${0##*/}"',
4910
+ 'case "$tool" in',
4911
+ " gh) provider=github; token_env=GH_TOKEN ;;",
4912
+ " glab) provider=gitlab; token_env=GITLAB_TOKEN ;;",
4913
+ " az) provider=azure_devops; token_env=AZURE_DEVOPS_EXT_PAT ;;",
4914
+ " *) provider=; token_env= ;;",
4915
+ "esac",
4916
+ 'if [ -n "$provider" ]; then',
4917
+ ' case "$provider" in',
4918
+ ' github) token_file="${OPENGENI_GIT_TOKEN_FILE:-$HOME/.opengeni/git-token}" ;;',
4919
+ ' *) token_file="${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/$provider-token" ;;',
4920
+ " esac",
4921
+ ' if [ -f "$token_file" ]; then',
4922
+ ' token="$(cat "$token_file" 2>/dev/null || true)"',
4923
+ ' if [ -n "$token" ]; then',
4924
+ ' case "$token_env" in',
4925
+ ' GH_TOKEN) export GH_TOKEN="$token" ;;',
4926
+ ' GITLAB_TOKEN) export GITLAB_TOKEN="$token" ;;',
4927
+ ' AZURE_DEVOPS_EXT_PAT) export AZURE_DEVOPS_EXT_PAT="$token" ;;',
4928
+ " esac",
4929
+ " fi",
4930
+ " fi",
3524
4931
  "fi",
4932
+ 'self_real="$(readlink -f "$0" 2>/dev/null || printf \'%s\\n\' "$0")"',
4933
+ 'old_ifs="$IFS"',
4934
+ "IFS=:",
4935
+ "for dir in $PATH; do",
4936
+ ' [ -n "$dir" ] || dir=.',
4937
+ ' candidate="$dir/$tool"',
4938
+ ' [ -x "$candidate" ] || continue',
4939
+ ' candidate_real="$(readlink -f "$candidate" 2>/dev/null || printf \'%s\\n\' "$candidate")"',
4940
+ ' [ "$candidate_real" = "$self_real" ] && continue',
4941
+ ' IFS="$old_ifs"',
4942
+ ' exec "$candidate" "$@"',
4943
+ "done",
4944
+ 'IFS="$old_ifs"',
4945
+ "printf '%s\\n' \"$tool: real command not found on PATH\" >&2",
4946
+ "exit 127",
4947
+ "CLI_WRAPPER_EOF",
4948
+ ' chmod 0755 "$wrapper.tmp.$$"',
4949
+ ' mv -f "$wrapper.tmp.$$" "$wrapper"',
4950
+ "done",
4951
+ ];
4952
+ }
4953
+
4954
+ /**
4955
+ * Build the off-manifest command used to atomically replace provider token files.
4956
+ *
4957
+ * Token values exist only in this one sandbox exec command. The stable askpass
4958
+ * and provider-CLI wrappers always read the files at invocation time, so an
4959
+ * in-flight multi-day turn observes the replacement without changing its
4960
+ * manifest environment or rebuilding the sandbox.
4961
+ */
4962
+ export function gitProviderTokenRefreshCommand(seeds: GitTokenSeeds): string {
4963
+ const seedPrefix = gitTokenSeedExportPrefix(seeds);
4964
+ if (!seedPrefix) {
4965
+ return "";
4966
+ }
4967
+ return [
4968
+ seedPrefix,
4969
+ "set -eu",
4970
+ 'export HOME="${HOME:-/workspace}"',
4971
+ ...gitCredentialTokenWriterCommandLines(),
4972
+ ].join("\n");
4973
+ }
4974
+
4975
+ export async function refreshGitProviderTokenFiles(
4976
+ session: GitCredentialTokenWriterSession,
4977
+ seeds: GitTokenSeeds,
4978
+ options: { runAs?: string } = {},
4979
+ ): Promise<void> {
4980
+ const command = gitProviderTokenRefreshCommand(seeds);
4981
+ if (!command) {
4982
+ return;
4983
+ }
4984
+ const args = {
4985
+ cmd: command,
4986
+ workdir: "/workspace",
4987
+ ...(options.runAs ? { runAs: options.runAs } : {}),
4988
+ yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
4989
+ maxOutputTokens: 4_000,
4990
+ };
4991
+ if (session.exec) {
4992
+ assertSandboxCommandSucceeded(await session.exec(args), "Git credential refresh");
4993
+ } else if (session.execCommand) {
4994
+ assertSandboxCommandSucceeded(await session.execCommand(args), "Git credential refresh");
4995
+ } else {
4996
+ throw new Error("Sandbox session does not support command execution");
4997
+ }
4998
+ }
4999
+
5000
+ export function repositoryCloneCommand(
5001
+ resources: Extract<ResourceRef, { kind: "repository" }>[],
5002
+ ): string {
5003
+ const commands = [
5004
+ "set -eu",
5005
+ 'export HOME="${HOME:-/workspace}"',
5006
+ 'export GIT_TERMINAL_PROMPT="${GIT_TERMINAL_PROMPT:-0}"',
5007
+ ...gitCredentialHelperCommandLines(resources),
3525
5008
  "ensure_git() {",
3526
5009
  " if command -v git >/dev/null 2>&1; then",
3527
5010
  " return 0",
@@ -3533,16 +5016,16 @@ export function repositoryCloneCommand(resources: Extract<ResourceRef, { kind: "
3533
5016
  " rm -rf /var/lib/apt/lists/*",
3534
5017
  " command -v git >/dev/null 2>&1 && return 0",
3535
5018
  " fi",
3536
- " echo \"git is not installed in the sandbox and could not be bootstrapped\" >&2",
5019
+ ' echo "git is not installed in the sandbox and could not be bootstrapped" >&2',
3537
5020
  " exit 127",
3538
5021
  "}",
3539
5022
  "ensure_git",
3540
5023
  "clone_repository() {",
3541
- " target=\"$1\"",
3542
- " uri=\"$2\"",
3543
- " ref=\"$3\"",
3544
- " subpath=\"$4\"",
3545
- " if [ -e \"$target\" ] && { [ -f \"$target\" ] || [ -n \"$(find \"$target\" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)\" ]; }; then",
5024
+ ' target="$1"',
5025
+ ' uri="$2"',
5026
+ ' ref="$3"',
5027
+ ' subpath="$4"',
5028
+ ' if [ -e "$target" ] && { [ -f "$target" ] || [ -n "$(find "$target" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]; }; then',
3546
5029
  // This hook re-runs every turn on a long-lived box, so \"non-empty\" alone is not
3547
5030
  // proof of a completed materialization: an interrupted clone (worker crash /
3548
5031
  // lifecycle timeout mid-mv/cp) leaves a partial tree that would otherwise pass
@@ -3550,75 +5033,77 @@ export function repositoryCloneCommand(resources: Extract<ResourceRef, { kind: "
3550
5033
  // skipped; a partial one is wiped and rebuilt (nothing legitimate writes under
3551
5034
  // the mount path before the repo exists). Subpath extracts are not git repos —
3552
5035
  // for those the plain non-empty check stands (no stronger signal available).
3553
- " if [ -n \"$subpath\" ] || git -C \"$target\" rev-parse --is-inside-work-tree >/dev/null 2>&1; then",
3554
- " echo \"Repository resource already present at $target\"",
5036
+ ' if [ -n "$subpath" ] || git -C "$target" rev-parse --is-inside-work-tree >/dev/null 2>&1; then',
5037
+ ' echo "Repository resource already present at $target"',
3555
5038
  " return 0",
3556
5039
  " fi",
3557
- " echo \"Re-materializing partial repository resource at $target\" >&2",
3558
- " find \"$target\" -mindepth 1 -maxdepth 1 -exec rm -rf {} +",
5040
+ ' echo "Re-materializing partial repository resource at $target" >&2',
5041
+ ' find "$target" -mindepth 1 -maxdepth 1 -exec rm -rf {} +',
3559
5042
  " fi",
3560
- " mkdir -p \"$(dirname \"$target\")\"",
3561
- " tmp=\"${target}.tmp.$$\"",
3562
- " rm -rf \"$tmp\"",
5043
+ ' mkdir -p "$(dirname "$target")"',
5044
+ ' tmp="${target}.tmp.$$"',
5045
+ ' rm -rf "$tmp"',
3563
5046
  // Fetch failures must not leak the pid-suffixed tmp clone beside the mount
3564
5047
  // (set -eu would exit before any cleanup).
3565
- " if ! { git init \"$tmp\" >/dev/null && git -C \"$tmp\" remote add origin \"$uri\" && git -C \"$tmp\" fetch --depth 1 --no-tags --filter=blob:none origin \"$ref\" && git -C \"$tmp\" checkout --detach FETCH_HEAD >/dev/null; }; then",
3566
- " rm -rf \"$tmp\"",
3567
- " echo \"Repository resource fetch failed for $target\" >&2",
5048
+ ' if ! { git init "$tmp" >/dev/null && git -C "$tmp" remote add origin "$uri" && git -C "$tmp" fetch --depth 1 --no-tags --filter=blob:none origin "$ref" && git -C "$tmp" checkout --detach FETCH_HEAD >/dev/null; }; then',
5049
+ ' rm -rf "$tmp"',
5050
+ ' echo "Repository resource fetch failed for $target" >&2',
3568
5051
  " exit 1",
3569
5052
  " fi",
3570
- " if [ -n \"$subpath\" ]; then",
3571
- " if [ ! -e \"$tmp/$subpath\" ]; then",
3572
- " echo \"Repository subpath not found: $subpath\" >&2",
3573
- " rm -rf \"$tmp\"",
5053
+ ' if [ -n "$subpath" ]; then',
5054
+ ' if [ ! -e "$tmp/$subpath" ]; then',
5055
+ ' echo "Repository subpath not found: $subpath" >&2',
5056
+ ' rm -rf "$tmp"',
3574
5057
  " exit 1",
3575
5058
  " fi",
3576
- " if [ -d \"$tmp/$subpath\" ]; then",
3577
- " mkdir -p \"$target\"",
3578
- " cp -a \"$tmp/$subpath/.\" \"$target/\"",
5059
+ ' if [ -d "$tmp/$subpath" ]; then',
5060
+ ' mkdir -p "$target"',
5061
+ ' cp -a "$tmp/$subpath/." "$target/"',
3579
5062
  " else",
3580
- " rmdir \"$target\" 2>/dev/null || true",
3581
- " cp -a \"$tmp/$subpath\" \"$target\"",
5063
+ ' rmdir "$target" 2>/dev/null || true',
5064
+ ' cp -a "$tmp/$subpath" "$target"',
3582
5065
  " fi",
3583
- " rm -rf \"$tmp\"",
5066
+ ' rm -rf "$tmp"',
3584
5067
  " else",
3585
- " rmdir \"$target\" 2>/dev/null || true",
5068
+ ' rmdir "$target" 2>/dev/null || true',
3586
5069
  // Two concurrent turn holders can race this install: without the existence
3587
5070
  // re-check the loser's un-flagged `mv` would nest its tmp clone INSIDE the
3588
5071
  // winner's tree as <name>.tmp.<pid>. If the winner produced a valid work tree,
3589
5072
  // accept it; a non-empty non-repo survivor here is a mount point the manifest
3590
5073
  // re-filled — install into it by content copy instead of rename.
3591
- " if [ -e \"$target\" ]; then",
3592
- " if git -C \"$target\" rev-parse --is-inside-work-tree >/dev/null 2>&1; then",
3593
- " rm -rf \"$tmp\"",
3594
- " echo \"Repository resource already present at $target\"",
5074
+ ' if [ -e "$target" ]; then',
5075
+ ' if git -C "$target" rev-parse --is-inside-work-tree >/dev/null 2>&1; then',
5076
+ ' rm -rf "$tmp"',
5077
+ ' echo "Repository resource already present at $target"',
3595
5078
  " return 0",
3596
5079
  " fi",
3597
- " cp -a \"$tmp/.\" \"$target/\"",
3598
- " rm -rf \"$tmp\"",
5080
+ ' cp -a "$tmp/." "$target/"',
5081
+ ' rm -rf "$tmp"',
3599
5082
  " else",
3600
- " mv \"$tmp\" \"$target\"",
5083
+ ' mv "$tmp" "$target"',
3601
5084
  " fi",
3602
- " git -C \"$target\" rev-parse --is-inside-work-tree >/dev/null",
5085
+ ' git -C "$target" rev-parse --is-inside-work-tree >/dev/null',
3603
5086
  " fi",
3604
- " if [ ! -e \"$target\" ]; then",
3605
- " echo \"Repository resource was not materialized at $target\" >&2",
5087
+ ' if [ ! -e "$target" ]; then',
5088
+ ' echo "Repository resource was not materialized at $target" >&2',
3606
5089
  " exit 1",
3607
5090
  " fi",
3608
- " echo \"Repository resource ready at $target\"",
5091
+ ' echo "Repository resource ready at $target"',
3609
5092
  "}",
3610
5093
  ];
3611
5094
  for (const resource of resources) {
3612
5095
  const url = new URL(resource.uri);
3613
5096
  const repo = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
3614
5097
  const mountPath = normalizeManifestPath(resource.mountPath ?? `repos/${repo}`);
3615
- commands.push([
3616
- "clone_repository",
3617
- shellQuote(posixPath.join("/workspace", mountPath)),
3618
- shellQuote(resource.uri),
3619
- shellQuote(resource.ref),
3620
- shellQuote(resource.subpath ? normalizeManifestPath(resource.subpath) : ""),
3621
- ].join(" "));
5098
+ commands.push(
5099
+ [
5100
+ "clone_repository",
5101
+ shellQuote(posixPath.join("/workspace", mountPath)),
5102
+ shellQuote(resource.uri),
5103
+ shellQuote(resource.ref),
5104
+ shellQuote(resource.subpath ? normalizeManifestPath(resource.subpath) : ""),
5105
+ ].join(" "),
5106
+ );
3622
5107
  }
3623
5108
  return commands.join("\n");
3624
5109
  }
@@ -3626,15 +5111,15 @@ export function repositoryCloneCommand(resources: Extract<ResourceRef, { kind: "
3626
5111
  export function toolspaceTokenSeedCommand(): string {
3627
5112
  return [
3628
5113
  "set -eu",
3629
- "export HOME=\"${HOME:-/workspace}\"",
3630
- "if [ -n \"${OPENGENI_TOOLSPACE_TOKEN_SEED:-}\" ]; then",
3631
- " seed_umask=\"$(umask)\"",
5114
+ 'export HOME="${HOME:-/workspace}"',
5115
+ 'if [ -n "${OPENGENI_TOOLSPACE_TOKEN_SEED:-}" ]; then',
5116
+ ' seed_umask="$(umask)"',
3632
5117
  " umask 077",
3633
- " token_file=\"${OPENGENI_TOOLSPACE_TOKEN_FILE:-$HOME/.opengeni/toolspace-token}\"",
3634
- " mkdir -p \"$(dirname \"$token_file\")\"",
3635
- " printf '%s' \"$OPENGENI_TOOLSPACE_TOKEN_SEED\" > \"$token_file.tmp.$$\"",
3636
- " mv -f \"$token_file.tmp.$$\" \"$token_file\"",
3637
- " umask \"$seed_umask\"",
5118
+ ' token_file="${OPENGENI_TOOLSPACE_TOKEN_FILE:-$HOME/.opengeni/toolspace-token}"',
5119
+ ' mkdir -p "$(dirname "$token_file")"',
5120
+ ' printf \'%s\' "$OPENGENI_TOOLSPACE_TOKEN_SEED" > "$token_file.tmp.$$"',
5121
+ ' mv -f "$token_file.tmp.$$" "$token_file"',
5122
+ ' umask "$seed_umask"',
3638
5123
  "fi",
3639
5124
  ].join("\n");
3640
5125
  }
@@ -3670,6 +5155,165 @@ export async function runToolspaceTokenSeedHook(
3670
5155
  }
3671
5156
  }
3672
5157
 
5158
+ // Bounds the setup output tail carried on a rig.setup failure event/error so a
5159
+ // runaway script can't bloat the session's event stream or the turn error.
5160
+ const RIG_SETUP_OUTPUT_TAIL_LIMIT = 4_000;
5161
+
5162
+ // A distinctive sentinel the guard prints when the idempotence marker already
5163
+ // exists, so the runtime can tell a SKIP from an actual run without a second
5164
+ // exec round-trip.
5165
+ const RIG_SETUP_SKIPPED_SENTINEL = "__OPENGENI_RIG_SETUP_SKIPPED__";
5166
+
5167
+ /**
5168
+ * The rig-setup command (M3). One idempotent bash program:
5169
+ * 1. `mkdir -p /var/opengeni` and, if the per-version marker already exists,
5170
+ * print the SKIP sentinel and exit 0 (a warm box re-running the hook).
5171
+ * 2. otherwise atomically claim a per-version lock directory. A loser waits
5172
+ * for the winner's marker, then skips; if the winner fails and releases the
5173
+ * lock, the loser retries the claim.
5174
+ * 3. the winner writes the rig's setup script to a temp file and runs it under
5175
+ * coreutils `timeout` (NOT `bash -e` — the script opts into `set -e`
5176
+ * itself if it wants), then captures the exit code and `touch`es the marker
5177
+ * ONLY on success (exit 0) so a failed/timed-out setup re-runs next turn.
5178
+ * The heredoc delimiter is quoted, so the script content is executed verbatim
5179
+ * with no host-side expansion.
5180
+ */
5181
+ export function rigSetupScriptCommand(
5182
+ script: string,
5183
+ versionId: string,
5184
+ timeoutMs = 600_000,
5185
+ markerRoot = "/var/opengeni",
5186
+ ): string {
5187
+ const timeoutSecs = Math.max(1, Math.ceil(timeoutMs / 1000));
5188
+ const lockWaitSecs = timeoutSecs + 6;
5189
+ const marker = `${markerRoot.replace(/\/+$/, "")}/rig-setup-${versionId}.done`;
5190
+ return [
5191
+ "set -u",
5192
+ `mkdir -p ${shellQuote(markerRoot)}`,
5193
+ `__OG_RIG_MARKER=${shellQuote(marker)}`,
5194
+ '__OG_RIG_LOCK="$__OG_RIG_MARKER.lock"',
5195
+ `__OG_RIG_TIMEOUT_SECS=${timeoutSecs}`,
5196
+ `__OG_RIG_LOCK_WAIT_SECS=${lockWaitSecs}`,
5197
+ `if [ -f "$__OG_RIG_MARKER" ]; then printf '%s\\n' ${shellQuote(RIG_SETUP_SKIPPED_SENTINEL)}; exit 0; fi`,
5198
+ "while :; do",
5199
+ ' if mkdir "$__OG_RIG_LOCK" 2>/dev/null; then',
5200
+ " trap 'rm -rf \"$__OG_RIG_LOCK\"' EXIT",
5201
+ ` if [ -f "$__OG_RIG_MARKER" ]; then printf '%s\\n' ${shellQuote(RIG_SETUP_SKIPPED_SENTINEL)}; exit 0; fi`,
5202
+ '__OG_RIG_SCRIPT="$(mktemp)"',
5203
+ "cat > \"$__OG_RIG_SCRIPT\" <<'__OPENGENI_RIG_SETUP_SCRIPT_EOF__'",
5204
+ script,
5205
+ "__OPENGENI_RIG_SETUP_SCRIPT_EOF__",
5206
+ ' timeout -k 5s "${__OG_RIG_TIMEOUT_SECS}s" bash "$__OG_RIG_SCRIPT"',
5207
+ "__OG_RIG_RC=$?",
5208
+ ' rm -f "$__OG_RIG_SCRIPT"',
5209
+ ' if [ "$__OG_RIG_RC" -eq 0 ]; then touch "$__OG_RIG_MARKER"; fi',
5210
+ ' exit "$__OG_RIG_RC"',
5211
+ " fi",
5212
+ " __OG_RIG_WAITED=0",
5213
+ ' while [ "$__OG_RIG_WAITED" -lt "$__OG_RIG_LOCK_WAIT_SECS" ]; do',
5214
+ ` if [ -f "$__OG_RIG_MARKER" ]; then printf '%s\\n' ${shellQuote(RIG_SETUP_SKIPPED_SENTINEL)}; exit 0; fi`,
5215
+ ' if [ ! -d "$__OG_RIG_LOCK" ]; then break; fi',
5216
+ " sleep 1",
5217
+ " __OG_RIG_WAITED=$((__OG_RIG_WAITED + 1))",
5218
+ " done",
5219
+ ` if [ -f "$__OG_RIG_MARKER" ]; then printf '%s\\n' ${shellQuote(RIG_SETUP_SKIPPED_SENTINEL)}; exit 0; fi`,
5220
+ ' if [ ! -d "$__OG_RIG_LOCK" ]; then continue; fi',
5221
+ ' rmdir "$__OG_RIG_LOCK" 2>/dev/null || true',
5222
+ "done",
5223
+ ].join("\n");
5224
+ }
5225
+
5226
+ /**
5227
+ * The rig-setup beforeAgentStart hook (M3). Runs the frozen rig version's setup
5228
+ * script exactly once per box (marker-guarded), under the RIG's own timeout
5229
+ * (context.rigSetup.timeoutMs, NOT the 120s lifecycle default). Emits
5230
+ * rig.setup.started, then one terminal event:
5231
+ * - rig.setup.skipped — marker already present,
5232
+ * - rig.setup.completed — script ran and exited 0,
5233
+ * - rig.setup.failed — nonzero exit / timeout,
5234
+ * and on failure THROWS (fail the turn closed) with a message naming the
5235
+ * rig/version and a bounded tail of the setup output.
5236
+ */
5237
+ export async function runRigSetupHook(
5238
+ session: SandboxSessionLike,
5239
+ context: SandboxLifecycleHookContext = { environment: {} },
5240
+ ): Promise<void> {
5241
+ const rigSetup = context.rigSetup;
5242
+ if (!rigSetup) {
5243
+ return;
5244
+ }
5245
+ const payload = {
5246
+ rigId: rigSetup.rigId,
5247
+ versionId: rigSetup.versionId,
5248
+ rigName: rigSetup.rigName,
5249
+ };
5250
+ await context.onRuntimeEvent?.({ type: "rig.setup.started", payload });
5251
+ const command = rigSetupScriptCommand(rigSetup.script, rigSetup.versionId, rigSetup.timeoutMs);
5252
+ const execArgs = {
5253
+ cmd: command,
5254
+ workdir: "/workspace",
5255
+ ...(context.runAs ? { runAs: context.runAs } : {}),
5256
+ // The in-box coreutils timeout is the hard deadline; the SDK yield waits a
5257
+ // little longer so it observes timeout's non-zero exit instead of a live
5258
+ // still-running process.
5259
+ yieldTimeMs: rigSetup.timeoutMs + 7_000,
5260
+ maxOutputTokens: 20_000,
5261
+ };
5262
+ let result: unknown;
5263
+ try {
5264
+ if (session.exec) {
5265
+ result = await session.exec(execArgs);
5266
+ } else if (session.execCommand) {
5267
+ result = await session.execCommand(execArgs);
5268
+ } else {
5269
+ throw new Error("Sandbox session does not support command execution");
5270
+ }
5271
+ } catch (error) {
5272
+ const message = error instanceof Error ? error.message : String(error);
5273
+ await context.onRuntimeEvent?.({
5274
+ type: "rig.setup.failed",
5275
+ payload: { ...payload, error: message.slice(-RIG_SETUP_OUTPUT_TAIL_LIMIT) },
5276
+ });
5277
+ throw new Error(
5278
+ `Rig setup failed for rig "${rigSetup.rigName}" (version ${rigSetup.versionId}): ${message}`,
5279
+ { cause: error },
5280
+ );
5281
+ }
5282
+ const output = sandboxCommandOutput(result);
5283
+ // Marker present → the guard skipped the script. Distinct terminal signal.
5284
+ if (output.includes(RIG_SETUP_SKIPPED_SENTINEL)) {
5285
+ await context.onRuntimeEvent?.({ type: "rig.setup.skipped", payload });
5286
+ return;
5287
+ }
5288
+ // Ran → classify. A "still running" result means the script outlived the rig
5289
+ // timeout; any nonzero/absent exit code is a setup failure. Both fail closed.
5290
+ const stillRunning = sandboxCommandStillRunning(result);
5291
+ const exitCode = sandboxCommandExitCode(result);
5292
+ if (stillRunning || exitCode === null || exitCode !== 0) {
5293
+ const tail =
5294
+ output.length > RIG_SETUP_OUTPUT_TAIL_LIMIT
5295
+ ? output.slice(-RIG_SETUP_OUTPUT_TAIL_LIMIT)
5296
+ : output;
5297
+ const reason = stillRunning
5298
+ ? `did not finish within the rig setup timeout (${rigSetup.timeoutMs}ms)`
5299
+ : exitCode === null
5300
+ ? "did not report an exit code"
5301
+ : `exited with code ${exitCode}`;
5302
+ const failure = new Error(
5303
+ `Rig setup failed for rig "${rigSetup.rigName}" (version ${rigSetup.versionId}): the setup script ${reason}${tail ? `:\n${tail}` : ""}`,
5304
+ );
5305
+ await context.onRuntimeEvent?.({
5306
+ type: "rig.setup.failed",
5307
+ payload: { ...payload, error: failure.message.slice(-RIG_SETUP_OUTPUT_TAIL_LIMIT) },
5308
+ });
5309
+ throw failure;
5310
+ }
5311
+ await context.onRuntimeEvent?.({
5312
+ type: "rig.setup.completed",
5313
+ payload: { ...payload, skipped: false },
5314
+ });
5315
+ }
5316
+
3673
5317
  export async function runRepositoryCloneHook(
3674
5318
  session: SandboxSessionLike,
3675
5319
  resources: Extract<ResourceRef, { kind: "repository" }>[],
@@ -3678,17 +5322,21 @@ export async function runRepositoryCloneHook(
3678
5322
  const payload = { name: "repository-clone", repositoryCount: resources.length };
3679
5323
  await context.onRuntimeEvent?.({ type: "sandbox.operation.started", payload });
3680
5324
  try {
3681
- // TOKEN-BROKER (B1): thread the run-scoped GitHub token PER-EXEC, never on the
3682
- // manifest. The SDK's ExecCommandArgs has no `environment` field (exec inherits
3683
- // the box's manifest env), so we can't hand the seed through an exec option — and
3684
- // we MUST NOT put it on the manifest (validateNoEnvironmentDelta would see a
3685
- // rotating value). We therefore inline it as an ephemeral `export` prefix on THIS
3686
- // exec's command text only: it lives in the command, not the box/agent manifest,
3687
- // and never persists. The clone command's gated seed block then writes it to the
3688
- // token FILE before the fetch, so GIT_ASKPASS reads it. Absent seed (e.g. the
3689
- // selfhosted path) -> no prefix, the clone runs byte-for-byte as before.
3690
- const command = context.gitTokenSeed
3691
- ? `export OPENGENI_GIT_TOKEN_SEED=${shellQuote(context.gitTokenSeed)}\n${repositoryCloneCommand(resources)}`
5325
+ // TOKEN-BROKER (B1): thread run-scoped provider tokens PER-EXEC, never on
5326
+ // the manifest. The SDK's ExecCommandArgs has no `environment` field (exec
5327
+ // inherits the box's manifest env), so we can't hand seeds through an exec
5328
+ // option — and we MUST NOT put them on the manifest (validateNoEnvironmentDelta
5329
+ // would see rotating values). We inline ephemeral `export` prefixes on THIS
5330
+ // exec's command text only. The clone command writes them to provider token
5331
+ // FILES before fetch/CLI use. Absent seeds -> no prefix; helper wrappers still
5332
+ // install and passthrough cleanly.
5333
+ const gitTokenSeeds = {
5334
+ ...(context.gitTokenSeeds ?? {}),
5335
+ ...(context.gitTokenSeed ? { github: context.gitTokenSeed } : {}),
5336
+ } satisfies GitTokenSeeds;
5337
+ const seedPrefix = gitTokenSeedExportPrefix(gitTokenSeeds);
5338
+ const command = seedPrefix
5339
+ ? `${seedPrefix}\n${repositoryCloneCommand(resources)}`
3692
5340
  : repositoryCloneCommand(resources);
3693
5341
  if (session.exec) {
3694
5342
  const result = await session.exec({
@@ -3726,20 +5374,20 @@ export async function runRepositoryCloneHook(
3726
5374
 
3727
5375
  export function azureCliLoginCommand(): string {
3728
5376
  return [
3729
- "export HOME=\"${HOME:-/workspace}\"",
3730
- "mkdir -p \"$HOME/.azure\"",
3731
- "CLIENT_ID=\"${AZURE_CLIENT_ID:-${ARM_CLIENT_ID:-}}\"",
3732
- "CLIENT_SECRET=\"${AZURE_CLIENT_SECRET:-${ARM_CLIENT_SECRET:-}}\"",
3733
- "TENANT_ID=\"${AZURE_TENANT_ID:-${ARM_TENANT_ID:-}}\"",
3734
- "SUBSCRIPTION_ID=\"${AZURE_SUBSCRIPTION_ID:-${ARM_SUBSCRIPTION_ID:-}}\"",
3735
- "if [ -n \"$CLIENT_ID\" ] && [ -n \"$CLIENT_SECRET\" ] && [ -n \"$TENANT_ID\" ]; then",
3736
- " command -v az >/dev/null 2>&1 || { echo \"Azure CLI is not installed in the sandbox\" >&2; exit 127; }",
3737
- " az account show --only-show-errors >/dev/null 2>&1 || az login --service-principal --username \"$CLIENT_ID\" --password \"$CLIENT_SECRET\" --tenant \"$TENANT_ID\" --allow-no-subscriptions --only-show-errors --output none",
5377
+ 'export HOME="${HOME:-/workspace}"',
5378
+ 'mkdir -p "$HOME/.azure"',
5379
+ 'CLIENT_ID="${AZURE_CLIENT_ID:-${ARM_CLIENT_ID:-}}"',
5380
+ 'CLIENT_SECRET="${AZURE_CLIENT_SECRET:-${ARM_CLIENT_SECRET:-}}"',
5381
+ 'TENANT_ID="${AZURE_TENANT_ID:-${ARM_TENANT_ID:-}}"',
5382
+ 'SUBSCRIPTION_ID="${AZURE_SUBSCRIPTION_ID:-${ARM_SUBSCRIPTION_ID:-}}"',
5383
+ 'if [ -n "$CLIENT_ID" ] && [ -n "$CLIENT_SECRET" ] && [ -n "$TENANT_ID" ]; then',
5384
+ ' command -v az >/dev/null 2>&1 || { echo "Azure CLI is not installed in the sandbox" >&2; exit 127; }',
5385
+ ' az account show --only-show-errors >/dev/null 2>&1 || az login --service-principal --username "$CLIENT_ID" --password "$CLIENT_SECRET" --tenant "$TENANT_ID" --allow-no-subscriptions --only-show-errors --output none',
3738
5386
  // if/fi, NOT `[ -n ] && az`: this line ends the credentialed if-body, so with a
3739
5387
  // no-subscription SP (an explicitly supported config — the login above passes
3740
5388
  // --allow-no-subscriptions) the bare `[ -n ]` would exit the whole script 1 and
3741
5389
  // fail the turn.
3742
- " if [ -n \"$SUBSCRIPTION_ID\" ]; then az account set --subscription \"$SUBSCRIPTION_ID\" --only-show-errors; fi",
5390
+ ' if [ -n "$SUBSCRIPTION_ID" ]; then az account set --subscription "$SUBSCRIPTION_ID" --only-show-errors; fi',
3743
5391
  "fi",
3744
5392
  ].join("\n");
3745
5393
  }
@@ -3767,6 +5415,10 @@ export function sandboxCommandExitCode(result: unknown): number | null {
3767
5415
  }
3768
5416
 
3769
5417
  export function sandboxCommandOutput(result: unknown): string {
5418
+ if (typeof result === "string") {
5419
+ const outputIndex = result.indexOf("Output:");
5420
+ return outputIndex >= 0 ? result.slice(outputIndex + "Output:".length).trim() : result.trim();
5421
+ }
3770
5422
  if (!result || typeof result !== "object") {
3771
5423
  return "";
3772
5424
  }
@@ -3783,11 +5435,15 @@ export function sandboxCommandOutput(result: unknown): string {
3783
5435
  function assertSandboxCommandSucceeded(result: unknown, operation: string): void {
3784
5436
  const output = sandboxCommandOutput(result);
3785
5437
  if (sandboxCommandStillRunning(result)) {
3786
- throw new Error(`${operation} did not finish before the lifecycle command timeout${output ? `:\n${output}` : ""}`);
5438
+ throw new Error(
5439
+ `${operation} did not finish before the lifecycle command timeout${output ? `:\n${output}` : ""}`,
5440
+ );
3787
5441
  }
3788
5442
  const exitCode = sandboxCommandExitCode(result);
3789
5443
  if (exitCode !== null && exitCode !== 0) {
3790
- throw new Error(output || `${operation} failed with exit code ${exitCode}`);
5444
+ throw new Error(
5445
+ `${operation} failed with exit code ${exitCode}${output ? `:\n${output}` : ""}`,
5446
+ );
3791
5447
  }
3792
5448
  if (exitCode === null) {
3793
5449
  throw new Error(output || `${operation} did not return a command exit code`);
@@ -3884,15 +5540,20 @@ let stagedBundledSkillsDir: string | null = null;
3884
5540
 
3885
5541
  function bundledSkillsDir(): string {
3886
5542
  const moduleDir = dirname(fileURLToPath(import.meta.url));
3887
- const packaged = [
3888
- join(moduleDir, "bundled_hashicorp_terraform_skills"),
3889
- join(moduleDir, "..", "src", "bundled_hashicorp_terraform_skills"),
3890
- ].find((candidate) => existsSync(candidate)) ?? join(moduleDir, "bundled_hashicorp_terraform_skills");
5543
+ const packaged =
5544
+ [
5545
+ join(moduleDir, "bundled_hashicorp_terraform_skills"),
5546
+ join(moduleDir, "..", "src", "bundled_hashicorp_terraform_skills"),
5547
+ ].find((candidate) => existsSync(candidate)) ??
5548
+ join(moduleDir, "bundled_hashicorp_terraform_skills");
3891
5549
  if (isPathWithin(process.cwd(), packaged)) {
3892
5550
  return packaged;
3893
5551
  }
3894
5552
  if (!stagedBundledSkillsDir) {
3895
- stagedBundledSkillsDir = stageBundledSkills(packaged, join(process.cwd(), ".opengeni", "bundled_hashicorp_terraform_skills"));
5553
+ stagedBundledSkillsDir = stageBundledSkills(
5554
+ packaged,
5555
+ join(process.cwd(), ".opengeni", "bundled_hashicorp_terraform_skills"),
5556
+ );
3896
5557
  }
3897
5558
  return stagedBundledSkillsDir;
3898
5559
  }
@@ -3950,12 +5611,18 @@ export function lazySkillSourceWithPackSkills(packSkills: PackSkill[]): LocalDir
3950
5611
  packNameKeys.add(skill.name.toLowerCase());
3951
5612
  packNames.add(skill.name);
3952
5613
  children[skill.name] = packSkillDirEntry(skill);
3953
- packIndex.push({ name: skill.name, description: packSkillDescription(skill), path: skill.name });
5614
+ packIndex.push({
5615
+ name: skill.name,
5616
+ description: packSkillDescription(skill),
5617
+ path: skill.name,
5618
+ });
3954
5619
  }
3955
5620
  return {
3956
5621
  source: dir({ children }),
3957
5622
  getIndex: (manifest, skillsPath) => [
3958
- ...(bundled.getIndex?.(manifest, skillsPath) ?? []).filter((entry) => !packNames.has(entry.path ?? entry.name)),
5623
+ ...(bundled.getIndex?.(manifest, skillsPath) ?? []).filter(
5624
+ (entry) => !packNames.has(entry.path ?? entry.name),
5625
+ ),
3959
5626
  ...packIndex,
3960
5627
  ],
3961
5628
  };
@@ -4020,7 +5687,11 @@ function assertSafePackSkillName(name: string): void {
4020
5687
 
4021
5688
  function packSkillPathSegments(skillName: string, path: string): string[] {
4022
5689
  const segments = path.split("/");
4023
- if (path.startsWith("/") || path.includes("\\") || segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
5690
+ if (
5691
+ path.startsWith("/") ||
5692
+ path.includes("\\") ||
5693
+ segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")
5694
+ ) {
4024
5695
  throw new Error(`Invalid pack skill file path for ${skillName}: ${path}`);
4025
5696
  }
4026
5697
  return segments;
@@ -4069,16 +5740,16 @@ function skillFrontmatterDescription(markdown: string): string | null {
4069
5740
  }
4070
5741
 
4071
5742
  function unquoteFrontmatterValue(value: string): string {
4072
- if (value.length >= 2 && value[0] === value[value.length - 1] && (value[0] === '"' || value[0] === "'")) {
5743
+ if (
5744
+ value.length >= 2 &&
5745
+ value[0] === value[value.length - 1] &&
5746
+ (value[0] === '"' || value[0] === "'")
5747
+ ) {
4073
5748
  return value.slice(1, -1);
4074
5749
  }
4075
5750
  return value;
4076
5751
  }
4077
5752
 
4078
- function stringValue(value: unknown): string | undefined {
4079
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
4080
- }
4081
-
4082
5753
  function isAsyncIterable<T>(source: Iterable<T> | AsyncIterable<T>): source is AsyncIterable<T> {
4083
5754
  return typeof (source as AsyncIterable<T>)[Symbol.asyncIterator] === "function";
4084
5755
  }
@@ -4092,7 +5763,11 @@ function sortJson(value: unknown): unknown {
4092
5763
  return value.map(sortJson);
4093
5764
  }
4094
5765
  if (value && typeof value === "object") {
4095
- return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, nested]) => [key, sortJson(nested)]));
5766
+ return Object.fromEntries(
5767
+ Object.entries(value)
5768
+ .sort(([a], [b]) => a.localeCompare(b))
5769
+ .map(([key, nested]) => [key, sortJson(nested)]),
5770
+ );
4096
5771
  }
4097
5772
  return value;
4098
5773
  }