@opengeni/runtime 0.3.0 → 0.4.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/LICENSE +190 -0
- package/dist/{chunk-D5KU3QUC.js → chunk-HGQ252FL.js} +22 -3
- package/dist/chunk-HGQ252FL.js.map +1 -0
- package/dist/index-CSGkld-v.d.ts +1801 -0
- package/dist/index.d.ts +93 -9
- package/dist/index.js +618 -66
- package/dist/index.js.map +1 -1
- package/dist/sandbox/index.d.ts +4 -1784
- package/dist/sandbox/index.js +1 -1
- package/package.json +6 -8
- package/src/context-compaction.ts +164 -16
- package/src/history-sanitizer.ts +37 -39
- package/src/index.ts +586 -19
- package/src/metrics.ts +5 -0
- package/src/sandbox/display-stack.ts +25 -4
- package/src/sandbox/index.ts +28 -1
- package/src/sandbox-computer.ts +167 -36
- package/src/screenshot-error-card.ts +25 -0
- package/dist/chunk-D5KU3QUC.js.map +0 -1
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ConfiguredModel, ContextCompactionMode, ModelProviderApi, ResolvedModelProvider, Settings } from "@opengeni/config";
|
|
2
2
|
import { AGENT_INSTRUCTIONS_CORE_PLACEHOLDER, collectSandboxEnvironment, contextInputBudgetTokens, contextServerCompactThreshold, firstPartyMcpBaseUrl, parseExposedPorts, resolveContextCompactionMode, resolveModelProvider, sandboxLifecycleHookIds } from "@opengeni/config";
|
|
3
|
-
import { CAPABILITY_DESCRIPTORS, isClearedRunStateBlob, signDelegatedAccessToken, type Permission, type ReasoningEffort, type ResourceRef, type SessionEventType, type ToolRef } from "@opengeni/contracts";
|
|
3
|
+
import { CAPABILITY_DESCRIPTORS, isClearedRunStateBlob, prefixedMcpToolName as sharedPrefixedMcpToolName, signDelegatedAccessToken, type McpServerConnectionRef, type Permission, type ReasoningEffort, type ResourceRef, type SessionEventType, type ToolAuthNeededPayload, type ToolRef } from "@opengeni/contracts";
|
|
4
4
|
import {
|
|
5
5
|
Agent,
|
|
6
6
|
AgentsError,
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
setDefaultOpenAIClient,
|
|
28
28
|
setDefaultOpenAIKey,
|
|
29
29
|
setOpenAIResponsesTransport,
|
|
30
|
+
setTracingDisabled,
|
|
30
31
|
// Hosted web_search tool factory. Re-exported from @openai/agents-openai via
|
|
31
32
|
// `export * from '@openai/agents-openai'` in @openai/agents' index (0.11.6);
|
|
32
33
|
// it returns a { type: 'hosted_tool', providerData: { type: 'web_search' } }
|
|
@@ -42,9 +43,11 @@ import {
|
|
|
42
43
|
type AgentInputItem,
|
|
43
44
|
type CallModelInputFilter,
|
|
44
45
|
type MCPServer,
|
|
46
|
+
type MCPToolErrorFunction,
|
|
45
47
|
type Model,
|
|
46
48
|
type ModelProvider,
|
|
47
49
|
type RunStreamEvent,
|
|
50
|
+
type Tool,
|
|
48
51
|
} from "@openai/agents";
|
|
49
52
|
import {
|
|
50
53
|
localDirLazySkillSource,
|
|
@@ -101,6 +104,9 @@ import {
|
|
|
101
104
|
setSelfhostedApplyDiff,
|
|
102
105
|
} from "./sandbox";
|
|
103
106
|
import { computerUse, type ComputerToolMode } from "./sandbox-computer";
|
|
107
|
+
import type { RuntimeMetricsHooks } from "./metrics";
|
|
108
|
+
|
|
109
|
+
export type { RuntimeMetricsHooks } from "./metrics";
|
|
104
110
|
|
|
105
111
|
// P4.3 computer-use surface (the agent's :0 driver). Re-exported from the barrel
|
|
106
112
|
// so callers (the worker, live proofs) reach SandboxComputer/ComputerUseCapability
|
|
@@ -145,8 +151,10 @@ export { OpenAIChatCompletionsModel, OpenAIResponsesModel } from "@openai/agents
|
|
|
145
151
|
export {
|
|
146
152
|
CompactionNeededError,
|
|
147
153
|
buildCompactionPromptInput,
|
|
154
|
+
buildDeterministicFallbackCompactionHistory,
|
|
148
155
|
buildCompactionReplacementHistory,
|
|
149
156
|
clientCompactionThresholdTokens,
|
|
157
|
+
clampCompactionThresholdRatio,
|
|
150
158
|
decideClientCompaction,
|
|
151
159
|
enforceInputBudget,
|
|
152
160
|
buildSummaryItem,
|
|
@@ -160,7 +168,9 @@ export {
|
|
|
160
168
|
COMPACTION_SUMMARY_MARKER,
|
|
161
169
|
COMPACTION_PROMPT,
|
|
162
170
|
COMPACT_USER_MESSAGE_MAX_TOKENS,
|
|
163
|
-
|
|
171
|
+
DEFAULT_COMPACTION_THRESHOLD_RATIO,
|
|
172
|
+
MIN_COMPACTION_THRESHOLD_RATIO,
|
|
173
|
+
MAX_COMPACTION_THRESHOLD_RATIO,
|
|
164
174
|
SUMMARY_BUFFER_TOKENS,
|
|
165
175
|
SUMMARY_PREFIX,
|
|
166
176
|
USER_MESSAGE_TRUNCATION_MARKER,
|
|
@@ -193,6 +203,29 @@ export type ModelResponseUsage = {
|
|
|
193
203
|
|
|
194
204
|
type RuntimeMcpTool = Awaited<ReturnType<MCPServer["listTools"]>>[number];
|
|
195
205
|
|
|
206
|
+
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
207
|
+
|
|
208
|
+
export type ResolveConnectionCredentialInput = {
|
|
209
|
+
workspaceId: string;
|
|
210
|
+
subjectId?: string;
|
|
211
|
+
serverId: string;
|
|
212
|
+
toolName?: string;
|
|
213
|
+
connectionRef: McpServerConnectionRef;
|
|
214
|
+
forceRefresh?: boolean;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export type ResolveConnectionCredentialResult =
|
|
218
|
+
| { status: "ok"; headers: Record<string, string>; connectionId: string; expiresAt?: Date | null }
|
|
219
|
+
| {
|
|
220
|
+
status: "auth_needed";
|
|
221
|
+
reason: ToolAuthNeededPayload["reason"];
|
|
222
|
+
providerDomain: string;
|
|
223
|
+
connectionId?: string;
|
|
224
|
+
scopes?: string[];
|
|
225
|
+
resource?: string;
|
|
226
|
+
authorizationUrl?: string;
|
|
227
|
+
};
|
|
228
|
+
|
|
196
229
|
export function ensureReadableStreamFrom(): void {
|
|
197
230
|
const ctor = globalThis.ReadableStream as (typeof ReadableStream & {
|
|
198
231
|
from?: <T>(source: Iterable<T> | AsyncIterable<T>) => ReadableStream<T>;
|
|
@@ -253,6 +286,12 @@ export type SandboxFileDownload = {
|
|
|
253
286
|
sizeBytes?: number;
|
|
254
287
|
};
|
|
255
288
|
|
|
289
|
+
let runtimeMetricsHooks: RuntimeMetricsHooks | null = null;
|
|
290
|
+
|
|
291
|
+
export function configureRuntimeMetricsHooks(hooks: RuntimeMetricsHooks | null | undefined): void {
|
|
292
|
+
runtimeMetricsHooks = hooks ?? null;
|
|
293
|
+
}
|
|
294
|
+
|
|
256
295
|
export type OpenGeniRuntime = {
|
|
257
296
|
configure: (settings: Settings) => void;
|
|
258
297
|
// Multi-provider per-turn model routing. Returns the resolved provider, its
|
|
@@ -270,11 +309,15 @@ export type OpenGeniRuntime = {
|
|
|
270
309
|
export type ProductionRuntimeOverrides = {
|
|
271
310
|
model?: Model;
|
|
272
311
|
sandboxClient?: unknown;
|
|
312
|
+
metrics?: RuntimeMetricsHooks;
|
|
273
313
|
};
|
|
274
314
|
|
|
275
315
|
export function createProductionAgentRuntime(overrides: ProductionRuntimeOverrides = {}): OpenGeniRuntime {
|
|
276
316
|
return {
|
|
277
|
-
configure:
|
|
317
|
+
configure: (settings) => {
|
|
318
|
+
configureRuntimeMetricsHooks(overrides.metrics);
|
|
319
|
+
configureOpenAI(settings);
|
|
320
|
+
},
|
|
278
321
|
// A test/override model shadows the registry routing entirely (the scripted
|
|
279
322
|
// model used in worker tests is not in any provider's allow-list), so when
|
|
280
323
|
// one is supplied resolveTurnModel reports "no resolution" and the caller
|
|
@@ -301,7 +344,7 @@ export function createProductionAgentRuntime(overrides: ProductionRuntimeOverrid
|
|
|
301
344
|
* the OpenAI-platform path has only a key (the SDK default client is used via
|
|
302
345
|
* setDefaultOpenAIKey there); the caller then constructs a key-only client.
|
|
303
346
|
*/
|
|
304
|
-
export function buildOpenAIClientFromSettings(settings: Settings): OpenAI {
|
|
347
|
+
export function buildOpenAIClientFromSettings(settings: Settings, providerId: string = settings.openaiProvider): OpenAI {
|
|
305
348
|
if (settings.openaiProvider === "azure") {
|
|
306
349
|
const baseURL = settings.azureOpenaiBaseUrl ?? azureDeploymentBaseUrl(settings);
|
|
307
350
|
const apiKey = settings.azureOpenaiApiKey ?? settings.azureOpenaiAdToken ?? "azure-ad-token";
|
|
@@ -318,13 +361,14 @@ export function buildOpenAIClientFromSettings(settings: Settings): OpenAI {
|
|
|
318
361
|
// seam — below the SDK responses converter, which always re-synthesizes BOTH
|
|
319
362
|
// `action` and `actions` (rejected 400 "exactly one of action or actions").
|
|
320
363
|
// See computerCallNormalizingFetch / rewriteComputerCallsToActionsOnly.
|
|
321
|
-
fetch: computerCallNormalizingFetch(globalThis.fetch),
|
|
364
|
+
fetch: computerCallNormalizingFetch(instrumentedModelFetch(providerId, globalThis.fetch)),
|
|
322
365
|
});
|
|
323
366
|
}
|
|
324
367
|
return new OpenAI({
|
|
325
368
|
apiKey: settings.openaiApiKey ?? process.env.OPENAI_API_KEY,
|
|
326
369
|
...(settings.openaiBaseUrl ? { baseURL: settings.openaiBaseUrl } : {}),
|
|
327
370
|
maxRetries: settings.openaiMaxRetries,
|
|
371
|
+
fetch: instrumentedModelFetch(providerId, globalThis.fetch),
|
|
328
372
|
});
|
|
329
373
|
}
|
|
330
374
|
|
|
@@ -346,7 +390,7 @@ export function buildProviderClient(provider: ResolvedModelProvider, settings: S
|
|
|
346
390
|
return cached;
|
|
347
391
|
}
|
|
348
392
|
const client = provider.builtin
|
|
349
|
-
? buildOpenAIClientFromSettings(settings)
|
|
393
|
+
? buildOpenAIClientFromSettings(settings, provider.id)
|
|
350
394
|
: provider.kind === "codex-subscription"
|
|
351
395
|
// Codex subscription: the static apiKey is a placeholder — the real per-request
|
|
352
396
|
// bearer + ChatGPT-Account-ID, the /responses->/codex/responses rewrite, and the
|
|
@@ -358,7 +402,7 @@ export function buildProviderClient(provider: ResolvedModelProvider, settings: S
|
|
|
358
402
|
apiKey: provider.apiKey ?? "codex-subscription",
|
|
359
403
|
...(provider.baseUrl ? { baseURL: provider.baseUrl } : {}),
|
|
360
404
|
maxRetries: settings.openaiMaxRetries,
|
|
361
|
-
fetch: codexSubscriptionFetch(globalThis.fetch),
|
|
405
|
+
fetch: codexSubscriptionFetch(instrumentedModelFetch(provider.id, globalThis.fetch)),
|
|
362
406
|
})
|
|
363
407
|
// ResolvedModelProvider.apiKey is already the resolved key (configuredProviders
|
|
364
408
|
// ran resolveProviderApiKey at config time, collapsing apiKey/apiKeyEnv), so it
|
|
@@ -369,6 +413,7 @@ export function buildProviderClient(provider: ResolvedModelProvider, settings: S
|
|
|
369
413
|
maxRetries: settings.openaiMaxRetries,
|
|
370
414
|
...(provider.defaultQuery ? { defaultQuery: provider.defaultQuery } : {}),
|
|
371
415
|
...(provider.defaultHeaders ? { defaultHeaders: provider.defaultHeaders } : {}),
|
|
416
|
+
fetch: instrumentedModelFetch(provider.id, globalThis.fetch),
|
|
372
417
|
});
|
|
373
418
|
providerClientCache.set(provider.id, client);
|
|
374
419
|
return client;
|
|
@@ -521,6 +566,7 @@ export class CodexSubscriptionUnavailableError extends Error {
|
|
|
521
566
|
|
|
522
567
|
export function configureOpenAI(settings: Settings): void {
|
|
523
568
|
setOpenAIResponsesTransport(settings.openaiResponsesTransport);
|
|
569
|
+
setTracingDisabled(settings.disableOpenaiTracing || !settings.observabilityOtlpEndpoint);
|
|
524
570
|
// Install the registry-aware router as the process default model provider so a
|
|
525
571
|
// model name re-resolved on the SandboxAgent/Modal path (where a Model instance
|
|
526
572
|
// does not survive) routes to its provider instead of the built-in client.
|
|
@@ -540,13 +586,59 @@ export function configureOpenAI(settings: Settings): void {
|
|
|
540
586
|
setDefaultModelProvider(router);
|
|
541
587
|
}
|
|
542
588
|
|
|
589
|
+
function instrumentedModelFetch(provider: string, inner: typeof fetch): typeof fetch {
|
|
590
|
+
return (async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
591
|
+
if (!isModelCallFetch(input)) {
|
|
592
|
+
return await inner(input, init);
|
|
593
|
+
}
|
|
594
|
+
const started = performance.now();
|
|
595
|
+
try {
|
|
596
|
+
const response = await inner(input, init);
|
|
597
|
+
recordModelCallMetric(provider, response.ok ? "completed" : "failed", started);
|
|
598
|
+
return response;
|
|
599
|
+
} catch (error) {
|
|
600
|
+
recordModelCallMetric(provider, "failed", started);
|
|
601
|
+
throw error;
|
|
602
|
+
}
|
|
603
|
+
}) as typeof fetch;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function isModelCallFetch(input: Parameters<typeof fetch>[0]): boolean {
|
|
607
|
+
const rawUrl = typeof input === "string"
|
|
608
|
+
? input
|
|
609
|
+
: input instanceof URL
|
|
610
|
+
? input.toString()
|
|
611
|
+
: (input as { url?: unknown }).url;
|
|
612
|
+
if (typeof rawUrl !== "string" || rawUrl.length === 0) {
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
try {
|
|
616
|
+
const pathname = new URL(rawUrl, "http://opengeni.local").pathname;
|
|
617
|
+
return pathname.endsWith("/responses")
|
|
618
|
+
|| pathname.endsWith("/chat/completions")
|
|
619
|
+
|| pathname.endsWith("/codex/responses");
|
|
620
|
+
} catch {
|
|
621
|
+
return /\/(?:codex\/)?responses(?:\?|$)|\/chat\/completions(?:\?|$)/.test(rawUrl);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function recordModelCallMetric(provider: string, outcome: "completed" | "failed", started: number): void {
|
|
626
|
+
const durationSeconds = Math.max(0, (performance.now() - started) / 1000);
|
|
627
|
+
try {
|
|
628
|
+
runtimeMetricsHooks?.onModelCall?.({ provider, outcome, durationSeconds });
|
|
629
|
+
} catch {
|
|
630
|
+
// Metrics emission must never affect a model call.
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
543
634
|
/**
|
|
544
635
|
* Run the compaction summarizer as one plain, tool-less, non-streaming model
|
|
545
636
|
* call against the resolved provider. `input` is the active history plus
|
|
546
637
|
* Codex's checkpoint prompt. Returns the trimmed summary text, or null on any
|
|
547
|
-
* failure (the caller
|
|
548
|
-
* -
|
|
549
|
-
* tools, or server-side compaction; it is a
|
|
638
|
+
* failure (the caller can retry with a harder-trimmed transcript, then fall
|
|
639
|
+
* back to deterministic non-LLM compaction). The call deliberately does NOT
|
|
640
|
+
* request reasoning encryption, tools, or server-side compaction; it is a
|
|
641
|
+
* self-contained summarize.
|
|
550
642
|
*
|
|
551
643
|
* Provider-aware: the summary always runs on the SAME provider that serves the
|
|
552
644
|
* turn (registry providers can't summarize through OpenAI/Azure, and vice
|
|
@@ -560,18 +652,24 @@ export function configureOpenAI(settings: Settings): void {
|
|
|
560
652
|
export async function summarizeForCompaction(
|
|
561
653
|
settings: Settings,
|
|
562
654
|
input: Array<Record<string, unknown>>,
|
|
563
|
-
options: { client?: OpenAI; api?: ModelProviderApi; maxOutputTokens?: number; model?: string } = {},
|
|
655
|
+
options: { client?: OpenAI; api?: ModelProviderApi; maxOutputTokens?: number; model?: string; maxTranscriptTokens?: number } = {},
|
|
564
656
|
): Promise<string | null> {
|
|
565
657
|
const client = options.client ?? buildOpenAIClientFromSettings(settings);
|
|
566
658
|
const api = options.api ?? "responses";
|
|
567
659
|
const model = options.model ?? settings.openaiModel;
|
|
568
660
|
const maxTokens = options.maxOutputTokens ?? SUMMARY_BUFFER_TOKENS;
|
|
661
|
+
const transcript = renderCompactionPromptInputForChat(
|
|
662
|
+
input,
|
|
663
|
+
typeof options.maxTranscriptTokens === "number" && options.maxTranscriptTokens > 0
|
|
664
|
+
? { maxEstimatedTokens: options.maxTranscriptTokens }
|
|
665
|
+
: {},
|
|
666
|
+
);
|
|
569
667
|
try {
|
|
570
668
|
if (api === "chat") {
|
|
571
669
|
const completion = await client.chat.completions.create({
|
|
572
670
|
model,
|
|
573
671
|
max_tokens: maxTokens,
|
|
574
|
-
messages: [{ role: "user", content:
|
|
672
|
+
messages: [{ role: "user", content: transcript }],
|
|
575
673
|
} as any);
|
|
576
674
|
const text = (completion as { choices?: Array<{ message?: { content?: unknown } }> }).choices?.[0]?.message?.content;
|
|
577
675
|
const trimmed = typeof text === "string" ? text.trim() : "";
|
|
@@ -584,13 +682,13 @@ export async function summarizeForCompaction(
|
|
|
584
682
|
// built-in path (api "responses"), so gate it on the built-in provider.
|
|
585
683
|
...(settings.openaiProvider === "azure" ? {} : { store: false }),
|
|
586
684
|
max_output_tokens: maxTokens,
|
|
587
|
-
input,
|
|
685
|
+
input: transcript,
|
|
588
686
|
} as any);
|
|
589
687
|
const text = extractResponseOutputText(response);
|
|
590
688
|
const trimmed = text.trim();
|
|
591
689
|
return trimmed.length > 0 ? trimmed : null;
|
|
592
690
|
} catch (error) {
|
|
593
|
-
console.error("context compaction summarize failed
|
|
691
|
+
console.error("context compaction summarize failed", error);
|
|
594
692
|
return null;
|
|
595
693
|
}
|
|
596
694
|
}
|
|
@@ -718,6 +816,11 @@ export type BuildAgentOptions = {
|
|
|
718
816
|
// seeds it to the box's token FILE before the clone runs. Omitted on the
|
|
719
817
|
// selfhosted path (the machine uses its own git creds) — a NO-OP there.
|
|
720
818
|
gitTokenSeed?: string;
|
|
819
|
+
// TOOLSPACE: the run-scoped delegated token to seed into
|
|
820
|
+
// $OPENGENI_TOOLSPACE_TOKEN_FILE. Like gitTokenSeed, this stays off the
|
|
821
|
+
// manifest/env delta and is written into the sandbox filesystem by a lifecycle
|
|
822
|
+
// hook before the agent starts.
|
|
823
|
+
toolspaceTokenSeed?: string;
|
|
721
824
|
// Genesis turn only: append a one-shot instruction to the agent's system
|
|
722
825
|
// prompt telling it to title the session via opengeni__set_session_title
|
|
723
826
|
// before responding. Delivered through the instructions channel (where the
|
|
@@ -851,11 +954,48 @@ const agentRepositoryCloneHooks = new WeakMap<object, SandboxLifecycleHook[]>();
|
|
|
851
954
|
// session env; runStream reads it to build the clone hook context. Absent when
|
|
852
955
|
// no repo is attached / on the selfhosted path.
|
|
853
956
|
const agentGitTokenSeed = new WeakMap<object, string>();
|
|
957
|
+
const agentToolspaceTokenSeed = new WeakMap<object, string>();
|
|
854
958
|
// The EFFECTIVE backend the turn resolved for this agent (undefined -> the home
|
|
855
959
|
// backend). Read by runStream's owned branch to keep platform box-setup hooks off
|
|
856
960
|
// connected machines (a user's real computer).
|
|
857
961
|
const agentActiveSandboxBackend = new WeakMap<object, Settings["sandboxBackend"]>();
|
|
858
962
|
|
|
963
|
+
/**
|
|
964
|
+
* The tool output emitted for an MCP tool call that FAILED with a THROWN error
|
|
965
|
+
* — a JSON-RPC protocol error (e.g. -32602 invalid params), an auth 401/403, a
|
|
966
|
+
* transport failure, a timeout, or tool-not-found. Shaped as an MCP
|
|
967
|
+
* `{ isError: true, content }` result so the failure is CARRIED on the tool
|
|
968
|
+
* output rather than erased.
|
|
969
|
+
*
|
|
970
|
+
* The SDK's `defaultToolErrorFunction` would instead turn a thrown tool error
|
|
971
|
+
* into a plain STRING ("An error occurred while running the tool…") returned as
|
|
972
|
+
* a NORMAL, successful-looking result — so `agent.toolCall.output` carried no
|
|
973
|
+
* error signal and the timeline rendered the failure as success. `normalizeSdkEvent`
|
|
974
|
+
* captures `RunToolCallOutputItem.output` (the raw function return) verbatim, so
|
|
975
|
+
* returning this object makes the emitted payload's `output.isError === true`,
|
|
976
|
+
* which the timeline projection's `isErrorOutput` (packages/react/src/timeline/
|
|
977
|
+
* projection.ts) settles to "failed" and downstream client normalizers read the same field.
|
|
978
|
+
*
|
|
979
|
+
* SCOPE: this only covers THROWN MCP failures (the ones that reach an
|
|
980
|
+
* errorFunction). An MCP tool result carrying `isError: true` INLINE alongside
|
|
981
|
+
* content is stripped to its `content` by @openai/agents-core's streamable-http
|
|
982
|
+
* shim (dist/shims/mcp-server/node.js `callTool` returns `parsed.content`,
|
|
983
|
+
* dropping `parsed.isError`) BEFORE the runtime ever sees it. Covering that mode
|
|
984
|
+
* requires an SDK-level change (preserve `isError`) or reading the raw
|
|
985
|
+
* CallToolResult — tracked separately.
|
|
986
|
+
*/
|
|
987
|
+
export function mcpToolErrorOutput(error: unknown): { isError: true; content: [{ type: "text"; text: string }] } {
|
|
988
|
+
const details = error instanceof Error ? error.message : String(error);
|
|
989
|
+
return { isError: true, content: [{ type: "text", text: `An error occurred while running the tool. Please try again. Error: ${details}` }] };
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Applied to EVERY MCP server via the agent's `mcpConfig.errorFunction`
|
|
993
|
+
// (server-level errorFunctions would win, but PrefixedMcpServer sets none). The
|
|
994
|
+
// SDK types the return as `string`; the runtime actually stores the raw return
|
|
995
|
+
// as the tool output, so we return the MCP-shaped error object and cast — this
|
|
996
|
+
// is the only way to attach an `isError` flag to a failed MCP tool call's output.
|
|
997
|
+
const mcpToolErrorFunction: MCPToolErrorFunction = ({ error }) => mcpToolErrorOutput(error) as unknown as string;
|
|
998
|
+
|
|
859
999
|
export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[], options: BuildAgentOptions = {}): Agent<any, any> {
|
|
860
1000
|
// Resolved per-turn gating. Each override defaults to today's settings-derived
|
|
861
1001
|
// behaviour, so the legacy global-client callers (no resolved model) build the
|
|
@@ -931,11 +1071,19 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
|
|
|
931
1071
|
// spread; the SDK concatenates these with MCP and sandbox capability tools.
|
|
932
1072
|
...(hostedTools.length ? { tools: hostedTools } : {}),
|
|
933
1073
|
...(options.mcpServers?.length ? { mcpServers: options.mcpServers } : {}),
|
|
1074
|
+
// Surface FAILED MCP tool calls as `{ isError: true }` tool output (see
|
|
1075
|
+
// mcpToolErrorFunction / mcpToolErrorOutput) instead of the SDK's default
|
|
1076
|
+
// flat error string, so a thrown MCP failure (protocol error, auth, timeout,
|
|
1077
|
+
// tool-not-found) settles the timeline tool to "failed" rather than rendering
|
|
1078
|
+
// as success. Applies to every MCP server on this agent (session, first-party,
|
|
1079
|
+
// capability, codex_apps) since none set a server-level errorFunction.
|
|
1080
|
+
mcpConfig: { errorFunction: mcpToolErrorFunction },
|
|
934
1081
|
} as const;
|
|
935
1082
|
|
|
936
1083
|
if (settings.sandboxBackend === "none") {
|
|
937
1084
|
const agent = new Agent(baseConfig);
|
|
938
1085
|
maybeInstallCodexToolSearch(agent, settings, options);
|
|
1086
|
+
applyMcpApprovalPolicy(agent, settings);
|
|
939
1087
|
return agent;
|
|
940
1088
|
}
|
|
941
1089
|
|
|
@@ -966,7 +1114,11 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
|
|
|
966
1114
|
if (options.gitTokenSeed) {
|
|
967
1115
|
agentGitTokenSeed.set(agent, options.gitTokenSeed);
|
|
968
1116
|
}
|
|
1117
|
+
if (options.toolspaceTokenSeed) {
|
|
1118
|
+
agentToolspaceTokenSeed.set(agent, options.toolspaceTokenSeed);
|
|
1119
|
+
}
|
|
969
1120
|
maybeInstallCodexToolSearch(agent, settings, options);
|
|
1121
|
+
applyMcpApprovalPolicy(agent, settings);
|
|
970
1122
|
return agent;
|
|
971
1123
|
}
|
|
972
1124
|
|
|
@@ -988,6 +1140,101 @@ function maybeInstallCodexToolSearch(agent: Agent<any, any>, settings: Settings,
|
|
|
988
1140
|
}
|
|
989
1141
|
}
|
|
990
1142
|
|
|
1143
|
+
/** True when the unprefixed tool `name` requires approval under `policy`. */
|
|
1144
|
+
function mcpToolRequiresApproval(policy: boolean | string[], unprefixedName: string): boolean {
|
|
1145
|
+
return policy === true || (Array.isArray(policy) && policy.includes(unprefixedName));
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** A per-server approval policy keyed by the server's `<id>__` tool prefix. */
|
|
1149
|
+
type McpApprovalPolicy = { prefix: string; requireApproval: boolean | string[] };
|
|
1150
|
+
|
|
1151
|
+
/** The subset of the agent surface the approval wrap needs — including `clone`. */
|
|
1152
|
+
type ApprovalCapableAgent = {
|
|
1153
|
+
getMcpTools: (runContext: unknown) => Promise<Tool<any>[]>;
|
|
1154
|
+
clone?: (config: unknown) => ApprovalCapableAgent;
|
|
1155
|
+
};
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Install the approval wrap on a single agent instance: replace `getMcpTools`
|
|
1159
|
+
* with one that stamps `needsApproval: () => true` on every MCP tool whose
|
|
1160
|
+
* server policy demands it. Tools are matched by the server's `<id>__` prefix
|
|
1161
|
+
* (LONGEST prefix first — see {@link applyMcpApprovalPolicy}), then the
|
|
1162
|
+
* unprefixed tool name.
|
|
1163
|
+
*
|
|
1164
|
+
* CLONE SURVIVAL (mirrors {@link installCodexToolSearch}): the sandbox runtime
|
|
1165
|
+
* resolves tools not on the agent we build here but on a FRESH clone —
|
|
1166
|
+
* `prepareSandboxAgent` calls `agent.clone(...)`, and `SandboxAgent.clone`
|
|
1167
|
+
* reconstructs from a FIXED field list (name/tools/mcpServers/…), so an
|
|
1168
|
+
* instance-own `getMcpTools` override is dropped and approval would silently
|
|
1169
|
+
* bypass on every sandbox turn. We therefore also wrap `clone` to RE-INSTALL the
|
|
1170
|
+
* policy onto every clone, recursively — covering clone-of-clone and the resume
|
|
1171
|
+
* paths. The base (non-sandbox) `Agent.clone` spreads `...this` and would carry
|
|
1172
|
+
* the override, but re-installing is idempotent there and keeps one code path.
|
|
1173
|
+
*/
|
|
1174
|
+
function installMcpApprovalPolicy(agent: ApprovalCapableAgent, policies: McpApprovalPolicy[]): void {
|
|
1175
|
+
const listMcpTools = agent.getMcpTools.bind(agent);
|
|
1176
|
+
agent.getMcpTools = async (runContext: unknown) => {
|
|
1177
|
+
const tools = await listMcpTools(runContext);
|
|
1178
|
+
return tools.map((tool) => {
|
|
1179
|
+
if (tool.type !== "function") {
|
|
1180
|
+
return tool;
|
|
1181
|
+
}
|
|
1182
|
+
const policy = policies.find((entry) => tool.name.startsWith(entry.prefix));
|
|
1183
|
+
if (!policy) {
|
|
1184
|
+
return tool;
|
|
1185
|
+
}
|
|
1186
|
+
const unprefixed = tool.name.slice(policy.prefix.length);
|
|
1187
|
+
return mcpToolRequiresApproval(policy.requireApproval, unprefixed)
|
|
1188
|
+
? { ...tool, needsApproval: async () => true }
|
|
1189
|
+
: tool;
|
|
1190
|
+
});
|
|
1191
|
+
};
|
|
1192
|
+
const originalClone = agent.clone?.bind(agent);
|
|
1193
|
+
if (originalClone) {
|
|
1194
|
+
agent.clone = (config: unknown) => {
|
|
1195
|
+
const cloned = originalClone(config);
|
|
1196
|
+
installMcpApprovalPolicy(cloned, policies);
|
|
1197
|
+
return cloned;
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Enforce per-MCP-server human approval. `settings.mcpServers[].requireApproval`
|
|
1204
|
+
* is `true` (every tool of that server requires approval) or a string[] of
|
|
1205
|
+
* UNPREFIXED tool names (only those do); absent = auto-run. The SDK converts MCP
|
|
1206
|
+
* tools to function tools with `needsApproval` unset (defaults false) and exposes
|
|
1207
|
+
* no per-server/agent approval knob, so we wrap the agent's `getMcpTools` to
|
|
1208
|
+
* attach a `needsApproval: () => true` predicate to the matching tools — matched
|
|
1209
|
+
* by the server's `<id>__` prefix, then the unprefixed tool name. A tool that
|
|
1210
|
+
* needs approval raises a run INTERRUPTION, which the worker turns into
|
|
1211
|
+
* `session.requiresAction` and resolves via `user.approvalDecision`
|
|
1212
|
+
* (resumeApproval) — the same generic path shell/computer-use approvals use, so
|
|
1213
|
+
* no extra plumbing. No-op when no server requests approval, so the default
|
|
1214
|
+
* (auto-run everything) is byte-for-byte unchanged.
|
|
1215
|
+
*
|
|
1216
|
+
* Two robustness properties the wrap must hold:
|
|
1217
|
+
* - LONGEST-PREFIX-FIRST. Server ids can be prefixes of one another (`my` vs
|
|
1218
|
+
* `my_`), so their tool prefixes collide (`my__` vs `my___`): a tool like
|
|
1219
|
+
* `my___run` (from server `my_`) also `startsWith` `my__` (server `my`). A
|
|
1220
|
+
* first-match `find` over unsorted policies could bind it to the WRONG
|
|
1221
|
+
* server's policy and bypass gating. Sorting policies by DESCENDING prefix
|
|
1222
|
+
* length makes the most-specific (longest) prefix win, so each tool resolves
|
|
1223
|
+
* to its own server.
|
|
1224
|
+
* - CLONE SURVIVAL. The wrap is re-installed onto every clone; see
|
|
1225
|
+
* {@link installMcpApprovalPolicy}.
|
|
1226
|
+
*/
|
|
1227
|
+
function applyMcpApprovalPolicy(agent: Agent<any, any>, settings: Settings): void {
|
|
1228
|
+
const policies: McpApprovalPolicy[] = settings.mcpServers
|
|
1229
|
+
.filter((server) => server.requireApproval === true || (Array.isArray(server.requireApproval) && server.requireApproval.length > 0))
|
|
1230
|
+
.map((server) => ({ prefix: prefixedMcpToolName(server.id, ""), requireApproval: server.requireApproval as boolean | string[] }))
|
|
1231
|
+
.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
1232
|
+
if (policies.length === 0) {
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
installMcpApprovalPolicy(agent as unknown as ApprovalCapableAgent, policies);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
991
1238
|
/**
|
|
992
1239
|
* Force a sandbox capability to emit its FUNCTION-transport tool variants instead
|
|
993
1240
|
* of the hosted ones, by dropping the model instance the SDK's transport
|
|
@@ -1156,6 +1403,8 @@ export type PrepareToolsOptions = {
|
|
|
1156
1403
|
// delegated token (manager-style sessions). The caller is responsible for
|
|
1157
1404
|
// having validated the set against the session creator's grant.
|
|
1158
1405
|
firstPartyPermissions?: Permission[];
|
|
1406
|
+
resolveCredential?: (input: ResolveConnectionCredentialInput) => Promise<ResolveConnectionCredentialResult>;
|
|
1407
|
+
onAuthNeeded?: (payload: ToolAuthNeededPayload) => Promise<void> | void;
|
|
1159
1408
|
};
|
|
1160
1409
|
|
|
1161
1410
|
export async function prepareAgentTools(settings: Settings, tools: ToolRef[], options: PrepareToolsOptions = {}): Promise<PreparedAgentTools> {
|
|
@@ -1173,6 +1422,12 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
|
|
|
1173
1422
|
throw new Error(`Unknown MCP server id: ${tool.id}`);
|
|
1174
1423
|
}
|
|
1175
1424
|
const url = firstPartyMcpServerUrlForRun(settings, config, options.workspaceId) ?? config.url;
|
|
1425
|
+
const baseFetch = isCodexAppsMcpServer(config)
|
|
1426
|
+
? codexAppsSanitizingFetch(globalThis.fetch, codexConnectorNamespaces)
|
|
1427
|
+
: globalThis.fetch;
|
|
1428
|
+
const fetchImpl = config.connectionRef
|
|
1429
|
+
? connectionBrokerFetch(baseFetch, config, options)
|
|
1430
|
+
: baseFetch;
|
|
1176
1431
|
const server = new PrefixedMcpServer(new MCPServerStreamableHttp({
|
|
1177
1432
|
url,
|
|
1178
1433
|
name: config.name ?? config.id,
|
|
@@ -1181,7 +1436,7 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
|
|
|
1181
1436
|
// MCP SDK's strict Tool schema rejects (fails the turn during tools/list);
|
|
1182
1437
|
// sanitize the response on the wire before validation. The namespace Set
|
|
1183
1438
|
// also captures each tool's original connector namespace (P4 Part B.1).
|
|
1184
|
-
...(
|
|
1439
|
+
...(fetchImpl !== globalThis.fetch ? { fetch: fetchImpl } : {}),
|
|
1185
1440
|
...await mcpServerRequestInit(settings, config, options),
|
|
1186
1441
|
...(config.timeoutMs ? {
|
|
1187
1442
|
timeout: config.timeoutMs,
|
|
@@ -1200,7 +1455,7 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
|
|
|
1200
1455
|
// with a warning, never killing the turn before the model runs. Bare
|
|
1201
1456
|
// refs stay strict (below), preserving the fail-loud default.
|
|
1202
1457
|
const optional = tool.optional === true;
|
|
1203
|
-
return { server, bestEffort: isCodexAppsMcpServer(config) || optional, optional };
|
|
1458
|
+
return { server, bestEffort: isCodexAppsMcpServer(config) || optional || !!config.connectionRef, optional };
|
|
1204
1459
|
}));
|
|
1205
1460
|
const requiredServers = servers.filter((entry) => !entry.bestEffort).map((entry) => entry.server);
|
|
1206
1461
|
const bestEffortServers = servers.filter((entry) => entry.bestEffort).map((entry) => entry.server);
|
|
@@ -1244,6 +1499,240 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
|
|
|
1244
1499
|
};
|
|
1245
1500
|
}
|
|
1246
1501
|
|
|
1502
|
+
function connectionBrokerFetch(
|
|
1503
|
+
baseFetch: FetchLike,
|
|
1504
|
+
config: Settings["mcpServers"][number],
|
|
1505
|
+
options: PrepareToolsOptions,
|
|
1506
|
+
): FetchLike {
|
|
1507
|
+
const connectionRef = config.connectionRef;
|
|
1508
|
+
if (!connectionRef) {
|
|
1509
|
+
return baseFetch;
|
|
1510
|
+
}
|
|
1511
|
+
return async (input, init) => {
|
|
1512
|
+
const request = await mcpRequestInfo(input, init);
|
|
1513
|
+
const first = await resolveConnectionForRequest(options, config.id, connectionRef, request.toolName, false);
|
|
1514
|
+
if (first.status === "auth_needed") {
|
|
1515
|
+
return await authNeededFetchResponse(options, config.id, request, first, connectionRef);
|
|
1516
|
+
}
|
|
1517
|
+
const response = await baseFetch(fetchInputForAttempt(input), withConnectionHeaders(input, init, first.headers));
|
|
1518
|
+
if (response.status === 401) {
|
|
1519
|
+
const refreshed = await resolveConnectionForRequest(options, config.id, connectionRef, request.toolName, true);
|
|
1520
|
+
if (refreshed.status === "auth_needed") {
|
|
1521
|
+
return await authNeededFetchResponse(options, config.id, request, refreshed, connectionRef);
|
|
1522
|
+
}
|
|
1523
|
+
const retry = await baseFetch(fetchInputForAttempt(input), withConnectionHeaders(input, init, refreshed.headers));
|
|
1524
|
+
if (retry.status === 403) {
|
|
1525
|
+
const auth = insufficientScopeAuth(retry.headers, connectionRef, refreshed.connectionId);
|
|
1526
|
+
return auth ? await authNeededFetchResponse(options, config.id, request, auth, connectionRef) : retry;
|
|
1527
|
+
}
|
|
1528
|
+
if (retry.status === 401) {
|
|
1529
|
+
return await authNeededFetchResponse(options, config.id, request, {
|
|
1530
|
+
status: "auth_needed",
|
|
1531
|
+
reason: "expired",
|
|
1532
|
+
providerDomain: connectionRef.providerDomain,
|
|
1533
|
+
connectionId: refreshed.connectionId,
|
|
1534
|
+
...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
|
|
1535
|
+
...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
|
|
1536
|
+
}, connectionRef);
|
|
1537
|
+
}
|
|
1538
|
+
return retry;
|
|
1539
|
+
}
|
|
1540
|
+
if (response.status === 403) {
|
|
1541
|
+
const auth = insufficientScopeAuth(response.headers, connectionRef, first.connectionId);
|
|
1542
|
+
return auth ? await authNeededFetchResponse(options, config.id, request, auth, connectionRef) : response;
|
|
1543
|
+
}
|
|
1544
|
+
return response;
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
async function resolveConnectionForRequest(
|
|
1549
|
+
options: PrepareToolsOptions,
|
|
1550
|
+
serverId: string,
|
|
1551
|
+
connectionRef: McpServerConnectionRef,
|
|
1552
|
+
toolName: string | undefined,
|
|
1553
|
+
forceRefresh: boolean,
|
|
1554
|
+
): Promise<ResolveConnectionCredentialResult> {
|
|
1555
|
+
if (!options.workspaceId || !options.resolveCredential) {
|
|
1556
|
+
return {
|
|
1557
|
+
status: "auth_needed",
|
|
1558
|
+
reason: "missing_connection",
|
|
1559
|
+
providerDomain: connectionRef.providerDomain,
|
|
1560
|
+
...(connectionRef.connectionId ? { connectionId: connectionRef.connectionId } : {}),
|
|
1561
|
+
...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
|
|
1562
|
+
...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
const request: ResolveConnectionCredentialInput = {
|
|
1566
|
+
workspaceId: options.workspaceId,
|
|
1567
|
+
serverId,
|
|
1568
|
+
connectionRef,
|
|
1569
|
+
forceRefresh,
|
|
1570
|
+
...(toolName ? { toolName } : {}),
|
|
1571
|
+
...(options.subjectId ? { subjectId: options.subjectId } : {}),
|
|
1572
|
+
};
|
|
1573
|
+
try {
|
|
1574
|
+
return await options.resolveCredential(request);
|
|
1575
|
+
} catch {
|
|
1576
|
+
return {
|
|
1577
|
+
status: "auth_needed",
|
|
1578
|
+
reason: "refresh_failed",
|
|
1579
|
+
providerDomain: connectionRef.providerDomain,
|
|
1580
|
+
...(connectionRef.connectionId ? { connectionId: connectionRef.connectionId } : {}),
|
|
1581
|
+
...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
|
|
1582
|
+
...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function insufficientScopeAuth(
|
|
1588
|
+
headers: Headers,
|
|
1589
|
+
connectionRef: McpServerConnectionRef,
|
|
1590
|
+
connectionId: string,
|
|
1591
|
+
): Extract<ResolveConnectionCredentialResult, { status: "auth_needed" }> | null {
|
|
1592
|
+
const challenge = parseWwwAuthenticate(headers.get("www-authenticate"));
|
|
1593
|
+
if (challenge.error !== "insufficient_scope") {
|
|
1594
|
+
return null;
|
|
1595
|
+
}
|
|
1596
|
+
return {
|
|
1597
|
+
status: "auth_needed",
|
|
1598
|
+
reason: "insufficient_scope",
|
|
1599
|
+
providerDomain: connectionRef.providerDomain,
|
|
1600
|
+
connectionId,
|
|
1601
|
+
...(challenge.scope?.length ? { scopes: challenge.scope } : connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
|
|
1602
|
+
...(challenge.resource ? { resource: challenge.resource } : connectionRef.resource ? { resource: connectionRef.resource } : {}),
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
async function authNeededFetchResponse(
|
|
1607
|
+
options: PrepareToolsOptions,
|
|
1608
|
+
serverId: string,
|
|
1609
|
+
request: McpRequestInfo,
|
|
1610
|
+
auth: Extract<ResolveConnectionCredentialResult, { status: "auth_needed" }>,
|
|
1611
|
+
connectionRef: McpServerConnectionRef,
|
|
1612
|
+
): Promise<Response> {
|
|
1613
|
+
const connectionId = auth.connectionId ?? connectionRef.connectionId;
|
|
1614
|
+
await publishAuthNeeded(options, {
|
|
1615
|
+
serverId,
|
|
1616
|
+
toolName: request.toolName ?? null,
|
|
1617
|
+
providerDomain: auth.providerDomain,
|
|
1618
|
+
reason: auth.reason,
|
|
1619
|
+
...(connectionId ? { connectionId } : {}),
|
|
1620
|
+
...(auth.scopes ? { scopes: auth.scopes } : connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
|
|
1621
|
+
...(auth.resource ? { resource: auth.resource } : connectionRef.resource ? { resource: connectionRef.resource } : {}),
|
|
1622
|
+
...(auth.authorizationUrl ? { authorizationUrl: auth.authorizationUrl } : {}),
|
|
1623
|
+
...(options.subjectId ? { subjectId: options.subjectId } : {}),
|
|
1624
|
+
});
|
|
1625
|
+
if (request.method === "tools/call") {
|
|
1626
|
+
return mcpToolAuthNeededResponse(request.id);
|
|
1627
|
+
}
|
|
1628
|
+
return new Response("Authentication required for MCP server connection", { status: 401 });
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
async function publishAuthNeeded(options: PrepareToolsOptions, payload: ToolAuthNeededPayload): Promise<void> {
|
|
1632
|
+
try {
|
|
1633
|
+
await options.onAuthNeeded?.(payload);
|
|
1634
|
+
} catch {
|
|
1635
|
+
// Auth-needed events are advisory UI/audit signals; a publisher failure must
|
|
1636
|
+
// not turn an auth-recoverable tool condition into a failed agent turn.
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
type McpRequestInfo = {
|
|
1641
|
+
method?: string;
|
|
1642
|
+
id?: string | number | null;
|
|
1643
|
+
toolName?: string;
|
|
1644
|
+
};
|
|
1645
|
+
|
|
1646
|
+
async function mcpRequestInfo(input: string | URL | Request, init?: RequestInit): Promise<McpRequestInfo> {
|
|
1647
|
+
const body = typeof init?.body === "string"
|
|
1648
|
+
? init.body
|
|
1649
|
+
: input instanceof Request && (init?.method ?? input.method).toUpperCase() === "POST"
|
|
1650
|
+
? await input.clone().text().catch(() => "")
|
|
1651
|
+
: "";
|
|
1652
|
+
if (!body) {
|
|
1653
|
+
return {};
|
|
1654
|
+
}
|
|
1655
|
+
try {
|
|
1656
|
+
const parsed = JSON.parse(body) as { id?: unknown; method?: unknown; params?: { name?: unknown } };
|
|
1657
|
+
const method = typeof parsed.method === "string" ? parsed.method : undefined;
|
|
1658
|
+
const id = typeof parsed.id === "string" || typeof parsed.id === "number" || parsed.id === null ? parsed.id : undefined;
|
|
1659
|
+
const toolName = method === "tools/call" && typeof parsed.params?.name === "string" ? parsed.params.name : undefined;
|
|
1660
|
+
return {
|
|
1661
|
+
...(method ? { method } : {}),
|
|
1662
|
+
...(id !== undefined ? { id } : {}),
|
|
1663
|
+
...(toolName ? { toolName } : {}),
|
|
1664
|
+
};
|
|
1665
|
+
} catch {
|
|
1666
|
+
return {};
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
function withConnectionHeaders(input: string | URL | Request, init: RequestInit | undefined, authHeaders: Record<string, string>): RequestInit {
|
|
1671
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
1672
|
+
for (const [name, value] of Object.entries(authHeaders)) {
|
|
1673
|
+
headers.set(name, value);
|
|
1674
|
+
}
|
|
1675
|
+
return { ...init, headers };
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
function fetchInputForAttempt(input: string | URL | Request): string | URL | Request {
|
|
1679
|
+
return input instanceof Request ? input.clone() : input;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
function parseWwwAuthenticate(header: string | null): { error?: string; scope?: string[]; resource?: string } {
|
|
1683
|
+
if (!header) {
|
|
1684
|
+
return {};
|
|
1685
|
+
}
|
|
1686
|
+
const bearerIndex = header.toLowerCase().indexOf("bearer");
|
|
1687
|
+
if (bearerIndex < 0) {
|
|
1688
|
+
return {};
|
|
1689
|
+
}
|
|
1690
|
+
const paramsText = header.slice(bearerIndex + "bearer".length);
|
|
1691
|
+
const params: Record<string, string> = {};
|
|
1692
|
+
const re = /([a-zA-Z_][a-zA-Z0-9_-]*)\s*=\s*("(?:[^"\\]|\\.)*"|[^,\s]+)/g;
|
|
1693
|
+
let match: RegExpExecArray | null;
|
|
1694
|
+
while ((match = re.exec(paramsText)) !== null) {
|
|
1695
|
+
const raw = match[2]!;
|
|
1696
|
+
params[match[1]!.toLowerCase()] = raw.startsWith("\"") ? raw.slice(1, -1).replace(/\\"/g, "\"") : raw;
|
|
1697
|
+
}
|
|
1698
|
+
return {
|
|
1699
|
+
...(params.error ? { error: params.error } : {}),
|
|
1700
|
+
...(params.scope ? { scope: params.scope.split(/\s+/).filter(Boolean) } : {}),
|
|
1701
|
+
...(params.resource ? { resource: params.resource } : {}),
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
// Application-defined JSON-RPC error code marking "this tool call needs a
|
|
1706
|
+
// connection". A RESULT carrying inline `isError: true` cannot be used here:
|
|
1707
|
+
// the agents SDK's streamable-http shim strips `isError` and returns only the
|
|
1708
|
+
// content, erasing the failure. A JSON-RPC error survives the shim as a thrown
|
|
1709
|
+
// McpError, which PrefixedMcpServer.callTool converts back into an MCP-shaped
|
|
1710
|
+
// `{ isError: true }` output for the model.
|
|
1711
|
+
const MCP_AUTH_NEEDED_ERROR_CODE = -32001;
|
|
1712
|
+
const MCP_AUTH_NEEDED_MESSAGE = "Authentication required - a connection link was posted to the session.";
|
|
1713
|
+
|
|
1714
|
+
function mcpToolAuthNeededResponse(id: string | number | null | undefined): Response {
|
|
1715
|
+
return new Response(JSON.stringify({
|
|
1716
|
+
jsonrpc: "2.0",
|
|
1717
|
+
id: id ?? null,
|
|
1718
|
+
error: {
|
|
1719
|
+
code: MCP_AUTH_NEEDED_ERROR_CODE,
|
|
1720
|
+
message: MCP_AUTH_NEEDED_MESSAGE,
|
|
1721
|
+
},
|
|
1722
|
+
}), {
|
|
1723
|
+
status: 200,
|
|
1724
|
+
headers: { "content-type": "application/json" },
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
function isAuthNeededMcpError(error: unknown): boolean {
|
|
1729
|
+
if (!(error instanceof Error)) {
|
|
1730
|
+
return false;
|
|
1731
|
+
}
|
|
1732
|
+
const code = (error as { code?: unknown }).code;
|
|
1733
|
+
return code === MCP_AUTH_NEEDED_ERROR_CODE || error.message.includes(MCP_AUTH_NEEDED_MESSAGE);
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1247
1736
|
async function mcpServerRequestInit(settings: Settings, config: Settings["mcpServers"][number], options: PrepareToolsOptions): Promise<{ requestInit: { headers: Record<string, string> } } | {}> {
|
|
1248
1737
|
// codex_apps is checked FIRST so the static-headers path can never apply to
|
|
1249
1738
|
// it: its refreshing ChatGPT/Codex bearer is resolved per-connect from the
|
|
@@ -1433,7 +1922,7 @@ function normalizeUrl(raw: string): string | null {
|
|
|
1433
1922
|
}
|
|
1434
1923
|
|
|
1435
1924
|
export function prefixedMcpToolName(registryId: string, toolName: string): string {
|
|
1436
|
-
return
|
|
1925
|
+
return sharedPrefixedMcpToolName(registryId, toolName);
|
|
1437
1926
|
}
|
|
1438
1927
|
|
|
1439
1928
|
class PrefixedMcpServer implements MCPServer {
|
|
@@ -1469,7 +1958,18 @@ class PrefixedMcpServer implements MCPServer {
|
|
|
1469
1958
|
if (!this.isAllowed(unprefixed)) {
|
|
1470
1959
|
throw new Error(`MCP tool ${unprefixed} is not allowed for server ${this.name}`);
|
|
1471
1960
|
}
|
|
1472
|
-
|
|
1961
|
+
try {
|
|
1962
|
+
return await this.inner.callTool(unprefixed, args, meta);
|
|
1963
|
+
} catch (error) {
|
|
1964
|
+
// The connection broker's auth-needed short-circuit arrives as a thrown
|
|
1965
|
+
// JSON-RPC error (an inline isError result would be stripped by the SDK
|
|
1966
|
+
// shim). Surface it to the model as a failed-but-recoverable tool result
|
|
1967
|
+
// instead of failing the turn; the timeline chip was already published.
|
|
1968
|
+
if (isAuthNeededMcpError(error)) {
|
|
1969
|
+
return { isError: true, content: [{ type: "text", text: MCP_AUTH_NEEDED_MESSAGE }] };
|
|
1970
|
+
}
|
|
1971
|
+
throw error;
|
|
1972
|
+
}
|
|
1473
1973
|
}
|
|
1474
1974
|
|
|
1475
1975
|
invalidateToolsCache(): Promise<void> {
|
|
@@ -1857,8 +2357,10 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
|
|
|
1857
2357
|
// TOKEN-BROKER (B1): the per-turn git token seed, forwarded OFF-MANIFEST so the
|
|
1858
2358
|
// repository-clone hook seeds it to the box's token file before the clone.
|
|
1859
2359
|
const ownedGitTokenSeed = gitTokenSeedForAgent(agent);
|
|
2360
|
+
const ownedToolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
|
|
1860
2361
|
const ownedHooks = [
|
|
1861
2362
|
...sandboxLifecycleHooksForIds(sandboxLifecycleHookIds(settings)),
|
|
2363
|
+
...sandboxToolspaceTokenHooksForAgent(agent),
|
|
1862
2364
|
...sandboxRepositoryCloneHooksForAgent(agent),
|
|
1863
2365
|
];
|
|
1864
2366
|
const ownedHookContext: SandboxLifecycleHookContext = {
|
|
@@ -1866,6 +2368,7 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
|
|
|
1866
2368
|
...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
|
|
1867
2369
|
...(runAs ? { runAs } : {}),
|
|
1868
2370
|
...(ownedGitTokenSeed ? { gitTokenSeed: ownedGitTokenSeed } : {}),
|
|
2371
|
+
...(ownedToolspaceTokenSeed ? { toolspaceTokenSeed: ownedToolspaceTokenSeed } : {}),
|
|
1869
2372
|
};
|
|
1870
2373
|
// OWNED-PATH HOOKS: the SDK NEVER calls client.create/resume when handed a live
|
|
1871
2374
|
// provided session (SandboxRuntimeManager uses `sandboxConfig.session` directly),
|
|
@@ -1940,15 +2443,18 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
|
|
|
1940
2443
|
// TOKEN-BROKER (B1): the per-turn git token seed, forwarded OFF-MANIFEST so the
|
|
1941
2444
|
// repository-clone hook seeds it to the box's token file before the clone.
|
|
1942
2445
|
const gitTokenSeed = gitTokenSeedForAgent(agent);
|
|
2446
|
+
const toolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
|
|
1943
2447
|
const client = resourceClient
|
|
1944
2448
|
? withSandboxLifecycleHooks(resourceClient, [
|
|
1945
2449
|
...sandboxLifecycleHooksForIds(sandboxLifecycleHookIds(settings)),
|
|
2450
|
+
...sandboxToolspaceTokenHooksForAgent(agent),
|
|
1946
2451
|
...sandboxRepositoryCloneHooksForAgent(agent),
|
|
1947
2452
|
], {
|
|
1948
2453
|
environment,
|
|
1949
2454
|
...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
|
|
1950
2455
|
...(runAs ? { runAs } : {}),
|
|
1951
2456
|
...(gitTokenSeed ? { gitTokenSeed } : {}),
|
|
2457
|
+
...(toolspaceTokenSeed ? { toolspaceTokenSeed } : {}),
|
|
1952
2458
|
})
|
|
1953
2459
|
: undefined;
|
|
1954
2460
|
const sandboxSessionState = prepared.sandboxSessionState
|
|
@@ -1982,7 +2488,6 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
|
|
|
1982
2488
|
// every mid-turn follow-up.
|
|
1983
2489
|
callModelInputFilter,
|
|
1984
2490
|
};
|
|
1985
|
-
void settings.disableOpenaiTracing;
|
|
1986
2491
|
if (client) {
|
|
1987
2492
|
runOptions.sandbox = {
|
|
1988
2493
|
client,
|
|
@@ -2752,6 +3257,7 @@ export type SandboxLifecycleHookContext = {
|
|
|
2752
3257
|
// the clone exec's per-call env (OPENGENI_GIT_TOKEN_SEED), NEVER the box/agent
|
|
2753
3258
|
// manifest env (validateNoEnvironmentDelta must never see a rotating value).
|
|
2754
3259
|
gitTokenSeed?: string;
|
|
3260
|
+
toolspaceTokenSeed?: string;
|
|
2755
3261
|
};
|
|
2756
3262
|
|
|
2757
3263
|
export type SandboxLifecycleHook = {
|
|
@@ -2852,6 +3358,20 @@ function gitTokenSeedForAgent(agent: Agent<any, any>): string | undefined {
|
|
|
2852
3358
|
return agentGitTokenSeed.get(agent);
|
|
2853
3359
|
}
|
|
2854
3360
|
|
|
3361
|
+
function toolspaceTokenSeedForAgent(agent: Agent<any, any>): string | undefined {
|
|
3362
|
+
return agentToolspaceTokenSeed.get(agent);
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
function sandboxToolspaceTokenHooksForAgent(agent: Agent<any, any>): SandboxLifecycleHook[] {
|
|
3366
|
+
return toolspaceTokenSeedForAgent(agent)
|
|
3367
|
+
? [{
|
|
3368
|
+
id: "toolspace-token",
|
|
3369
|
+
phase: "beforeAgentStart",
|
|
3370
|
+
run: runToolspaceTokenSeedHook,
|
|
3371
|
+
}]
|
|
3372
|
+
: [];
|
|
3373
|
+
}
|
|
3374
|
+
|
|
2855
3375
|
function sandboxRepositoryCloneHooks(
|
|
2856
3376
|
settings: Settings,
|
|
2857
3377
|
resources: ResourceRef[],
|
|
@@ -3053,6 +3573,53 @@ export function repositoryCloneCommand(resources: Extract<ResourceRef, { kind: "
|
|
|
3053
3573
|
return commands.join("\n");
|
|
3054
3574
|
}
|
|
3055
3575
|
|
|
3576
|
+
export function toolspaceTokenSeedCommand(): string {
|
|
3577
|
+
return [
|
|
3578
|
+
"set -eu",
|
|
3579
|
+
"export HOME=\"${HOME:-/workspace}\"",
|
|
3580
|
+
"if [ -n \"${OPENGENI_TOOLSPACE_TOKEN_SEED:-}\" ]; then",
|
|
3581
|
+
" seed_umask=\"$(umask)\"",
|
|
3582
|
+
" umask 077",
|
|
3583
|
+
" token_file=\"${OPENGENI_TOOLSPACE_TOKEN_FILE:-$HOME/.opengeni/toolspace-token}\"",
|
|
3584
|
+
" mkdir -p \"$(dirname \"$token_file\")\"",
|
|
3585
|
+
" printf '%s' \"$OPENGENI_TOOLSPACE_TOKEN_SEED\" > \"$token_file.tmp.$$\"",
|
|
3586
|
+
" mv -f \"$token_file.tmp.$$\" \"$token_file\"",
|
|
3587
|
+
" umask \"$seed_umask\"",
|
|
3588
|
+
"fi",
|
|
3589
|
+
].join("\n");
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
export async function runToolspaceTokenSeedHook(
|
|
3593
|
+
session: SandboxSessionLike,
|
|
3594
|
+
context: SandboxLifecycleHookContext,
|
|
3595
|
+
): Promise<void> {
|
|
3596
|
+
if (!context.toolspaceTokenSeed) {
|
|
3597
|
+
return;
|
|
3598
|
+
}
|
|
3599
|
+
const command = `export OPENGENI_TOOLSPACE_TOKEN_SEED=${shellQuote(context.toolspaceTokenSeed)}\n${toolspaceTokenSeedCommand()}`;
|
|
3600
|
+
if (session.exec) {
|
|
3601
|
+
const result = await session.exec({
|
|
3602
|
+
cmd: command,
|
|
3603
|
+
workdir: "/workspace",
|
|
3604
|
+
...(context.runAs ? { runAs: context.runAs } : {}),
|
|
3605
|
+
yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
|
|
3606
|
+
maxOutputTokens: 4_000,
|
|
3607
|
+
});
|
|
3608
|
+
assertSandboxCommandSucceeded(result, "Toolspace token seed hook");
|
|
3609
|
+
} else if (session.execCommand) {
|
|
3610
|
+
const result = await session.execCommand({
|
|
3611
|
+
cmd: command,
|
|
3612
|
+
workdir: "/workspace",
|
|
3613
|
+
...(context.runAs ? { runAs: context.runAs } : {}),
|
|
3614
|
+
yieldTimeMs: SANDBOX_LIFECYCLE_COMMAND_TIMEOUT_MS,
|
|
3615
|
+
maxOutputTokens: 4_000,
|
|
3616
|
+
});
|
|
3617
|
+
assertSandboxCommandSucceeded(result, "Toolspace token seed hook");
|
|
3618
|
+
} else {
|
|
3619
|
+
throw new Error("Sandbox session does not support command execution");
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
|
|
3056
3623
|
export async function runRepositoryCloneHook(
|
|
3057
3624
|
session: SandboxSessionLike,
|
|
3058
3625
|
resources: Extract<ResourceRef, { kind: "repository" }>[],
|