@opengeni/runtime 0.6.0 → 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 (44) hide show
  1. package/dist/{chunk-BHA2SU3H.js → chunk-RYDCVTMW.js} +1959 -208
  2. package/dist/chunk-RYDCVTMW.js.map +1 -0
  3. package/dist/{index-otZkoLYq.d.ts → index-DIiiwQY2.d.ts} +693 -55
  4. package/dist/index.d.ts +293 -121
  5. package/dist/index.js +1819 -786
  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 -897
  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 +99 -24
  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-BHA2SU3H.js.map +0 -1
  44. package/src/image-history.ts +0 -149
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,14 +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 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";
184
217
 
185
218
  ensureReadableStreamFrom();
186
219
 
@@ -198,6 +231,7 @@ export type ModelResponseUsage = {
198
231
  outputTokens?: number;
199
232
  totalTokens?: number;
200
233
  inputTokensDetails?: Record<string, number> | Array<Record<string, number>>;
234
+ outputTokensDetails?: Record<string, number> | Array<Record<string, number>>;
201
235
  };
202
236
  };
203
237
 
@@ -217,19 +251,21 @@ export type ResolveConnectionCredentialInput = {
217
251
  export type ResolveConnectionCredentialResult =
218
252
  | { status: "ok"; headers: Record<string, string>; connectionId: string; expiresAt?: Date | null }
219
253
  | {
220
- status: "auth_needed";
221
- reason: ToolAuthNeededPayload["reason"];
222
- providerDomain: string;
223
- connectionId?: string;
224
- scopes?: string[];
225
- resource?: string;
226
- authorizationUrl?: string;
227
- };
254
+ status: "auth_needed";
255
+ reason: ToolAuthNeededPayload["reason"];
256
+ providerDomain: string;
257
+ connectionId?: string;
258
+ scopes?: string[];
259
+ resource?: string;
260
+ authorizationUrl?: string;
261
+ };
228
262
 
