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