@opengeni/runtime 0.11.0 → 0.13.3

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/src/index.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  import {
15
15
  approvalIdentifier,
16
16
  CAPABILITY_DESCRIPTORS,
17
+ DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
17
18
  assertUniqueResourceMountPaths,
18
19
  gitCredentialBindingIdForRepository,
19
20
  gitCredentialProviderForRepository,
@@ -27,6 +28,7 @@ import {
27
28
  signDelegatedAccessToken,
28
29
  RequestHumanInputToolInput,
29
30
  type GitCredentialProvider,
31
+ type GitCredentialTransport,
30
32
  type HumanInputResponse,
31
33
  type McpServerConnectionRef,
32
34
  type Permission,
@@ -37,6 +39,19 @@ import {
37
39
  type ToolAuthNeededPayload,
38
40
  type ToolRef,
39
41
  } from "@opengeni/contracts";
42
+ import {
43
+ MCP_MAX_CONCURRENT_SERVER_OPERATIONS,
44
+ MCP_MAX_TOOL_RESULT_BYTES,
45
+ McpAggregateToolListBudget,
46
+ assertMcpPayloadWithinBytes,
47
+ assertMcpServerSelectionWithinBounds,
48
+ assertMcpToolListWithinBounds,
49
+ boundedParallelMap,
50
+ cancelMcpResponseBody,
51
+ guardedMcpFetch,
52
+ mcpOuterConnectTimeoutMs,
53
+ undiciFetch,
54
+ } from "./mcp-network";
40
55
  import {
41
56
  Agent,
42
57
  AgentsError,
@@ -117,6 +132,7 @@ import {
117
132
  CODEX_APPS_MCP_SERVER_ID,
118
133
  CODEX_MODEL_ID_PREFIX,
119
134
  CODEX_ORIGINATOR,
135
+ CODEX_RESPONSE_SDK_OUTER_TIMEOUT_MS,
120
136
  boundModelToolOutputItems,
121
137
  codexAppsSanitizingFetch,
122
138
  codexRequestStorage,
@@ -181,7 +197,16 @@ export {
181
197
  } from "./skill-library";
182
198
 
183
199
  export type { RuntimeMetricsHooks } from "./metrics";
184
- export type { TurnToolCancellationFence } from "./sandbox/turn-tool-cancellation";
200
+ export {
201
+ createTurnToolCancellationController,
202
+ TurnSandboxCommandCancelledError,
203
+ } from "./sandbox/turn-tool-cancellation";
204
+ export type {
205
+ TurnSandboxCommandArgs,
206
+ TurnSandboxCommandSession,
207
+ TurnToolCancellationController,
208
+ TurnToolCancellationFence,
209
+ } from "./sandbox/turn-tool-cancellation";
185
210
 
186
211
  // P4.3 computer-use surface (the agent's :0 driver). Re-exported from the barrel
187
212
  // so callers (the worker, live proofs) reach SandboxComputer/ComputerUseCapability
@@ -269,8 +294,17 @@ export type {
269
294
  CompactionItem,
270
295
  PreparedCompactionPromptInput,
271
296
  } from "./context-compaction";
272
- export { modelCallUsageTelemetry } from "./usage-telemetry";
273
- export type { ModelCallUsageTelemetry } from "./usage-telemetry";
297
+ export {
298
+ MAX_MODEL_USAGE_TOKEN_COUNT,
299
+ modelCallUsageTelemetry,
300
+ modelUsageTokenCountOrNull,
301
+ normalizeModelCallUsage,
302
+ } from "./usage-telemetry";
303
+ export type {
304
+ ModelCallUsageInput,
305
+ ModelCallUsageNormalization,
306
+ ModelCallUsageTelemetry,
307
+ } from "./usage-telemetry";
274
308
 
275
309
  ensureReadableStreamFrom();
276
310
 
@@ -289,6 +323,18 @@ export type ModelResponseUsage = {
289
323
  totalTokens?: number;
290
324
  inputTokensDetails?: Record<string, number> | Array<Record<string, number>>;
291
325
  outputTokensDetails?: Record<string, number> | Array<Record<string, number>>;
326
+ requestUsageEntries?: Array<{
327
+ inputTokens?: number;
328
+ input_tokens?: number;
329
+ outputTokens?: number;
330
+ output_tokens?: number;
331
+ totalTokens?: number;
332
+ total_tokens?: number;
333
+ inputTokensDetails?: Record<string, number>;
334
+ input_tokens_details?: Record<string, number>;
335
+ outputTokensDetails?: Record<string, number>;
336
+ output_tokens_details?: Record<string, number>;
337
+ }>;
292
338
  };
293
339
  };
294
340
 
@@ -302,6 +348,8 @@ export type ResolveConnectionCredentialInput = {
302
348
  serverId: string;
303
349
  toolName?: string;
304
350
  connectionRef: McpServerConnectionRef;
351
+ /** Exact MCP destination whose request would receive the resolved headers. */
352
+ destinationUrl: string;
305
353
  forceRefresh?: boolean;
306
354
  };
307
355
 
@@ -564,6 +612,10 @@ export function buildProviderClient(provider: ResolvedModelProvider, settings: S
564
612
  // SDK retries on network/5xx/partial streams can replay provider work
565
613
  // or external tool side effects without a durable checkpoint.
566
614
  maxRetries: 0,
615
+ // The Codex transport owns finer headers/idle/whole deadlines and
616
+ // emits typed durable evidence. Keep the SDK's opaque envelope beyond
617
+ // that whole-response budget so `Request timed out.` cannot win first.
618
+ timeout: CODEX_RESPONSE_SDK_OUTER_TIMEOUT_MS,
567
619
  fetch: codexSubscriptionFetch(instrumentedModelFetch(provider.id, globalThis.fetch)),
568
620
  })
569
621
  : // ResolvedModelProvider.apiKey is already the resolved key (configuredProviders
@@ -625,7 +677,7 @@ export function resolveTurnModel(
625
677
  return {
626
678
  provider: resolved.provider,
627
679
  client,
628
- model: buildModelInstance(resolved.provider, client, resolved.model.id),
680
+ model: buildModelInstance(resolved.provider, client, resolved.model.upstreamModelId),
629
681
  configured: resolved.model,
630
682
  };
631
683
  }
@@ -1188,6 +1240,7 @@ export type GitCredentialBindingSeed = {
1188
1240
  credentialBindingId: string;
1189
1241
  provider: GitCredentialProvider;
1190
1242
  token: string;
1243
+ transport?: GitCredentialTransport;
1191
1244
  expiresAt?: string;
1192
1245
  /** Total active bindings for this provider, used to suppress unsafe aliases. */
1193
1246
  providerBindingCount?: number;
@@ -1907,14 +1960,17 @@ function maybeInstallCodexToolSearch(
1907
1960
  }
1908
1961
 
1909
1962
  /** True when the unprefixed tool `name` requires approval under `policy`. */
1910
- function mcpToolRequiresApproval(policy: boolean | string[], unprefixedName: string): boolean {
1911
- return policy === true || (Array.isArray(policy) && policy.includes(unprefixedName));
1963
+ function mcpToolRequiresApproval(
1964
+ policy: boolean | ReadonlySet<string>,
1965
+ unprefixedName: string,
1966
+ ): boolean {
1967
+ return policy === true || (policy !== false && policy.has(unprefixedName));
1912
1968
  }
1913
1969
 
1914
1970
  /** A per-server approval policy keyed by the server's `<id>__` tool prefix. */
1915
1971
  type McpApprovalPolicy = {
1916
1972
  prefix: string;
1917
- requireApproval: boolean | string[];
1973
+ requireApproval: boolean | ReadonlySet<string>;
1918
1974
  };
1919
1975
 
1920
1976
  /** The subset of the agent surface the approval wrap needs — including `clone`. */
@@ -2005,7 +2061,8 @@ function applyMcpApprovalPolicy(agent: Agent<any, any>, settings: Settings): voi
2005
2061
  )
2006
2062
  .map((server) => ({
2007
2063
  prefix: prefixedMcpToolName(server.id, ""),
2008
- requireApproval: server.requireApproval as boolean | string[],
2064
+ requireApproval:
2065
+ server.requireApproval === true ? true : new Set(server.requireApproval as string[]),
2009
2066
  }))
2010
2067
  .sort((a, b) => b.prefix.length - a.prefix.length);
2011
2068
  if (policies.length === 0) {
@@ -2266,8 +2323,76 @@ export type PrepareToolsOptions = {
2266
2323
  input: ResolveConnectionCredentialInput,
2267
2324
  ) => Promise<ResolveConnectionCredentialResult>;
2268
2325
  onAuthNeeded?: (payload: ToolAuthNeededPayload) => Promise<void> | void;
2326
+ /** Injectable final MCP transport for tests and embedded hosts. */
2327
+ mcpFetchImpl?: FetchLike;
2269
2328
  };
2270
2329
 
2330
+ type ConnectedMcpServerBatch = Awaited<ReturnType<typeof connectMcpServers>>;
2331
+
2332
+ export type ConnectedMcpServerBatches = {
2333
+ active: MCPServer[];
2334
+ failed: MCPServer[];
2335
+ errors: ReadonlyMap<MCPServer, Error>;
2336
+ close: () => Promise<void>;
2337
+ };
2338
+
2339
+ /**
2340
+ * Connect SDK-managed MCP servers in stable, bounded batches. The SDK cleans a
2341
+ * failing strict batch; this wrapper additionally closes every earlier batch
2342
+ * before rethrowing, so a later-batch failure cannot leak live connections.
2343
+ */
2344
+ export async function connectMcpServersInBatches(
2345
+ servers: MCPServer[],
2346
+ options: { strict: boolean; connectTimeoutMs?: number },
2347
+ ): Promise<ConnectedMcpServerBatches> {
2348
+ assertMcpServerSelectionWithinBounds(servers);
2349
+ const batches: ConnectedMcpServerBatch[] = [];
2350
+ try {
2351
+ for (let offset = 0; offset < servers.length; offset += MCP_MAX_CONCURRENT_SERVER_OPERATIONS) {
2352
+ batches.push(
2353
+ await connectMcpServers(
2354
+ servers.slice(offset, offset + MCP_MAX_CONCURRENT_SERVER_OPERATIONS),
2355
+ {
2356
+ ...(options.connectTimeoutMs === undefined
2357
+ ? {}
2358
+ : { connectTimeoutMs: options.connectTimeoutMs }),
2359
+ connectInParallel: true,
2360
+ strict: options.strict,
2361
+ },
2362
+ ),
2363
+ );
2364
+ }
2365
+ } catch (error) {
2366
+ await closeMcpServerBatches(batches).catch(() => undefined);
2367
+ throw error;
2368
+ }
2369
+
2370
+ const errors = new Map<MCPServer, Error>();
2371
+ for (const batch of batches) {
2372
+ for (const [server, error] of batch.errors) errors.set(server, error);
2373
+ }
2374
+ return {
2375
+ active: batches.flatMap((batch) => batch.active),
2376
+ failed: batches.flatMap((batch) => batch.failed),
2377
+ errors,
2378
+ close: async () => {
2379
+ await closeMcpServerBatches(batches);
2380
+ },
2381
+ };
2382
+ }
2383
+
2384
+ async function closeMcpServerBatches(batches: ConnectedMcpServerBatch[]): Promise<void> {
2385
+ let firstError: unknown;
2386
+ for (const batch of [...batches].reverse()) {
2387
+ try {
2388
+ await batch.close();
2389
+ } catch (error) {
2390
+ firstError ??= error;
2391
+ }
2392
+ }
2393
+ if (firstError !== undefined) throw firstError;
2394
+ }
2395
+
2271
2396
  export async function prepareAgentTools(
2272
2397
  settings: Settings,
2273
2398
  tools: ToolRef[],
@@ -2277,25 +2402,36 @@ export async function prepareAgentTools(
2277
2402
  // codex_apps sanitizing fetch so every tools/list this turn accumulates the
2278
2403
  // account's connector namespaces. Surfaced on PreparedAgentTools for the worker.
2279
2404
  const codexConnectorNamespaces = new Set<string>();
2405
+ assertMcpServerSelectionWithinBounds(tools);
2280
2406
  if (tools.length === 0) {
2281
2407
  return { mcpServers: [], close: async () => {}, codexConnectorNamespaces };
2282
2408
  }
2283
2409
  const registry = new Map(settings.mcpServers.map((server) => [server.id, server]));
2284
- const servers = await Promise.all(
2285
- tools.map(async (tool) => {
2410
+ const aggregateToolBudget = new McpAggregateToolListBudget();
2411
+ const mcpFetchImpl = options.mcpFetchImpl ?? undiciFetch;
2412
+ const servers = await boundedParallelMap(
2413
+ tools,
2414
+ MCP_MAX_CONCURRENT_SERVER_OPERATIONS,
2415
+ async (tool, index) => {
2286
2416
  const config = registry.get(tool.id);
2287
2417
  if (!config) {
2288
2418
  throw new Error(`Unknown MCP server id: ${tool.id}`);
2289
2419
  }
2290
2420
  const url = firstPartyMcpServerUrlForRun(settings, config, options.workspaceId) ?? config.url;
2421
+ const firstParty = isFirstPartyMcpServer(settings, config);
2291
2422
  const baseFetch = isCodexAppsMcpServer(config)
2292
- ? codexAppsSanitizingFetch(globalThis.fetch, codexConnectorNamespaces)
2293
- : globalThis.fetch;
2423
+ ? codexAppsSanitizingFetch(mcpFetchImpl, codexConnectorNamespaces)
2424
+ : mcpFetchImpl;
2425
+ const guardedFetch = guardedMcpFetch(
2426
+ firstParty ? { ...settings, integrationsAllowPrivateNetworkTargets: true } : settings,
2427
+ baseFetch,
2428
+ firstParty ? { requireHttpsOutsideLocalTest: false } : {},
2429
+ );
2294
2430
  const fetchImpl = config.connectionRef
2295
- ? connectionBrokerFetch(baseFetch, config, options)
2296
- : isFirstPartyMcpServer(settings, config)
2297
- ? firstPartyAuthFetch(baseFetch, settings, options)
2298
- : baseFetch;
2431
+ ? connectionBrokerFetch(guardedFetch, config, options)
2432
+ : firstParty
2433
+ ? firstPartyAuthFetch(guardedFetch, settings, options)
2434
+ : guardedFetch;
2299
2435
  // A server is connected BEST-EFFORT (a connect OR tools-list failure drops
2300
2436
  // it — its tools go unavailable for the turn — instead of failing the turn)
2301
2437
  // in two cases:
@@ -2327,7 +2463,7 @@ export async function prepareAgentTools(
2327
2463
  // MCP SDK's strict Tool schema rejects (fails the turn during tools/list);
2328
2464
  // sanitize the response on the wire before validation. The namespace Set
2329
2465
  // also captures each tool's original connector namespace (P4 Part B.1).
2330
- ...(fetchImpl !== globalThis.fetch ? { fetch: fetchImpl } : {}),
2466
+ fetch: fetchImpl,
2331
2467
  ...(await mcpServerRequestInit(settings, config)),
2332
2468
  ...(config.timeoutMs
2333
2469
  ? {
@@ -2339,36 +2475,50 @@ export async function prepareAgentTools(
2339
2475
  config.id,
2340
2476
  config.allowedTools,
2341
2477
  bestEffort,
2478
+ aggregateToolBudget,
2479
+ `${config.id}:${index}`,
2342
2480
  );
2343
2481
  return {
2344
2482
  server,
2345
2483
  bestEffort,
2346
2484
  optional,
2485
+ timeoutMs: config.timeoutMs,
2347
2486
  };
2348
- }),
2487
+ },
2349
2488
  );
2350
- const requiredServers = servers.filter((entry) => !entry.bestEffort).map((entry) => entry.server);
2351
- const bestEffortServers = servers
2352
- .filter((entry) => entry.bestEffort)
2353
- .map((entry) => entry.server);
2489
+ const requiredEntries = servers.filter((entry) => !entry.bestEffort);
2490
+ const bestEffortEntries = servers.filter((entry) => entry.bestEffort);
2491
+ const requiredServers = requiredEntries.map((entry) => entry.server);
2492
+ const bestEffortServers = bestEffortEntries.map((entry) => entry.server);
2354
2493
  // Names of the OPTIONAL servers (not codex_apps) so a drop is surfaced as a
2355
2494
  // warning; codex_apps keeps its historically-quiet drop (a not-logged-in
2356
2495
  // ChatGPT plan is a normal, non-noteworthy state).
2357
2496
  const optionalServerNames = new Set(
2358
2497
  servers.filter((entry) => entry.optional).map((entry) => entry.server.name),
2359
2498
  );
2360
- const connectedRequired = await connectMcpServers(requiredServers, {
2361
- connectInParallel: true,
2499
+ const connectedRequired = await connectMcpServersInBatches(requiredServers, {
2362
2500
  strict: true,
2501
+ connectTimeoutMs: mcpOuterConnectTimeoutMs(requiredEntries.map((entry) => entry.timeoutMs)),
2363
2502
  });
2364
- const connectedBestEffort = bestEffortServers.length
2365
- ? await connectMcpServers(bestEffortServers, {
2366
- connectInParallel: true,
2367
- strict: false,
2368
- })
2369
- : null;
2503
+ let connectedBestEffort: ConnectedMcpServerBatches | null = null;
2504
+ try {
2505
+ connectedBestEffort = bestEffortServers.length
2506
+ ? await connectMcpServersInBatches(bestEffortServers, {
2507
+ strict: false,
2508
+ connectTimeoutMs: mcpOuterConnectTimeoutMs(
2509
+ bestEffortEntries.map((entry) => entry.timeoutMs),
2510
+ ),
2511
+ })
2512
+ : null;
2513
+ } catch (error) {
2514
+ await connectedRequired.close().catch(() => undefined);
2515
+ throw error;
2516
+ }
2370
2517
  if (connectedBestEffort) {
2371
2518
  for (const failed of connectedBestEffort.failed) {
2519
+ if (failed instanceof PrefixedMcpServer) {
2520
+ failed.releaseAggregateBudget();
2521
+ }
2372
2522
  if (!optionalServerNames.has(failed.name)) {
2373
2523
  continue;
2374
2524
  }
@@ -2382,10 +2532,20 @@ export async function prepareAgentTools(
2382
2532
  return {
2383
2533
  mcpServers: [...connectedRequired.active, ...(connectedBestEffort?.active ?? [])],
2384
2534
  close: async () => {
2385
- await connectedRequired.close();
2535
+ let firstError: unknown;
2386
2536
  if (connectedBestEffort) {
2387
- await connectedBestEffort.close();
2537
+ try {
2538
+ await connectedBestEffort.close();
2539
+ } catch (error) {
2540
+ firstError ??= error;
2541
+ }
2388
2542
  }
2543
+ try {
2544
+ await connectedRequired.close();
2545
+ } catch (error) {
2546
+ firstError ??= error;
2547
+ }
2548
+ if (firstError !== undefined) throw firstError;
2389
2549
  },
2390
2550
  codexConnectorNamespaces,
2391
2551
  };
@@ -2402,10 +2562,12 @@ function connectionBrokerFetch(
2402
2562
  }
2403
2563
  return async (input, init) => {
2404
2564
  const request = await mcpRequestInfo(input, init);
2565
+ const destinationUrl = mcpRequestDestinationUrl(input);
2405
2566
  const first = await resolveConnectionForRequest(
2406
2567
  options,
2407
2568
  config.id,
2408
2569
  connectionRef,
2570
+ destinationUrl,
2409
2571
  request.toolName,
2410
2572
  false,
2411
2573
  );
@@ -2417,10 +2579,12 @@ function connectionBrokerFetch(
2417
2579
  withConnectionHeaders(input, init, first.headers),
2418
2580
  );
2419
2581
  if (response.status === 401) {
2582
+ await cancelMcpResponseBody(response);
2420
2583
  const refreshed = await resolveConnectionForRequest(
2421
2584
  options,
2422
2585
  config.id,
2423
2586
  connectionRef,
2587
+ destinationUrl,
2424
2588
  request.toolName,
2425
2589
  true,
2426
2590
  );
@@ -2433,11 +2597,14 @@ function connectionBrokerFetch(
2433
2597
  );
2434
2598
  if (retry.status === 403) {
2435
2599
  const auth = insufficientScopeAuth(retry.headers, connectionRef, refreshed.connectionId);
2436
- return auth
2437
- ? await authNeededFetchResponse(options, config.id, request, auth, connectionRef)
2438
- : retry;
2600
+ if (auth) {
2601
+ await cancelMcpResponseBody(retry);
2602
+ return await authNeededFetchResponse(options, config.id, request, auth, connectionRef);
2603
+ }
2604
+ return retry;
2439
2605
  }
2440
2606
  if (retry.status === 401) {
2607
+ await cancelMcpResponseBody(retry);
2441
2608
  return await authNeededFetchResponse(
2442
2609
  options,
2443
2610
  config.id,
@@ -2461,9 +2628,11 @@ function connectionBrokerFetch(
2461
2628
  }
2462
2629
  if (response.status === 403) {
2463
2630
  const auth = insufficientScopeAuth(response.headers, connectionRef, first.connectionId);
2464
- return auth
2465
- ? await authNeededFetchResponse(options, config.id, request, auth, connectionRef)
2466
- : response;
2631
+ if (auth) {
2632
+ await cancelMcpResponseBody(response);
2633
+ return await authNeededFetchResponse(options, config.id, request, auth, connectionRef);
2634
+ }
2635
+ return response;
2467
2636
  }
2468
2637
  return response;
2469
2638
  };
@@ -2473,6 +2642,7 @@ async function resolveConnectionForRequest(
2473
2642
  options: PrepareToolsOptions,
2474
2643
  serverId: string,
2475
2644
  connectionRef: McpServerConnectionRef,
2645
+ destinationUrl: string,
2476
2646
  toolName: string | undefined,
2477
2647
  forceRefresh: boolean,
2478
2648
  ): Promise<ResolveConnectionCredentialResult> {
@@ -2494,6 +2664,7 @@ async function resolveConnectionForRequest(
2494
2664
  workspaceId: options.workspaceId,
2495
2665
  serverId,
2496
2666
  connectionRef,
2667
+ destinationUrl,
2497
2668
  forceRefresh,
2498
2669
  ...(toolName ? { toolName } : {}),
2499
2670
  ...(options.subjectId ? { subjectId: options.subjectId } : {}),
@@ -2610,6 +2781,10 @@ type McpRequestInfo = {
2610
2781
  toolName?: string;
2611
2782
  };
2612
2783
 
2784
+ function mcpRequestDestinationUrl(input: string | URL | Request): string {
2785
+ return new URL(input instanceof Request ? input.url : input.toString()).toString();
2786
+ }
2787
+
2613
2788
  async function mcpRequestInfo(
2614
2789
  input: string | URL | Request,
2615
2790
  init?: RequestInit,
@@ -2838,7 +3013,7 @@ async function signFirstPartyDelegatedBearer(
2838
3013
  workspaceId: options.workspaceId,
2839
3014
  subjectId: options.subjectId ?? "worker:first-party-mcp",
2840
3015
  ...(options.subjectLabel ? { subjectLabel: options.subjectLabel } : {}),
2841
- permissions: options.firstPartyPermissions ?? firstPartyMcpPermissions,
3016
+ permissions: options.firstPartyPermissions ?? [...DEFAULT_FIRST_PARTY_MCP_PERMISSIONS],
2842
3017
  ...(options.sessionId ? { sessionId: options.sessionId } : {}),
2843
3018
  ...(options.turnId ? { turnId: options.turnId } : {}),
2844
3019
  ...(options.attemptId ? { attemptId: options.attemptId } : {}),
@@ -2922,22 +3097,6 @@ async function codexAppsMcpRequestInit(
2922
3097
  // level scopes (billing/account/members/api_keys/workspace:admin) are
2923
3098
  // intentionally excluded: they gate no first-party tool and are not agent
2924
3099
  // capabilities. (A finer-grained capability model comes later.)
2925
- const firstPartyMcpPermissions: Permission[] = [
2926
- "workspace:read",
2927
- "files:read",
2928
- "documents:search",
2929
- "scheduled_tasks:manage",
2930
- "scheduled_tasks:run",
2931
- "goals:manage",
2932
- "sessions:read",
2933
- "sessions:create",
2934
- "sessions:control",
2935
- "variable-sets:use",
2936
- "variable-sets:manage",
2937
- "rigs:use",
2938
- "github:use",
2939
- ];
2940
-
2941
3100
  // codex_apps is third-party-by-trust (the external ChatGPT connectors backend)
2942
3101
  // but needs DYNAMIC auth, so it is its own category — deliberately NOT folded
2943
3102
  // into the first-party allowlist, which would wrongly sign an OpenGeni delegated
@@ -3044,6 +3203,8 @@ class PrefixedMcpServer implements MCPServer {
3044
3203
  registryId: string,
3045
3204
  allowedTools?: string[],
3046
3205
  bestEffort = false,
3206
+ private readonly aggregateToolBudget?: McpAggregateToolListBudget,
3207
+ private readonly aggregateSourceId = registryId,
3047
3208
  ) {
3048
3209
  this.name = registryId;
3049
3210
  this.prefix = prefixedMcpToolName(registryId, "");
@@ -3057,13 +3218,27 @@ class PrefixedMcpServer implements MCPServer {
3057
3218
  }
3058
3219
 
3059
3220
  close(): Promise<void> {
3221
+ this.releaseAggregateBudget();
3060
3222
  return this.inner.close();
3061
3223
  }
3062
3224
 
3225
+ releaseAggregateBudget(): void {
3226
+ this.aggregateToolBudget?.remove(this.aggregateSourceId);
3227
+ }
3228
+
3063
3229
  async listTools(): Promise<RuntimeMcpTool[]> {
3064
- let tools: RuntimeMcpTool[];
3065
3230
  try {
3066
- tools = await this.inner.listTools();
3231
+ const tools = assertMcpToolListWithinBounds(await this.inner.listTools()) as RuntimeMcpTool[];
3232
+ const exposed = tools
3233
+ .filter((tool) => this.isAllowed(tool.name))
3234
+ .map((tool) => ({
3235
+ ...tool,
3236
+ name: prefixedMcpToolName(this.name, tool.name),
3237
+ }));
3238
+ const bounded = (this.aggregateToolBudget?.replace(this.aggregateSourceId, exposed) ??
3239
+ assertMcpToolListWithinBounds(exposed)) as RuntimeMcpTool[];
3240
+ this.listedToolSchemaTokens = estimateSerializedValueTokens(bounded);
3241
+ return bounded;
3067
3242
  } catch (error) {
3068
3243
  // A REQUIRED server's tools/list failure is fatal (fail-loud default): the
3069
3244
  // caller explicitly requested it, so its absence must fail the turn.
@@ -3092,16 +3267,9 @@ class PrefixedMcpServer implements MCPServer {
3092
3267
  { serverId: this.name, ...safeMcpErrorFields(error) },
3093
3268
  );
3094
3269
  }
3270
+ this.releaseAggregateBudget();
3095
3271
  return [];
3096
3272
  }
3097
- const exposed = tools
3098
- .filter((tool) => this.isAllowed(tool.name))
3099
- .map((tool) => ({
3100
- ...tool,
3101
- name: prefixedMcpToolName(this.name, tool.name),
3102
- }));
3103
- this.listedToolSchemaTokens = estimateSerializedValueTokens(exposed);
3104
- return exposed;
3105
3273
  }
3106
3274
 
3107
3275
  /** Latest exact tools/list projection used to build the model request. */
@@ -3119,7 +3287,9 @@ class PrefixedMcpServer implements MCPServer {
3119
3287
  throw new Error(`MCP tool ${unprefixed} is not allowed for server ${this.name}`);
3120
3288
  }
3121
3289
  try {
3122
- return await this.inner.callTool(unprefixed, args, meta);
3290
+ const output = await this.inner.callTool(unprefixed, args, meta);
3291
+ assertMcpPayloadWithinBytes(output, MCP_MAX_TOOL_RESULT_BYTES, "MCP tool result");
3292
+ return output;
3123
3293
  } catch (error) {
3124
3294
  // The connection broker's auth-needed short-circuit arrives as a thrown
3125
3295
  // JSON-RPC error (an inline isError result would be stripped by the SDK
@@ -4829,6 +4999,7 @@ function usageFromResponse(response: unknown): ModelResponseUsage["usage"] | nul
4829
4999
  ...numberProp(record, "totalTokens", "totalTokens", "total_tokens"),
4830
5000
  ...inputTokenDetailsProp(record),
4831
5001
  ...outputTokenDetailsProp(record),
5002
+ ...requestUsageEntriesProp(record),
4832
5003
  };
4833
5004
  return Object.keys(usage).length > 0 ? usage : null;
4834
5005
  }
@@ -4839,7 +5010,11 @@ function numberProp(
4839
5010
  ...keys: string[]
4840
5011
  ): Partial<ModelResponseUsage["usage"]> {
4841
5012
  const value = keys.map((key) => raw[key]).find((candidate) => candidate !== undefined);
4842
- return typeof value === "number" && Number.isFinite(value) ? { [outputKey]: value } : {};
5013
+ // Preserve numeric provider values verbatim here, including malformed ones.
5014
+ // The shared usage normalizer is the single bounded validation boundary and
5015
+ // needs to see NaN/infinite/fractional/oversized values so it can emit safe
5016
+ // field-path diagnostics rather than silently erasing the evidence.
5017
+ return typeof value === "number" ? { [outputKey]: value } : {};
4843
5018
  }
4844
5019
 
4845
5020
  function inputTokenDetailsProp(raw: Record<string, unknown>): Partial<ModelResponseUsage["usage"]> {
@@ -4848,7 +5023,7 @@ function inputTokenDetailsProp(raw: Record<string, unknown>): Partial<ModelRespo
4848
5023
  raw.input_tokens_details ??
4849
5024
  raw.promptTokensDetails ??
4850
5025
  raw.prompt_tokens_details;
4851
- if (!details || typeof details !== "object") {
5026
+ if (details === undefined || details === null) {
4852
5027
  return {};
4853
5028
  }
4854
5029
  return {
@@ -4861,7 +5036,7 @@ function outputTokenDetailsProp(
4861
5036
  ): Partial<ModelResponseUsage["usage"]> {
4862
5037
  const details = raw.outputTokensDetails ?? raw.output_tokens_details;
4863
5038
  const normalized = details ?? raw.completionTokensDetails ?? raw.completion_tokens_details;
4864
- if (!normalized || typeof normalized !== "object") {
5039
+ if (normalized === undefined || normalized === null) {
4865
5040
  return {};
4866
5041
  }
4867
5042
  return {
@@ -4869,6 +5044,21 @@ function outputTokenDetailsProp(
4869
5044
  };
4870
5045
  }
4871
5046
 
5047
+ function requestUsageEntriesProp(
5048
+ raw: Record<string, unknown>,
5049
+ ): Partial<ModelResponseUsage["usage"]> {
5050
+ const entries = raw.requestUsageEntries ?? raw.request_usage_entries;
5051
+ if (entries === undefined || entries === null) {
5052
+ return {};
5053
+ }
5054
+ return {
5055
+ // The normalizer validates every entry and all supported field aliases.
5056
+ // Preserve the SDK objects rather than rebuilding them and accidentally
5057
+ // dropping provider detail fields such as cache_write_tokens.
5058
+ requestUsageEntries: entries as NonNullable<ModelResponseUsage["usage"]["requestUsageEntries"]>,
5059
+ };
5060
+ }
5061
+
4872
5062
  export function serializeApprovals(interruptions: unknown[]): unknown[] {
4873
5063
  return interruptions
4874
5064
  .filter((item) => interruptionToolName(item) !== HUMAN_INPUT_TOOL_NAME)
@@ -5571,6 +5761,17 @@ type RuntimeGitBindingDescriptor = {
5571
5761
  uri: string;
5572
5762
  };
5573
5763
 
5764
+ type RuntimeGitHttpBrokerRouteDescriptor = {
5765
+ provider: GitCredentialProvider;
5766
+ credentialBindingId: string;
5767
+ bindingHash: string;
5768
+ repositoryUri: string;
5769
+ brokerUri: string;
5770
+ protocol: "https";
5771
+ host: string;
5772
+ path: string;
5773
+ };
5774
+
5574
5775
  function runtimeGitBindingDescriptors(
5575
5776
  resources: Extract<ResourceRef, { kind: "repository" }>[],
5576
5777
  ): RuntimeGitBindingDescriptor[] {
@@ -5614,6 +5815,115 @@ function runtimeGitBindingDescriptors(
5614
5815
  });
5615
5816
  }
5616
5817
 
5818
+ function gitCredentialBindingKey(
5819
+ provider: GitCredentialProvider,
5820
+ credentialBindingId: string,
5821
+ ): string {
5822
+ return `${provider}\u0000${credentialBindingId}`;
5823
+ }
5824
+
5825
+ function brokeredGitCredentialBindingKeys(
5826
+ bindings: GitCredentialBindingSeed[],
5827
+ ): ReadonlySet<string> {
5828
+ return new Set(
5829
+ bindings
5830
+ .filter((binding) => binding.transport?.kind === "http_broker")
5831
+ .map((binding) => gitCredentialBindingKey(binding.provider, binding.credentialBindingId)),
5832
+ );
5833
+ }
5834
+
5835
+ function runtimeGitHttpBrokerRouteDescriptors(
5836
+ resources: Extract<ResourceRef, { kind: "repository" }>[],
5837
+ bindings: GitCredentialBindingSeed[],
5838
+ ): RuntimeGitHttpBrokerRouteDescriptor[] {
5839
+ const resourceDescriptors = runtimeGitBindingDescriptors(resources);
5840
+ const byBindingAndUri = new Map<string, RuntimeGitBindingDescriptor>();
5841
+ for (const descriptor of resourceDescriptors) {
5842
+ byBindingAndUri.set(
5843
+ `${gitCredentialBindingKey(descriptor.provider, descriptor.credentialBindingId)}\u0000${descriptor.uri}`,
5844
+ descriptor,
5845
+ );
5846
+ }
5847
+
5848
+ const routes: RuntimeGitHttpBrokerRouteDescriptor[] = [];
5849
+ const claimedRepositoryUris = new Set<string>();
5850
+ const claimedBrokerUris = new Set<string>();
5851
+ for (const binding of bindings) {
5852
+ if (!binding.transport) continue;
5853
+ if (
5854
+ typeof binding.transport !== "object" ||
5855
+ binding.transport.kind !== "http_broker" ||
5856
+ !Array.isArray(binding.transport.repositories)
5857
+ ) {
5858
+ throw new Error(
5859
+ `Git credential binding ${binding.credentialBindingId} uses an unsupported transport`,
5860
+ );
5861
+ }
5862
+ const bindingKey = gitCredentialBindingKey(binding.provider, binding.credentialBindingId);
5863
+ const expected = resourceDescriptors.filter(
5864
+ (descriptor) =>
5865
+ gitCredentialBindingKey(descriptor.provider, descriptor.credentialBindingId) === bindingKey,
5866
+ );
5867
+ if (expected.length === 0 || binding.transport.repositories.length !== expected.length) {
5868
+ throw new Error(
5869
+ `Git HTTP broker binding ${binding.credentialBindingId} does not cover its exact repository set`,
5870
+ );
5871
+ }
5872
+ for (const route of binding.transport.repositories) {
5873
+ if (
5874
+ !route ||
5875
+ typeof route !== "object" ||
5876
+ typeof route.repositoryUri !== "string" ||
5877
+ typeof route.brokerUri !== "string"
5878
+ ) {
5879
+ throw new Error(
5880
+ `Git HTTP broker binding ${binding.credentialBindingId} contains an invalid repository route`,
5881
+ );
5882
+ }
5883
+ const descriptor = byBindingAndUri.get(`${bindingKey}\u0000${route.repositoryUri}`);
5884
+ if (!descriptor || claimedRepositoryUris.has(route.repositoryUri)) {
5885
+ throw new Error(
5886
+ `Git HTTP broker binding ${binding.credentialBindingId} contains an unexpected repository route`,
5887
+ );
5888
+ }
5889
+ let brokerUrl: URL;
5890
+ try {
5891
+ brokerUrl = new URL(route.brokerUri);
5892
+ } catch {
5893
+ throw new Error(
5894
+ `Git HTTP broker binding ${binding.credentialBindingId} contains an invalid broker URI`,
5895
+ );
5896
+ }
5897
+ if (
5898
+ brokerUrl.protocol !== "https:" ||
5899
+ brokerUrl.username ||
5900
+ brokerUrl.password ||
5901
+ brokerUrl.search ||
5902
+ brokerUrl.hash ||
5903
+ brokerUrl.href !== route.brokerUri ||
5904
+ claimedBrokerUris.has(brokerUrl.href)
5905
+ ) {
5906
+ throw new Error(
5907
+ `Git HTTP broker binding ${binding.credentialBindingId} contains an unsafe broker URI`,
5908
+ );
5909
+ }
5910
+ claimedRepositoryUris.add(route.repositoryUri);
5911
+ claimedBrokerUris.add(brokerUrl.href);
5912
+ routes.push({
5913
+ provider: binding.provider,
5914
+ credentialBindingId: binding.credentialBindingId,
5915
+ bindingHash: gitCredentialBindingHash(binding.credentialBindingId),
5916
+ repositoryUri: route.repositoryUri,
5917
+ brokerUri: route.brokerUri,
5918
+ protocol: "https",
5919
+ host: brokerUrl.host.toLowerCase(),
5920
+ path: brokerUrl.pathname.replace(/^\/+|\/+$/g, ""),
5921
+ });
5922
+ }
5923
+ }
5924
+ return routes;
5925
+ }
5926
+
5617
5927
  function gitUsernameForProvider(provider: GitCredentialProvider): string {
5618
5928
  if (provider === "github") return "x-access-token";
5619
5929
  if (provider === "gitlab") return "oauth2";
@@ -5622,22 +5932,48 @@ function gitUsernameForProvider(provider: GitCredentialProvider): string {
5622
5932
 
5623
5933
  function gitCredentialHelperBindingCaseLines(
5624
5934
  resources: Extract<ResourceRef, { kind: "repository" }>[],
5935
+ bindings: GitCredentialBindingSeed[],
5936
+ ): string[] {
5937
+ const brokeredBindings = brokeredGitCredentialBindingKeys(bindings);
5938
+ return runtimeGitBindingDescriptors(resources)
5939
+ .filter(
5940
+ (descriptor) =>
5941
+ !brokeredBindings.has(
5942
+ gitCredentialBindingKey(descriptor.provider, descriptor.credentialBindingId),
5943
+ ),
5944
+ )
5945
+ .flatMap((descriptor) => {
5946
+ const paths = new Set([
5947
+ descriptor.path,
5948
+ descriptor.path.replace(/\.git$/, ""),
5949
+ `${descriptor.path.replace(/\.git$/, "")}.git`,
5950
+ ]);
5951
+ return [...paths].map(
5952
+ (path) =>
5953
+ ` ${shellQuote(`${descriptor.protocol}|${descriptor.host}|${path}`)}) username=${shellQuote(gitUsernameForProvider(descriptor.provider))}; token_file="$credential_dir/${descriptor.bindingHash}-token" ;;`,
5954
+ );
5955
+ });
5956
+ }
5957
+
5958
+ function gitCredentialHelperBrokerCaseLines(
5959
+ routes: RuntimeGitHttpBrokerRouteDescriptor[],
5625
5960
  ): string[] {
5626
- return runtimeGitBindingDescriptors(resources).flatMap((descriptor) => {
5961
+ return routes.flatMap((route) => {
5627
5962
  const paths = new Set([
5628
- descriptor.path,
5629
- descriptor.path.replace(/\.git$/, ""),
5630
- `${descriptor.path.replace(/\.git$/, "")}.git`,
5963
+ route.path,
5964
+ route.path.replace(/\.git$/, ""),
5965
+ `${route.path.replace(/\.git$/, "")}.git`,
5631
5966
  ]);
5632
5967
  return [...paths].map(
5633
5968
  (path) =>
5634
- ` ${shellQuote(`${descriptor.protocol}|${descriptor.host}|${path}`)}) username=${shellQuote(gitUsernameForProvider(descriptor.provider))}; token_file="$credential_dir/${descriptor.bindingHash}-token" ;;`,
5969
+ ` ${shellQuote(`${route.protocol}|${route.host}|${path}`)}) username=opengeni; token_file="$credential_dir/${route.bindingHash}-token" ;;`,
5635
5970
  );
5636
5971
  });
5637
5972
  }
5638
5973
 
5639
5974
  function gitAskpassHostProviderCaseLines(
5640
5975
  resources: Extract<ResourceRef, { kind: "repository" }>[],
5976
+ brokerRoutes: RuntimeGitHttpBrokerRouteDescriptor[],
5641
5977
  ): string[] {
5642
5978
  const hosts = new Map<string, { provider: GitCredentialProvider; bindings: Set<string> }>();
5643
5979
  for (const descriptor of runtimeGitBindingDescriptors(resources)) {
@@ -5648,13 +5984,19 @@ function gitAskpassHostProviderCaseLines(
5648
5984
  entry.bindings.add(`${descriptor.provider}\u0000${descriptor.credentialBindingId}`);
5649
5985
  hosts.set(descriptor.host, entry);
5650
5986
  }
5651
- return [...hosts.entries()]
5652
- .filter(([, entry]) => entry.bindings.size === 1)
5653
- .sort(([a], [b]) => a.localeCompare(b))
5654
- .map(
5655
- ([hostname, entry]) =>
5656
- ` ${shellQuote(hostname)}) printf '%s\\n' ${entry.provider}; return 0 ;;`,
5657
- );
5987
+ const brokerHosts = [...new Set(brokerRoutes.map((route) => route.host))]
5988
+ .sort((a, b) => a.localeCompare(b))
5989
+ .map((hostname) => ` ${shellQuote(hostname)}) printf '\\n'; return 0 ;;`);
5990
+ return [
5991
+ ...brokerHosts,
5992
+ ...[...hosts.entries()]
5993
+ .filter(([, entry]) => entry.bindings.size === 1)
5994
+ .sort(([a], [b]) => a.localeCompare(b))
5995
+ .map(
5996
+ ([hostname, entry]) =>
5997
+ ` ${shellQuote(hostname)}) printf '%s\\n' ${entry.provider}; return 0 ;;`,
5998
+ ),
5999
+ ];
5658
6000
  }
5659
6001
 
5660
6002
  function gitCredentialTokenWriterCommandLines(bindings: GitCredentialBindingSeed[] = []): string[] {
@@ -5664,7 +6006,7 @@ function gitCredentialTokenWriterCommandLines(bindings: GitCredentialBindingSeed
5664
6006
  const count = Math.max(1, binding.providerBindingCount ?? 1);
5665
6007
  const provider = binding.provider;
5666
6008
  const lines = [`write_git_binding_token ${shellQuote(hash)} "\${${seedEnv}:-}"`];
5667
- if (count === 1) {
6009
+ if (count === 1 && binding.transport?.kind !== "http_broker") {
5668
6010
  lines.push(`write_git_provider_token ${shellQuote(provider)} "\${${seedEnv}:-}"`);
5669
6011
  } else {
5670
6012
  lines.push(`remove_git_provider_token ${shellQuote(provider)}`);
@@ -5726,14 +6068,45 @@ function gitCredentialTokenWriterCommandLines(bindings: GitCredentialBindingSeed
5726
6068
  ];
5727
6069
  }
5728
6070
 
6071
+ function gitHttpBrokerConfigCommandLines(routes: RuntimeGitHttpBrokerRouteDescriptor[]): string[] {
6072
+ return [
6073
+ 'git_http_broker_config="${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/http-broker.gitconfig"',
6074
+ 'mkdir -p "$(dirname "$git_http_broker_config")"',
6075
+ 'broker_umask="$(umask)"',
6076
+ "umask 077",
6077
+ ': > "$git_http_broker_config.tmp.$$"',
6078
+ ...routes.map(
6079
+ (route) =>
6080
+ `git config --file "$git_http_broker_config.tmp.$$" --add ${shellQuote(`url.${route.brokerUri}.insteadOf`)} ${shellQuote(route.repositoryUri)}`,
6081
+ ),
6082
+ 'mv -f "$git_http_broker_config.tmp.$$" "$git_http_broker_config"',
6083
+ 'umask "$broker_umask"',
6084
+ 'git config --global --unset-all include.path "$git_http_broker_config" >/dev/null 2>&1 || true',
6085
+ 'git config --global --add include.path "$git_http_broker_config"',
6086
+ ];
6087
+ }
6088
+
5729
6089
  function gitCredentialHelperCommandLines(
5730
6090
  resources: Extract<ResourceRef, { kind: "repository" }>[] = [],
5731
6091
  bindings: GitCredentialBindingSeed[] = [],
5732
6092
  ): string[] {
5733
- const hostProviderCases = gitAskpassHostProviderCaseLines(resources);
5734
- const bindingCases = gitCredentialHelperBindingCaseLines(resources);
6093
+ const brokerRoutes = runtimeGitHttpBrokerRouteDescriptors(resources, bindings);
6094
+ const hostProviderCases = gitAskpassHostProviderCaseLines(resources, brokerRoutes);
6095
+ const bindingCases = [
6096
+ ...gitCredentialHelperBindingCaseLines(resources, bindings),
6097
+ ...gitCredentialHelperBrokerCaseLines(brokerRoutes),
6098
+ ];
5735
6099
  const descriptors = runtimeGitBindingDescriptors(resources);
5736
- const wrapperDescriptors = bindings.length > 0 ? descriptors : [];
6100
+ const brokeredBindings = brokeredGitCredentialBindingKeys(bindings);
6101
+ const wrapperDescriptors =
6102
+ bindings.length > 0
6103
+ ? descriptors.filter(
6104
+ (descriptor) =>
6105
+ !brokeredBindings.has(
6106
+ gitCredentialBindingKey(descriptor.provider, descriptor.credentialBindingId),
6107
+ ),
6108
+ )
6109
+ : [];
5737
6110
  const bindingProviders = new Map<GitCredentialProvider, Set<string>>();
5738
6111
  for (const descriptor of runtimeGitBindingDescriptors(resources)) {
5739
6112
  const ids = bindingProviders.get(descriptor.provider) ?? new Set<string>();
@@ -5766,6 +6139,31 @@ function gitCredentialHelperCommandLines(
5766
6139
  .filter(([, ids]) => ids.size > 1)
5767
6140
  .map(([provider]) => provider)
5768
6141
  : [];
6142
+ const brokeredOriginCases = brokerRoutes.flatMap((route) => {
6143
+ const base = route.repositoryUri.replace(/\.git$/, "");
6144
+ return [...new Set([route.repositoryUri, base, `${base}.git`])].map(
6145
+ (uri) => ` ${shellQuote(`${route.provider}|${uri}`)}) return 0 ;;`,
6146
+ );
6147
+ });
6148
+ const brokeredBindingHashCases = bindings
6149
+ .filter((binding) => binding.transport?.kind === "http_broker")
6150
+ .map(
6151
+ (binding) =>
6152
+ ` ${shellQuote(`${binding.provider}|${gitCredentialBindingHash(binding.credentialBindingId)}`)}) return 0 ;;`,
6153
+ );
6154
+ const providerBindingKinds = new Map<
6155
+ GitCredentialProvider,
6156
+ { direct: number; brokered: number }
6157
+ >();
6158
+ for (const binding of bindings) {
6159
+ const counts = providerBindingKinds.get(binding.provider) ?? { direct: 0, brokered: 0 };
6160
+ if (binding.transport?.kind === "http_broker") counts.brokered += 1;
6161
+ else counts.direct += 1;
6162
+ providerBindingKinds.set(binding.provider, counts);
6163
+ }
6164
+ const brokerOnlyProviders = [...providerBindingKinds.entries()]
6165
+ .filter(([, counts]) => counts.brokered > 0 && counts.direct === 0)
6166
+ .map(([provider]) => provider);
5769
6167
  return [
5770
6168
  ...gitCredentialTokenWriterCommandLines(bindings),
5771
6169
  // Provision git/provider-CLI helpers at SETUP (runtime) before any clone
@@ -5857,6 +6255,7 @@ function gitCredentialHelperCommandLines(
5857
6255
  "git config --global --add credential.helper ''",
5858
6256
  'git config --global --add credential.helper "$git_credential_helper"',
5859
6257
  "git config --global credential.useHttpPath true",
6258
+ ...gitHttpBrokerConfigCommandLines(brokerRoutes),
5860
6259
  'wrapper_dir="${OPENGENI_GIT_CLI_WRAPPER_DIR:-$HOME/.opengeni/bin}"',
5861
6260
  'mkdir -p "$wrapper_dir"',
5862
6261
  "for opengeni_git_cli_tool in gh glab az; do",
@@ -5884,12 +6283,24 @@ function gitCredentialHelperCommandLines(
5884
6283
  " *) return 1 ;;",
5885
6284
  " esac",
5886
6285
  "}",
6286
+ "binding_hash_is_brokered() {",
6287
+ ' case "$provider|$1" in',
6288
+ ...brokeredBindingHashCases,
6289
+ " *) return 1 ;;",
6290
+ " esac",
6291
+ "}",
5887
6292
  "binding_hash_for_origin() {",
5888
6293
  ' case "$provider|$1" in',
5889
6294
  ...originWrapperHashes,
5890
6295
  " *) return 1 ;;",
5891
6296
  " esac",
5892
6297
  "}",
6298
+ "origin_is_brokered() {",
6299
+ ' case "$provider|$1" in',
6300
+ ...brokeredOriginCases,
6301
+ " *) return 1 ;;",
6302
+ " esac",
6303
+ "}",
5893
6304
  "sole_binding_hash() {",
5894
6305
  ' case "$provider" in',
5895
6306
  ...soleWrapperHashes,
@@ -5897,19 +6308,25 @@ function gitCredentialHelperCommandLines(
5897
6308
  " esac",
5898
6309
  "}",
5899
6310
  `multi_binding_providers=${shellQuote(multiWrapperProviders.join(" "))}`,
6311
+ `broker_only_providers=${shellQuote(brokerOnlyProviders.join(" "))}`,
5900
6312
  'if [ -n "$provider" ]; then',
5901
6313
  " binding_hash=",
5902
6314
  ' if [ -n "${OPENGENI_GIT_BINDING:-}" ]; then',
5903
6315
  ' binding_hash="$(hash_binding_id "$OPENGENI_GIT_BINDING")"',
6316
+ ' binding_hash_is_brokered "$binding_hash" && { printf \'%s\\n\' "$tool provider API authentication is host-brokered for OPENGENI_GIT_BINDING; use the configured provider MCP tools" >&2; exit 2; }',
5904
6317
  ' binding_hash_allowed "$binding_hash" || { printf \'%s\\n\' "OPENGENI_GIT_BINDING does not select a $provider credential attached to this session" >&2; exit 2; }',
5905
6318
  " elif command -v git >/dev/null 2>&1; then",
5906
6319
  ' origin="$(git config --get remote.origin.url 2>/dev/null || true)"',
6320
+ ' [ -z "$origin" ] || ! origin_is_brokered "$origin" || { printf \'%s\\n\' "$tool provider API authentication is host-brokered for this repository; use the configured provider MCP tools" >&2; exit 2; }',
5907
6321
  ' [ -z "$origin" ] || binding_hash="$(binding_hash_for_origin "$origin" 2>/dev/null || true)"',
5908
6322
  " fi",
5909
6323
  ' [ -n "$binding_hash" ] || binding_hash="$(sole_binding_hash 2>/dev/null || true)"',
5910
6324
  ' if [ -n "$binding_hash" ]; then',
5911
6325
  ' token_file="${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/$binding_hash-token"',
5912
6326
  " else",
6327
+ ' case " $broker_only_providers " in',
6328
+ ' *" $provider "*) printf \'%s\\n\' "$tool provider API authentication is host-brokered for this session; use the configured provider MCP tools" >&2; exit 2 ;;',
6329
+ " esac",
5913
6330
  ' case " $multi_binding_providers " in',
5914
6331
  ' *" $provider "*) printf \'%s\\n\' "Unable to select one of multiple $provider credentials; run inside an attached repository or set OPENGENI_GIT_BINDING" >&2; exit 2 ;;',
5915
6332
  " esac",