@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.
- package/dist/{chunk-BHA2SU3H.js → chunk-RYDCVTMW.js} +1959 -208
- package/dist/chunk-RYDCVTMW.js.map +1 -0
- package/dist/{index-otZkoLYq.d.ts → index-DIiiwQY2.d.ts} +693 -55
- package/dist/index.d.ts +293 -121
- package/dist/index.js +1819 -786
- package/dist/index.js.map +1 -1
- package/dist/sandbox/index.d.ts +1 -1
- package/dist/sandbox/index.js +79 -3
- package/package.json +21 -20
- package/src/codex-tool-search.ts +185 -31
- package/src/context-compaction.ts +78 -243
- package/src/history-sanitizer.ts +172 -20
- package/src/index.ts +2573 -897
- package/src/metrics.ts +28 -2
- package/src/sandbox/capabilities.ts +12 -3
- package/src/sandbox/channel-a.ts +420 -72
- package/src/sandbox/display-stack.ts +18 -4
- package/src/sandbox/errors.ts +13 -0
- package/src/sandbox/index.ts +398 -112
- package/src/sandbox/op-correlation.ts +70 -0
- package/src/sandbox/providers/index.ts +9 -3
- package/src/sandbox/providers/modal.ts +99 -24
- package/src/sandbox/providers/selfhosted.ts +1 -4
- package/src/sandbox/recording.ts +146 -90
- package/src/sandbox/routing/backend-resolver.ts +144 -10
- package/src/sandbox/routing/routing-session.ts +129 -25
- package/src/sandbox/select.ts +60 -8
- package/src/sandbox/selfhosted/capabilities.ts +59 -25
- package/src/sandbox/selfhosted/control-rpc.ts +160 -28
- package/src/sandbox/selfhosted/fault-rendering.ts +226 -0
- package/src/sandbox/selfhosted/op-observer.ts +54 -0
- package/src/sandbox/selfhosted/op-stream.ts +801 -0
- package/src/sandbox/selfhosted/op-testing.ts +468 -0
- package/src/sandbox/selfhosted/op-transport.ts +151 -0
- package/src/sandbox/selfhosted/retry-policy.ts +177 -0
- package/src/sandbox/selfhosted/session.ts +498 -55
- package/src/sandbox/selfhosted/testing.ts +60 -19
- package/src/sandbox/stream-port.ts +17 -5
- package/src/sandbox/stream-token.ts +8 -2
- package/src/sandbox/terminal-server.ts +18 -4
- package/src/sandbox-computer.ts +332 -70
- package/src/usage-telemetry.ts +73 -0
- package/dist/chunk-BHA2SU3H.js.map +0 -1
- package/src/image-history.ts +0 -149
package/src/index.ts
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
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 {
|
|
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 {
|
|
88
|
-
|
|
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
|
-
|
|
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(
|
|
169
|
+
setSelfhostedApplyDiff(
|
|
170
|
+
applyDiff as unknown as (input: string, diff: string, mode?: "default" | "create") => string,
|
|
171
|
+
);
|
|
140
172
|
|
|
141
|
-
export {
|
|
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
|
-
|
|
193
|
+
compactionThresholdTokens,
|
|
157
194
|
clampCompactionThresholdRatio,
|
|
158
|
-
|
|
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 {
|
|
179
|
-
export {
|
|
180
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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: (
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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(
|
|
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) =>
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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) =>
|
|
333
|
-
|
|
334
|
-
|
|
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(
|
|
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:
|
|
357
|
-
|
|
358
|
-
|
|
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
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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(
|
|
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
|
-
): {
|
|
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(
|
|
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 (
|
|
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)
|
|
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
|
|
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
|
-
|
|
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 =
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
|
618
|
-
|
|
619
|
-
|
|
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(
|
|
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.
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
* request reasoning encryption, tools, or
|
|
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
|
|
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: {
|
|
656
|
-
|
|
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
|
-
|
|
662
|
-
input
|
|
663
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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 (
|
|
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
|
-
//
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
//
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
//
|
|
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:
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
// model
|
|
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
|
|
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(
|
|
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(
|
|
921
|
-
|
|
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(
|
|
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
|
|
971
|
-
// repository-clone hooks (a parallel map keyed by the agent). Kept
|
|
972
|
-
// manifest/defaultManifest so
|
|
973
|
-
// session env; runStream reads
|
|
974
|
-
// no repo is attached / on the selfhosted path.
|
|
975
|
-
const
|
|
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): {
|
|
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 {
|
|
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 }) =>
|
|
1305
|
+
const mcpToolErrorFunction: MCPToolErrorFunction = ({ error }) =>
|
|
1306
|
+
mcpToolErrorOutput(error) as unknown as string;
|
|
1017
1307
|
|
|
1018
|
-
export function buildOpenGeniAgent(
|
|
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
|
|
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
|
|
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. +
|
|
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
|
-
//
|
|
1059
|
-
//
|
|
1060
|
-
// is
|
|
1061
|
-
|
|
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
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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.
|
|
1375
|
+
options.persistentSessionSettings,
|
|
1070
1376
|
),
|
|
1071
1377
|
modelSettings: {
|
|
1072
|
-
reasoning: {
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
-
...(
|
|
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(
|
|
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
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
...(options.computerToolMode !== undefined
|
|
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
|
-
|
|
1129
|
-
|
|
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
|
|
1139
|
-
//
|
|
1140
|
-
|
|
1141
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
1256
|
-
|
|
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(
|
|
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 =
|
|
1643
|
+
(capability as unknown as { bindModel: typeof forceFunctionTransport }).bindModel =
|
|
1644
|
+
forceFunctionTransport;
|
|
1293
1645
|
}
|
|
1294
1646
|
|
|
1295
1647
|
/**
|
|
1296
|
-
* Build the SandboxAgent capability set
|
|
1297
|
-
*
|
|
1298
|
-
*
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
*
|
|
1302
|
-
*
|
|
1303
|
-
*
|
|
1304
|
-
*
|
|
1305
|
-
*
|
|
1306
|
-
*
|
|
1307
|
-
*
|
|
1308
|
-
*
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
1311
|
-
*
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
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()
|
|
1343
|
-
|
|
1344
|
-
|
|
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> = [
|
|
1349
|
-
|
|
1350
|
-
|
|
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
|
-
|
|
1364
|
-
|
|
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
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
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?: (
|
|
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(
|
|
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(
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
//
|
|
1462
|
-
//
|
|
1463
|
-
//
|
|
1464
|
-
//
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
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
|
|
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
|
|
1628
|
-
|
|
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
|
|
1647
|
-
|
|
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(
|
|
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(
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
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 {
|
|
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 =
|
|
1685
|
-
|
|
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(
|
|
1697
|
-
|
|
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): {
|
|
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("
|
|
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 =
|
|
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(
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
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(
|
|
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
|
|
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
|
-
"
|
|
1869
|
-
"
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1911
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 & {
|
|
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 & {
|
|
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 & {
|
|
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
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
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
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
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
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
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
|
-
|
|
2130
|
-
|
|
2131
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
2139
|
-
//
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
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(
|
|
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;
|
|
2193
|
-
session: unknown;
|
|
2194
|
-
sessionState?: unknown;
|
|
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
|
|
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.
|
|
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
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
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 (
|
|
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
|
|
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
|
|
2347
|
-
*
|
|
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[] = [
|
|
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(
|
|
2362
|
-
|
|
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 =
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
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
|
|
3098
|
+
const ownedGitTokenSeeds = gitTokenSeedsForAgent(agent);
|
|
2393
3099
|
const ownedToolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
|
|
3100
|
+
const ownedRigSetup = rigSetupDescriptorForAgent(agent);
|
|
2394
3101
|
const ownedHooks = [
|
|
2395
|
-
|
|
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
|
-
...(
|
|
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(
|
|
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(
|
|
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 =
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
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
|
|
3176
|
+
const gitTokenSeeds = gitTokenSeedsForAgent(agent);
|
|
2495
3177
|
const toolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
|
|
3178
|
+
const legacyRigSetup = rigSetupDescriptorForAgent(agent);
|
|
2496
3179
|
const client = resourceClient
|
|
2497
|
-
? withSandboxLifecycleHooks(
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
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(
|
|
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,
|
|
2536
|
-
//
|
|
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(
|
|
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(
|
|
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
|
|
2615
|
-
|
|
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
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
...(client.
|
|
2625
|
-
|
|
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
|
-
|
|
2630
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
3441
|
+
const environmentChanged =
|
|
3442
|
+
stableJson(currentManifest.environment) !== stableJson(target.environment);
|
|
2663
3443
|
if (environmentChanged && !session.applyManifest) {
|
|
2664
|
-
throw new Error(
|
|
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
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
...(client.
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
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<
|
|
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?.({
|
|
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
|
-
|
|
3805
|
+
result = session.exec
|
|
2761
3806
|
? await session.exec({
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
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
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
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?.({
|
|
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:
|
|
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(
|
|
2798
|
-
|
|
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 =
|
|
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
|
|
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: {
|
|
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 =
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
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:
|
|
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(
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
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(
|
|
3021
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 (
|
|
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(
|
|
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(
|
|
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(
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
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(
|
|
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 (
|
|
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 -
|
|
4434
|
+
"set -eu",
|
|
3252
4435
|
`mkdir -p -- ${shellQuote(targetDir)}`,
|
|
3253
4436
|
`if [ ! -f ${shellQuote(targetPath)} ]; then`,
|
|
3254
4437
|
` tmp=${shellQuote(tmpPath)}`,
|
|
3255
|
-
|
|
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):
|
|
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(
|
|
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
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
...(client.
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
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
|
|
3407
|
-
return
|
|
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
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
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(
|
|
3430
|
-
resource
|
|
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
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
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
|
|
4741
|
+
return (
|
|
4742
|
+
settings.sandboxBackend === "modal" ||
|
|
4743
|
+
Boolean(resource.githubInstallationId && resource.githubRepositoryId) ||
|
|
4744
|
+
Boolean(resource.provider)
|
|
4745
|
+
);
|
|
3471
4746
|
}
|
|
3472
4747
|
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
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
|
-
"
|
|
3515
|
-
"
|
|
3516
|
-
|
|
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
|
-
|
|
3521
|
-
|
|
3522
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
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
|
-
|
|
3553
|
-
|
|
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
|
-
|
|
3557
|
-
|
|
5040
|
+
' echo "Re-materializing partial repository resource at $target" >&2',
|
|
5041
|
+
' find "$target" -mindepth 1 -maxdepth 1 -exec rm -rf {} +',
|
|
3558
5042
|
" fi",
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
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
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
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
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
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
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
5059
|
+
' if [ -d "$tmp/$subpath" ]; then',
|
|
5060
|
+
' mkdir -p "$target"',
|
|
5061
|
+
' cp -a "$tmp/$subpath/." "$target/"',
|
|
3578
5062
|
" else",
|
|
3579
|
-
|
|
3580
|
-
|
|
5063
|
+
' rmdir "$target" 2>/dev/null || true',
|
|
5064
|
+
' cp -a "$tmp/$subpath" "$target"',
|
|
3581
5065
|
" fi",
|
|
3582
|
-
|
|
5066
|
+
' rm -rf "$tmp"',
|
|
3583
5067
|
" else",
|
|
3584
|
-
|
|
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
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
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
|
-
|
|
3597
|
-
|
|
5080
|
+
' cp -a "$tmp/." "$target/"',
|
|
5081
|
+
' rm -rf "$tmp"',
|
|
3598
5082
|
" else",
|
|
3599
|
-
|
|
5083
|
+
' mv "$tmp" "$target"',
|
|
3600
5084
|
" fi",
|
|
3601
|
-
|
|
5085
|
+
' git -C "$target" rev-parse --is-inside-work-tree >/dev/null',
|
|
3602
5086
|
" fi",
|
|
3603
|
-
|
|
3604
|
-
|
|
5087
|
+
' if [ ! -e "$target" ]; then',
|
|
5088
|
+
' echo "Repository resource was not materialized at $target" >&2',
|
|
3605
5089
|
" exit 1",
|
|
3606
5090
|
" fi",
|
|
3607
|
-
|
|
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
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
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
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
5114
|
+
'export HOME="${HOME:-/workspace}"',
|
|
5115
|
+
'if [ -n "${OPENGENI_TOOLSPACE_TOKEN_SEED:-}" ]; then',
|
|
5116
|
+
' seed_umask="$(umask)"',
|
|
3631
5117
|
" umask 077",
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
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
|
|
3681
|
-
// manifest. The SDK's ExecCommandArgs has no `environment` field (exec
|
|
3682
|
-
// the box's manifest env), so we can't hand
|
|
3683
|
-
// we MUST NOT put
|
|
3684
|
-
// rotating
|
|
3685
|
-
// exec's command text only
|
|
3686
|
-
//
|
|
3687
|
-
//
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
?
|
|
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
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
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(
|
|
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({
|
|
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(
|
|
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 (
|
|
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 (
|
|
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(
|
|
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
|
}
|