@opengeni/runtime 0.3.1 → 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/index.d.ts +87 -7
- package/dist/index.js +458 -29
- package/dist/index.js.map +1 -1
- package/package.json +6 -8
- package/src/context-compaction.ts +164 -16
- package/src/history-sanitizer.ts +2 -1
- package/src/index.ts +519 -13
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,
|
|
@@ -43,9 +43,11 @@ import {
|
|
|
43
43
|
type AgentInputItem,
|
|
44
44
|
type CallModelInputFilter,
|
|
45
45
|
type MCPServer,
|
|
46
|
+
type MCPToolErrorFunction,
|
|
46
47
|
type Model,
|
|
47
48
|
type ModelProvider,
|
|
48
49
|
type RunStreamEvent,
|
|
50
|
+
type Tool,
|
|
49
51
|
} from "@openai/agents";
|
|
50
52
|
import {
|
|
51
53
|
localDirLazySkillSource,
|
|
@@ -149,8 +151,10 @@ export { OpenAIChatCompletionsModel, OpenAIResponsesModel } from "@openai/agents
|
|
|
149
151
|
export {
|
|
150
152
|
CompactionNeededError,
|
|
151
153
|
buildCompactionPromptInput,
|
|
154
|
+
buildDeterministicFallbackCompactionHistory,
|
|
152
155
|
buildCompactionReplacementHistory,
|
|
153
156
|
clientCompactionThresholdTokens,
|
|
157
|
+
clampCompactionThresholdRatio,
|
|
154
158
|
decideClientCompaction,
|
|
155
159
|
enforceInputBudget,
|
|
156
160
|
buildSummaryItem,
|
|
@@ -164,7 +168,9 @@ export {
|
|
|
164
168
|
COMPACTION_SUMMARY_MARKER,
|
|
165
169
|
COMPACTION_PROMPT,
|
|
166
170
|
COMPACT_USER_MESSAGE_MAX_TOKENS,
|
|
167
|
-
|
|
171
|
+
DEFAULT_COMPACTION_THRESHOLD_RATIO,
|
|
172
|
+
MIN_COMPACTION_THRESHOLD_RATIO,
|
|
173
|
+
MAX_COMPACTION_THRESHOLD_RATIO,
|
|
168
174
|
SUMMARY_BUFFER_TOKENS,
|
|
169
175
|
SUMMARY_PREFIX,
|
|
170
176
|
USER_MESSAGE_TRUNCATION_MARKER,
|
|
@@ -197,6 +203,29 @@ export type ModelResponseUsage = {
|
|
|
197
203
|
|
|
198
204
|
type RuntimeMcpTool = Awaited<ReturnType<MCPServer["listTools"]>>[number];
|
|
199
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
|
+
|
|
200
229
|
export function ensureReadableStreamFrom(): void {
|
|
201
230
|
const ctor = globalThis.ReadableStream as (typeof ReadableStream & {
|
|
202
231
|
from?: <T>(source: Iterable<T> | AsyncIterable<T>) => ReadableStream<T>;
|
|
@@ -606,9 +635,10 @@ function recordModelCallMetric(provider: string, outcome: "completed" | "failed"
|
|
|
606
635
|
* Run the compaction summarizer as one plain, tool-less, non-streaming model
|
|
607
636
|
* call against the resolved provider. `input` is the active history plus
|
|
608
637
|
* Codex's checkpoint prompt. Returns the trimmed summary text, or null on any
|
|
609
|
-
* failure (the caller
|
|
610
|
-
* -
|
|
611
|
-
* 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.
|
|
612
642
|
*
|
|
613
643
|
* Provider-aware: the summary always runs on the SAME provider that serves the
|
|
614
644
|
* turn (registry providers can't summarize through OpenAI/Azure, and vice
|
|
@@ -622,18 +652,24 @@ function recordModelCallMetric(provider: string, outcome: "completed" | "failed"
|
|
|
622
652
|
export async function summarizeForCompaction(
|
|
623
653
|
settings: Settings,
|
|
624
654
|
input: Array<Record<string, unknown>>,
|
|
625
|
-
options: { client?: OpenAI; api?: ModelProviderApi; maxOutputTokens?: number; model?: string } = {},
|
|
655
|
+
options: { client?: OpenAI; api?: ModelProviderApi; maxOutputTokens?: number; model?: string; maxTranscriptTokens?: number } = {},
|
|
626
656
|
): Promise<string | null> {
|
|
627
657
|
const client = options.client ?? buildOpenAIClientFromSettings(settings);
|
|
628
658
|
const api = options.api ?? "responses";
|
|
629
659
|
const model = options.model ?? settings.openaiModel;
|
|
630
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
|
+
);
|
|
631
667
|
try {
|
|
632
668
|
if (api === "chat") {
|
|
633
669
|
const completion = await client.chat.completions.create({
|
|
634
670
|
model,
|
|
635
671
|
max_tokens: maxTokens,
|
|
636
|
-
messages: [{ role: "user", content:
|
|
672
|
+
messages: [{ role: "user", content: transcript }],
|
|
637
673
|
} as any);
|
|
638
674
|
const text = (completion as { choices?: Array<{ message?: { content?: unknown } }> }).choices?.[0]?.message?.content;
|
|
639
675
|
const trimmed = typeof text === "string" ? text.trim() : "";
|
|
@@ -646,13 +682,13 @@ export async function summarizeForCompaction(
|
|
|
646
682
|
// built-in path (api "responses"), so gate it on the built-in provider.
|
|
647
683
|
...(settings.openaiProvider === "azure" ? {} : { store: false }),
|
|
648
684
|
max_output_tokens: maxTokens,
|
|
649
|
-
input,
|
|
685
|
+
input: transcript,
|
|
650
686
|
} as any);
|
|
651
687
|
const text = extractResponseOutputText(response);
|
|
652
688
|
const trimmed = text.trim();
|
|
653
689
|
return trimmed.length > 0 ? trimmed : null;
|
|
654
690
|
} catch (error) {
|
|
655
|
-
console.error("context compaction summarize failed
|
|
691
|
+
console.error("context compaction summarize failed", error);
|
|
656
692
|
return null;
|
|
657
693
|
}
|
|
658
694
|
}
|
|
@@ -780,6 +816,11 @@ export type BuildAgentOptions = {
|
|
|
780
816
|
// seeds it to the box's token FILE before the clone runs. Omitted on the
|
|
781
817
|
// selfhosted path (the machine uses its own git creds) — a NO-OP there.
|
|
782
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;
|
|
783
824
|
// Genesis turn only: append a one-shot instruction to the agent's system
|
|
784
825
|
// prompt telling it to title the session via opengeni__set_session_title
|
|
785
826
|
// before responding. Delivered through the instructions channel (where the
|
|
@@ -913,11 +954,48 @@ const agentRepositoryCloneHooks = new WeakMap<object, SandboxLifecycleHook[]>();
|
|
|
913
954
|
// session env; runStream reads it to build the clone hook context. Absent when
|
|
914
955
|
// no repo is attached / on the selfhosted path.
|
|
915
956
|
const agentGitTokenSeed = new WeakMap<object, string>();
|
|
957
|
+
const agentToolspaceTokenSeed = new WeakMap<object, string>();
|
|
916
958
|
// The EFFECTIVE backend the turn resolved for this agent (undefined -> the home
|
|
917
959
|
// backend). Read by runStream's owned branch to keep platform box-setup hooks off
|
|
918
960
|
// connected machines (a user's real computer).
|
|
919
961
|
const agentActiveSandboxBackend = new WeakMap<object, Settings["sandboxBackend"]>();
|
|
920
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
|
+
|
|
921
999
|
export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[], options: BuildAgentOptions = {}): Agent<any, any> {
|
|
922
1000
|
// Resolved per-turn gating. Each override defaults to today's settings-derived
|
|
923
1001
|
// behaviour, so the legacy global-client callers (no resolved model) build the
|
|
@@ -993,11 +1071,19 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
|
|
|
993
1071
|
// spread; the SDK concatenates these with MCP and sandbox capability tools.
|
|
994
1072
|
...(hostedTools.length ? { tools: hostedTools } : {}),
|
|
995
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 },
|
|
996
1081
|
} as const;
|
|
997
1082
|
|
|
998
1083
|
if (settings.sandboxBackend === "none") {
|
|
999
1084
|
const agent = new Agent(baseConfig);
|
|
1000
1085
|
maybeInstallCodexToolSearch(agent, settings, options);
|
|
1086
|
+
applyMcpApprovalPolicy(agent, settings);
|
|
1001
1087
|
return agent;
|
|
1002
1088
|
}
|
|
1003
1089
|
|
|
@@ -1028,7 +1114,11 @@ export function buildOpenGeniAgent(settings: Settings, resources: ResourceRef[],
|
|
|
1028
1114
|
if (options.gitTokenSeed) {
|
|
1029
1115
|
agentGitTokenSeed.set(agent, options.gitTokenSeed);
|
|
1030
1116
|
}
|
|
1117
|
+
if (options.toolspaceTokenSeed) {
|
|
1118
|
+
agentToolspaceTokenSeed.set(agent, options.toolspaceTokenSeed);
|
|
1119
|
+
}
|
|
1031
1120
|
maybeInstallCodexToolSearch(agent, settings, options);
|
|
1121
|
+
applyMcpApprovalPolicy(agent, settings);
|
|
1032
1122
|
return agent;
|
|
1033
1123
|
}
|
|
1034
1124
|
|
|
@@ -1050,6 +1140,101 @@ function maybeInstallCodexToolSearch(agent: Agent<any, any>, settings: Settings,
|
|
|
1050
1140
|
}
|
|
1051
1141
|
}
|
|
1052
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
|
+
|
|
1053
1238
|
/**
|
|
1054
1239
|
* Force a sandbox capability to emit its FUNCTION-transport tool variants instead
|
|
1055
1240
|
* of the hosted ones, by dropping the model instance the SDK's transport
|
|
@@ -1218,6 +1403,8 @@ export type PrepareToolsOptions = {
|
|
|
1218
1403
|
// delegated token (manager-style sessions). The caller is responsible for
|
|
1219
1404
|
// having validated the set against the session creator's grant.
|
|
1220
1405
|
firstPartyPermissions?: Permission[];
|
|
1406
|
+
resolveCredential?: (input: ResolveConnectionCredentialInput) => Promise<ResolveConnectionCredentialResult>;
|
|
1407
|
+
onAuthNeeded?: (payload: ToolAuthNeededPayload) => Promise<void> | void;
|
|
1221
1408
|
};
|
|
1222
1409
|
|
|
1223
1410
|
export async function prepareAgentTools(settings: Settings, tools: ToolRef[], options: PrepareToolsOptions = {}): Promise<PreparedAgentTools> {
|
|
@@ -1235,6 +1422,12 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
|
|
|
1235
1422
|
throw new Error(`Unknown MCP server id: ${tool.id}`);
|
|
1236
1423
|
}
|
|
1237
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;
|
|
1238
1431
|
const server = new PrefixedMcpServer(new MCPServerStreamableHttp({
|
|
1239
1432
|
url,
|
|
1240
1433
|
name: config.name ?? config.id,
|
|
@@ -1243,7 +1436,7 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
|
|
|
1243
1436
|
// MCP SDK's strict Tool schema rejects (fails the turn during tools/list);
|
|
1244
1437
|
// sanitize the response on the wire before validation. The namespace Set
|
|
1245
1438
|
// also captures each tool's original connector namespace (P4 Part B.1).
|
|
1246
|
-
...(
|
|
1439
|
+
...(fetchImpl !== globalThis.fetch ? { fetch: fetchImpl } : {}),
|
|
1247
1440
|
...await mcpServerRequestInit(settings, config, options),
|
|
1248
1441
|
...(config.timeoutMs ? {
|
|
1249
1442
|
timeout: config.timeoutMs,
|
|
@@ -1262,7 +1455,7 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
|
|
|
1262
1455
|
// with a warning, never killing the turn before the model runs. Bare
|
|
1263
1456
|
// refs stay strict (below), preserving the fail-loud default.
|
|
1264
1457
|
const optional = tool.optional === true;
|
|
1265
|
-
return { server, bestEffort: isCodexAppsMcpServer(config) || optional, optional };
|
|
1458
|
+
return { server, bestEffort: isCodexAppsMcpServer(config) || optional || !!config.connectionRef, optional };
|
|
1266
1459
|
}));
|
|
1267
1460
|
const requiredServers = servers.filter((entry) => !entry.bestEffort).map((entry) => entry.server);
|
|
1268
1461
|
const bestEffortServers = servers.filter((entry) => entry.bestEffort).map((entry) => entry.server);
|
|
@@ -1306,6 +1499,240 @@ export async function prepareAgentTools(settings: Settings, tools: ToolRef[], op
|
|
|
1306
1499
|
};
|
|
1307
1500
|
}
|
|
1308
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
|
+
|
|
1309
1736
|
async function mcpServerRequestInit(settings: Settings, config: Settings["mcpServers"][number], options: PrepareToolsOptions): Promise<{ requestInit: { headers: Record<string, string> } } | {}> {
|
|
1310
1737
|
// codex_apps is checked FIRST so the static-headers path can never apply to
|
|
1311
1738
|
// it: its refreshing ChatGPT/Codex bearer is resolved per-connect from the
|
|
@@ -1495,7 +1922,7 @@ function normalizeUrl(raw: string): string | null {
|
|
|
1495
1922
|
}
|
|
1496
1923
|
|
|
1497
1924
|
export function prefixedMcpToolName(registryId: string, toolName: string): string {
|
|
1498
|
-
return
|
|
1925
|
+
return sharedPrefixedMcpToolName(registryId, toolName);
|
|
1499
1926
|
}
|
|
1500
1927
|
|
|
1501
1928
|
class PrefixedMcpServer implements MCPServer {
|
|
@@ -1531,7 +1958,18 @@ class PrefixedMcpServer implements MCPServer {
|
|
|
1531
1958
|
if (!this.isAllowed(unprefixed)) {
|
|
1532
1959
|
throw new Error(`MCP tool ${unprefixed} is not allowed for server ${this.name}`);
|
|
1533
1960
|
}
|
|
1534
|
-
|
|
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
|
+
}
|
|
1535
1973
|
}
|
|
1536
1974
|
|
|
1537
1975
|
invalidateToolsCache(): Promise<void> {
|
|
@@ -1919,8 +2357,10 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
|
|
|
1919
2357
|
// TOKEN-BROKER (B1): the per-turn git token seed, forwarded OFF-MANIFEST so the
|
|
1920
2358
|
// repository-clone hook seeds it to the box's token file before the clone.
|
|
1921
2359
|
const ownedGitTokenSeed = gitTokenSeedForAgent(agent);
|
|
2360
|
+
const ownedToolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
|
|
1922
2361
|
const ownedHooks = [
|
|
1923
2362
|
...sandboxLifecycleHooksForIds(sandboxLifecycleHookIds(settings)),
|
|
2363
|
+
...sandboxToolspaceTokenHooksForAgent(agent),
|
|
1924
2364
|
...sandboxRepositoryCloneHooksForAgent(agent),
|
|
1925
2365
|
];
|
|
1926
2366
|
const ownedHookContext: SandboxLifecycleHookContext = {
|
|
@@ -1928,6 +2368,7 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
|
|
|
1928
2368
|
...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
|
|
1929
2369
|
...(runAs ? { runAs } : {}),
|
|
1930
2370
|
...(ownedGitTokenSeed ? { gitTokenSeed: ownedGitTokenSeed } : {}),
|
|
2371
|
+
...(ownedToolspaceTokenSeed ? { toolspaceTokenSeed: ownedToolspaceTokenSeed } : {}),
|
|
1931
2372
|
};
|
|
1932
2373
|
// OWNED-PATH HOOKS: the SDK NEVER calls client.create/resume when handed a live
|
|
1933
2374
|
// provided session (SandboxRuntimeManager uses `sandboxConfig.session` directly),
|
|
@@ -2002,15 +2443,18 @@ export async function runAgentStream(agent: Agent<any, any>, input: PreparedAgen
|
|
|
2002
2443
|
// TOKEN-BROKER (B1): the per-turn git token seed, forwarded OFF-MANIFEST so the
|
|
2003
2444
|
// repository-clone hook seeds it to the box's token file before the clone.
|
|
2004
2445
|
const gitTokenSeed = gitTokenSeedForAgent(agent);
|
|
2446
|
+
const toolspaceTokenSeed = toolspaceTokenSeedForAgent(agent);
|
|
2005
2447
|
const client = resourceClient
|
|
2006
2448
|
? withSandboxLifecycleHooks(resourceClient, [
|
|
2007
2449
|
...sandboxLifecycleHooksForIds(sandboxLifecycleHookIds(settings)),
|
|
2450
|
+
...sandboxToolspaceTokenHooksForAgent(agent),
|
|
2008
2451
|
...sandboxRepositoryCloneHooksForAgent(agent),
|
|
2009
2452
|
], {
|
|
2010
2453
|
environment,
|
|
2011
2454
|
...(overrides.onRuntimeEvent ? { onRuntimeEvent: overrides.onRuntimeEvent } : {}),
|
|
2012
2455
|
...(runAs ? { runAs } : {}),
|
|
2013
2456
|
...(gitTokenSeed ? { gitTokenSeed } : {}),
|
|
2457
|
+
...(toolspaceTokenSeed ? { toolspaceTokenSeed } : {}),
|
|
2014
2458
|
})
|
|
2015
2459
|
: undefined;
|
|
2016
2460
|
const sandboxSessionState = prepared.sandboxSessionState
|
|
@@ -2813,6 +3257,7 @@ export type SandboxLifecycleHookContext = {
|
|
|
2813
3257
|
// the clone exec's per-call env (OPENGENI_GIT_TOKEN_SEED), NEVER the box/agent
|
|
2814
3258
|
// manifest env (validateNoEnvironmentDelta must never see a rotating value).
|
|
2815
3259
|
gitTokenSeed?: string;
|
|
3260
|
+
toolspaceTokenSeed?: string;
|
|
2816
3261
|
};
|
|
2817
3262
|
|
|
2818
3263
|
export type SandboxLifecycleHook = {
|
|
@@ -2913,6 +3358,20 @@ function gitTokenSeedForAgent(agent: Agent<any, any>): string | undefined {
|
|
|
2913
3358
|
return agentGitTokenSeed.get(agent);
|
|
2914
3359
|
}
|
|
2915
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
|
+
|
|
2916
3375
|
function sandboxRepositoryCloneHooks(
|
|
2917
3376
|
settings: Settings,
|
|
2918
3377
|
resources: ResourceRef[],
|
|
@@ -3114,6 +3573,53 @@ export function repositoryCloneCommand(resources: Extract<ResourceRef, { kind: "
|
|
|
3114
3573
|
return commands.join("\n");
|
|
3115
3574
|
}
|
|
3116
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
|
+
|
|
3117
3623
|
export async function runRepositoryCloneHook(
|
|
3118
3624
|
session: SandboxSessionLike,
|
|
3119
3625
|
resources: Extract<ResourceRef, { kind: "repository" }>[],
|