229
263
  export function ensureReadableStreamFrom(): void {
230
- const ctor = globalThis.ReadableStream as (typeof ReadableStream & {
231
- from?: <T>(source: Iterable<T> | AsyncIterable<T>) => ReadableStream<T>;
232
- }) | undefined;
264
+ const ctor = globalThis.ReadableStream as
265
+ | (typeof ReadableStream & {
266
+ from?: <T>(source: Iterable<T> | AsyncIterable<T>) => ReadableStream<T>;
267
+ })
268
+ | undefined;
233
269
  if (!ctor || typeof ctor.from === "function") {
234
270
  return;
235
271
  }
@@ -259,21 +295,27 @@ export function ensureReadableStreamFrom(): void {
259
295
 
260
296
  export type AgentSegmentInput =
261
297
  | {
262
- kind: "message";
263
- text: string;
264
- serializedRunState?: string | null;
265
- // Items-mode conversation truth (issue #35): when provided, turn input is
266
- // built from these verbatim AgentInputItems and the stored sandbox
267
- // envelope — no RunState deserialization, no SDK-version coupling.
268
- historyItems?: AgentInputItem[] | null;
269
- sandboxEnvelope?: Record<string, unknown> | null;
270
- }
271
- | { 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
+ };
272
315
 
273
316
  export type PreparedAgentInput = {
274
317
  input: string | AgentInputItem[] | RunState<any, any>;
275
318
  sandboxSessionState?: SandboxSessionState;
276
- serializedRunStateForSandbox?: string;
277
319
  };
278
320
 
279
321
  export type SandboxFileDownload = {
@@ -286,6 +328,19 @@ export type SandboxFileDownload = {
286
328
  sizeBytes?: number;
287
329
  };
288
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
+
289
344
  let runtimeMetricsHooks: RuntimeMetricsHooks | null = null;
290
345
 
291
346
  export function configureRuntimeMetricsHooks(hooks: RuntimeMetricsHooks | null | undefined): void {
@@ -299,10 +354,27 @@ export type OpenGeniRuntime = {
299
354
  // shape; null when the turn's model is not in the registry, so the caller
300
355
  // falls back to the legacy global-client path (settings.openaiModel).
301
356
  resolveTurnModel: (settings: Settings, modelId: string) => ReturnType<typeof resolveTurnModel>;
302
- buildAgent: (settings: Settings, resources: ResourceRef[], options?: BuildAgentOptions) => Agent<any, any>;
303
- prepareTools: (settings: Settings, tools: ToolRef[], options?: PrepareToolsOptions) => Promise<PreparedAgentTools>;
304
- prepareInput: (agent: Agent<any, any>, input: AgentSegmentInput, options?: PrepareInputOptions) => Promise<PreparedAgentInput>;
305
- 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>>>;
306
378
  serializeApprovals: (interruptions: unknown[]) => unknown[];
307
379
  };
308
380
 
@@ -312,7 +384,9 @@ export type ProductionRuntimeOverrides = {
312
384
  metrics?: RuntimeMetricsHooks;
313
385
  };
314
386
 
315
- export function createProductionAgentRuntime(overrides: ProductionRuntimeOverrides = {}): OpenGeniRuntime {
387
+ export function createProductionAgentRuntime(
388
+ overrides: ProductionRuntimeOverrides = {},
389
+ ): OpenGeniRuntime {
316
390
  return {
317
391
  configure: (settings) => {
318
392
  configureRuntimeMetricsHooks(overrides.metrics);
@@ -322,17 +396,20 @@ export function createProductionAgentRuntime(overrides: ProductionRuntimeOverrid
322
396
  // model used in worker tests is not in any provider's allow-list), so when
323
397
  // one is supplied resolveTurnModel reports "no resolution" and the caller
324
398
  // keeps the legacy global-client path with the override model.
325
- resolveTurnModel: (settings, modelId) => (overrides.model ? null : resolveTurnModel(settings, modelId)),
326
- buildAgent: (settings, resources, options) => buildOpenGeniAgent(settings, resources, {
327
- ...options,
328
- ...(overrides.model ? { model: overrides.model } : {}),
329
- }),
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
+ }),
330
406
  prepareTools: prepareAgentTools,
331
407
  prepareInput: prepareRunInput,
332
- runStream: async (agent, input, settings, options) => await runAgentStream(agent, input, settings, {
333
- ...options,
334
- sandboxClient: overrides.sandboxClient,
335
- }),
408
+ runStream: async (agent, input, settings, options) =>
409
+ await runAgentStream(agent, input, settings, {
410
+ ...options,
411
+ sandboxClient: overrides.sandboxClient,
412
+ }),
336
413
  serializeApprovals,
337
414
  };
338
415
  }
@@ -344,7 +421,10 @@ export function createProductionAgentRuntime(overrides: ProductionRuntimeOverrid
344
421
  * the OpenAI-platform path has only a key (the SDK default client is used via
345
422
  * setDefaultOpenAIKey there); the caller then constructs a key-only client.
346
423
  */
347
- export function buildOpenAIClientFromSettings(settings: Settings, providerId: string = settings.openaiProvider): OpenAI {
424
+ export function buildOpenAIClientFromSettings(
425
+ settings: Settings,
426
+ providerId: string = settings.openaiProvider,
427
+ ): OpenAI {
348
428
  if (settings.openaiProvider === "azure") {
349
429
  const baseURL = settings.azureOpenaiBaseUrl ?? azureDeploymentBaseUrl(settings);
350
430
  const apiKey = settings.azureOpenaiApiKey ?? settings.azureOpenaiAdToken ?? "azure-ad-token";
@@ -353,11 +433,12 @@ export function buildOpenAIClientFromSettings(settings: Settings, providerId: st
353
433
  baseURL,
354
434
  maxRetries: settings.openaiMaxRetries,
355
435
  defaultQuery: azureOpenAIDefaultQuery(settings, baseURL),
356
- defaultHeaders: settings.azureOpenaiAdToken && !settings.azureOpenaiApiKey
357
- ? { Authorization: `Bearer ${settings.azureOpenaiAdToken}` }
358
- : undefined,
436
+ defaultHeaders:
437
+ settings.azureOpenaiAdToken && !settings.azureOpenaiApiKey
438
+ ? { Authorization: `Bearer ${settings.azureOpenaiAdToken}` }
439
+ : undefined,
359
440
  // Rewrite every outbound /responses computer_call to the ACTIONS-ONLY shape
360
- // 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
361
442
  // seam — below the SDK responses converter, which always re-synthesizes BOTH
362
443
  // `action` and `actions` (rejected 400 "exactly one of action or actions").
363
444
  // See computerCallNormalizingFetch / rewriteComputerCallsToActionsOnly.
@@ -392,29 +473,32 @@ export function buildProviderClient(provider: ResolvedModelProvider, settings: S
392
473
  const client = provider.builtin
393
474
  ? buildOpenAIClientFromSettings(settings, provider.id)
394
475
  : provider.kind === "codex-subscription"
395
- // Codex subscription: the static apiKey is a placeholder — the real per-request
396
- // bearer + ChatGPT-Account-ID, the /responses->/codex/responses rewrite, and the
397
- // body normalization are all injected by codexSubscriptionFetch, which reads the
398
- // per-workspace token from codexRequestStorage (AsyncLocalStorage) at call time.
399
- // The provider id is constant ("codex-subscription"), so one cached client serves
400
- // every workspace without baking a token into it.
401
- ? new OpenAI({
402
- apiKey: provider.apiKey ?? "codex-subscription",
403
- ...(provider.baseUrl ? { baseURL: provider.baseUrl } : {}),
404
- maxRetries: settings.openaiMaxRetries,
405
- fetch: codexSubscriptionFetch(instrumentedModelFetch(provider.id, globalThis.fetch)),
406
- })
407
- // ResolvedModelProvider.apiKey is already the resolved key (configuredProviders
408
- // ran resolveProviderApiKey at config time, collapsing apiKey/apiKeyEnv), so it
409
- // is passed straight through here rather than re-resolved.
410
- : new OpenAI({
411
- ...(provider.apiKey ? { apiKey: provider.apiKey } : {}),
412
- ...(provider.baseUrl ? { baseURL: provider.baseUrl } : {}),
413
- maxRetries: settings.openaiMaxRetries,
414
- ...(provider.defaultQuery ? { defaultQuery: provider.defaultQuery } : {}),
415
- ...(provider.defaultHeaders ? { defaultHeaders: provider.defaultHeaders } : {}),
416
- fetch: instrumentedModelFetch(provider.id, globalThis.fetch),
417
- });
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
+ });
418
502
  providerClientCache.set(provider.id, client);
419
503
  return client;
420
504
  }
@@ -428,7 +512,11 @@ export function buildProviderClient(provider: ResolvedModelProvider, settings: S
428
512
  * the agent is what routes a turn to its provider without mutating the global
429
513
  * default client.
430
514
  */
431
- 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 {
432
520
  return provider.api === "chat"
433
521
  ? new OpenAIChatCompletionsModel(client, modelId)
434
522
  : new OpenAIResponsesModel(client, modelId);
@@ -445,7 +533,12 @@ export function buildModelInstance(provider: ResolvedModelProvider, client: Open
445
533
  export function resolveTurnModel(
446
534
  settings: Settings,
447
535
  modelId: string,
448
- ): { provider: ResolvedModelProvider; client: OpenAI; model: Model; configured: ConfiguredModel } | null {
536
+ ): {
537
+ provider: ResolvedModelProvider;
538
+ client: OpenAI;
539
+ model: Model;
540
+ configured: ConfiguredModel;
541
+ } | null {
449
542
  const resolved = resolveModelProvider(settings, modelId);
450
543
  if (!resolved) {
451
544
  return null;
@@ -486,7 +579,10 @@ export class MultiProviderModelProvider implements ModelProvider {
486
579
 
487
580
  async getModel(modelName?: string): Promise<Model> {
488
581
  if (modelName) {
489
- const resolved = resolveTurnModel(settingsForRunScopedModelResolution(this.settings, modelName), modelName);
582
+ const resolved = resolveTurnModel(
583
+ settingsForRunScopedModelResolution(this.settings, modelName),
584
+ modelName,
585
+ );
490
586
  if (resolved) {
491
587
  // Fail-loud floor (defense in depth): a `codex/<slug>` id must only ever
492
588
  // resolve through the synthetic codex-subscription provider (which installs
@@ -496,7 +592,10 @@ export class MultiProviderModelProvider implements ModelProvider {
496
592
  // to Azure/OpenAI as a deployment name (DeploymentNotFound 404). Refuse it
497
593
  // here so codex can never reach a non-codex client on ANY backend; the
498
594
  // primary fix (config configuredModels) keeps this a no-op in practice.
499
- 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
+ ) {
500
599
  throw new CodexSubscriptionUnavailableError(modelName);
501
600
  }
502
601
  return resolved.model;
@@ -538,11 +637,16 @@ function settingsForRunScopedModelResolution(settings: Settings, modelName: stri
538
637
  // bare registry id would otherwise be claimed by the built-in only because of
539
638
  // that per-turn override. Resolve the run-scoped router against the deployment
540
639
  // allow-list head instead; real built-in models stay in the allow-list.
541
- return builtinAllowed.includes(modelName) ? settings : { ...settings, openaiModel: fallbackBuiltin };
640
+ return builtinAllowed.includes(modelName)
641
+ ? settings
642
+ : { ...settings, openaiModel: fallbackBuiltin };
542
643
  }
543
644
 
544
645
  function splitOpenaiAllowedModels(value: string): string[] {
545
- return value.split(",").map((item) => item.trim()).filter(Boolean);
646
+ return value
647
+ .split(",")
648
+ .map((item) => item.trim())
649
+ .filter(Boolean);
546
650
  }
547
651
 
548
652
  /**
@@ -557,13 +661,34 @@ function splitOpenaiAllowedModels(value: string): string[] {
557
661
  export class CodexSubscriptionUnavailableError extends Error {
558
662
  constructor(modelName: string) {
559
663
  super(
560
- `Codex subscription model "${modelName}" is unavailable: no active Codex subscription is connected for this workspace. `
561
- + `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.`,
562
666
  );
563
667
  this.name = "CodexSubscriptionUnavailableError";
564
668
  }
565
669
  }
566
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
+
567
692
  export function configureOpenAI(settings: Settings): void {
568
693
  setOpenAIResponsesTransport(settings.openaiResponsesTransport);
569
694
  setTracingDisabled(settings.disableOpenaiTracing || !settings.observabilityOtlpEndpoint);
@@ -604,25 +729,32 @@ function instrumentedModelFetch(provider: string, inner: typeof fetch): typeof f
604
729
  }
605
730
 
606
731
  function isModelCallFetch(input: Parameters<typeof fetch>[0]): boolean {
607
- const rawUrl = typeof input === "string"
608
- ? input
609
- : input instanceof URL
610
- ? input.toString()
611
- : (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;
612
738
  if (typeof rawUrl !== "string" || rawUrl.length === 0) {
613
739
  return false;
614
740
  }
615
741
  try {
616
742
  const pathname = new URL(rawUrl, "http://opengeni.local").pathname;
617
- return pathname.endsWith("/responses")
618
- || pathname.endsWith("/chat/completions")
619
- || pathname.endsWith("/codex/responses");
743
+ return (
744
+ pathname.endsWith("/responses") ||
745
+ pathname.endsWith("/chat/completions") ||
746
+ pathname.endsWith("/codex/responses")
747
+ );
620
748
  } catch {
621
749
  return /\/(?:codex\/)?responses(?:\?|$)|\/chat\/completions(?:\?|$)/.test(rawUrl);
622
750
  }
623
751
  }
624
752
 
625
- 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 {
626
758
  const durationSeconds = Math.max(0, (performance.now() - started) / 1000);
627
759
  try {
628
760
  runtimeMetricsHooks?.onModelCall?.({ provider, outcome, durationSeconds });
@@ -634,10 +766,10 @@ function recordModelCallMetric(provider: string, outcome: "completed" | "failed"
634
766
  /**
635
767
  * Run the compaction summarizer as one plain, tool-less, non-streaming model
636
768
  * call against the resolved provider. `input` is the active history plus
637
- * Codex's checkpoint prompt. Returns the trimmed summary text, or null on any
638
- * failure (the caller can retry with a harder-trimmed transcript, then fall
639
- * back to deterministic non-LLM compaction). The call deliberately does NOT
640
- * request reasoning encryption, tools, or server-side compaction; it is a
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
641
773
  * self-contained summarize.
642
774
  *
643
775
  * Provider-aware: the summary always runs on the SAME provider that serves the
@@ -645,52 +777,69 @@ function recordModelCallMetric(provider: string, outcome: "completed" | "failed"
645
777
  * versa). `api: "chat"` providers (Fireworks) speak /v1/chat/completions, where
646
778
  * the summary is choices[0].message.content; `api: "responses"` (the default,
647
779
  * built-in OpenAI/Azure) speaks /v1/responses as before. When no client/api is
648
- * supplied it falls back to the built-in OpenAI/Azure Responses path so the
649
- * 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
650
781
  * only on the OpenAI-platform Responses path (Azure rejects it; chat ignores it).
651
782
  */
652
783
  export async function summarizeForCompaction(
653
784
  settings: Settings,
654
785
  input: Array<Record<string, unknown>>,
655
- options: { client?: OpenAI; api?: ModelProviderApi; maxOutputTokens?: number; model?: string; maxTranscriptTokens?: number } = {},
656
- ): 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> {
657
796
  const client = options.client ?? buildOpenAIClientFromSettings(settings);
658
797
  const api = options.api ?? "responses";
659
798
  const model = options.model ?? settings.openaiModel;
660
799
  const maxTokens = options.maxOutputTokens ?? SUMMARY_BUFFER_TOKENS;
661
- const transcript = renderCompactionPromptInputForChat(
662
- input,
663
- typeof options.maxTranscriptTokens === "number" && options.maxTranscriptTokens > 0
664
- ? { maxEstimatedTokens: options.maxTranscriptTokens }
665
- : {},
666
- );
667
- try {
668
- if (api === "chat") {
669
- const completion = await client.chat.completions.create({
670
- model,
671
- max_tokens: maxTokens,
672
- messages: [{ role: "user", content: transcript }],
673
- } as any);
674
- const text = (completion as { choices?: Array<{ message?: { content?: unknown } }> }).choices?.[0]?.message?.content;
675
- const trimmed = typeof text === "string" ? text.trim() : "";
676
- return trimmed.length > 0 ? trimmed : null;
677
- }
678
- const response = await client.responses.create({
800
+ if (api === "chat") {
801
+ const transcript = renderCompactionPromptInputForChat(input);
802
+ const completion = await client.chat.completions.create({
679
803
  model,
680
- // store:false is the OpenAI-platform-only storeless precondition; Azure
681
- // rejects it. The summarizer's resolved client is OpenAI/Azure on the
682
- // built-in path (api "responses"), so gate it on the built-in provider.
683
- ...(settings.openaiProvider === "azure" ? {} : { store: false }),
684
- max_output_tokens: maxTokens,
685
- input: transcript,
804
+ max_tokens: maxTokens,
805
+ messages: [{ role: "user", content: transcript }],
806
+ ...(options.promptCacheKey ? { prompt_cache_key: options.promptCacheKey } : {}),
686
807
  } as any);
687
- const text = extractResponseOutputText(response);
688
- const trimmed = text.trim();
689
- return trimmed.length > 0 ? trimmed : null;
690
- } catch (error) {
691
- console.error("context compaction summarize failed", error);
692
- 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);
693
841
  }
842
+ return extractResponseOutputText(response).trim();
694
843
  }
695
844
 
696
845
  /**
@@ -730,7 +879,11 @@ export function extractResponseOutputText(response: unknown): string {
730
879
  continue;
731
880
  }
732
881
  for (const part of content) {
733
- 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
+ ) {
734
887
  parts.push((part as { text: string }).text);
735
888
  }
736
889
  }
@@ -738,6 +891,21 @@ export function extractResponseOutputText(response: unknown): string {
738
891
  return parts.join("");
739
892
  }
740
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
+
741
909
  export type BuildAgentOptions = {
742
910
  model?: Model;
743
911
  reasoningEffort?: ReasoningEffort;
@@ -745,11 +913,6 @@ export type BuildAgentOptions = {
745
913
  // today's settings-derived behaviour when omitted, so the legacy
746
914
  // global-client callers (no model resolution) are byte-for-byte unchanged.
747
915
  //
748
- // - compactionMode: the resolved context-compaction path. Drives whether the
749
- // sandbox `compaction()` capability is attached AND whether `store: false`
750
- // is set (the OpenAI-platform-only storeless precondition). Registry
751
- // providers resolve to "client", so neither is applied to them.
752
- // Default: resolveContextCompactionMode(settings).
753
916
  // - hostedWebSearch: attach the hosted web_search tool. Only the providers
754
917
  // that actually execute it (built-in OpenAI/Azure; a registry model that
755
918
  // opts in) should get it — Fireworks accepts the param but no-ops it, which
@@ -758,9 +921,6 @@ export type BuildAgentOptions = {
758
921
  // providerData.include. Only the Responses API carries it; the chat wire
759
922
  // API has no such field, so registry "chat" providers turn it off.
760
923
  // Default: settings.openaiReasoningEncryptedContent.
761
- // - contextWindowTokens: the model's effective window, used to derive the
762
- // server-path compaction threshold. A registry model can declare its own
763
- // (e.g. GLM 5.2's 1,048,576). Default: settings.contextWindowTokens.
764
924
  // - structuredToolTransport: whether the backend supports the Responses
765
925
  // STRUCTURED/HOSTED sandbox-tool transport — the hosted `apply_patch` tool
766
926
  // type and structured `view_image` output. The SDK's sandbox capabilities
@@ -773,10 +933,8 @@ export type BuildAgentOptions = {
773
933
  // function `apply_patch` + text `view_image` variants it accepts. Default
774
934
  // true (let the SDK decide from the model instance) — non-codex paths are
775
935
  // byte-for-byte unchanged.
776
- compactionMode?: ContextCompactionMode;
777
936
  hostedWebSearch?: boolean;
778
937
  encryptedReasoning?: boolean;
779
- contextWindowTokens?: number;
780
938
  structuredToolTransport?: boolean;
781
939
  // EXPLICIT computer-use tool transport, decided where provider identity is
782
940
  // authoritative (the worker's model resolution — agent-turn.ts). Threaded into
@@ -784,12 +942,24 @@ export type BuildAgentOptions = {
784
942
  // on the SDK's constructor-name sniff. When omitted, the legacy sniff +
785
943
  // `structuredToolTransport` neutralize path is preserved byte-for-byte.
786
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>;
787
952
  // The LIVE, by-reference connector-namespace Set from prepareAgentTools
788
953
  // (codexConnectorNamespaces): fills during each turn's codex_apps tools/list,
789
954
  // read per model call by the codex tool_search description so the model sees
790
955
  // the account's ACTUALLY-connected sources (codex-rs parity). Only meaningful
791
956
  // on the codex tool-search path.
792
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;
793
963
  sandboxEnvironment?: Record<string, string>;
794
964
  // The EFFECTIVE/active compute backend for this turn. `settings.sandboxBackend`
795
965
  // is the session's HOME backend (the default cloud group box it was created
@@ -806,27 +976,49 @@ export type BuildAgentOptions = {
806
976
  activeSandboxBackend?: Settings["sandboxBackend"];
807
977
  fileResourceDownloads?: SandboxFileDownload[];
808
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;
809
983
  workspaceEnvironment?: WorkspaceEnvironmentContext;
810
- // TOKEN-BROKER (B1): the run-scoped GitHub App installation token, minted ONCE
811
- // per turn by the worker (sandboxEnvironmentForRun's `gitToken`). Threaded here
812
- // OFF-MANIFEST — it is NOT part of sandboxEnvironment (the manifest env), so the
813
- // token VALUE never triggers the SDK's provided-session env-delta guard even
814
- // though it rotates every turn. buildAgent stashes it alongside the agent's
815
- // repository-clone hooks; runStream forwards it into the clone hook context, which
816
- // seeds it to the box's token FILE before the clone runs. Omitted on the
817
- // 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.
818
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;
819
1006
  // TOOLSPACE: the run-scoped delegated token to seed into
820
1007
  // $OPENGENI_TOOLSPACE_TOKEN_FILE. Like gitTokenSeed, this stays off the
821
1008
  // manifest/env delta and is written into the sandbox filesystem by a lifecycle
822
1009
  // hook before the agent starts.
823
1010
  toolspaceTokenSeed?: string;
824
- // Genesis turn only: append a one-shot instruction to the agent's system
825
- // prompt telling it to title the session via opengeni__set_session_title
826
- // before responding. Delivered through the instructions channel (where the
827
- // model actually obeys), appended AFTER the non-bypassable core so a
828
- // 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.
829
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;
830
1022
  // Per-call agent persona override (the white-label surface). Resolved by the
831
1023
  // caller as session > workspace > deployment default; when omitted the
832
1024
  // runtime falls back to settings.agentInstructionsTemplate. The runtime
@@ -876,7 +1068,35 @@ export type WorkspaceEnvironmentContext = {
876
1068
  variableNames?: string[];
877
1069
  };
878
1070
 
879
- 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[] {
880
1100
  const lines = [
881
1101
  `A workspace environment named "${environment.name}" is attached to this session; its variables are exported in the sandbox shell environment.`,
882
1102
  ];
@@ -902,10 +1122,16 @@ export function workspaceEnvironmentInstructions(environment: WorkspaceEnvironme
902
1122
  * drop: composeAgentInstructions() substitutes it at the persona template's
903
1123
  * {{core}} marker, and appends it when the marker is absent.
904
1124
  */
905
- export function coreInstructions(workspaceEnvironment?: WorkspaceEnvironmentContext): string[] {
1125
+ export function coreInstructions(
1126
+ workspaceEnvironment?: WorkspaceEnvironmentContext,
1127
+ rig?: RigInstructionsContext,
1128
+ ): string[] {
906
1129
  return [
907
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.",
908
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) : []),
909
1135
  ];
910
1136
  }
911
1137
 
@@ -917,8 +1143,12 @@ export function coreInstructions(workspaceEnvironment?: WorkspaceEnvironmentCont
917
1143
  * and the append both join by " ", so the DEFAULT_AGENT_INSTRUCTIONS template
918
1144
  * with an empty environment reproduces the historical preamble byte-for-byte.
919
1145
  */
920
- export function composeAgentInstructions(template: string, workspaceEnvironment?: WorkspaceEnvironmentContext): string {
921
- 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(" ");
922
1152
  if (template.includes(AGENT_INSTRUCTIONS_CORE_PLACEHOLDER)) {
923
1153
  return template.split(AGENT_INSTRUCTIONS_CORE_PLACEHOLDER).join(core);
924
1154
  }
@@ -937,6 +1167,41 @@ export function appendSessionInstructions(composed: string, sessionInstructions?
937
1167
  return trimmed ? `${composed} ${trimmed}` : composed;
938
1168
  }
939
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
+
940
1205
  /**
941
1206
  * Appends the generic programmatic-tool-calling (toolspace) directive to the
942
1207
  * composed workspace + CORE instructions, joined by " ". This is GENERIC
@@ -961,23 +1226,36 @@ export function appendToolspaceInstructions(composed: string, toolspaceAvailable
961
1226
  * " " and always LAST so a white-label persona template or a per-session
962
1227
  * instruction can't drop it. A no-op when the hint is absent.
963
1228
  */
964
- export function appendGenesisTitleDirective(instructions: string, genesisTitleHint?: boolean): string {
1229
+ export function appendGenesisTitleDirective(
1230
+ instructions: string,
1231
+ genesisTitleHint?: boolean,
1232
+ ): string {
965
1233
  return genesisTitleHint ? `${instructions} ${GENESIS_TITLE_DIRECTIVE}` : instructions;
966
1234
  }
967
1235
 
968
1236
  const agentFileDownloads = new WeakMap<object, SandboxFileDownload[]>();
969
1237
  const agentRepositoryCloneHooks = new WeakMap<object, SandboxLifecycleHook[]>();
970
- // TOKEN-BROKER (B1): the per-turn git token seed, stashed alongside the agent's
971
- // repository-clone hooks (a parallel map keyed by the agent). Kept OFF the
972
- // manifest/defaultManifest so the rotating value never rides the SDK's provided-
973
- // session env; runStream reads it to build the clone hook context. Absent when
974
- // no repo is attached / on the selfhosted path.
975
- 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>();
976
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>();
977
1249
  // The EFFECTIVE backend the turn resolved for this agent (undefined -> the home
978
1250
  // backend). Read by runStream's owned branch to keep platform box-setup hooks off
979
1251
  // connected machines (a user's real computer).
980
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[]>();
981
1259
 
982
1260
  /**
983
1261
  * The tool output emitted for an MCP tool call that FAILED with a THROWN error
@@ -1003,9 +1281,20 @@ const agentActiveSandboxBackend = new WeakMap<object, Settings["sandboxBackend"]
1003
1281
  * requires an SDK-level change (preserve `isError`) or reading the raw
1004
1282
  * CallToolResult — tracked separately.
1005
1283
  */
1006
- 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
+ } {
1007
1288
  const details = error instanceof Error ? error.message : String(error);
1008
- return { isError: true, content: [{ type: "text", text: `An error occurred while running the tool. Please try again. Error: ${details}` }] };
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
+ };
1009
1298
  }
1010
1299
 
1011
1300
  // Applied to EVERY MCP server via the agent's `mcpConfig.errorFunction`
@@ -1013,17 +1302,24 @@ export function mcpToolErrorOutput(error: unknown): { isError: true; content: [{
1013
1302
  // SDK types the return as `string`; the runtime actually stores the raw return
1014
1303
  // as the tool output, so we return the MCP-shaped error object and cast — this
1015
1304
  // is the only way to attach an `isError` flag to a failed MCP tool call's output.
1016
- const mcpToolErrorFunction: MCPToolErrorFunction = ({ error }) => mcpToolErrorOutput(error) as unknown as string;
1305
+ const mcpToolErrorFunction: MCPToolErrorFunction = ({ error }) =>
1306
+ mcpToolErrorOutput(error) as unknown as string;
1017
1307
 
1018
- 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> {
1019
1313
  // Resolved per-turn gating. Each override defaults to today's settings-derived
1020
1314
  // behaviour, so the legacy global-client callers (no resolved model) build the
1021
1315
  // exact same agent as before; the multi-provider worker path passes the
1022
- // resolved provider's mode/api/window/web-search instead.
1023
- const compactionMode = options.compactionMode ?? resolveContextCompactionMode(settings);
1316
+ // resolved provider's api/window/web-search instead.
1024
1317
  const hostedWebSearch = options.hostedWebSearch ?? settings.webSearchEnabled;
1025
1318
  const encryptedReasoning = options.encryptedReasoning ?? settings.openaiReasoningEncryptedContent;
1026
- const contextWindowTokens = options.contextWindowTokens ?? settings.contextWindowTokens;
1319
+ const providerData = {
1320
+ ...(encryptedReasoning ? { include: ["reasoning.encrypted_content"] } : {}),
1321
+ ...(options.promptCacheKey ? { prompt_cache_key: options.promptCacheKey } : {}),
1322
+ };
1027
1323
  // Native hosted tools attached to every constructed agent. webSearchEnabled
1028
1324
  // is ON by default and provider-unconditional on the built-in path (the live
1029
1325
  // Azure Responses path executes the hosted web_search tool); a registry model
@@ -1053,32 +1349,36 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
1053
1349
  // when a toolspace token was minted for this turn (feature enabled + a
1054
1350
  // per-turn seed — the mint gate, which includes selfhosted turns since
1055
1351
  // they now receive the token too) — appendToolspaceInstructions,
1056
- // 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
1057
1355
  // refines both the workspace persona and the substrate note),
1058
- // 4. + the one-shot genesis title directive (genesis turn only).
1059
- // With no toolspace token, no session instructions, and no genesis hint this
1060
- // is byte-identical to the historical composed instructions.
1061
- 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(
1062
1361
  appendSessionInstructions(
1063
- appendToolspaceInstructions(
1064
- composeAgentInstructions(options.instructionsTemplate ?? settings.agentInstructionsTemplate, options.workspaceEnvironment),
1065
- 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,
1066
1372
  ),
1067
1373
  options.sessionInstructions,
1068
1374
  ),
1069
- options.genesisTitleHint,
1375
+ options.persistentSessionSettings,
1070
1376
  ),
1071
1377
  modelSettings: {
1072
- reasoning: { effort: options.reasoningEffort ?? settings.openaiReasoningEffort, summary: "detailed" },
1073
- // Server-side compaction (OpenAI platform) requires store=false: the
1074
- // server emits an opaque ENCRYPTED 'compaction' item that round-trips in
1075
- // the request rather than being anchored to a stored response. OpenGeni
1076
- // already runs storeless (provider item ids stripped, encrypted reasoning
1077
- // round-tripped), so this is consistent with the existing design and
1078
- // only set where the server compaction capability is attached. Gated on
1079
- // the RESOLVED compaction mode (registry providers resolve to "client",
1080
- // so they never carry store:false).
1081
- ...(compactionMode === "server" ? { store: false } : {}),
1378
+ reasoning: {
1379
+ effort: options.reasoningEffort ?? settings.openaiReasoningEffort,
1380
+ summary: "detailed",
1381
+ },
1082
1382
  // Round-trip the encrypted reasoning payload with every call so chains
1083
1383
  // of thought survive without provider-side response storage (which is
1084
1384
  // what stripped provider item ids opt us out of — see
@@ -1087,9 +1387,7 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
1087
1387
  // function tools, which contribute none. Gated on the resolved
1088
1388
  // encryptedReasoning flag: the chat wire API has no encrypted_content
1089
1389
  // field, so registry "chat" providers turn it off.
1090
- ...(encryptedReasoning
1091
- ? { providerData: { include: ["reasoning.encrypted_content"] } }
1092
- : {}),
1390
+ ...(Object.keys(providerData).length > 0 ? { providerData } : {}),
1093
1391
  },
1094
1392
  // Explicit hosted tools (web_search when enabled). Threaded into BOTH the
1095
1393
  // `new Agent(baseConfig)` path (sandboxBackend === "none") and the
@@ -1108,6 +1406,9 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
1108
1406
 
1109
1407
  if (settings.sandboxBackend === "none") {
1110
1408
  const agent = new Agent(baseConfig);
1409
+ if (options.genesisTitleHint) {
1410
+ agentsNeedingGenesisTitleDirective.add(agent);
1411
+ }
1111
1412
  maybeInstallCodexToolSearch(agent, settings, options);
1112
1413
  applyMcpApprovalPolicy(agent, settings);
1113
1414
  return agent;
@@ -1116,17 +1417,36 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
1116
1417
  const runAs = sandboxRunAs(settings);
1117
1418
  const agent = new SandboxAgent({
1118
1419
  ...baseConfig,
1119
- defaultManifest: buildManifest(settings, resources, options.sandboxEnvironment, options.fileResourceDownloads),
1420
+ defaultManifest: buildManifest(
1421
+ settings,
1422
+ resources,
1423
+ options.sandboxEnvironment,
1424
+ options.fileResourceDownloads,
1425
+ ),
1120
1426
  ...(runAs ? { runAs } : {}),
1121
1427
  capabilities: buildAgentCapabilities(settings, options.packSkills ?? [], {
1122
- compactionMode,
1123
- contextWindowTokens,
1124
- ...(options.structuredToolTransport !== undefined ? { structuredToolTransport: options.structuredToolTransport } : {}),
1125
- ...(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 } : {}),
1126
1435
  }),
1127
1436
  });
1128
- agentFileDownloads.set(agent, normalizeSandboxFileDownloads(options.fileResourceDownloads ?? []).filter((download) => !download.content));
1129
- 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
+ );
1130
1450
  // Stash the EFFECTIVE backend so runStream's owned branch can skip the direct
1131
1451
  // beforeAgentStart hook run on a connected machine: the box there is the user's
1132
1452
  // REAL computer — the platform must not run setup (az login) against it. The
@@ -1135,14 +1455,29 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
1135
1455
  if (options.activeSandboxBackend) {
1136
1456
  agentActiveSandboxBackend.set(agent, options.activeSandboxBackend);
1137
1457
  }
1138
- // TOKEN-BROKER (B1): stash the per-turn seed off-manifest so runStream can seed the
1139
- // clone hook without the token ever touching defaultManifest / sandboxEnvironment.
1140
- if (options.gitTokenSeed) {
1141
- 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);
1142
1467
  }
1143
1468
  if (options.toolspaceTokenSeed) {
1144
1469
  agentToolspaceTokenSeed.set(agent, options.toolspaceTokenSeed);
1145
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
+ }
1146
1481
  maybeInstallCodexToolSearch(agent, settings, options);
1147
1482
  applyMcpApprovalPolicy(agent, settings);
1148
1483
  return agent;
@@ -1157,7 +1492,11 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
1157
1492
  * codex_apps schemas + add the client tool_search tool, whose description renders
1158
1493
  * the live connector namespaces threaded from prepareAgentTools.
1159
1494
  */
1160
- 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 {
1161
1500
  if (settings.codexToolSearchEnabled && options.structuredToolTransport === false) {
1162
1501
  installCodexToolSearch(
1163
1502
  agent as unknown as Parameters<typeof installCodexToolSearch>[0],
@@ -1197,7 +1536,10 @@ type ApprovalCapableAgent = {
1197
1536
  * paths. The base (non-sandbox) `Agent.clone` spreads `...this` and would carry
1198
1537
  * the override, but re-installing is idempotent there and keeps one code path.
1199
1538
  */
1200
- function installMcpApprovalPolicy(agent: ApprovalCapableAgent, policies: McpApprovalPolicy[]): void {
1539
+ function installMcpApprovalPolicy(
1540
+ agent: ApprovalCapableAgent,
1541
+ policies: McpApprovalPolicy[],
1542
+ ): void {
1201
1543
  const listMcpTools = agent.getMcpTools.bind(agent);
1202
1544
  agent.getMcpTools = async (runContext: unknown) => {
1203
1545
  const tools = await listMcpTools(runContext);
@@ -1252,8 +1594,15 @@ function installMcpApprovalPolicy(agent: ApprovalCapableAgent, policies: McpAppr
1252
1594
  */
1253
1595
  function applyMcpApprovalPolicy(agent: Agent<any, any>, settings: Settings): void {
1254
1596
  const policies: McpApprovalPolicy[] = settings.mcpServers
1255
- .filter((server) => server.requireApproval === true || (Array.isArray(server.requireApproval) && server.requireApproval.length > 0))
1256
- .map((server) => ({ prefix: prefixedMcpToolName(server.id, ""), requireApproval: server.requireApproval as boolean | string[] }))
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
+ }))
1257
1606
  .sort((a, b) => b.prefix.length - a.prefix.length);
1258
1607
  if (policies.length === 0) {
1259
1608
  return;
@@ -1275,7 +1624,9 @@ function applyMcpApprovalPolicy(agent: Agent<any, any>, settings: Settings): voi
1275
1624
  * `computer_screenshot` for computer-use. `bindModel` still returns the capability
1276
1625
  * so the SDK's bind chain (`.bind().bindRunAs().bindModel()`) is preserved.
1277
1626
  */
1278
- function neutralizeStructuredToolTransport(capability: ReturnType<typeof filesystem> | ReturnType<typeof computerUse>): void {
1627
+ function neutralizeStructuredToolTransport(
1628
+ capability: ReturnType<typeof filesystem> | ReturnType<typeof computerUse>,
1629
+ ): void {
1279
1630
  // Use `this` (NOT a captured reference to `capability`): the SandboxAgent binds
1280
1631
  // via `cap.clone().bind(session).bindRunAs(runAs).bindModel(model, instance)` and
1281
1632
  // runs tools() on the object the CHAIN returns. Capability.clone() copies this
@@ -1289,49 +1640,95 @@ function neutralizeStructuredToolTransport(capability: ReturnType<typeof filesys
1289
1640
  this._modelInstance = undefined;
1290
1641
  return this;
1291
1642
  };
1292
- (capability as unknown as { bindModel: typeof forceFunctionTransport }).bindModel = forceFunctionTransport;
1643
+ (capability as unknown as { bindModel: typeof forceFunctionTransport }).bindModel =
1644
+ forceFunctionTransport;
1293
1645
  }
1294
1646
 
1295
1647
  /**
1296
- * Build the SandboxAgent capability set provider-aware.
1297
- *
1298
- * The SDK's `Capabilities.default()` force-includes `compaction()`, whose
1299
- * sampling params emit `context_management:[{type:'compaction', …}]` to the
1300
- * Responses transport. The OpenAI platform honors that (server-side compaction);
1301
- * AZURE rejects it with `400 unsupported_parameter` — which is exactly the live
1302
- * production failure on Azure today. So we MUST NOT attach the compaction
1303
- * capability on the Azure / client / off paths.
1304
- *
1305
- * We rebuild the base set explicitly (`filesystem()`, `shell()`, the same
1306
- * factories the SDK default uses) and add `compaction()` ONLY on the server
1307
- * path, with an explicit `StaticCompactionPolicy(threshold)` so gpt-5.5 — which
1308
- * is absent from the SDK's hardcoded context-window map and would otherwise hit
1309
- * the wrong 240k fallback — gets the correct threshold. The SDK has no
1310
- * window-registration API, so an explicit threshold is the only way to fix it.
1311
- *
1312
- * The resolved compaction mode and the effective context window are now passed
1313
- * IN (the multi-provider caller resolves them per provider/model) rather than
1314
- * re-derived from settings here. Both default to the settings-derived value so
1315
- * callers that don't route per-model (and the existing tests) keep today's exact
1316
- * behaviour; the effective window only changes the server-path threshold when a
1317
- * 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.
1318
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
+
1319
1719
  export function buildAgentCapabilities(
1320
1720
  settings: Settings,
1321
1721
  packSkills: PackSkill[],
1322
1722
  options: {
1323
- compactionMode?: ContextCompactionMode;
1324
- contextWindowTokens?: number;
1325
1723
  structuredToolTransport?: boolean;
1326
1724
  // EXPLICIT computer-use transport (see BuildAgentOptions.computerToolMode). When
1327
1725
  // present, computerUse() is handed the mode directly and its tools() obeys it
1328
1726
  // without the constructor-name sniff. When absent, the legacy neutralize +
1329
1727
  // imageFunctionResults path (driven by structuredToolTransport) is unchanged.
1330
1728
  computerToolMode?: ComputerToolMode;
1729
+ onComputerUseReady?: (session: SandboxSessionLike) => Promise<void>;
1331
1730
  } = {},
1332
1731
  ): ReturnType<typeof Capabilities.default> {
1333
- const mode = options.compactionMode ?? resolveContextCompactionMode(settings);
1334
- const contextWindowTokens = options.contextWindowTokens ?? settings.contextWindowTokens;
1335
1732
  // The `filesystem()` capability picks hosted-vs-function tool variants from the
1336
1733
  // bound model instance (supportsApplyPatchTransport / structured tool output).
1337
1734
  // When the caller declares the backend does NOT support that structured/hosted
@@ -1339,16 +1736,19 @@ export function buildAgentCapabilities(
1339
1736
  // neutralize this capability's model binding so tools() falls to the function
1340
1737
  // `apply_patch` + text `view_image` variants the backend accepts — the SDK
1341
1738
  // handles their function_call round-trip natively, so no reimplementation.
1342
- // Scoped to filesystem: shell() (always function tools) and compaction() (a
1343
- // sampling param, dropped by the codex normalizer) are untouched.
1344
- 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
+ });
1345
1745
  if (options.structuredToolTransport === false) {
1346
1746
  neutralizeStructuredToolTransport(filesystemCapability);
1347
1747
  }
1348
- const caps: ReturnType<typeof Capabilities.default> = [filesystemCapability, shell()];
1349
- if (mode === "server") {
1350
- caps.push(compaction({ policy: new StaticCompactionPolicy(contextServerCompactThreshold({ ...settings, contextWindowTokens })) }));
1351
- }
1748
+ const caps: ReturnType<typeof Capabilities.default> = [
1749
+ filesystemCapability,
1750
+ shell({ configureTools: withExecOpCorrelation }),
1751
+ ];
1352
1752
  caps.push(skills({ lazyFrom: lazySkillSourceWithPackSkills(packSkills) }));
1353
1753
  // P4.3 computer-use: the agent drives the SAME :0 humans watch (xdotool/XTEST +
1354
1754
  // scrot), but only when the desktop tier is ON, computer-use is enabled, and the
@@ -1359,9 +1759,9 @@ export function buildAgentCapabilities(
1359
1759
  // run time (the SandboxAgent merge); xdotool drives :0 regardless of whether any
1360
1760
  // viewer is attached, so no pixel-tunnel dependency.
1361
1761
  if (
1362
- settings.computerUseEnabled
1363
- && settings.sandboxDesktopEnabled
1364
- && desktopCapableBackend(settings.sandboxBackend)
1762
+ settings.computerUseEnabled &&
1763
+ settings.sandboxDesktopEnabled &&
1764
+ desktopCapableBackend(settings.sandboxBackend)
1365
1765
  ) {
1366
1766
  // computer-use is transport-aware, exactly like filesystem: `tools()` emits the
1367
1767
  // HOSTED `computer_use_preview` tool on the structured transport and a set of
@@ -1381,15 +1781,18 @@ export function buildAgentCapabilities(
1381
1781
  const computerCapability = computerUse({
1382
1782
  dimensions: [settings.streamResolutionWidth, settings.streamResolutionHeight],
1383
1783
  readOnly: settings.computerUseReadOnly,
1784
+ ...(options.onComputerUseReady ? { onReady: options.onComputerUseReady } : {}),
1384
1785
  ...(explicitMode
1385
1786
  ? { toolMode: explicitMode }
1386
- // Legacy (no explicit mode): on the codex path the function tools deliver
1387
- // screenshots as a real image the model can see. The ChatGPT/Codex backend
1388
- // rejects HOSTED tool types but DOES accept `input_image` content items inside a
1389
- // `function_call_output` (proven by openai/codex codex-rs, whose view_image tool
1390
- // ships exactly that shape) — so a structured image tool result is seen, where a
1391
- // text data-URL would be unreadable.
1392
- : 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
+ : {}),
1393
1796
  });
1394
1797
  // Neutralize ONLY on the legacy sniff path. With an explicit toolMode the mode
1395
1798
  // already forces the function tools, so the constructor-name override is moot.
@@ -1423,17 +1826,26 @@ export type PrepareToolsOptions = {
1423
1826
  // Worker-asserted session scope for first-party MCP calls; enables
1424
1827
  // session-scoped tools such as goal management on the API side.
1425
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;
1426
1832
  subjectId?: string;
1427
1833
  subjectLabel?: string;
1428
1834
  // Overrides the fixed first-party MCP permission set for this session's
1429
1835
  // delegated token (manager-style sessions). The caller is responsible for
1430
1836
  // having validated the set against the session creator's grant.
1431
1837
  firstPartyPermissions?: Permission[];
1432
- resolveCredential?: (input: ResolveConnectionCredentialInput) => Promise<ResolveConnectionCredentialResult>;
1838
+ resolveCredential?: (
1839
+ input: ResolveConnectionCredentialInput,
1840
+ ) => Promise<ResolveConnectionCredentialResult>;
1433
1841
  onAuthNeeded?: (payload: ToolAuthNeededPayload) => Promise<void> | void;
1434
1842
  };
1435
1843
 
1436
- 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> {
1437
1849
  // P4 (Part B.1): one Set per prepareTools call, shared by reference into the
1438
1850
  // codex_apps sanitizing fetch so every tools/list this turn accumulates the
1439
1851
  // account's connector namespaces. Surfaced on PreparedAgentTools for the worker.
@@ -1442,49 +1854,76 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
1442
1854
  return { mcpServers: [], close: async () => {}, codexConnectorNamespaces };
1443
1855
  }
1444
1856
  const registry = new Map(settings.mcpServers.map((server) => [server.id, server]));
1445
- const servers = await Promise.all(tools.map(async (tool) => {
1446
- const config = registry.get(tool.id);
1447
- if (!config) {
1448
- throw new Error(`Unknown MCP server id: ${tool.id}`);
1449
- }
1450
- const url = firstPartyMcpServerUrlForRun(settings, config, options.workspaceId) ?? config.url;
1451
- const baseFetch = isCodexAppsMcpServer(config)
1452
- ? codexAppsSanitizingFetch(globalThis.fetch, codexConnectorNamespaces)
1453
- : globalThis.fetch;
1454
- const fetchImpl = config.connectionRef
1455
- ? connectionBrokerFetch(baseFetch, config, options)
1456
- : baseFetch;
1457
- const server = new PrefixedMcpServer(new MCPServerStreamableHttp({
1458
- url,
1459
- name: config.name ?? config.id,
1460
- cacheToolsList: config.cacheToolsList,
1461
- // codex_apps returns connector tools with empty `outputSchema: {}` that the
1462
- // MCP SDK's strict Tool schema rejects (fails the turn during tools/list);
1463
- // sanitize the response on the wire before validation. The namespace Set
1464
- // also captures each tool's original connector namespace (P4 Part B.1).
1465
- ...(fetchImpl !== globalThis.fetch ? { fetch: fetchImpl } : {}),
1466
- ...await mcpServerRequestInit(settings, config, options),
1467
- ...(config.timeoutMs ? {
1468
- timeout: config.timeoutMs,
1469
- clientSessionTimeoutSeconds: Math.ceil(config.timeoutMs / 1000),
1470
- } : {}),
1471
- }), config.id, config.allowedTools);
1472
- // A server is connected BEST-EFFORT (a connect / tools-list failure drops
1473
- // it instead of failing the turn) in two cases:
1474
- // - codex_apps: connector availability is RUNTIME-DISCOVERED — the
1475
- // device-code login may lack the connector scopes, and the backend can
1476
- // reject the bearer at the initialize/tools-list handshake, so a 401/403
1477
- // (or a missing/failed token) drops the server.
1478
- // - an optional ToolRef: either an auto-attached workspace-default
1479
- // capability MCP or a client/pack-selected portable ref. A
1480
- // broken/expired credential or unavailable endpoint skips the server
1481
- // with a warning, never killing the turn before the model runs. Bare
1482
- // refs stay strict (below), preserving the fail-loud default.
1483
- const optional = tool.optional === true;
1484
- return { server, bestEffort: isCodexAppsMcpServer(config) || optional || !!config.connectionRef, optional };
1485
- }));
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
+ );
1486
1923
  const requiredServers = servers.filter((entry) => !entry.bestEffort).map((entry) => entry.server);
1487
- 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);
1488
1927
  // Names of the OPTIONAL servers (not codex_apps) so a drop is surfaced as a
1489
1928
  // warning; codex_apps keeps its historically-quiet drop (a not-logged-in
1490
1929
  // ChatGPT plan is a normal, non-noteworthy state).
@@ -1536,36 +1975,64 @@ function connectionBrokerFetch(
1536
1975
  }
1537
1976
  return async (input, init) => {
1538
1977
  const request = await mcpRequestInfo(input, init);
1539
- 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
+ );
1540
1985
  if (first.status === "auth_needed") {
1541
1986
  return await authNeededFetchResponse(options, config.id, request, first, connectionRef);
1542
1987
  }
1543
- 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
+ );
1544
1992
  if (response.status === 401) {
1545
- 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
+ );
1546
2000
  if (refreshed.status === "auth_needed") {
1547
2001
  return await authNeededFetchResponse(options, config.id, request, refreshed, connectionRef);
1548
2002
  }
1549
- 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
+ );
1550
2007
  if (retry.status === 403) {
1551
2008
  const auth = insufficientScopeAuth(retry.headers, connectionRef, refreshed.connectionId);
1552
- 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;
1553
2012
  }
1554
2013
  if (retry.status === 401) {
1555
- return await authNeededFetchResponse(options, config.id, request, {
1556
- status: "auth_needed",
1557
- reason: "expired",
1558
- providerDomain: connectionRef.providerDomain,
1559
- connectionId: refreshed.connectionId,
1560
- ...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
1561
- ...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
1562
- }, connectionRef);
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
+ );
1563
2028
  }
1564
2029
  return retry;
1565
2030
  }
1566
2031
  if (response.status === 403) {
1567
2032
  const auth = insufficientScopeAuth(response.headers, connectionRef, first.connectionId);
1568
- 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;
1569
2036
  }
1570
2037
  return response;
1571
2038
  };
@@ -1624,8 +2091,16 @@ function insufficientScopeAuth(
1624
2091
  reason: "insufficient_scope",
1625
2092
  providerDomain: connectionRef.providerDomain,
1626
2093
  connectionId,
1627
- ...(challenge.scope?.length ? { scopes: challenge.scope } : connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
1628
- ...(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
+ : {}),
1629
2104
  };
1630
2105
  }
1631
2106
 
@@ -1643,8 +2118,16 @@ async function authNeededFetchResponse(
1643
2118
  providerDomain: auth.providerDomain,
1644
2119
  reason: auth.reason,
1645
2120
  ...(connectionId ? { connectionId } : {}),
1646
- ...(auth.scopes ? { scopes: auth.scopes } : connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
1647
- ...(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
+ : {}),
1648
2131
  ...(auth.authorizationUrl ? { authorizationUrl: auth.authorizationUrl } : {}),
1649
2132
  ...(options.subjectId ? { subjectId: options.subjectId } : {}),
1650
2133
  });
@@ -1654,7 +2137,10 @@ async function authNeededFetchResponse(
1654
2137
  return new Response("Authentication required for MCP server connection", { status: 401 });
1655
2138
  }
1656
2139
 
1657
- async function publishAuthNeeded(options: PrepareToolsOptions, payload: ToolAuthNeededPayload): Promise<void> {
2140
+ async function publishAuthNeeded(
2141
+ options: PrepareToolsOptions,
2142
+ payload: ToolAuthNeededPayload,
2143
+ ): Promise<void> {
1658
2144
  try {
1659
2145
  await options.onAuthNeeded?.(payload);
1660
2146
  } catch {
@@ -1669,20 +2155,37 @@ type McpRequestInfo = {
1669
2155
  toolName?: string;
1670
2156
  };
1671
2157
 
1672
- async function mcpRequestInfo(input: string | URL | Request, init?: RequestInit): Promise<McpRequestInfo> {
1673
- const body = typeof init?.body === "string"
1674
- ? init.body
1675
- : input instanceof Request && (init?.method ?? input.method).toUpperCase() === "POST"
1676
- ? await input.clone().text().catch(() => "")
1677
- : "";
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
+ : "";
1678
2171
  if (!body) {
1679
2172
  return {};
1680
2173
  }
1681
2174
  try {
1682
- 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
+ };
1683
2180
  const method = typeof parsed.method === "string" ? parsed.method : undefined;
1684
- const id = typeof parsed.id === "string" || typeof parsed.id === "number" || parsed.id === null ? parsed.id : undefined;
1685
- const toolName = method === "tools/call" && typeof parsed.params?.name === "string" ? parsed.params.name : undefined;
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;
1686
2189
  return {
1687
2190
  ...(method ? { method } : {}),
1688
2191
  ...(id !== undefined ? { id } : {}),
@@ -1693,8 +2196,14 @@ async function mcpRequestInfo(input: string | URL | Request, init?: RequestInit)
1693
2196
  }
1694
2197
  }
1695
2198
 
1696
- function withConnectionHeaders(input: string | URL | Request, init: RequestInit | undefined, authHeaders: Record<string, string>): RequestInit {
1697
- const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
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
+ );
1698
2207
  for (const [name, value] of Object.entries(authHeaders)) {
1699
2208
  headers.set(name, value);
1700
2209
  }
@@ -1705,7 +2214,11 @@ function fetchInputForAttempt(input: string | URL | Request): string | URL | Req
1705
2214
  return input instanceof Request ? input.clone() : input;
1706
2215
  }
1707
2216
 
1708
- 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
+ } {
1709
2222
  if (!header) {
1710
2223
  return {};
1711
2224
  }
@@ -1719,7 +2232,9 @@ function parseWwwAuthenticate(header: string | null): { error?: string; scope?:
1719
2232
  let match: RegExpExecArray | null;
1720
2233
  while ((match = re.exec(paramsText)) !== null) {
1721
2234
  const raw = match[2]!;
1722
- 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;
1723
2238
  }
1724
2239
  return {
1725
2240
  ...(params.error ? { error: params.error } : {}),
@@ -1735,20 +2250,24 @@ function parseWwwAuthenticate(header: string | null): { error?: string; scope?:
1735
2250
  // McpError, which PrefixedMcpServer.callTool converts back into an MCP-shaped
1736
2251
  // `{ isError: true }` output for the model.
1737
2252
  const MCP_AUTH_NEEDED_ERROR_CODE = -32001;
1738
- 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.";
1739
2255
 
1740
2256
  function mcpToolAuthNeededResponse(id: string | number | null | undefined): Response {
1741
- return new Response(JSON.stringify({
1742
- jsonrpc: "2.0",
1743
- id: id ?? null,
1744
- error: {
1745
- code: MCP_AUTH_NEEDED_ERROR_CODE,
1746
- message: MCP_AUTH_NEEDED_MESSAGE,
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" },
1747
2269
  },
1748
- }), {
1749
- status: 200,
1750
- headers: { "content-type": "application/json" },
1751
- });
2270
+ );
1752
2271
  }
1753
2272
 
1754
2273
  function isAuthNeededMcpError(error: unknown): boolean {
@@ -1759,7 +2278,44 @@ function isAuthNeededMcpError(error: unknown): boolean {
1759
2278
  return code === MCP_AUTH_NEEDED_ERROR_CODE || error.message.includes(MCP_AUTH_NEEDED_MESSAGE);
1760
2279
  }
1761
2280
 
1762
- 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> } } | {}> {
1763
2319
  // codex_apps is checked FIRST so the static-headers path can never apply to
1764
2320
  // it: its refreshing ChatGPT/Codex bearer is resolved per-connect from the
1765
2321
  // codex ALS, never from a baked `config.headers` value.
@@ -1767,7 +2323,7 @@ async function mcpServerRequestInit(settings: Settings, config: Settings["mcpSer
1767
2323
  return await codexAppsMcpRequestInit(settings);
1768
2324
  }
1769
2325
  if (isFirstPartyMcpServer(settings, config)) {
1770
- return await firstPartyMcpRequestInit(settings, config, options);
2326
+ return await firstPartyMcpRequestInit(settings, config);
1771
2327
  }
1772
2328
  // Third-party MCP servers get their configured credential headers (for
1773
2329
  // example workspace-enabled capability MCP credentials) and nothing else —
@@ -1778,7 +2334,10 @@ async function mcpServerRequestInit(settings: Settings, config: Settings["mcpSer
1778
2334
  return {};
1779
2335
  }
1780
2336
 
1781
- 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> } } | {}> {
1782
2341
  if (!isFirstPartyMcpServer(settings, config)) {
1783
2342
  return {};
1784
2343
  }
@@ -1786,17 +2345,14 @@ async function firstPartyMcpRequestInit(settings: Settings, config: Settings["mc
1786
2345
  if (settings.authRequired && settings.accessKey) {
1787
2346
  headers["x-opengeni-access-key"] = settings.accessKey;
1788
2347
  }
1789
- if (settings.delegationSecret && options.accountId && options.workspaceId) {
1790
- headers.authorization = `Bearer ${await signDelegatedAccessToken(settings.delegationSecret, {
1791
- accountId: options.accountId,
1792
- workspaceId: options.workspaceId,
1793
- subjectId: options.subjectId ?? "worker:first-party-mcp",
1794
- ...(options.subjectLabel ? { subjectLabel: options.subjectLabel } : {}),
1795
- permissions: options.firstPartyPermissions ?? firstPartyMcpPermissions,
1796
- ...(options.sessionId ? { sessionId: options.sessionId } : {}),
1797
- exp: Math.floor(Date.now() / 1000) + 60 * 60,
1798
- })}`;
1799
- }
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.
1800
2356
  if (Object.keys(headers).length === 0) {
1801
2357
  return {};
1802
2358
  }
@@ -1807,6 +2363,55 @@ async function firstPartyMcpRequestInit(settings: Settings, config: Settings["mc
1807
2363
  };
1808
2364
  }
1809
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
+
1810
2415
  /**
1811
2416
  * Builds the connect-time auth headers for the codex_apps connectors MCP. The
1812
2417
  * bearer is resolved from codexRequestStorage — the SAME refreshing token source
@@ -1815,7 +2420,9 @@ async function firstPartyMcpRequestInit(settings: Settings, config: Settings["mc
1815
2420
  * ran outside the ALS) or a token failure (needs_relogin) returns {} so the
1816
2421
  * best-effort connect drops the server rather than crashing the turn.
1817
2422
  */
1818
- 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> } } | {}> {
1819
2426
  const ctx = codexRequestStorage.getStore();
1820
2427
  if (!ctx) {
1821
2428
  return {};
@@ -1848,8 +2455,8 @@ async function codexAppsMcpRequestInit(settings: Settings): Promise<{ requestIni
1848
2455
  // The first-party MCP permission set signed into a worker's delegated token
1849
2456
  // when the session does not specify its own. POWERFUL BY DEFAULT: it carries
1850
2457
  // every permission that unlocks a first-party tool — session orchestration
1851
- // (sessions:*), workspace environments (environments:*), and GitHub
1852
- // (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
1853
2460
  // specific session by setting a narrower session.firstPartyMcpPermissions (the
1854
2461
  // create-session permission picker), which the worker uses instead. Account-
1855
2462
  // level scopes (billing/account/members/api_keys/workspace:admin) are
@@ -1865,8 +2472,9 @@ const firstPartyMcpPermissions: Permission[] = [
1865
2472
  "sessions:read",
1866
2473
  "sessions:create",
1867
2474
  "sessions:control",
1868
- "environments:use",
1869
- "environments:manage",
2475
+ "variable-sets:use",
2476
+ "variable-sets:manage",
2477
+ "rigs:use",
1870
2478
  "github:use",
1871
2479
  ];
1872
2480
 
@@ -1878,7 +2486,10 @@ function isCodexAppsMcpServer(config: Settings["mcpServers"][number]): boolean {
1878
2486
  return config.id === CODEX_APPS_MCP_SERVER_ID;
1879
2487
  }
1880
2488
 
1881
- function isFirstPartyMcpServer(settings: Settings, config: Settings["mcpServers"][number]): boolean {
2489
+ function isFirstPartyMcpServer(
2490
+ settings: Settings,
2491
+ config: Settings["mcpServers"][number],
2492
+ ): boolean {
1882
2493
  if (!["opengeni", "files", "docs"].includes(config.id)) {
1883
2494
  return false;
1884
2495
  }
@@ -1892,7 +2503,11 @@ function isFirstPartyMcpServer(settings: Settings, config: Settings["mcpServers"
1892
2503
  return firstPartyMcpUrls(settings).some((candidate) => candidate === url);
1893
2504
  }
1894
2505
 
1895
- 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 {
1896
2511
  if (!workspaceId || !["opengeni", "files", "docs"].includes(config.id)) {
1897
2512
  return null;
1898
2513
  }
@@ -1906,9 +2521,9 @@ function firstPartyMcpServerUrlForRun(settings: Settings, config: Settings["mcpS
1906
2521
  ? settings.opengeniMcpUrl.replaceAll("{workspaceId}", workspaceId)
1907
2522
  : settings.opengeniMcpUrl
1908
2523
  ? scopedMcpUrlFromConfiguredBase(settings.opengeniMcpUrl, workspaceId)
1909
- // unset → the shared loopback default (a `{workspaceId}` template owned by
1910
- // @opengeni/config's firstPartyMcpBaseUrl), scoped to this run's workspace.
1911
- : 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);
1912
2527
  const url = new URL(rawBase);
1913
2528
  if (config.id === "docs") {
1914
2529
  url.pathname = `${url.pathname.replace(/\/+$/, "")}/docs`;
@@ -1956,12 +2571,24 @@ class PrefixedMcpServer implements MCPServer {
1956
2571
  readonly name: string;
1957
2572
  readonly prefix: string;
1958
2573
  private readonly allowedTools: Set<string> | undefined;
1959
-
1960
- 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
+ ) {
1961
2587
  this.name = registryId;
1962
2588
  this.prefix = prefixedMcpToolName(registryId, "");
1963
2589
  this.cacheToolsList = inner.cacheToolsList;
1964
2590
  this.allowedTools = allowedTools ? new Set(allowedTools) : undefined;
2591
+ this.bestEffort = bestEffort;
1965
2592
  }
1966
2593
 
1967
2594
  connect(): Promise<void> {
@@ -1973,13 +2600,49 @@ class PrefixedMcpServer implements MCPServer {
1973
2600
  }
1974
2601
 
1975
2602
  async listTools(): Promise<RuntimeMcpTool[]> {
1976
- 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
+ }
1977
2636
  return tools
1978
2637
  .filter((tool) => this.isAllowed(tool.name))
1979
2638
  .map((tool) => ({ ...tool, name: prefixedMcpToolName(this.name, tool.name) }));
1980
2639
  }
1981
2640
 
1982
- 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> {
1983
2646
  const unprefixed = this.unprefixToolName(toolName);
1984
2647
  if (!this.isAllowed(unprefixed)) {
1985
2648
  throw new Error(`MCP tool ${unprefixed} is not allowed for server ${this.name}`);
@@ -1991,9 +2654,34 @@ class PrefixedMcpServer implements MCPServer {
1991
2654
  // JSON-RPC error (an inline isError result would be stripped by the SDK
1992
2655
  // shim). Surface it to the model as a failed-but-recoverable tool result
1993
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.
1994
2659
  if (isAuthNeededMcpError(error)) {
1995
2660
  return { isError: true, content: [{ type: "text", text: MCP_AUTH_NEEDED_MESSAGE }] };
1996
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
+ }
1997
2685
  throw error;
1998
2686
  }
1999
2687
  }
@@ -2003,7 +2691,9 @@ class PrefixedMcpServer implements MCPServer {
2003
2691
  }
2004
2692
 
2005
2693
  async listResources(params?: Record<string, unknown>): Promise<any> {
2006
- 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
+ };
2007
2697
  if (!resourcesServer.listResources) {
2008
2698
  throw new Error(`MCP server ${this.name} does not support resources`);
2009
2699
  }
@@ -2011,7 +2701,9 @@ class PrefixedMcpServer implements MCPServer {
2011
2701
  }
2012
2702
 
2013
2703
  async listResourceTemplates(params?: Record<string, unknown>): Promise<any> {
2014
- 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
+ };
2015
2707
  if (!resourcesServer.listResourceTemplates) {
2016
2708
  throw new Error(`MCP server ${this.name} does not support resource templates`);
2017
2709
  }
@@ -2019,7 +2711,9 @@ class PrefixedMcpServer implements MCPServer {
2019
2711
  }
2020
2712
 
2021
2713
  async readResource(uri: string): Promise<any> {
2022
- 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
+ };
2023
2717
  if (!resourcesServer.readResource) {
2024
2718
  throw new Error(`MCP server ${this.name} does not support resource reads`);
2025
2719
  }
@@ -2043,112 +2737,58 @@ class PrefixedMcpServer implements MCPServer {
2043
2737
 
2044
2738
  export type PrepareInputOptions = {
2045
2739
  sandboxClient?: unknown;
2046
- /**
2047
- * Usable input-token budget B (window - reserved output). When set, the
2048
- * assembled history is passed through `enforceInputBudget` so a single
2049
- * over-budget input can never be sent — the last-resort backstop behind the
2050
- * best-effort pre-turn compaction. Omitted (undefined) disables the guard
2051
- * (no behaviour change for callers that don't opt in).
2052
- */
2053
- inputBudgetTokens?: number;
2054
2740
  };
2055
2741
 
2056
- /**
2057
- * Apply the read-path budget guard to an assembled model input: drop the oldest
2058
- * history at a clean turn boundary until the request fits B. Orphan-safe (only
2059
- * cuts at user-message boundaries) and only active when a budget is supplied.
2060
- * The trailing user message is counted against the budget but never dropped.
2061
- */
2062
- function guardAssembledInput(
2063
- history: AgentInputItem[],
2064
- trailing: AgentInputItem,
2065
- inputBudgetTokens: number | undefined,
2066
- ): AgentInputItem[] {
2067
- if (typeof inputBudgetTokens !== "number" || inputBudgetTokens <= 0) {
2068
- return [...history, trailing];
2069
- }
2070
- const trailingTokens = estimateItemTokens(trailing as unknown as Record<string, unknown>);
2071
- const guarded = enforceInputBudget(
2072
- history as unknown as Array<Record<string, unknown>>,
2073
- inputBudgetTokens,
2074
- trailingTokens,
2075
- );
2076
- if (guarded.trimmed) {
2077
- console.warn(
2078
- `read-path budget guard trimmed ${guarded.droppedCount} oldest history item(s) to fit input budget (${inputBudgetTokens} tokens); the over-budget input was NOT sent`,
2079
- );
2080
- }
2081
- return [...(guarded.items as unknown as AgentInputItem[]), trailing];
2082
- }
2083
-
2084
- 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> {
2085
2747
  if (input.kind === "message") {
2086
- if (input.historyItems && input.historyItems.length > 0) {
2087
- // Items mode: conversation truth comes from the database, the sandbox
2088
- // recovery descriptor from its own store. The RunState blob is not
2089
- // touched at all on this path.
2090
- const sandboxSessionState = input.sandboxEnvelope
2091
- ? await restoredSandboxSessionStateFromEntry(input.sandboxEnvelope, options.sandboxClient)
2092
- : undefined;
2093
- // Replayed conversation truth is reloaded verbatim from the database, so
2094
- // it can contain a tool-call pairing the Responses API rejects (most
2095
- // destructively an orphaned function_call_result with no matching
2096
- // function_call — which 400s every turn and bricks the session until the
2097
- // row is hand-deleted). Sanitize the in-memory copy before it reaches the
2098
- // model so existing corruption self-heals and a future write-path race is
2099
- // non-fatal; the stored rows are never touched.
2100
- const sanitizedHistory = sanitizeHistoryItemsForModel(
2101
- input.historyItems as unknown as Array<Record<string, unknown>>,
2102
- ) as unknown as AgentInputItem[];
2103
- return {
2104
- // Read-path budget guard: even after the orphan sanitizer, an assembled
2105
- // input can exceed the model window (pre-turn compaction is best-effort
2106
- // and can no-op). Trim the oldest history at a clean turn boundary so an
2107
- // over-budget request is never sent. No-op when no budget is supplied.
2108
- input: guardAssembledInput(
2109
- sanitizedHistory,
2110
- {
2111
- type: "message",
2112
- role: "user",
2113
- content: input.text,
2114
- } as AgentInputItem,
2115
- options.inputBudgetTokens,
2116
- ),
2117
- ...(sandboxSessionState ? { sandboxSessionState } : {}),
2118
- };
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);
2119
2762
  }
2120
- // No prior state, or a cleared sentinel: start fresh. The clear sentinel
2121
- // ({@link CLEARED_RUN_STATE_BLOB}) is not a real serialized run state — it
2122
- // carries no $schemaVersion, so RunState.fromString would throw on it. In
2123
- // run_state history mode this message path is the one that reads the blob
2124
- // after a /clear, so recognizing the sentinel here is what keeps the next
2125
- // turn working (a fresh, empty context) instead of bricking on deserialize.
2126
- if (!input.serializedRunState || isClearedRunStateBlob(input.serializedRunState)) {
2127
- 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");
2128
2770
  }
2129
- const state = await RunState.fromString(agent, input.serializedRunState);
2130
- const sandboxSessionState = await restoredSandboxSessionState(state, options.sandboxClient);
2131
- // state.history already runs the SDK's own orphan-tool-call pruning, but
2132
- // applying the same sanitizer keeps the legacy run-state resume path under
2133
- // 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.
2134
2774
  const sanitizedHistory = sanitizeHistoryItemsForModel(
2135
- state.history as unknown as Array<Record<string, unknown>>,
2775
+ (input.historyItems ?? []) as unknown as Array<Record<string, unknown>>,
2136
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
+ }
2137
2784
  return {
2138
- // Read-path budget guard (see the items path above): keep an over-budget
2139
- // resumed history off the wire by trimming the oldest turns when a budget
2140
- // is supplied.
2141
- input: guardAssembledInput(
2142
- sanitizedHistory,
2143
- {
2144
- type: "message",
2145
- role: "user",
2146
- content: input.text,
2147
- } as AgentInputItem,
2148
- options.inputBudgetTokens,
2149
- ),
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,
2150
2791
  ...(sandboxSessionState ? { sandboxSessionState } : {}),
2151
- serializedRunStateForSandbox: input.serializedRunState,
2152
2792
  };
2153
2793
  }
2154
2794
  // An approval can only be resumed against a real saved run state. If the
@@ -2156,7 +2796,9 @@ export async function prepareRunInput(agent: Agent<any, any>, input: AgentSegmen
2156
2796
  // refuses clear in requires_action, so this is a defensive guard) — fail with
2157
2797
  // an honest message instead of the cryptic SDK "missing schema version".
2158
2798
  if (isClearedRunStateBlob(input.serializedRunState)) {
2159
- 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
+ );
2160
2802
  }
2161
2803
  const state = await RunState.fromString(agent, input.serializedRunState);
2162
2804
  const interruptions = state.getInterruptions();
@@ -2177,6 +2819,12 @@ export type RunAgentStreamOptions = {
2177
2819
  sandboxEnvironment?: Record<string, string>;
2178
2820
  onRuntimeEvent?: (event: NormalizedRuntimeEvent) => Promise<void> | void;
2179
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;
2180
2828
  // OWNERSHIP INVERSION (P1.2): an externally-owned, already-live sandbox
2181
2829
  // session resolved by the per-turn resume-by-id path. When present,
2182
2830
  // runAgentStream does NOT build (or resume, or discard) a client — it threads
@@ -2189,15 +2837,23 @@ export type RunAgentStreamOptions = {
2189
2837
  // re-applied around the resumed client here; the live `session`/`sessionState`
2190
2838
  // carry the box, so no create()/resume() is re-invoked inside run().
2191
2839
  ownedSandbox?: {
2192
- client: unknown; // built by the per-turn resume path (the raw provider client)
2193
- session: unknown; // SandboxSessionLike — the live, NON-OWNED handle (never reaped)
2194
- 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
2195
2843
  // The UN-PROXIED established box for platform setup (lifecycle hooks + file
2196
2844
  // resource materialization). `session` may be the mid-turn routing proxy whose
2197
2845
  // every exec re-reads the active pointer — platform-initiated setup must NOT
2198
2846
  // follow a swap onto a connected machine (the user's real computer), so it
2199
2847
  // runs against this pinned handle instead. Absent -> falls back to `session`.
2200
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;
2201
2857
  };
2202
2858
  // A per-turn model-input filter chained AFTER the provider-item-id strip.
2203
2859
  // Used by the genesis-title injection to prepend a hidden, NON-PERSISTED
@@ -2209,17 +2865,48 @@ export type RunAgentStreamOptions = {
2209
2865
 
2210
2866
  export type ContextRobustnessFilterOptions = {
2211
2867
  contextCompactionSignalTokens?: () => number | null | undefined;
2868
+ contextCompactionRequested?: () => boolean | Promise<boolean>;
2212
2869
  throwOnCompactionNeeded?: boolean;
2213
2870
  };
2214
2871
 
2215
- // 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
2216
2873
  // (see buildOpenGeniAgent's genesisTitleHint). Delivered through the
2217
2874
  // authoritative instructions channel so the model reliably obeys; references
2218
2875
  // the prefixed tool name the agent actually sees (opengeni__set_session_title).
2219
- // Appended after the non-bypassable core so a white-label persona can't drop it.
2220
2876
  export const GENESIS_TITLE_DIRECTIVE =
2221
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.";
2222
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
+
2223
2910
  // Generic substrate prompting for programmatic tool calling (toolspace). Same
2224
2911
  // text for every host; gated per-turn by appendToolspaceInstructions on the
2225
2912
  // presence of a minted toolspace token, so it only appears when the sandbox
@@ -2256,7 +2943,7 @@ export const stripProviderItemIdsFilter: CallModelInputFilter = ({ modelData })
2256
2943
  * `action` and `actions` down to EXACTLY ONE (keeps `actions`, drops `action`).
2257
2944
  * The Azure computer-use endpoint rejects a request whose computer_call has
2258
2945
  * both with `400 Computer call input must include exactly one of `action` or
2259
- * `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
2260
2947
  * rejects the `action`-only form, accepting ONLY the batched plural `actions`.
2261
2948
  * The SDK 0.11.6 schema allows both, so a freshly-emitted
2262
2949
  * screenshot call carries the redundant pair. This filter runs before EVERY
@@ -2272,43 +2959,43 @@ export const normalizeComputerCallsFilter: CallModelInputFilter = ({ modelData }
2272
2959
  ) as unknown as AgentInputItem[],
2273
2960
  });
2274
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
+
2275
2974
  export function contextRobustnessFilterForSettings(
2276
2975
  settings: Settings,
2277
2976
  options: ContextRobustnessFilterOptions = {},
2278
2977
  ): CallModelInputFilter {
2279
- const inputBudgetTokens = modelCallBudgetTokens(settings);
2280
- const clientCompactionMode = resolveContextCompactionMode(settings) === "client";
2281
- const compactionThresholdTokens = clientCompactionThresholdTokens(settings);
2282
- return ({ modelData }) => {
2283
- const images = elideStaleScreenshotImages(modelData.input);
2284
- if (images.elidedCount > 0) {
2285
- console.warn(
2286
- `per-call image history policy elided ${images.elidedCount} older screenshot image(s), keeping the last ${Math.min(3, images.imageCount)} full image(s)`,
2287
- );
2288
- }
2289
- let input = images.items;
2290
- if (inputBudgetTokens !== undefined) {
2291
- const guarded = enforceInputBudget(
2292
- input as unknown as Array<Record<string, unknown>>,
2293
- inputBudgetTokens,
2294
- );
2295
- if (guarded.trimmed) {
2296
- console.warn(
2297
- `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`,
2298
- );
2299
- input = guarded.items as unknown as AgentInputItem[];
2300
- }
2301
- }
2302
- if (clientCompactionMode && options.throwOnCompactionNeeded) {
2978
+ const thresholdTokens = compactionThresholdTokens(settings);
2979
+ return async ({ modelData }) => {
2980
+ const input = modelData.input;
2981
+ if (options.throwOnCompactionNeeded) {
2303
2982
  const reported = options.contextCompactionSignalTokens?.();
2304
2983
  const hasReported = typeof reported === "number" && reported > 0;
2305
2984
  const signalTokens = hasReported
2306
2985
  ? reported
2307
2986
  : estimateTokens(input as unknown as Array<Record<string, unknown>>);
2308
- 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) {
2309
2996
  throw new CompactionNeededError({
2310
2997
  signalTokens,
2311
- thresholdTokens: compactionThresholdTokens,
2998
+ thresholdTokens,
2312
2999
  signalSource: hasReported ? "provider" : "estimate",
2313
3000
  });
2314
3001
  }
@@ -2317,14 +3004,6 @@ export function contextRobustnessFilterForSettings(
2317
3004
  };
2318
3005
  }
2319
3006
 
2320
- function modelCallBudgetTokens(settings: Settings): number | undefined {
2321
- if (resolveContextCompactionMode(settings) !== "client") {
2322
- return undefined;
2323
- }
2324
- const budget = contextInputBudgetTokens(settings);
2325
- return budget > 0 ? budget : undefined;
2326
- }
2327
-
2328
3007
  /**
2329
3008
  * Compose a list of callModelInputFilters into one, applied left-to-right so
2330
3009
  * each sees the prior filter's output.
@@ -2343,14 +3022,18 @@ function composeCallModelInputFilters(filters: CallModelInputFilter[]): CallMode
2343
3022
  * The model-input filter applied before every model call. The computer_call
2344
3023
  * action/actions normalizer is ALWAYS on (the Azure endpoint 400s without it);
2345
3024
  * the provider-item-id strip is layered on top when the configured policy
2346
- * selects it; the context-robustness guard then elides stale screenshots on
2347
- * 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.
2348
3028
  */
2349
3029
  export function callModelInputFilterForSettings(
2350
3030
  settings: Settings,
2351
3031
  options: ContextRobustnessFilterOptions = {},
2352
3032
  ): CallModelInputFilter | undefined {
2353
- const filters: CallModelInputFilter[] = [normalizeComputerCallsFilter];
3033
+ const filters: CallModelInputFilter[] = [
3034
+ normalizeComputerCallsFilter,
3035
+ elideSupersededViewImagesFilter,
3036
+ ];
2354
3037
  if (settings.openaiProviderItemIds === "strip") {
2355
3038
  filters.push(stripProviderItemIdsFilter);
2356
3039
  }
@@ -2358,9 +3041,16 @@ export function callModelInputFilterForSettings(
2358
3041
  return composeCallModelInputFilters(filters);
2359
3042
  }
2360
3043
 
2361
- export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgentInput | string | RunState<any, any>, settings: Settings, overrides: RunAgentStreamOptions = {}) {
2362
- 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;
2363
3052
  const environment = overrides.sandboxEnvironment ?? collectSandboxEnvironment(settings);
3053
+ const genesisTitleInputFilter = takeGenesisTitleInputFilter(agent);
2364
3054
 
2365
3055
  // OWNED PATH (P1.2 ownership inversion): the per-turn resume path injected a
2366
3056
  // live, externally-owned box. We thread the live `session` straight into
@@ -2379,90 +3069,75 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
2379
3069
  // whose per-op pointer re-read could land these execs on a machine swapped in
2380
3070
  // mid-turn.
2381
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
+ }
2382
3087
  const runAs = sandboxRunAs(settings);
2383
3088
  const fileDownloads = sandboxFileDownloadsForAgent(agent);
2384
- const resourceClient = fileDownloads.length > 0
2385
- ? withSandboxFileDownloads(ownedClient as SandboxClient, fileDownloads, {
2386
- ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
2387
- ...(runAs ? { runAs } : {}),
2388
- })
2389
- : (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);
2390
3096
  // TOKEN-BROKER (B1): the per-turn git token seed, forwarded OFF-MANIFEST so the
2391
3097
  // repository-clone hook seeds it to the box's token file before the clone.
2392
- const ownedGitTokenSeed = gitTokenSeedForAgent(agent);
3098
+ const ownedGitTokenSeeds = gitTokenSeedsForAgent(agent);
2393
3099
  const ownedToolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
3100
+ const ownedRigSetup = rigSetupDescriptorForAgent(agent);
2394
3101
  const ownedHooks = [
2395
- ...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
+ ),
2396
3110
  ...sandboxToolspaceTokenHooksForAgent(agent),
2397
3111
  ...sandboxRepositoryCloneHooksForAgent(agent),
3112
+ ...gitCredentialSessionRegistrationHooks(overrides.onGitCredentialSessionReady),
2398
3113
  ];
2399
3114
  const ownedHookContext: SandboxLifecycleHookContext = {
2400
3115
  environment,
2401
3116
  ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
2402
3117
  ...(runAs ? { runAs } : {}),
2403
- ...(ownedGitTokenSeed ? { gitTokenSeed: ownedGitTokenSeed } : {}),
3118
+ ...(ownedGitTokenSeeds ? { gitTokenSeeds: ownedGitTokenSeeds } : {}),
2404
3119
  ...(ownedToolspaceTokenSeed ? { toolspaceTokenSeed: ownedToolspaceTokenSeed } : {}),
3120
+ ...(ownedRigSetup ? { rigSetup: ownedRigSetup } : {}),
2405
3121
  };
2406
- // OWNED-PATH HOOKS: the SDK NEVER calls client.create/resume when handed a live
2407
- // provided session (SandboxRuntimeManager uses `sandboxConfig.session` directly),
2408
- // so the withSandboxLifecycleHooks decoration below can never fire on this branch —
2409
- // it only wraps create/resume. Run the beforeAgentStart hooks directly against the
2410
- // provided box, once per turn, BEFORE the run starts: this is what executes the
2411
- // repository-clone hook (which also seeds the B1 askpass + token file) and the
2412
- // azure-cli-login hook on lease-owned boxes. Re-running on a warm box is safe by
2413
- // construction: clone skips when the target is already materialized, the token
2414
- // seed OVERWRITES the file (the desired per-turn refresh), and az login is
2415
- // idempotent. A turn resumed after preemption re-enters here and re-seeds the
2416
- // freshly minted token — which is exactly what a >1h-old warm box needs.
2417
- // EXCEPT on a connected machine (effective backend "selfhosted"): the box is the
2418
- // user's REAL computer — the platform must not run setup against it (the clone
2419
- // hooks are already empty there; this keeps az login off it too).
2420
- if (agentActiveSandboxBackend.get(agent) !== "selfhosted") {
2421
- await runBeforeAgentStartHooks(setupSession, ownedHooks, ownedHookContext);
2422
- // FILE RESOURCES: withSandboxFileDownloads below has the IDENTICAL provided-
2423
- // session blind spot (it too wraps only create/resume), so signed-URL file
2424
- // materialization must also run directly against the pinned box. The download
2425
- // command is idempotent (skips an existing file) and atomic (tmp + rename),
2426
- // so the per-turn re-run is safe; the turn re-signs URLs each run, so a
2427
- // re-warmed box always gets fresh links.
2428
- if (fileDownloads.length > 0) {
2429
- await materializeSandboxFileDownloads(setupSession, fileDownloads, {
2430
- ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
2431
- ...(runAs ? { runAs } : {}),
2432
- });
2433
- }
2434
- } else {
2435
- // SELFHOSTED TOOLSPACE (parity): the platform setup hooks (repository clone,
2436
- // az login) and file materialization stay OFF the user's real machine — but
2437
- // the toolspace token seed is the ONE piece of per-turn material that must
2438
- // reach it. It writes a scoped, own-session-bound token to
2439
- // $OPENGENI_TOOLSPACE_TOKEN_FILE over the SAME exec channel the clone-seed
2440
- // uses (off-manifest, value never on the manifest), and it grants no more
2441
- // than the machine owner's own authority (toolspace:call, this session, turn
2442
- // TTL, budgeted, approval-tools excluded) — the invariant that makes it safe
2443
- // to cross to a user machine when the git token is not. It is also the
2444
- // machine's only path to programmatic tool calling. Seed it (only) here; the
2445
- // hook list is empty when no toolspace token was minted for this turn.
2446
- const toolspaceHooks = sandboxToolspaceTokenHooksForAgent(agent);
2447
- if (toolspaceHooks.length > 0) {
2448
- await runBeforeAgentStartHooks(setupSession, toolspaceHooks, ownedHookContext);
2449
- }
2450
- }
2451
3122
  // Keep the decoration as a safety net for any session the SDK does create/resume
2452
3123
  // through the client during this run (it is inert for the provided session).
2453
3124
  const decoratedClient = withSandboxLifecycleHooks(resourceClient, ownedHooks, ownedHookContext);
2454
3125
  const ownedFilter = composeCallModelInputFilters(
2455
3126
  [
2456
3127
  callModelInputFilterForSettings(settings, {
2457
- throwOnCompactionNeeded: Boolean(overrides.contextCompactionSignalTokens),
3128
+ throwOnCompactionNeeded: Boolean(
3129
+ overrides.contextCompactionSignalTokens || overrides.contextCompactionRequested,
3130
+ ),
2458
3131
  ...(overrides.contextCompactionSignalTokens
2459
3132
  ? { contextCompactionSignalTokens: overrides.contextCompactionSignalTokens }
2460
3133
  : {}),
3134
+ ...(overrides.contextCompactionRequested
3135
+ ? { contextCompactionRequested: overrides.contextCompactionRequested }
3136
+ : {}),
2461
3137
  }),
3138
+ genesisTitleInputFilter,
2462
3139
  overrides.callModelInputFilter,
2463
- ].filter(
2464
- (f): f is CallModelInputFilter => Boolean(f),
2465
- ),
3140
+ ].filter((f): f is CallModelInputFilter => Boolean(f)),
2466
3141
  );
2467
3142
  const ownedRunOptions: Parameters<typeof run>[2] = {
2468
3143
  stream: true,
@@ -2479,37 +3154,55 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
2479
3154
 
2480
3155
  const rawClient = overrides.sandboxClient ?? createSandboxClient(settings, environment);
2481
3156
  const refreshedClient = rawClient
2482
- ? 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
+ )
2483
3164
  : undefined;
2484
3165
  const runAs = sandboxRunAs(settings);
2485
3166
  const fileDownloads = sandboxFileDownloadsForAgent(agent);
2486
- const resourceClient = refreshedClient && fileDownloads.length > 0
2487
- ? withSandboxFileDownloads(refreshedClient, fileDownloads, {
2488
- ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
2489
- ...(runAs ? { runAs } : {}),
2490
- })
2491
- : 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;
2492
3174
  // TOKEN-BROKER (B1): the per-turn git token seed, forwarded OFF-MANIFEST so the
2493
3175
  // repository-clone hook seeds it to the box's token file before the clone.
2494
- const gitTokenSeed = gitTokenSeedForAgent(agent);
3176
+ const gitTokenSeeds = gitTokenSeedsForAgent(agent);
2495
3177
  const toolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
3178
+ const legacyRigSetup = rigSetupDescriptorForAgent(agent);
2496
3179
  const client = resourceClient
2497
- ? withSandboxLifecycleHooks(resourceClient, [
2498
- ...sandboxLifecycleHooksForIds(sandboxLifecycleHookIds(settings)),
2499
- ...sandboxToolspaceTokenHooksForAgent(agent),
2500
- ...sandboxRepositoryCloneHooksForAgent(agent),
2501
- ], {
2502
- environment,
2503
- ...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
2504
- ...(runAs ? { runAs } : {}),
2505
- ...(gitTokenSeed ? { gitTokenSeed } : {}),
2506
- ...(toolspaceTokenSeed ? { toolspaceTokenSeed } : {}),
2507
- })
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
+ )
2508
3204
  : undefined;
2509
- const sandboxSessionState = prepared.sandboxSessionState
2510
- ?? (prepared.serializedRunStateForSandbox && client
2511
- ? await restoredSandboxSessionState(await RunState.fromString(agent, prepared.serializedRunStateForSandbox), client)
2512
- : undefined);
3205
+ const sandboxSessionState = prepared.sandboxSessionState;
2513
3206
  // Apply the built-in per-call filters (computer-call normalization, optional
2514
3207
  // provider-id stripping, image/budget guard), then any per-turn filter
2515
3208
  // (genesis title directive). A callModelInputFilter only shapes the per-call
@@ -2518,22 +3211,26 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
2518
3211
  const callModelInputFilter = composeCallModelInputFilters(
2519
3212
  [
2520
3213
  callModelInputFilterForSettings(settings, {
2521
- throwOnCompactionNeeded: Boolean(overrides.contextCompactionSignalTokens),
3214
+ throwOnCompactionNeeded: Boolean(
3215
+ overrides.contextCompactionSignalTokens || overrides.contextCompactionRequested,
3216
+ ),
2522
3217
  ...(overrides.contextCompactionSignalTokens
2523
3218
  ? { contextCompactionSignalTokens: overrides.contextCompactionSignalTokens }
2524
3219
  : {}),
3220
+ ...(overrides.contextCompactionRequested
3221
+ ? { contextCompactionRequested: overrides.contextCompactionRequested }
3222
+ : {}),
2525
3223
  }),
3224
+ genesisTitleInputFilter,
2526
3225
  overrides.callModelInputFilter,
2527
- ].filter(
2528
- (f): f is CallModelInputFilter => Boolean(f),
2529
- ),
3226
+ ].filter((f): f is CallModelInputFilter => Boolean(f)),
2530
3227
  );
2531
3228
  const runOptions: Parameters<typeof run>[2] = {
2532
3229
  stream: true,
2533
3230
  maxTurns: settings.agentMaxModelCallsPerTurn,
2534
3231
  // Built-in per-call guard chain: normalize computer calls, optionally strip
2535
- // provider ids, elide stale screenshots in every mode, and trim to the input
2536
- // 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
2537
3234
  // every mid-turn follow-up.
2538
3235
  callModelInputFilter,
2539
3236
  };
@@ -2546,6 +3243,26 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
2546
3243
  return await runScopedRunner(settings).run(agent, prepared.input, runOptions);
2547
3244
  }
2548
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
+
2549
3266
  /**
2550
3267
  * A per-run `Runner` whose `modelProvider` is built from THIS turn's settings.
2551
3268
  *
@@ -2577,7 +3294,9 @@ export { MaxTurnsExceededError } from "@openai/agents";
2577
3294
  * the work. When the SDK attached the run state at the moment the cap hit,
2578
3295
  * the serialized form is returned so the resumed turn keeps full context.
2579
3296
  */
2580
- export function maxTurnsExceededRunState(error: unknown): { serializedRunState: string | null } | null {
3297
+ export function maxTurnsExceededRunState(
3298
+ error: unknown,
3299
+ ): { serializedRunState: string | null } | null {
2581
3300
  if (!(error instanceof MaxTurnsExceededError)) {
2582
3301
  return null;
2583
3302
  }
@@ -2605,42 +3324,102 @@ export function agentsErrorRunState(error: unknown): string | null {
2605
3324
  }
2606
3325
  }
2607
3326
 
2608
- 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 {
2609
3332
  if (!targetManifest || !client.resume) {
2610
3333
  return client;
2611
3334
  }
2612
3335
  return {
2613
3336
  backendId: client.backendId,
2614
- ...(client.supportsDefaultOptions !== undefined ? { supportsDefaultOptions: client.supportsDefaultOptions } : {}),
2615
- ...(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
+ : {}),
2616
3343
  resume: async (state: SandboxSessionState) => {
2617
3344
  const session = await client.resume!(state);
2618
- await applyMissingManifestEntries(session, targetManifest);
3345
+ await applyMissingManifestEntries(session, targetManifest, context);
2619
3346
  return session;
2620
3347
  },
2621
- ...(client.delete ? { delete: async (state: SandboxSessionState) => await client.delete!(state) } : {}),
2622
- ...(client.serializeSessionState ? { serializeSessionState: async (state: SandboxSessionState, options) => await client.serializeSessionState!(state, options) } : {}),
2623
- ...(client.canPersistOwnedSessionState ? { canPersistOwnedSessionState: async (state: SandboxSessionState) => await client.canPersistOwnedSessionState!(state) } : {}),
2624
- ...(client.canReusePreservedOwnedSession ? { canReusePreservedOwnedSession: async (state: SandboxSessionState) => await client.canReusePreservedOwnedSession!(state) } : {}),
2625
- ...(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
+ : {}),
2626
3375
  };
2627
3376
  }
2628
3377
 
2629
- export async function applyMissingManifestEntries(session: SandboxSessionLike, targetManifest: Manifest): Promise<void> {
2630
- 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;
2631
3402
  const currentManifest = currentManifestValue ? ensureManifest(currentManifestValue) : undefined;
2632
3403
  const target = ensureManifest(targetManifest);
2633
3404
  if (!currentManifest) {
2634
3405
  if (Object.keys(target.entries).length === 0) {
2635
3406
  return;
2636
3407
  }
2637
- 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
+ );
2638
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);
2639
3416
  if (!session.applyManifest && !session.materializeEntry) {
2640
3417
  if (Object.keys(target.entries).length === 0) {
2641
3418
  return;
2642
3419
  }
2643
- 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
+ );
2644
3423
  }
2645
3424
  if (Object.keys(target.entries).length === 0) {
2646
3425
  return;
@@ -2659,9 +3438,12 @@ export async function applyMissingManifestEntries(session: SandboxSessionLike, t
2659
3438
  throw new Error(`Cannot replace existing sandbox manifest entry: ${path}`);
2660
3439
  }
2661
3440
  }
2662
- const environmentChanged = stableJson(currentManifest.environment) !== stableJson(target.environment);
3441
+ const environmentChanged =
3442
+ stableJson(currentManifest.environment) !== stableJson(target.environment);
2663
3443
  if (environmentChanged && !session.applyManifest) {
2664
- 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
+ );
2665
3447
  }
2666
3448
  if (Object.keys(entries).length === 0 && !environmentChanged) {
2667
3449
  return;
@@ -2694,6 +3476,216 @@ export async function applyMissingManifestEntries(session: SandboxSessionLike, t
2694
3476
  });
2695
3477
  }
2696
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
+
2697
3689
  function mergePathGrants(
2698
3690
  current: Manifest["extraPathGrants"] | undefined,
2699
3691
  target: Manifest["extraPathGrants"] | undefined,
@@ -2724,14 +3716,48 @@ export function withSandboxFileDownloads(
2724
3716
  };
2725
3717
  return {
2726
3718
  backendId: client.backendId,
2727
- ...(client.supportsDefaultOptions !== undefined ? { supportsDefaultOptions: client.supportsDefaultOptions } : {}),
2728
- ...(client.create ? { create: async (...args: any[]) => await wrapSession(await (client.create as any)(...args)) } : {}),
2729
- ...(client.resume ? { resume: async (state: SandboxSessionState) => await wrapSession(await client.resume!(state)) } : {}),
2730
- ...(client.delete ? { delete: async (state: SandboxSessionState) => await client.delete!(state) } : {}),
2731
- ...(client.serializeSessionState ? { serializeSessionState: async (state: SandboxSessionState, options) => await client.serializeSessionState!(state, options) } : {}),
2732
- ...(client.canPersistOwnedSessionState ? { canPersistOwnedSessionState: async (state: SandboxSessionState) => await client.canPersistOwnedSessionState!(state) } : {}),
2733
- ...(client.canReusePreservedOwnedSession ? { canReusePreservedOwnedSession: async (state: SandboxSessionState) => await client.canReusePreservedOwnedSession!(state) } : {}),
2734
- ...(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
+ : {}),
2735
3761
  };
2736
3762
  }
2737
3763
 
@@ -2739,14 +3765,12 @@ export async function materializeSandboxFileDownloads(
2739
3765
  session: SandboxSessionLike,
2740
3766
  downloads: SandboxFileDownload[],
2741
3767
  context: Pick<SandboxLifecycleHookContext, "onRuntimeEvent" | "runAs"> = {},
2742
- ): Promise<void> {
3768
+ ): Promise<SandboxFileDownloadMaterializationResult> {
2743
3769
  const normalizedDownloads = normalizeSandboxFileDownloads(downloads);
2744
3770
  if (normalizedDownloads.length === 0) {
2745
- return;
2746
- }
2747
- if (!session.exec && !session.execCommand) {
2748
- throw new Error("Sandbox file download materialization requires command execution support");
3771
+ return { failures: [] };
2749
3772
  }
3773
+ const failures: SandboxFileDownloadFailure[] = [];
2750
3774
  for (const download of normalizedDownloads) {
2751
3775
  const targetPath = sandboxDownloadTargetPath(download);
2752
3776
  const payload = {
@@ -2755,37 +3779,94 @@ export async function materializeSandboxFileDownloads(
2755
3779
  sizeBytes: download.sizeBytes ?? null,
2756
3780
  expiresAt: download.expiresAt ? new Date(download.expiresAt).toISOString() : null,
2757
3781
  };
2758
- 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;
2759
3804
  try {
2760
- const result = session.exec
3805
+ result = session.exec
2761
3806
  ? await session.exec({
2762
- cmd: sandboxFileDownloadCommand(download, targetPath),
2763
- workdir: "/workspace",
2764
- ...(context.runAs ? { runAs: context.runAs } : {}),
2765
- yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
2766
- maxOutputTokens: 20_000,
2767
- })
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
+ })
2768
3813
  : await session.execCommand!({
2769
- cmd: sandboxFileDownloadCommand(download, targetPath),
2770
- workdir: "/workspace",
2771
- ...(context.runAs ? { runAs: context.runAs } : {}),
2772
- yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
2773
- maxOutputTokens: 20_000,
2774
- });
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
+ });
2775
3820
  assertSandboxCommandSucceeded(result, `Sandbox file resource download ${download.fileId}`);
2776
- 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
+ });
2777
3825
  } catch (error) {
3826
+ const failure = sandboxFileDownloadFailure(download, targetPath, error, result);
3827
+ failures.push(failure);
2778
3828
  await context.onRuntimeEvent?.({
2779
3829
  type: "sandbox.operation.failed",
2780
3830
  payload: {
2781
3831
  name: "file-resource-download",
2782
3832
  ...payload,
2783
- 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 } : {}),
2784
3836
  },
2785
3837
  });
2786
- throw error;
2787
3838
  }
2788
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");
2789
3870
  }
2790
3871
 
2791
3872
  export function sandboxFileDownloadsForAgent(agent: unknown): SandboxFileDownload[] {
@@ -2794,8 +3875,20 @@ export function sandboxFileDownloadsForAgent(agent: unknown): SandboxFileDownloa
2794
3875
  : [];
2795
3876
  }
2796
3877
 
2797
- function ensureManifest(manifest: Manifest | { root?: string; entries?: Record<string, any>; environment?: Record<string, any>; extraPathGrants?: any[] }): Manifest {
2798
- 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
+ ) {
2799
3892
  return manifest;
2800
3893
  }
2801
3894
  return new Manifest({
@@ -2846,7 +3939,10 @@ function structuredImageToDataUrl(value: unknown): string | null {
2846
3939
  if (typeof image.url === "string" && image.url.length > 0) {
2847
3940
  return image.url;
2848
3941
  }
2849
- 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";
2850
3946
  if (typeof image.data === "string") {
2851
3947
  return image.data.startsWith("data:") ? image.data : `data:${mediaType};base64,${image.data}`;
2852
3948
  }
@@ -2894,6 +3990,9 @@ export function normalizeSdkEvent(event: RunStreamEvent): NormalizedRuntimeEvent
2894
3990
  out.push({ type: "agent.message.delta", payload: { text: data.delta } });
2895
3991
  return out;
2896
3992
  }
3993
+ if (data?.type === "response_done") {
3994
+ return out;
3995
+ }
2897
3996
  }
2898
3997
  if (isOpenAIResponsesRawModelStreamEvent(event)) {
2899
3998
  const raw = (event as any).data?.event;
@@ -2952,13 +4051,21 @@ export function normalizeSdkEvent(event: RunStreamEvent): NormalizedRuntimeEvent
2952
4051
  } else if (item.type === "tool_search_output_item") {
2953
4052
  const raw = item.rawItem ?? {};
2954
4053
  const disclosed = Array.isArray(raw.tools)
2955
- ? 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)
2956
4057
  : [];
2957
4058
  out.push({
2958
4059
  type: "agent.toolCall.output",
2959
4060
  payload: {
2960
4061
  id: raw.call_id ?? raw.callId ?? item.id ?? null,
2961
- 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
+ },
2962
4069
  },
2963
4070
  });
2964
4071
  } else if (item.type === "message_output_item") {
@@ -2972,15 +4079,21 @@ export function normalizeSdkEvent(event: RunStreamEvent): NormalizedRuntimeEvent
2972
4079
 
2973
4080
  export function modelResponseUsageFromSdkEvent(event: RunStreamEvent): ModelResponseUsage | null {
2974
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 {
2975
4087
  const usage = usageFromResponse(response);
2976
4088
  if (!usage) {
2977
4089
  return null;
2978
4090
  }
2979
- const responseId = typeof response?.id === "string"
2980
- ? response.id
2981
- : typeof response?.responseId === "string"
2982
- ? response.responseId
2983
- : 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;
2984
4097
  return {
2985
4098
  ...(responseId ? { responseId } : {}),
2986
4099
  usage,
@@ -3003,33 +4116,70 @@ function modelResponseFromSdkEvent(event: RunStreamEvent): any {
3003
4116
  return null;
3004
4117
  }
3005
4118
 
3006
- function usageFromResponse(response: any): ModelResponseUsage["usage"] | null {
3007
- const raw = response?.usage;
4119
+ function usageFromResponse(response: unknown): ModelResponseUsage["usage"] | null {
4120
+ const raw = (response as { usage?: unknown } | null)?.usage;
3008
4121
  if (!raw || typeof raw !== "object") {
3009
4122
  return null;
3010
4123
  }
4124
+ const record = raw as Record<string, unknown>;
3011
4125
  const usage = {
3012
- ...numberProp(raw, "inputTokens", "inputTokens", "input_tokens"),
3013
- ...numberProp(raw, "outputTokens", "outputTokens", "output_tokens"),
3014
- ...numberProp(raw, "totalTokens", "totalTokens", "total_tokens"),
3015
- ...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),
3016
4145
  };
3017
4146
  return Object.keys(usage).length > 0 ? usage : null;
3018
4147
  }
3019
4148
 
3020
- function numberProp(raw: Record<string, unknown>, outputKey: "inputTokens" | "outputTokens" | "totalTokens", camel: string, snake: string): Partial<ModelResponseUsage["usage"]> {
3021
- 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);
3022
4155
  return typeof value === "number" && Number.isFinite(value) ? { [outputKey]: value } : {};
3023
4156
  }
3024
4157
 
3025
4158
  function inputTokenDetailsProp(raw: Record<string, unknown>): Partial<ModelResponseUsage["usage"]> {
3026
- 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;
3027
4164
  if (!details || typeof details !== "object") {
3028
4165
  return {};
3029
4166
  }
3030
4167
  return { inputTokensDetails: details as Record<string, number> | Array<Record<string, number>> };
3031
4168
  }
3032
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
+
3033
4183
  export function serializeApprovals(interruptions: unknown[]): unknown[] {
3034
4184
  return interruptions.map((item: any) => {
3035
4185
  if (typeof item?.toJSON === "function") {
@@ -3051,7 +4201,12 @@ export function buildManifest(
3051
4201
  fileResourceDownloads: SandboxFileDownload[] = [],
3052
4202
  ): Manifest {
3053
4203
  const entries: Record<string, any> = {};
3054
- 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
+ );
3055
4210
  for (const resource of resources) {
3056
4211
  if (resource.kind === "repository") {
3057
4212
  const url = new URL(resource.uri);
@@ -3092,7 +4247,9 @@ export function buildManifest(
3092
4247
 
3093
4248
  function sandboxDownloadDirectory(download: SandboxFileDownload, mountPath: string): any {
3094
4249
  if (download.mountPath !== mountPath) {
3095
- 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
+ );
3096
4253
  }
3097
4254
  assertSafeSandboxFilename(download.filename, download.fileId);
3098
4255
  if (download.content) {
@@ -3113,7 +4270,9 @@ function objectStorageFileMount(settings: Settings, prefix: string): any {
3113
4270
  const nativeBucketMount = CAPABILITY_DESCRIPTORS[settings.sandboxBackend].nativeBucketMount;
3114
4271
  if (settings.objectStorageBackend === "azure-blob") {
3115
4272
  if (nativeBucketMount) {
3116
- 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
+ );
3117
4276
  }
3118
4277
  const config = azureBlobMountConfig(settings);
3119
4278
  return azureBlobMount({
@@ -3127,7 +4286,9 @@ function objectStorageFileMount(settings: Settings, prefix: string): any {
3127
4286
  });
3128
4287
  }
3129
4288
  if (settings.objectStorageBackend === "aws-s3" || settings.objectStorageBackend === "gcs") {
3130
- 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
+ );
3131
4292
  }
3132
4293
  const config = s3CompatibleMountConfig(settings);
3133
4294
  return s3Mount({
@@ -3154,7 +4315,11 @@ function s3CompatibleMountConfig(settings: Settings): {
3154
4315
  secretAccessKey: string;
3155
4316
  } {
3156
4317
  const endpointUrl = settings.objectStorageSandboxEndpoint ?? settings.objectStorageEndpoint;
3157
- if (!endpointUrl || !settings.objectStorageAccessKeyId || !settings.objectStorageSecretAccessKey) {
4318
+ if (
4319
+ !endpointUrl ||
4320
+ !settings.objectStorageAccessKeyId ||
4321
+ !settings.objectStorageSecretAccessKey
4322
+ ) {
3158
4323
  throw new Error("File resources require configured S3-compatible object storage");
3159
4324
  }
3160
4325
  return {
@@ -3181,7 +4346,10 @@ function azureBlobMountConfig(settings: Settings): {
3181
4346
  if (!accountName || !accountKey) {
3182
4347
  throw new Error("File resources require Azure Blob account name and account key");
3183
4348
  }
3184
- const endpointUrl = azureBlobManifestEndpoint(settings.objectStorageAzureEndpoint ?? parsed.BlobEndpoint, accountName);
4349
+ const endpointUrl = azureBlobManifestEndpoint(
4350
+ settings.objectStorageAzureEndpoint ?? parsed.BlobEndpoint,
4351
+ accountName,
4352
+ );
3185
4353
  return {
3186
4354
  container: settings.objectStorageBucket,
3187
4355
  accountName,
@@ -3190,7 +4358,10 @@ function azureBlobMountConfig(settings: Settings): {
3190
4358
  };
3191
4359
  }
3192
4360
 
3193
- function azureBlobManifestEndpoint(endpoint: string | undefined, accountName: string): string | undefined {
4361
+ function azureBlobManifestEndpoint(
4362
+ endpoint: string | undefined,
4363
+ accountName: string,
4364
+ ): string | undefined {
3194
4365
  if (!endpoint) {
3195
4366
  return undefined;
3196
4367
  }
@@ -3200,13 +4371,16 @@ function azureBlobManifestEndpoint(endpoint: string | undefined, accountName: st
3200
4371
  }
3201
4372
 
3202
4373
  function parseAzureConnectionString(value: string): Record<string, string> {
3203
- return Object.fromEntries(value.split(";")
3204
- .map((part) => part.trim())
3205
- .filter(Boolean)
3206
- .map((part) => {
3207
- const index = part.indexOf("=");
3208
- return index === -1 ? [part, ""] : [part.slice(0, index), part.slice(index + 1)];
3209
- }));
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
+ );
3210
4384
  }
3211
4385
 
3212
4386
  function normalizeManifestPath(path: string): string {
@@ -3222,7 +4396,9 @@ function normalizeSandboxFileDownloads(downloads: SandboxFileDownload[]): Sandbo
3222
4396
  const mountPath = normalizeManifestPath(download.mountPath);
3223
4397
  assertSafeSandboxFilename(download.filename, download.fileId);
3224
4398
  if (!download.content && !download.url?.trim()) {
3225
- 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
+ );
3226
4402
  }
3227
4403
  return {
3228
4404
  ...download,
@@ -3232,7 +4408,14 @@ function normalizeSandboxFileDownloads(downloads: SandboxFileDownload[]): Sandbo
3232
4408
  }
3233
4409
 
3234
4410
  function assertSafeSandboxFilename(filename: string, fileId: string): void {
3235
- 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
+ ) {
3236
4419
  throw new Error(`Invalid sandbox file name for ${fileId}: ${filename}`);
3237
4420
  }
3238
4421
  }
@@ -3248,11 +4431,11 @@ function sandboxFileDownloadCommand(download: SandboxFileDownload, targetPath: s
3248
4431
  const targetDir = posixPath.dirname(targetPath);
3249
4432
  const tmpPath = `${targetPath}.opengeni-download-$$`;
3250
4433
  return [
3251
- "set -euo pipefail",
4434
+ "set -eu",
3252
4435
  `mkdir -p -- ${shellQuote(targetDir)}`,
3253
4436
  `if [ ! -f ${shellQuote(targetPath)} ]; then`,
3254
4437
  ` tmp=${shellQuote(tmpPath)}`,
3255
- " cleanup() { rm -f -- \"$tmp\"; }",
4438
+ ' cleanup() { rm -f -- "$tmp"; }',
3256
4439
  " trap cleanup EXIT",
3257
4440
  ` curl --fail --location --silent --show-error --retry 3 --retry-delay 1 --output "$tmp" ${shellQuote(download.url)}`,
3258
4441
  ` mv -- "$tmp" ${shellQuote(targetPath)}`,
@@ -3266,47 +4449,36 @@ function shellQuote(value: string): string {
3266
4449
  return `'${value.replace(/'/g, "'\\''")}'`;
3267
4450
  }
3268
4451
 
3269
- async function restoredSandboxSessionState(state: RunState<any, any>, client: unknown): Promise<SandboxSessionState | undefined> {
3270
- if (!client) {
3271
- return undefined;
3272
- }
3273
- const sandboxState = (state as any)._sandbox;
3274
- const entry = sandboxState?.sessionsByAgent?.[sandboxState.currentAgentKey]
3275
- ?? (sandboxState?.currentAgentKey && sandboxState?.sessionState
3276
- ? {
3277
- backendId: sandboxState.backendId,
3278
- currentAgentKey: sandboxState.currentAgentKey,
3279
- currentAgentName: sandboxState.currentAgentName,
3280
- sessionState: sandboxState.sessionState,
3281
- }
3282
- : undefined);
3283
- if (!entry) {
3284
- return undefined;
3285
- }
3286
- if ((client as SandboxClient).backendId !== entry.backendId) {
3287
- throw new Error("RunState sandbox backend does not match the configured sandbox client");
3288
- }
3289
- return await deserializeSandboxSessionStateEnvelope(client as SandboxClient, entry.sessionState);
3290
- }
3291
-
3292
- // sandboxStateEntryFromRunState + restoredSandboxSessionStateFromEntry +
3293
- // deserializeSandboxSessionStateEnvelope moved to the agent-loop-free leaf
3294
- // ./sandbox; re-exported via `export * from "./sandbox"`. The private
3295
- // restoredSandboxSessionState above (which takes an agent-loop RunState) calls
3296
- // the moved deserializeSandboxSessionStateEnvelope, imported from ./sandbox.
3297
-
3298
4452
  export type SandboxLifecycleHookPhase = "beforeAgentStart";
3299
4453
 
3300
4454
  export type SandboxLifecycleHookContext = {
3301
4455
  environment: Record<string, string>;
3302
4456
  onRuntimeEvent?: (event: NormalizedRuntimeEvent) => Promise<void> | void;
3303
4457
  runAs?: string;
3304
- // TOKEN-BROKER (B1): the run-scoped GitHub token to seed into the box's token
3305
- // FILE before the repository clone runs. Threaded OFF-MANIFEST — it rides ONLY
3306
- // the clone exec's per-call env (OPENGENI_GIT_TOKEN_SEED), NEVER the box/agent
3307
- // manifest env (validateNoEnvironmentDelta must never see a rotating value).
4458
+ // TOKEN-BROKER (B1): back-compat GitHub alias for gitTokenSeeds.github.
3308
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;
3309
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;
3310
4482
  };
3311
4483
 
3312
4484
  export type SandboxLifecycleHook = {
@@ -3339,7 +4511,9 @@ function applicableBeforeAgentStartHooks(
3339
4511
  hooks: SandboxLifecycleHook[],
3340
4512
  context: SandboxLifecycleHookContext,
3341
4513
  ): SandboxLifecycleHook[] {
3342
- 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
+ );
3343
4517
  }
3344
4518
 
3345
4519
  /**
@@ -3384,14 +4558,48 @@ export function withSandboxLifecycleHooks(
3384
4558
  };
3385
4559
  const wrapped: SandboxClient = {
3386
4560
  backendId: client.backendId,
3387
- ...(client.supportsDefaultOptions !== undefined ? { supportsDefaultOptions: client.supportsDefaultOptions } : {}),
3388
- ...(client.create ? { create: async (...args: any[]) => await wrapSession(await (client.create as any)(...args)) } : {}),
3389
- ...(client.resume ? { resume: async (state: SandboxSessionState) => await wrapSession(await client.resume!(state)) } : {}),
3390
- ...(client.delete ? { delete: async (state: SandboxSessionState) => await client.delete!(state) } : {}),
3391
- ...(client.serializeSessionState ? { serializeSessionState: async (state: SandboxSessionState, options) => await client.serializeSessionState!(state, options) } : {}),
3392
- ...(client.canPersistOwnedSessionState ? { canPersistOwnedSessionState: async (state: SandboxSessionState) => await client.canPersistOwnedSessionState!(state) } : {}),
3393
- ...(client.canReusePreservedOwnedSession ? { canReusePreservedOwnedSession: async (state: SandboxSessionState) => await client.canReusePreservedOwnedSession!(state) } : {}),
3394
- ...(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
+ : {}),
3395
4603
  };
3396
4604
  return wrapped;
3397
4605
  }
@@ -3403,8 +4611,8 @@ function sandboxRepositoryCloneHooksForAgent(agent: Agent<any, any>): SandboxLif
3403
4611
  // TOKEN-BROKER (B1): the per-turn git token seed stashed for this agent (undefined
3404
4612
  // when no repo is attached / on the selfhosted path). Read into the clone hook
3405
4613
  // context at runStream so the token is seeded off-manifest.
3406
- function gitTokenSeedForAgent(agent: Agent<any, any>): string | undefined {
3407
- return agentGitTokenSeed.get(agent);
4614
+ function gitTokenSeedsForAgent(agent: Agent<any, any>): GitTokenSeeds | undefined {
4615
+ return agentGitTokenSeeds.get(agent);
3408
4616
  }
3409
4617
 
3410
4618
  function toolspaceTokenSeedForAgent(agent: Agent<any, any>): string | undefined {
@@ -3413,11 +4621,70 @@ function toolspaceTokenSeedForAgent(agent: Agent<any, any>): string | undefined
3413
4621
 
3414
4622
  function sandboxToolspaceTokenHooksForAgent(agent: Agent<any, any>): SandboxLifecycleHook[] {
3415
4623
  return toolspaceTokenSeedForAgent(agent)
3416
- ? [{
3417
- id: "toolspace-token",
3418
- phase: "beforeAgentStart",
3419
- run: runToolspaceTokenSeedHook,
3420
- }]
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
+ ]
3421
4688
  : [];
3422
4689
  }
3423
4690
 
@@ -3426,19 +4693,23 @@ function sandboxRepositoryCloneHooks(
3426
4693
  resources: ResourceRef[],
3427
4694
  activeSandboxBackend: Settings["sandboxBackend"] = settings.sandboxBackend,
3428
4695
  ): SandboxLifecycleHook[] {
3429
- const repositories = resources.filter((resource): resource is Extract<ResourceRef, { kind: "repository" }> => (
3430
- resource.kind === "repository" && repositoryUsesSandboxClone(settings, resource, activeSandboxBackend)
3431
- ));
4696
+ const repositories = resources.filter(
4697
+ (resource): resource is Extract<ResourceRef, { kind: "repository" }> =>
4698
+ resource.kind === "repository" &&
4699
+ repositoryUsesSandboxClone(settings, resource, activeSandboxBackend),
4700
+ );
3432
4701
  if (repositories.length === 0) {
3433
4702
  return [];
3434
4703
  }
3435
- return [{
3436
- id: "repository-clone",
3437
- phase: "beforeAgentStart",
3438
- run: async (session, context) => {
3439
- 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
+ },
3440
4711
  },
3441
- }];
4712
+ ];
3442
4713
  }
3443
4714
 
3444
4715
  /**
@@ -3467,60 +4738,273 @@ export function repositoryUsesSandboxClone(
3467
4738
  if (activeSandboxBackend === "selfhosted") {
3468
4739
  return false;
3469
4740
  }
3470
- 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
+ );
3471
4746
  }
3472
4747
 
3473
- export function repositoryCloneCommand(resources: Extract<ResourceRef, { kind: "repository" }>[]): string {
3474
- const commands = [
3475
- "set -eu",
3476
- "export HOME=\"${HOME:-/workspace}\"",
3477
- "export GIT_TERMINAL_PROMPT=\"${GIT_TERMINAL_PROMPT:-0}\"",
3478
- // TOKEN-BROKER (B1/B2): seed the run-scoped GitHub token into the STABLE token FILE
3479
- // AND provision the git-askpass helper into the box AT SETUP (runtime) BEFORE any
3480
- // clone runs, so GIT_ASKPASS points at a per-box, user-writable script that reads
3481
- // that file for the fetch below. Provisioning the askpass here (rather than relying
3482
- // on a baked image script at /usr/local/bin/opengeni-git-askpass) removes the
3483
- // image-rebuild rollout gate: the askpass is correct on ANY box image, including
3484
- // pre-existing warm boxes on their next turn's clone hook, and no product image has
3485
- // to carry it. The seed rides the per-exec env (OPENGENI_GIT_TOKEN_SEED) — NEVER the
3486
- // box/agent manifest (validateNoEnvironmentDelta must not see a rotating value), so
3487
- // this whole block is a no-op when the seed is absent (e.g. the selfhosted path,
3488
- // which uses its own git creds). The token file lives at $OPENGENI_GIT_TOKEN_FILE
3489
- // (stable, from the shared base) with a $HOME/.opengeni/git-token fallback.
3490
- // $GIT_ASKPASS is on the box manifest env (set by
3491
- // sandboxEnvironmentForRun to $HOME/.opengeni/askpass), so it is available to this
3492
- // exec; the askpass script we write is byte-identical to docker/opengeni-git-askpass
3493
- // and is written via a QUOTED heredoc (<<'ASKPASS_EOF') so NOTHING inside it expands
3494
- // ($1, $HOME, ${OPENGENI_GIT_TOKEN_FILE:-...}, and the literal \n in printf all land
3495
- // verbatim), then chmod 0755 so git can exec it.
3496
- //
3497
- // ATOMIC REWRITE: this block now re-runs at the start of EVERY turn on a warm box
3498
- // that other turn holders may be actively using — an in-flight `git fetch` from a
3499
- // concurrent turn can invoke the askpass (which cats the token file) at any moment.
3500
- // Both files are therefore written to a pid-suffixed temp under umask 077 and
3501
- // renamed into place: rename is atomic, concurrent readers keep the old inode, and
3502
- // the token is never observable world-readable (no post-hoc chmod window).
3503
- "if [ -n \"${OPENGENI_GIT_TOKEN_SEED:-}\" ]; then",
3504
- " seed_umask=\"$(umask)\"",
3505
- " umask 077",
3506
- " git_token_file=\"${OPENGENI_GIT_TOKEN_FILE:-$HOME/.opengeni/git-token}\"",
3507
- " mkdir -p \"$(dirname \"$git_token_file\")\"",
3508
- " printf '%s' \"$OPENGENI_GIT_TOKEN_SEED\" > \"$git_token_file.tmp.$$\"",
3509
- " mv -f \"$git_token_file.tmp.$$\" \"$git_token_file\"",
3510
- " git_askpass=\"${GIT_ASKPASS:-$HOME/.opengeni/askpass}\"",
3511
- " mkdir -p \"$(dirname \"$git_askpass\")\"",
3512
- " 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'",
3513
4853
  "#!/usr/bin/env sh",
3514
- "case \"$1\" in",
3515
- " *Username*) printf '%s\\n' \"x-access-token\" ;;",
3516
- " *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\' ;;',
3517
4897
  " *) printf '\\n' ;;",
3518
4898
  "esac",
3519
4899
  "ASKPASS_EOF",
3520
- " chmod 0755 \"$git_askpass.tmp.$$\"",
3521
- " mv -f \"$git_askpass.tmp.$$\" \"$git_askpass\"",
3522
- " 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",
3523
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),
3524
5008
  "ensure_git() {",
3525
5009
  " if command -v git >/dev/null 2>&1; then",
3526
5010
  " return 0",
@@ -3532,16 +5016,16 @@ export function repositoryCloneCommand(resources: Extract<ResourceRef, { kind: "
3532
5016
  " rm -rf /var/lib/apt/lists/*",
3533
5017
  " command -v git >/dev/null 2>&1 && return 0",
3534
5018
  " fi",
3535
- " 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',
3536
5020
  " exit 127",
3537
5021
  "}",
3538
5022
  "ensure_git",
3539
5023
  "clone_repository() {",
3540
- " target=\"$1\"",
3541
- " uri=\"$2\"",
3542
- " ref=\"$3\"",
3543
- " subpath=\"$4\"",
3544
- " 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',
3545
5029
  // This hook re-runs every turn on a long-lived box, so \"non-empty\" alone is not
3546
5030
  // proof of a completed materialization: an interrupted clone (worker crash /
3547
5031
  // lifecycle timeout mid-mv/cp) leaves a partial tree that would otherwise pass
@@ -3549,75 +5033,77 @@ export function repositoryCloneCommand(resources: Extract<ResourceRef, { kind: "
3549
5033
  // skipped; a partial one is wiped and rebuilt (nothing legitimate writes under
3550
5034
  // the mount path before the repo exists). Subpath extracts are not git repos —
3551
5035
  // for those the plain non-empty check stands (no stronger signal available).
3552
- " if [ -n \"$subpath\" ] || git -C \"$target\" rev-parse --is-inside-work-tree >/dev/null 2>&1; then",
3553
- " 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"',
3554
5038
  " return 0",
3555
5039
  " fi",
3556
- " echo \"Re-materializing partial repository resource at $target\" >&2",
3557
- " 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 {} +',
3558
5042
  " fi",
3559
- " mkdir -p \"$(dirname \"$target\")\"",
3560
- " tmp=\"${target}.tmp.$$\"",
3561
- " rm -rf \"$tmp\"",
5043
+ ' mkdir -p "$(dirname "$target")"',
5044
+ ' tmp="${target}.tmp.$$"',
5045
+ ' rm -rf "$tmp"',
3562
5046
  // Fetch failures must not leak the pid-suffixed tmp clone beside the mount
3563
5047
  // (set -eu would exit before any cleanup).
3564
- " 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",
3565
- " rm -rf \"$tmp\"",
3566
- " 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',
3567
5051
  " exit 1",
3568
5052
  " fi",
3569
- " if [ -n \"$subpath\" ]; then",
3570
- " if [ ! -e \"$tmp/$subpath\" ]; then",
3571
- " echo \"Repository subpath not found: $subpath\" >&2",
3572
- " 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"',
3573
5057
  " exit 1",
3574
5058
  " fi",
3575
- " if [ -d \"$tmp/$subpath\" ]; then",
3576
- " mkdir -p \"$target\"",
3577
- " cp -a \"$tmp/$subpath/.\" \"$target/\"",
5059
+ ' if [ -d "$tmp/$subpath" ]; then',
5060
+ ' mkdir -p "$target"',
5061
+ ' cp -a "$tmp/$subpath/." "$target/"',
3578
5062
  " else",
3579
- " rmdir \"$target\" 2>/dev/null || true",
3580
- " cp -a \"$tmp/$subpath\" \"$target\"",
5063
+ ' rmdir "$target" 2>/dev/null || true',
5064
+ ' cp -a "$tmp/$subpath" "$target"',
3581
5065
  " fi",
3582
- " rm -rf \"$tmp\"",
5066
+ ' rm -rf "$tmp"',
3583
5067
  " else",
3584
- " rmdir \"$target\" 2>/dev/null || true",
5068
+ ' rmdir "$target" 2>/dev/null || true',
3585
5069
  // Two concurrent turn holders can race this install: without the existence
3586
5070
  // re-check the loser's un-flagged `mv` would nest its tmp clone INSIDE the
3587
5071
  // winner's tree as <name>.tmp.<pid>. If the winner produced a valid work tree,
3588
5072
  // accept it; a non-empty non-repo survivor here is a mount point the manifest
3589
5073
  // re-filled — install into it by content copy instead of rename.
3590
- " if [ -e \"$target\" ]; then",
3591
- " if git -C \"$target\" rev-parse --is-inside-work-tree >/dev/null 2>&1; then",
3592
- " rm -rf \"$tmp\"",
3593
- " 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"',
3594
5078
  " return 0",
3595
5079
  " fi",
3596
- " cp -a \"$tmp/.\" \"$target/\"",
3597
- " rm -rf \"$tmp\"",
5080
+ ' cp -a "$tmp/." "$target/"',
5081
+ ' rm -rf "$tmp"',
3598
5082
  " else",
3599
- " mv \"$tmp\" \"$target\"",
5083
+ ' mv "$tmp" "$target"',
3600
5084
  " fi",
3601
- " git -C \"$target\" rev-parse --is-inside-work-tree >/dev/null",
5085
+ ' git -C "$target" rev-parse --is-inside-work-tree >/dev/null',
3602
5086
  " fi",
3603
- " if [ ! -e \"$target\" ]; then",
3604
- " echo \"Repository resource was not materialized at $target\" >&2",
5087
+ ' if [ ! -e "$target" ]; then',
5088
+ ' echo "Repository resource was not materialized at $target" >&2',
3605
5089
  " exit 1",
3606
5090
  " fi",
3607
- " echo \"Repository resource ready at $target\"",
5091
+ ' echo "Repository resource ready at $target"',
3608
5092
  "}",
3609
5093
  ];
3610
5094
  for (const resource of resources) {
3611
5095
  const url = new URL(resource.uri);
3612
5096
  const repo = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
3613
5097
  const mountPath = normalizeManifestPath(resource.mountPath ?? `repos/${repo}`);
3614
- commands.push([
3615
- "clone_repository",
3616
- shellQuote(posixPath.join("/workspace", mountPath)),
3617
- shellQuote(resource.uri),
3618
- shellQuote(resource.ref),
3619
- shellQuote(resource.subpath ? normalizeManifestPath(resource.subpath) : ""),
3620
- ].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
+ );
3621
5107
  }
3622
5108
  return commands.join("\n");
3623
5109
  }
@@ -3625,15 +5111,15 @@ export function repositoryCloneCommand(resources: Extract<ResourceRef, { kind: "
3625
5111
  export function toolspaceTokenSeedCommand(): string {
3626
5112
  return [
3627
5113
  "set -eu",
3628
- "export HOME=\"${HOME:-/workspace}\"",
3629
- "if [ -n \"${OPENGENI_TOOLSPACE_TOKEN_SEED:-}\" ]; then",
3630
- " seed_umask=\"$(umask)\"",
5114
+ 'export HOME="${HOME:-/workspace}"',
5115
+ 'if [ -n "${OPENGENI_TOOLSPACE_TOKEN_SEED:-}" ]; then',
5116
+ ' seed_umask="$(umask)"',
3631
5117
  " umask 077",
3632
- " token_file=\"${OPENGENI_TOOLSPACE_TOKEN_FILE:-$HOME/.opengeni/toolspace-token}\"",
3633
- " mkdir -p \"$(dirname \"$token_file\")\"",
3634
- " printf '%s' \"$OPENGENI_TOOLSPACE_TOKEN_SEED\" > \"$token_file.tmp.$$\"",
3635
- " mv -f \"$token_file.tmp.$$\" \"$token_file\"",
3636
- " umask \"$seed_umask\"",
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"',
3637
5123
  "fi",
3638
5124
  ].join("\n");
3639
5125
  }
@@ -3669,6 +5155,165 @@ export async function runToolspaceTokenSeedHook(
3669
5155
  }
3670
5156
  }
3671
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
+
3672
5317
  export async function runRepositoryCloneHook(
3673
5318
  session: SandboxSessionLike,
3674
5319
  resources: Extract<ResourceRef, { kind: "repository" }>[],
@@ -3677,17 +5322,21 @@ export async function runRepositoryCloneHook(
3677
5322
  const payload = { name: "repository-clone", repositoryCount: resources.length };
3678
5323
  await context.onRuntimeEvent?.({ type: "sandbox.operation.started", payload });
3679
5324
  try {
3680
- // TOKEN-BROKER (B1): thread the run-scoped GitHub token PER-EXEC, never on the
3681
- // manifest. The SDK's ExecCommandArgs has no `environment` field (exec inherits
3682
- // the box's manifest env), so we can't hand the seed through an exec option — and
3683
- // we MUST NOT put it on the manifest (validateNoEnvironmentDelta would see a
3684
- // rotating value). We therefore inline it as an ephemeral `export` prefix on THIS
3685
- // exec's command text only: it lives in the command, not the box/agent manifest,
3686
- // and never persists. The clone command's gated seed block then writes it to the
3687
- // token FILE before the fetch, so GIT_ASKPASS reads it. Absent seed (e.g. the
3688
- // selfhosted path) -> no prefix, the clone runs byte-for-byte as before.
3689
- const command = context.gitTokenSeed
3690
- ? `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)}`
3691
5340
  : repositoryCloneCommand(resources);
3692
5341
  if (session.exec) {
3693
5342
  const result = await session.exec({
@@ -3725,20 +5374,20 @@ export async function runRepositoryCloneHook(
3725
5374
 
3726
5375
  export function azureCliLoginCommand(): string {
3727
5376
  return [
3728
- "export HOME=\"${HOME:-/workspace}\"",
3729
- "mkdir -p \"$HOME/.azure\"",
3730
- "CLIENT_ID=\"${AZURE_CLIENT_ID:-${ARM_CLIENT_ID:-}}\"",
3731
- "CLIENT_SECRET=\"${AZURE_CLIENT_SECRET:-${ARM_CLIENT_SECRET:-}}\"",
3732
- "TENANT_ID=\"${AZURE_TENANT_ID:-${ARM_TENANT_ID:-}}\"",
3733
- "SUBSCRIPTION_ID=\"${AZURE_SUBSCRIPTION_ID:-${ARM_SUBSCRIPTION_ID:-}}\"",
3734
- "if [ -n \"$CLIENT_ID\" ] && [ -n \"$CLIENT_SECRET\" ] && [ -n \"$TENANT_ID\" ]; then",
3735
- " command -v az >/dev/null 2>&1 || { echo \"Azure CLI is not installed in the sandbox\" >&2; exit 127; }",
3736
- " 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',
3737
5386
  // if/fi, NOT `[ -n ] && az`: this line ends the credentialed if-body, so with a
3738
5387
  // no-subscription SP (an explicitly supported config — the login above passes
3739
5388
  // --allow-no-subscriptions) the bare `[ -n ]` would exit the whole script 1 and
3740
5389
  // fail the turn.
3741
- " 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',
3742
5391
  "fi",
3743
5392
  ].join("\n");
3744
5393
  }
@@ -3766,6 +5415,10 @@ export function sandboxCommandExitCode(result: unknown): number | null {
3766
5415
  }
3767
5416
 
3768
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
+ }
3769
5422
  if (!result || typeof result !== "object") {
3770
5423
  return "";
3771
5424
  }
@@ -3782,11 +5435,15 @@ export function sandboxCommandOutput(result: unknown): string {
3782
5435
  function assertSandboxCommandSucceeded(result: unknown, operation: string): void {
3783
5436
  const output = sandboxCommandOutput(result);
3784
5437
  if (sandboxCommandStillRunning(result)) {
3785
- 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
+ );
3786
5441
  }
3787
5442
  const exitCode = sandboxCommandExitCode(result);
3788
5443
  if (exitCode !== null && exitCode !== 0) {
3789
- 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
+ );
3790
5447
  }
3791
5448
  if (exitCode === null) {
3792
5449
  throw new Error(output || `${operation} did not return a command exit code`);
@@ -3883,15 +5540,20 @@ let stagedBundledSkillsDir: string | null = null;
3883
5540
 
3884
5541
  function bundledSkillsDir(): string {
3885
5542
  const moduleDir = dirname(fileURLToPath(import.meta.url));
3886
- const packaged = [
3887
- join(moduleDir, "bundled_hashicorp_terraform_skills"),
3888
- join(moduleDir, "..", "src", "bundled_hashicorp_terraform_skills"),
3889
- ].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");
3890
5549
  if (isPathWithin(process.cwd(), packaged)) {
3891
5550
  return packaged;
3892
5551
  }
3893
5552
  if (!stagedBundledSkillsDir) {
3894
- 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
+ );
3895
5557
  }
3896
5558
  return stagedBundledSkillsDir;
3897
5559
  }
@@ -3949,12 +5611,18 @@ export function lazySkillSourceWithPackSkills(packSkills: PackSkill[]): LocalDir
3949
5611
  packNameKeys.add(skill.name.toLowerCase());
3950
5612
  packNames.add(skill.name);
3951
5613
  children[skill.name] = packSkillDirEntry(skill);
3952
- 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
+ });
3953
5619
  }
3954
5620
  return {
3955
5621
  source: dir({ children }),
3956
5622
  getIndex: (manifest, skillsPath) => [
3957
- ...(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
+ ),
3958
5626
  ...packIndex,
3959
5627
  ],
3960
5628
  };
@@ -4019,7 +5687,11 @@ function assertSafePackSkillName(name: string): void {
4019
5687
 
4020
5688
  function packSkillPathSegments(skillName: string, path: string): string[] {
4021
5689
  const segments = path.split("/");
4022
- 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
+ ) {
4023
5695
  throw new Error(`Invalid pack skill file path for ${skillName}: ${path}`);
4024
5696
  }
4025
5697
  return segments;
@@ -4068,16 +5740,16 @@ function skillFrontmatterDescription(markdown: string): string | null {
4068
5740
  }
4069
5741
 
4070
5742
  function unquoteFrontmatterValue(value: string): string {
4071
- 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
+ ) {
4072
5748
  return value.slice(1, -1);
4073
5749
  }
4074
5750
  return value;
4075
5751
  }
4076
5752
 
4077
- function stringValue(value: unknown): string | undefined {
4078
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
4079
- }
4080
-
4081
5753
  function isAsyncIterable<T>(source: Iterable<T> | AsyncIterable<T>): source is AsyncIterable<T> {
4082
5754
  return typeof (source as AsyncIterable<T>)[Symbol.asyncIterator] === "function";
4083
5755
  }
@@ -4091,7 +5763,11 @@ function sortJson(value: unknown): unknown {
4091
5763
  return value.map(sortJson);
4092
5764
  }
4093
5765
  if (value && typeof value === "object") {
4094
- 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
+ );
4095
5771
  }
4096
5772
  return value;
4097
5773
  }