@opengeni/runtime 0.11.0 → 0.13.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/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,18 @@ 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
+ undiciFetch,
53
+ } from "./mcp-network";
40
54
  import {
41
55
  Agent,
42
56
  AgentsError,
@@ -117,6 +131,7 @@ import {
117
131
  CODEX_APPS_MCP_SERVER_ID,
118
132
  CODEX_MODEL_ID_PREFIX,
119
133
  CODEX_ORIGINATOR,
134
+ CODEX_RESPONSE_SDK_OUTER_TIMEOUT_MS,
120
135
  boundModelToolOutputItems,
121
136
  codexAppsSanitizingFetch,
122
137
  codexRequestStorage,
@@ -181,7 +196,16 @@ export {
181
196
  } from "./skill-library";
182
197
 
183
198
  export type { RuntimeMetricsHooks } from "./metrics";
184
- export type { TurnToolCancellationFence } from "./sandbox/turn-tool-cancellation";
199
+ export {
200
+ createTurnToolCancellationController,
201
+ TurnSandboxCommandCancelledError,
202
+ } from "./sandbox/turn-tool-cancellation";
203
+ export type {
204
+ TurnSandboxCommandArgs,
205
+ TurnSandboxCommandSession,
206
+ TurnToolCancellationController,
207
+ TurnToolCancellationFence,
208
+ } from "./sandbox/turn-tool-cancellation";
185
209
 
186
210
  // P4.3 computer-use surface (the agent's :0 driver). Re-exported from the barrel
187
211
  // so callers (the worker, live proofs) reach SandboxComputer/ComputerUseCapability
@@ -269,8 +293,17 @@ export type {
269
293
  CompactionItem,
270
294
  PreparedCompactionPromptInput,
271
295
  } from "./context-compaction";
272
- export { modelCallUsageTelemetry } from "./usage-telemetry";
273
- export type { ModelCallUsageTelemetry } from "./usage-telemetry";
296
+ export {
297
+ MAX_MODEL_USAGE_TOKEN_COUNT,
298
+ modelCallUsageTelemetry,
299
+ modelUsageTokenCountOrNull,
300
+ normalizeModelCallUsage,
301
+ } from "./usage-telemetry";
302
+ export type {
303
+ ModelCallUsageInput,
304
+ ModelCallUsageNormalization,
305
+ ModelCallUsageTelemetry,
306
+ } from "./usage-telemetry";
274
307
 
275
308
  ensureReadableStreamFrom();
276
309
 
@@ -289,6 +322,18 @@ export type ModelResponseUsage = {
289
322
  totalTokens?: number;
290
323
  inputTokensDetails?: Record<string, number> | Array<Record<string, number>>;
291
324
  outputTokensDetails?: Record<string, number> | Array<Record<string, number>>;
325
+ requestUsageEntries?: Array<{
326
+ inputTokens?: number;
327
+ input_tokens?: number;
328
+ outputTokens?: number;
329
+ output_tokens?: number;
330
+ totalTokens?: number;
331
+ total_tokens?: number;
332
+ inputTokensDetails?: Record<string, number>;
333
+ input_tokens_details?: Record<string, number>;
334
+ outputTokensDetails?: Record<string, number>;
335
+ output_tokens_details?: Record<string, number>;
336
+ }>;
292
337
  };
293
338
  };
294
339
 
@@ -302,6 +347,8 @@ export type ResolveConnectionCredentialInput = {
302
347
  serverId: string;
303
348
  toolName?: string;
304
349
  connectionRef: McpServerConnectionRef;
350
+ /** Exact MCP destination whose request would receive the resolved headers. */
351
+ destinationUrl: string;
305
352
  forceRefresh?: boolean;
306
353
  };
307
354
 
@@ -564,6 +611,10 @@ export function buildProviderClient(provider: ResolvedModelProvider, settings: S
564
611
  // SDK retries on network/5xx/partial streams can replay provider work
565
612
  // or external tool side effects without a durable checkpoint.
566
613
  maxRetries: 0,
614
+ // The Codex transport owns finer headers/idle/whole deadlines and
615
+ // emits typed durable evidence. Keep the SDK's opaque envelope beyond
616
+ // that whole-response budget so `Request timed out.` cannot win first.
617
+ timeout: CODEX_RESPONSE_SDK_OUTER_TIMEOUT_MS,
567
618
  fetch: codexSubscriptionFetch(instrumentedModelFetch(provider.id, globalThis.fetch)),
568
619
  })
569
620
  : // ResolvedModelProvider.apiKey is already the resolved key (configuredProviders
@@ -625,7 +676,7 @@ export function resolveTurnModel(
625
676
  return {
626
677
  provider: resolved.provider,
627
678
  client,
628
- model: buildModelInstance(resolved.provider, client, resolved.model.id),
679
+ model: buildModelInstance(resolved.provider, client, resolved.model.upstreamModelId),
629
680
  configured: resolved.model,
630
681
  };
631
682
  }
@@ -1188,6 +1239,7 @@ export type GitCredentialBindingSeed = {
1188
1239
  credentialBindingId: string;
1189
1240
  provider: GitCredentialProvider;
1190
1241
  token: string;
1242
+ transport?: GitCredentialTransport;
1191
1243
  expiresAt?: string;
1192
1244
  /** Total active bindings for this provider, used to suppress unsafe aliases. */
1193
1245
  providerBindingCount?: number;
@@ -1907,14 +1959,17 @@ function maybeInstallCodexToolSearch(
1907
1959
  }
1908
1960
 
1909
1961
  /** 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));
1962
+ function mcpToolRequiresApproval(
1963
+ policy: boolean | ReadonlySet<string>,
1964
+ unprefixedName: string,
1965
+ ): boolean {
1966
+ return policy === true || (policy !== false && policy.has(unprefixedName));
1912
1967
  }
1913
1968
 
1914
1969
  /** A per-server approval policy keyed by the server's `<id>__` tool prefix. */
1915
1970
  type McpApprovalPolicy = {
1916
1971
  prefix: string;
1917
- requireApproval: boolean | string[];
1972
+ requireApproval: boolean | ReadonlySet<string>;
1918
1973
  };
1919
1974
 
1920
1975
  /** The subset of the agent surface the approval wrap needs — including `clone`. */
@@ -2005,7 +2060,8 @@ function applyMcpApprovalPolicy(agent: Agent<any, any>, settings: Settings): voi
2005
2060
  )
2006
2061
  .map((server) => ({
2007
2062
  prefix: prefixedMcpToolName(server.id, ""),
2008
- requireApproval: server.requireApproval as boolean | string[],
2063
+ requireApproval:
2064
+ server.requireApproval === true ? true : new Set(server.requireApproval as string[]),
2009
2065
  }))
2010
2066
  .sort((a, b) => b.prefix.length - a.prefix.length);
2011
2067
  if (policies.length === 0) {
@@ -2266,8 +2322,73 @@ export type PrepareToolsOptions = {
2266
2322
  input: ResolveConnectionCredentialInput,
2267
2323
  ) => Promise<ResolveConnectionCredentialResult>;
2268
2324
  onAuthNeeded?: (payload: ToolAuthNeededPayload) => Promise<void> | void;
2325
+ /** Injectable final MCP transport for tests and embedded hosts. */
2326
+ mcpFetchImpl?: FetchLike;
2269
2327
  };
2270
2328
 
2329
+ type ConnectedMcpServerBatch = Awaited<ReturnType<typeof connectMcpServers>>;
2330
+
2331
+ export type ConnectedMcpServerBatches = {
2332
+ active: MCPServer[];
2333
+ failed: MCPServer[];
2334
+ errors: ReadonlyMap<MCPServer, Error>;
2335
+ close: () => Promise<void>;
2336
+ };
2337
+
2338
+ /**
2339
+ * Connect SDK-managed MCP servers in stable, bounded batches. The SDK cleans a
2340
+ * failing strict batch; this wrapper additionally closes every earlier batch
2341
+ * before rethrowing, so a later-batch failure cannot leak live connections.
2342
+ */
2343
+ export async function connectMcpServersInBatches(
2344
+ servers: MCPServer[],
2345
+ options: { strict: boolean },
2346
+ ): Promise<ConnectedMcpServerBatches> {
2347
+ assertMcpServerSelectionWithinBounds(servers);
2348
+ const batches: ConnectedMcpServerBatch[] = [];
2349
+ try {
2350
+ for (let offset = 0; offset < servers.length; offset += MCP_MAX_CONCURRENT_SERVER_OPERATIONS) {
2351
+ batches.push(
2352
+ await connectMcpServers(
2353
+ servers.slice(offset, offset + MCP_MAX_CONCURRENT_SERVER_OPERATIONS),
2354
+ {
2355
+ connectInParallel: true,
2356
+ strict: options.strict,
2357
+ },
2358
+ ),
2359
+ );
2360
+ }
2361
+ } catch (error) {
2362
+ await closeMcpServerBatches(batches).catch(() => undefined);
2363
+ throw error;
2364
+ }
2365
+
2366
+ const errors = new Map<MCPServer, Error>();
2367
+ for (const batch of batches) {
2368
+ for (const [server, error] of batch.errors) errors.set(server, error);
2369
+ }
2370
+ return {
2371
+ active: batches.flatMap((batch) => batch.active),
2372
+ failed: batches.flatMap((batch) => batch.failed),
2373
+ errors,
2374
+ close: async () => {
2375
+ await closeMcpServerBatches(batches);
2376
+ },
2377
+ };
2378
+ }
2379
+
2380
+ async function closeMcpServerBatches(batches: ConnectedMcpServerBatch[]): Promise<void> {
2381
+ let firstError: unknown;
2382
+ for (const batch of [...batches].reverse()) {
2383
+ try {
2384
+ await batch.close();
2385
+ } catch (error) {
2386
+ firstError ??= error;
2387
+ }
2388
+ }
2389
+ if (firstError !== undefined) throw firstError;
2390
+ }
2391
+
2271
2392
  export async function prepareAgentTools(
2272
2393
  settings: Settings,
2273
2394
  tools: ToolRef[],
@@ -2277,25 +2398,36 @@ export async function prepareAgentTools(
2277
2398
  // codex_apps sanitizing fetch so every tools/list this turn accumulates the
2278
2399
  // account's connector namespaces. Surfaced on PreparedAgentTools for the worker.
2279
2400
  const codexConnectorNamespaces = new Set<string>();
2401
+ assertMcpServerSelectionWithinBounds(tools);
2280
2402
  if (tools.length === 0) {
2281
2403
  return { mcpServers: [], close: async () => {}, codexConnectorNamespaces };
2282
2404
  }
2283
2405
  const registry = new Map(settings.mcpServers.map((server) => [server.id, server]));
2284
- const servers = await Promise.all(
2285
- tools.map(async (tool) => {
2406
+ const aggregateToolBudget = new McpAggregateToolListBudget();
2407
+ const mcpFetchImpl = options.mcpFetchImpl ?? undiciFetch;
2408
+ const servers = await boundedParallelMap(
2409
+ tools,
2410
+ MCP_MAX_CONCURRENT_SERVER_OPERATIONS,
2411
+ async (tool, index) => {
2286
2412
  const config = registry.get(tool.id);
2287
2413
  if (!config) {
2288
2414
  throw new Error(`Unknown MCP server id: ${tool.id}`);
2289
2415
  }
2290
2416
  const url = firstPartyMcpServerUrlForRun(settings, config, options.workspaceId) ?? config.url;
2417
+ const firstParty = isFirstPartyMcpServer(settings, config);
2291
2418
  const baseFetch = isCodexAppsMcpServer(config)
2292
- ? codexAppsSanitizingFetch(globalThis.fetch, codexConnectorNamespaces)
2293
- : globalThis.fetch;
2419
+ ? codexAppsSanitizingFetch(mcpFetchImpl, codexConnectorNamespaces)
2420
+ : mcpFetchImpl;
2421
+ const guardedFetch = guardedMcpFetch(
2422
+ firstParty ? { ...settings, integrationsAllowPrivateNetworkTargets: true } : settings,
2423
+ baseFetch,
2424
+ firstParty ? { requireHttpsOutsideLocalTest: false } : {},
2425
+ );
2294
2426
  const fetchImpl = config.connectionRef
2295
- ? connectionBrokerFetch(baseFetch, config, options)
2296
- : isFirstPartyMcpServer(settings, config)
2297
- ? firstPartyAuthFetch(baseFetch, settings, options)
2298
- : baseFetch;
2427
+ ? connectionBrokerFetch(guardedFetch, config, options)
2428
+ : firstParty
2429
+ ? firstPartyAuthFetch(guardedFetch, settings, options)
2430
+ : guardedFetch;
2299
2431
  // A server is connected BEST-EFFORT (a connect OR tools-list failure drops
2300
2432
  // it — its tools go unavailable for the turn — instead of failing the turn)
2301
2433
  // in two cases:
@@ -2327,7 +2459,7 @@ export async function prepareAgentTools(
2327
2459
  // MCP SDK's strict Tool schema rejects (fails the turn during tools/list);
2328
2460
  // sanitize the response on the wire before validation. The namespace Set
2329
2461
  // also captures each tool's original connector namespace (P4 Part B.1).
2330
- ...(fetchImpl !== globalThis.fetch ? { fetch: fetchImpl } : {}),
2462
+ fetch: fetchImpl,
2331
2463
  ...(await mcpServerRequestInit(settings, config)),
2332
2464
  ...(config.timeoutMs
2333
2465
  ? {
@@ -2339,13 +2471,15 @@ export async function prepareAgentTools(
2339
2471
  config.id,
2340
2472
  config.allowedTools,
2341
2473
  bestEffort,
2474
+ aggregateToolBudget,
2475
+ `${config.id}:${index}`,
2342
2476
  );
2343
2477
  return {
2344
2478
  server,
2345
2479
  bestEffort,
2346
2480
  optional,
2347
2481
  };
2348
- }),
2482
+ },
2349
2483
  );
2350
2484
  const requiredServers = servers.filter((entry) => !entry.bestEffort).map((entry) => entry.server);
2351
2485
  const bestEffortServers = servers
@@ -2357,18 +2491,21 @@ export async function prepareAgentTools(
2357
2491
  const optionalServerNames = new Set(
2358
2492
  servers.filter((entry) => entry.optional).map((entry) => entry.server.name),
2359
2493
  );
2360
- const connectedRequired = await connectMcpServers(requiredServers, {
2361
- connectInParallel: true,
2362
- strict: true,
2363
- });
2364
- const connectedBestEffort = bestEffortServers.length
2365
- ? await connectMcpServers(bestEffortServers, {
2366
- connectInParallel: true,
2367
- strict: false,
2368
- })
2369
- : null;
2494
+ const connectedRequired = await connectMcpServersInBatches(requiredServers, { strict: true });
2495
+ let connectedBestEffort: ConnectedMcpServerBatches | null = null;
2496
+ try {
2497
+ connectedBestEffort = bestEffortServers.length
2498
+ ? await connectMcpServersInBatches(bestEffortServers, { strict: false })
2499
+ : null;
2500
+ } catch (error) {
2501
+ await connectedRequired.close().catch(() => undefined);
2502
+ throw error;
2503
+ }
2370
2504
  if (connectedBestEffort) {
2371
2505
  for (const failed of connectedBestEffort.failed) {
2506
+ if (failed instanceof PrefixedMcpServer) {
2507
+ failed.releaseAggregateBudget();
2508
+ }
2372
2509
  if (!optionalServerNames.has(failed.name)) {
2373
2510
  continue;
2374
2511
  }
@@ -2382,10 +2519,20 @@ export async function prepareAgentTools(
2382
2519
  return {
2383
2520
  mcpServers: [...connectedRequired.active, ...(connectedBestEffort?.active ?? [])],
2384
2521
  close: async () => {
2385
- await connectedRequired.close();
2522
+ let firstError: unknown;
2386
2523
  if (connectedBestEffort) {
2387
- await connectedBestEffort.close();
2524
+ try {
2525
+ await connectedBestEffort.close();
2526
+ } catch (error) {
2527
+ firstError ??= error;
2528
+ }
2529
+ }
2530
+ try {
2531
+ await connectedRequired.close();
2532
+ } catch (error) {
2533
+ firstError ??= error;
2388
2534
  }
2535
+ if (firstError !== undefined) throw firstError;
2389
2536
  },
2390
2537
  codexConnectorNamespaces,
2391
2538
  };
@@ -2402,10 +2549,12 @@ function connectionBrokerFetch(
2402
2549
  }
2403
2550
  return async (input, init) => {
2404
2551
  const request = await mcpRequestInfo(input, init);
2552
+ const destinationUrl = mcpRequestDestinationUrl(input);
2405
2553
  const first = await resolveConnectionForRequest(
2406
2554
  options,
2407
2555
  config.id,
2408
2556
  connectionRef,
2557
+ destinationUrl,
2409
2558
  request.toolName,
2410
2559
  false,
2411
2560
  );
@@ -2417,10 +2566,12 @@ function connectionBrokerFetch(
2417
2566
  withConnectionHeaders(input, init, first.headers),
2418
2567
  );
2419
2568
  if (response.status === 401) {
2569
+ await cancelMcpResponseBody(response);
2420
2570
  const refreshed = await resolveConnectionForRequest(
2421
2571
  options,
2422
2572
  config.id,
2423
2573
  connectionRef,
2574
+ destinationUrl,
2424
2575
  request.toolName,
2425
2576
  true,
2426
2577
  );
@@ -2433,11 +2584,14 @@ function connectionBrokerFetch(
2433
2584
  );
2434
2585
  if (retry.status === 403) {
2435
2586
  const auth = insufficientScopeAuth(retry.headers, connectionRef, refreshed.connectionId);
2436
- return auth
2437
- ? await authNeededFetchResponse(options, config.id, request, auth, connectionRef)
2438
- : retry;
2587
+ if (auth) {
2588
+ await cancelMcpResponseBody(retry);
2589
+ return await authNeededFetchResponse(options, config.id, request, auth, connectionRef);
2590
+ }
2591
+ return retry;
2439
2592
  }
2440
2593
  if (retry.status === 401) {
2594
+ await cancelMcpResponseBody(retry);
2441
2595
  return await authNeededFetchResponse(
2442
2596
  options,
2443
2597
  config.id,
@@ -2461,9 +2615,11 @@ function connectionBrokerFetch(
2461
2615
  }
2462
2616
  if (response.status === 403) {
2463
2617
  const auth = insufficientScopeAuth(response.headers, connectionRef, first.connectionId);
2464
- return auth
2465
- ? await authNeededFetchResponse(options, config.id, request, auth, connectionRef)
2466
- : response;
2618
+ if (auth) {
2619
+ await cancelMcpResponseBody(response);
2620
+ return await authNeededFetchResponse(options, config.id, request, auth, connectionRef);
2621
+ }
2622
+ return response;
2467
2623
  }
2468
2624
  return response;
2469
2625
  };
@@ -2473,6 +2629,7 @@ async function resolveConnectionForRequest(
2473
2629
  options: PrepareToolsOptions,
2474
2630
  serverId: string,
2475
2631
  connectionRef: McpServerConnectionRef,
2632
+ destinationUrl: string,
2476
2633
  toolName: string | undefined,
2477
2634
  forceRefresh: boolean,
2478
2635
  ): Promise<ResolveConnectionCredentialResult> {
@@ -2494,6 +2651,7 @@ async function resolveConnectionForRequest(
2494
2651
  workspaceId: options.workspaceId,
2495
2652
  serverId,
2496
2653
  connectionRef,
2654
+ destinationUrl,
2497
2655
  forceRefresh,
2498
2656
  ...(toolName ? { toolName } : {}),
2499
2657
  ...(options.subjectId ? { subjectId: options.subjectId } : {}),
@@ -2610,6 +2768,10 @@ type McpRequestInfo = {
2610
2768
  toolName?: string;
2611
2769
  };
2612
2770
 
2771
+ function mcpRequestDestinationUrl(input: string | URL | Request): string {
2772
+ return new URL(input instanceof Request ? input.url : input.toString()).toString();
2773
+ }
2774
+
2613
2775
  async function mcpRequestInfo(
2614
2776
  input: string | URL | Request,
2615
2777
  init?: RequestInit,
@@ -2838,7 +3000,7 @@ async function signFirstPartyDelegatedBearer(
2838
3000
  workspaceId: options.workspaceId,
2839
3001
  subjectId: options.subjectId ?? "worker:first-party-mcp",
2840
3002
  ...(options.subjectLabel ? { subjectLabel: options.subjectLabel } : {}),
2841
- permissions: options.firstPartyPermissions ?? firstPartyMcpPermissions,
3003
+ permissions: options.firstPartyPermissions ?? [...DEFAULT_FIRST_PARTY_MCP_PERMISSIONS],
2842
3004
  ...(options.sessionId ? { sessionId: options.sessionId } : {}),
2843
3005
  ...(options.turnId ? { turnId: options.turnId } : {}),
2844
3006
  ...(options.attemptId ? { attemptId: options.attemptId } : {}),
@@ -2922,22 +3084,6 @@ async function codexAppsMcpRequestInit(
2922
3084
  // level scopes (billing/account/members/api_keys/workspace:admin) are
2923
3085
  // intentionally excluded: they gate no first-party tool and are not agent
2924
3086
  // 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
3087
  // codex_apps is third-party-by-trust (the external ChatGPT connectors backend)
2942
3088
  // but needs DYNAMIC auth, so it is its own category — deliberately NOT folded
2943
3089
  // into the first-party allowlist, which would wrongly sign an OpenGeni delegated
@@ -3044,6 +3190,8 @@ class PrefixedMcpServer implements MCPServer {
3044
3190
  registryId: string,
3045
3191
  allowedTools?: string[],
3046
3192
  bestEffort = false,
3193
+ private readonly aggregateToolBudget?: McpAggregateToolListBudget,
3194
+ private readonly aggregateSourceId = registryId,
3047
3195
  ) {
3048
3196
  this.name = registryId;
3049
3197
  this.prefix = prefixedMcpToolName(registryId, "");
@@ -3057,13 +3205,27 @@ class PrefixedMcpServer implements MCPServer {
3057
3205
  }
3058
3206
 
3059
3207
  close(): Promise<void> {
3208
+ this.releaseAggregateBudget();
3060
3209
  return this.inner.close();
3061
3210
  }
3062
3211
 
3212
+ releaseAggregateBudget(): void {
3213
+ this.aggregateToolBudget?.remove(this.aggregateSourceId);
3214
+ }
3215
+
3063
3216
  async listTools(): Promise<RuntimeMcpTool[]> {
3064
- let tools: RuntimeMcpTool[];
3065
3217
  try {
3066
- tools = await this.inner.listTools();
3218
+ const tools = assertMcpToolListWithinBounds(await this.inner.listTools()) as RuntimeMcpTool[];
3219
+ const exposed = tools
3220
+ .filter((tool) => this.isAllowed(tool.name))
3221
+ .map((tool) => ({
3222
+ ...tool,
3223
+ name: prefixedMcpToolName(this.name, tool.name),
3224
+ }));
3225
+ const bounded = (this.aggregateToolBudget?.replace(this.aggregateSourceId, exposed) ??
3226
+ assertMcpToolListWithinBounds(exposed)) as RuntimeMcpTool[];
3227
+ this.listedToolSchemaTokens = estimateSerializedValueTokens(bounded);
3228
+ return bounded;
3067
3229
  } catch (error) {
3068
3230
  // A REQUIRED server's tools/list failure is fatal (fail-loud default): the
3069
3231
  // caller explicitly requested it, so its absence must fail the turn.
@@ -3092,16 +3254,9 @@ class PrefixedMcpServer implements MCPServer {
3092
3254
  { serverId: this.name, ...safeMcpErrorFields(error) },
3093
3255
  );
3094
3256
  }
3257
+ this.releaseAggregateBudget();
3095
3258
  return [];
3096
3259
  }
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
3260
  }
3106
3261
 
3107
3262
  /** Latest exact tools/list projection used to build the model request. */
@@ -3119,7 +3274,9 @@ class PrefixedMcpServer implements MCPServer {
3119
3274
  throw new Error(`MCP tool ${unprefixed} is not allowed for server ${this.name}`);
3120
3275
  }
3121
3276
  try {
3122
- return await this.inner.callTool(unprefixed, args, meta);
3277
+ const output = await this.inner.callTool(unprefixed, args, meta);
3278
+ assertMcpPayloadWithinBytes(output, MCP_MAX_TOOL_RESULT_BYTES, "MCP tool result");
3279
+ return output;
3123
3280
  } catch (error) {
3124
3281
  // The connection broker's auth-needed short-circuit arrives as a thrown
3125
3282
  // JSON-RPC error (an inline isError result would be stripped by the SDK
@@ -4829,6 +4986,7 @@ function usageFromResponse(response: unknown): ModelResponseUsage["usage"] | nul
4829
4986
  ...numberProp(record, "totalTokens", "totalTokens", "total_tokens"),
4830
4987
  ...inputTokenDetailsProp(record),
4831
4988
  ...outputTokenDetailsProp(record),
4989
+ ...requestUsageEntriesProp(record),
4832
4990
  };
4833
4991
  return Object.keys(usage).length > 0 ? usage : null;
4834
4992
  }
@@ -4839,7 +4997,11 @@ function numberProp(
4839
4997
  ...keys: string[]
4840
4998
  ): Partial<ModelResponseUsage["usage"]> {
4841
4999
  const value = keys.map((key) => raw[key]).find((candidate) => candidate !== undefined);
4842
- return typeof value === "number" && Number.isFinite(value) ? { [outputKey]: value } : {};
5000
+ // Preserve numeric provider values verbatim here, including malformed ones.
5001
+ // The shared usage normalizer is the single bounded validation boundary and
5002
+ // needs to see NaN/infinite/fractional/oversized values so it can emit safe
5003
+ // field-path diagnostics rather than silently erasing the evidence.
5004
+ return typeof value === "number" ? { [outputKey]: value } : {};
4843
5005
  }
4844
5006
 
4845
5007
  function inputTokenDetailsProp(raw: Record<string, unknown>): Partial<ModelResponseUsage["usage"]> {
@@ -4848,7 +5010,7 @@ function inputTokenDetailsProp(raw: Record<string, unknown>): Partial<ModelRespo
4848
5010
  raw.input_tokens_details ??
4849
5011
  raw.promptTokensDetails ??
4850
5012
  raw.prompt_tokens_details;
4851
- if (!details || typeof details !== "object") {
5013
+ if (details === undefined || details === null) {
4852
5014
  return {};
4853
5015
  }
4854
5016
  return {
@@ -4861,7 +5023,7 @@ function outputTokenDetailsProp(
4861
5023
  ): Partial<ModelResponseUsage["usage"]> {
4862
5024
  const details = raw.outputTokensDetails ?? raw.output_tokens_details;
4863
5025
  const normalized = details ?? raw.completionTokensDetails ?? raw.completion_tokens_details;
4864
- if (!normalized || typeof normalized !== "object") {
5026
+ if (normalized === undefined || normalized === null) {
4865
5027
  return {};
4866
5028
  }
4867
5029
  return {
@@ -4869,6 +5031,21 @@ function outputTokenDetailsProp(
4869
5031
  };
4870
5032
  }
4871
5033
 
5034
+ function requestUsageEntriesProp(
5035
+ raw: Record<string, unknown>,
5036
+ ): Partial<ModelResponseUsage["usage"]> {
5037
+ const entries = raw.requestUsageEntries ?? raw.request_usage_entries;
5038
+ if (entries === undefined || entries === null) {
5039
+ return {};
5040
+ }
5041
+ return {
5042
+ // The normalizer validates every entry and all supported field aliases.
5043
+ // Preserve the SDK objects rather than rebuilding them and accidentally
5044
+ // dropping provider detail fields such as cache_write_tokens.
5045
+ requestUsageEntries: entries as NonNullable<ModelResponseUsage["usage"]["requestUsageEntries"]>,
5046
+ };
5047
+ }
5048
+
4872
5049
  export function serializeApprovals(interruptions: unknown[]): unknown[] {
4873
5050
  return interruptions
4874
5051
  .filter((item) => interruptionToolName(item) !== HUMAN_INPUT_TOOL_NAME)
@@ -5571,6 +5748,17 @@ type RuntimeGitBindingDescriptor = {
5571
5748
  uri: string;
5572
5749
  };
5573
5750
 
5751
+ type RuntimeGitHttpBrokerRouteDescriptor = {
5752
+ provider: GitCredentialProvider;
5753
+ credentialBindingId: string;
5754
+ bindingHash: string;
5755
+ repositoryUri: string;
5756
+ brokerUri: string;
5757
+ protocol: "https";
5758
+ host: string;
5759
+ path: string;
5760
+ };
5761
+
5574
5762
  function runtimeGitBindingDescriptors(
5575
5763
  resources: Extract<ResourceRef, { kind: "repository" }>[],
5576
5764
  ): RuntimeGitBindingDescriptor[] {
@@ -5614,6 +5802,115 @@ function runtimeGitBindingDescriptors(
5614
5802
  });
5615
5803
  }
5616
5804
 
5805
+ function gitCredentialBindingKey(
5806
+ provider: GitCredentialProvider,
5807
+ credentialBindingId: string,
5808
+ ): string {
5809
+ return `${provider}\u0000${credentialBindingId}`;
5810
+ }
5811
+
5812
+ function brokeredGitCredentialBindingKeys(
5813
+ bindings: GitCredentialBindingSeed[],
5814
+ ): ReadonlySet<string> {
5815
+ return new Set(
5816
+ bindings
5817
+ .filter((binding) => binding.transport?.kind === "http_broker")
5818
+ .map((binding) => gitCredentialBindingKey(binding.provider, binding.credentialBindingId)),
5819
+ );
5820
+ }
5821
+
5822
+ function runtimeGitHttpBrokerRouteDescriptors(
5823
+ resources: Extract<ResourceRef, { kind: "repository" }>[],
5824
+ bindings: GitCredentialBindingSeed[],
5825
+ ): RuntimeGitHttpBrokerRouteDescriptor[] {
5826
+ const resourceDescriptors = runtimeGitBindingDescriptors(resources);
5827
+ const byBindingAndUri = new Map<string, RuntimeGitBindingDescriptor>();
5828
+ for (const descriptor of resourceDescriptors) {
5829
+ byBindingAndUri.set(
5830
+ `${gitCredentialBindingKey(descriptor.provider, descriptor.credentialBindingId)}\u0000${descriptor.uri}`,
5831
+ descriptor,
5832
+ );
5833
+ }
5834
+
5835
+ const routes: RuntimeGitHttpBrokerRouteDescriptor[] = [];
5836
+ const claimedRepositoryUris = new Set<string>();
5837
+ const claimedBrokerUris = new Set<string>();
5838
+ for (const binding of bindings) {
5839
+ if (!binding.transport) continue;
5840
+ if (
5841
+ typeof binding.transport !== "object" ||
5842
+ binding.transport.kind !== "http_broker" ||
5843
+ !Array.isArray(binding.transport.repositories)
5844
+ ) {
5845
+ throw new Error(
5846
+ `Git credential binding ${binding.credentialBindingId} uses an unsupported transport`,
5847
+ );
5848
+ }
5849
+ const bindingKey = gitCredentialBindingKey(binding.provider, binding.credentialBindingId);
5850
+ const expected = resourceDescriptors.filter(
5851
+ (descriptor) =>
5852
+ gitCredentialBindingKey(descriptor.provider, descriptor.credentialBindingId) === bindingKey,
5853
+ );
5854
+ if (expected.length === 0 || binding.transport.repositories.length !== expected.length) {
5855
+ throw new Error(
5856
+ `Git HTTP broker binding ${binding.credentialBindingId} does not cover its exact repository set`,
5857
+ );
5858
+ }
5859
+ for (const route of binding.transport.repositories) {
5860
+ if (
5861
+ !route ||
5862
+ typeof route !== "object" ||
5863
+ typeof route.repositoryUri !== "string" ||
5864
+ typeof route.brokerUri !== "string"
5865
+ ) {
5866
+ throw new Error(
5867
+ `Git HTTP broker binding ${binding.credentialBindingId} contains an invalid repository route`,
5868
+ );
5869
+ }
5870
+ const descriptor = byBindingAndUri.get(`${bindingKey}\u0000${route.repositoryUri}`);
5871
+ if (!descriptor || claimedRepositoryUris.has(route.repositoryUri)) {
5872
+ throw new Error(
5873
+ `Git HTTP broker binding ${binding.credentialBindingId} contains an unexpected repository route`,
5874
+ );
5875
+ }
5876
+ let brokerUrl: URL;
5877
+ try {
5878
+ brokerUrl = new URL(route.brokerUri);
5879
+ } catch {
5880
+ throw new Error(
5881
+ `Git HTTP broker binding ${binding.credentialBindingId} contains an invalid broker URI`,
5882
+ );
5883
+ }
5884
+ if (
5885
+ brokerUrl.protocol !== "https:" ||
5886
+ brokerUrl.username ||
5887
+ brokerUrl.password ||
5888
+ brokerUrl.search ||
5889
+ brokerUrl.hash ||
5890
+ brokerUrl.href !== route.brokerUri ||
5891
+ claimedBrokerUris.has(brokerUrl.href)
5892
+ ) {
5893
+ throw new Error(
5894
+ `Git HTTP broker binding ${binding.credentialBindingId} contains an unsafe broker URI`,
5895
+ );
5896
+ }
5897
+ claimedRepositoryUris.add(route.repositoryUri);
5898
+ claimedBrokerUris.add(brokerUrl.href);
5899
+ routes.push({
5900
+ provider: binding.provider,
5901
+ credentialBindingId: binding.credentialBindingId,
5902
+ bindingHash: gitCredentialBindingHash(binding.credentialBindingId),
5903
+ repositoryUri: route.repositoryUri,
5904
+ brokerUri: route.brokerUri,
5905
+ protocol: "https",
5906
+ host: brokerUrl.host.toLowerCase(),
5907
+ path: brokerUrl.pathname.replace(/^\/+|\/+$/g, ""),
5908
+ });
5909
+ }
5910
+ }
5911
+ return routes;
5912
+ }
5913
+
5617
5914
  function gitUsernameForProvider(provider: GitCredentialProvider): string {
5618
5915
  if (provider === "github") return "x-access-token";
5619
5916
  if (provider === "gitlab") return "oauth2";
@@ -5622,22 +5919,48 @@ function gitUsernameForProvider(provider: GitCredentialProvider): string {
5622
5919
 
5623
5920
  function gitCredentialHelperBindingCaseLines(
5624
5921
  resources: Extract<ResourceRef, { kind: "repository" }>[],
5922
+ bindings: GitCredentialBindingSeed[],
5923
+ ): string[] {
5924
+ const brokeredBindings = brokeredGitCredentialBindingKeys(bindings);
5925
+ return runtimeGitBindingDescriptors(resources)
5926
+ .filter(
5927
+ (descriptor) =>
5928
+ !brokeredBindings.has(
5929
+ gitCredentialBindingKey(descriptor.provider, descriptor.credentialBindingId),
5930
+ ),
5931
+ )
5932
+ .flatMap((descriptor) => {
5933
+ const paths = new Set([
5934
+ descriptor.path,
5935
+ descriptor.path.replace(/\.git$/, ""),
5936
+ `${descriptor.path.replace(/\.git$/, "")}.git`,
5937
+ ]);
5938
+ return [...paths].map(
5939
+ (path) =>
5940
+ ` ${shellQuote(`${descriptor.protocol}|${descriptor.host}|${path}`)}) username=${shellQuote(gitUsernameForProvider(descriptor.provider))}; token_file="$credential_dir/${descriptor.bindingHash}-token" ;;`,
5941
+ );
5942
+ });
5943
+ }
5944
+
5945
+ function gitCredentialHelperBrokerCaseLines(
5946
+ routes: RuntimeGitHttpBrokerRouteDescriptor[],
5625
5947
  ): string[] {
5626
- return runtimeGitBindingDescriptors(resources).flatMap((descriptor) => {
5948
+ return routes.flatMap((route) => {
5627
5949
  const paths = new Set([
5628
- descriptor.path,
5629
- descriptor.path.replace(/\.git$/, ""),
5630
- `${descriptor.path.replace(/\.git$/, "")}.git`,
5950
+ route.path,
5951
+ route.path.replace(/\.git$/, ""),
5952
+ `${route.path.replace(/\.git$/, "")}.git`,
5631
5953
  ]);
5632
5954
  return [...paths].map(
5633
5955
  (path) =>
5634
- ` ${shellQuote(`${descriptor.protocol}|${descriptor.host}|${path}`)}) username=${shellQuote(gitUsernameForProvider(descriptor.provider))}; token_file="$credential_dir/${descriptor.bindingHash}-token" ;;`,
5956
+ ` ${shellQuote(`${route.protocol}|${route.host}|${path}`)}) username=opengeni; token_file="$credential_dir/${route.bindingHash}-token" ;;`,
5635
5957
  );
5636
5958
  });
5637
5959
  }
5638
5960
 
5639
5961
  function gitAskpassHostProviderCaseLines(
5640
5962
  resources: Extract<ResourceRef, { kind: "repository" }>[],
5963
+ brokerRoutes: RuntimeGitHttpBrokerRouteDescriptor[],
5641
5964
  ): string[] {
5642
5965
  const hosts = new Map<string, { provider: GitCredentialProvider; bindings: Set<string> }>();
5643
5966
  for (const descriptor of runtimeGitBindingDescriptors(resources)) {
@@ -5648,13 +5971,19 @@ function gitAskpassHostProviderCaseLines(
5648
5971
  entry.bindings.add(`${descriptor.provider}\u0000${descriptor.credentialBindingId}`);
5649
5972
  hosts.set(descriptor.host, entry);
5650
5973
  }
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
- );
5974
+ const brokerHosts = [...new Set(brokerRoutes.map((route) => route.host))]
5975
+ .sort((a, b) => a.localeCompare(b))
5976
+ .map((hostname) => ` ${shellQuote(hostname)}) printf '\\n'; return 0 ;;`);
5977
+ return [
5978
+ ...brokerHosts,
5979
+ ...[...hosts.entries()]
5980
+ .filter(([, entry]) => entry.bindings.size === 1)
5981
+ .sort(([a], [b]) => a.localeCompare(b))
5982
+ .map(
5983
+ ([hostname, entry]) =>
5984
+ ` ${shellQuote(hostname)}) printf '%s\\n' ${entry.provider}; return 0 ;;`,
5985
+ ),
5986
+ ];
5658
5987
  }
5659
5988
 
5660
5989
  function gitCredentialTokenWriterCommandLines(bindings: GitCredentialBindingSeed[] = []): string[] {
@@ -5664,7 +5993,7 @@ function gitCredentialTokenWriterCommandLines(bindings: GitCredentialBindingSeed
5664
5993
  const count = Math.max(1, binding.providerBindingCount ?? 1);
5665
5994
  const provider = binding.provider;
5666
5995
  const lines = [`write_git_binding_token ${shellQuote(hash)} "\${${seedEnv}:-}"`];
5667
- if (count === 1) {
5996
+ if (count === 1 && binding.transport?.kind !== "http_broker") {
5668
5997
  lines.push(`write_git_provider_token ${shellQuote(provider)} "\${${seedEnv}:-}"`);
5669
5998
  } else {
5670
5999
  lines.push(`remove_git_provider_token ${shellQuote(provider)}`);
@@ -5726,14 +6055,45 @@ function gitCredentialTokenWriterCommandLines(bindings: GitCredentialBindingSeed
5726
6055
  ];
5727
6056
  }
5728
6057
 
6058
+ function gitHttpBrokerConfigCommandLines(routes: RuntimeGitHttpBrokerRouteDescriptor[]): string[] {
6059
+ return [
6060
+ 'git_http_broker_config="${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/http-broker.gitconfig"',
6061
+ 'mkdir -p "$(dirname "$git_http_broker_config")"',
6062
+ 'broker_umask="$(umask)"',
6063
+ "umask 077",
6064
+ ': > "$git_http_broker_config.tmp.$$"',
6065
+ ...routes.map(
6066
+ (route) =>
6067
+ `git config --file "$git_http_broker_config.tmp.$$" --add ${shellQuote(`url.${route.brokerUri}.insteadOf`)} ${shellQuote(route.repositoryUri)}`,
6068
+ ),
6069
+ 'mv -f "$git_http_broker_config.tmp.$$" "$git_http_broker_config"',
6070
+ 'umask "$broker_umask"',
6071
+ 'git config --global --unset-all include.path "$git_http_broker_config" >/dev/null 2>&1 || true',
6072
+ 'git config --global --add include.path "$git_http_broker_config"',
6073
+ ];
6074
+ }
6075
+
5729
6076
  function gitCredentialHelperCommandLines(
5730
6077
  resources: Extract<ResourceRef, { kind: "repository" }>[] = [],
5731
6078
  bindings: GitCredentialBindingSeed[] = [],
5732
6079
  ): string[] {
5733
- const hostProviderCases = gitAskpassHostProviderCaseLines(resources);
5734
- const bindingCases = gitCredentialHelperBindingCaseLines(resources);
6080
+ const brokerRoutes = runtimeGitHttpBrokerRouteDescriptors(resources, bindings);
6081
+ const hostProviderCases = gitAskpassHostProviderCaseLines(resources, brokerRoutes);
6082
+ const bindingCases = [
6083
+ ...gitCredentialHelperBindingCaseLines(resources, bindings),
6084
+ ...gitCredentialHelperBrokerCaseLines(brokerRoutes),
6085
+ ];
5735
6086
  const descriptors = runtimeGitBindingDescriptors(resources);
5736
- const wrapperDescriptors = bindings.length > 0 ? descriptors : [];
6087
+ const brokeredBindings = brokeredGitCredentialBindingKeys(bindings);
6088
+ const wrapperDescriptors =
6089
+ bindings.length > 0
6090
+ ? descriptors.filter(
6091
+ (descriptor) =>
6092
+ !brokeredBindings.has(
6093
+ gitCredentialBindingKey(descriptor.provider, descriptor.credentialBindingId),
6094
+ ),
6095
+ )
6096
+ : [];
5737
6097
  const bindingProviders = new Map<GitCredentialProvider, Set<string>>();
5738
6098
  for (const descriptor of runtimeGitBindingDescriptors(resources)) {
5739
6099
  const ids = bindingProviders.get(descriptor.provider) ?? new Set<string>();
@@ -5766,6 +6126,31 @@ function gitCredentialHelperCommandLines(
5766
6126
  .filter(([, ids]) => ids.size > 1)
5767
6127
  .map(([provider]) => provider)
5768
6128
  : [];
6129
+ const brokeredOriginCases = brokerRoutes.flatMap((route) => {
6130
+ const base = route.repositoryUri.replace(/\.git$/, "");
6131
+ return [...new Set([route.repositoryUri, base, `${base}.git`])].map(
6132
+ (uri) => ` ${shellQuote(`${route.provider}|${uri}`)}) return 0 ;;`,
6133
+ );
6134
+ });
6135
+ const brokeredBindingHashCases = bindings
6136
+ .filter((binding) => binding.transport?.kind === "http_broker")
6137
+ .map(
6138
+ (binding) =>
6139
+ ` ${shellQuote(`${binding.provider}|${gitCredentialBindingHash(binding.credentialBindingId)}`)}) return 0 ;;`,
6140
+ );
6141
+ const providerBindingKinds = new Map<
6142
+ GitCredentialProvider,
6143
+ { direct: number; brokered: number }
6144
+ >();
6145
+ for (const binding of bindings) {
6146
+ const counts = providerBindingKinds.get(binding.provider) ?? { direct: 0, brokered: 0 };
6147
+ if (binding.transport?.kind === "http_broker") counts.brokered += 1;
6148
+ else counts.direct += 1;
6149
+ providerBindingKinds.set(binding.provider, counts);
6150
+ }
6151
+ const brokerOnlyProviders = [...providerBindingKinds.entries()]
6152
+ .filter(([, counts]) => counts.brokered > 0 && counts.direct === 0)
6153
+ .map(([provider]) => provider);
5769
6154
  return [
5770
6155
  ...gitCredentialTokenWriterCommandLines(bindings),
5771
6156
  // Provision git/provider-CLI helpers at SETUP (runtime) before any clone
@@ -5857,6 +6242,7 @@ function gitCredentialHelperCommandLines(
5857
6242
  "git config --global --add credential.helper ''",
5858
6243
  'git config --global --add credential.helper "$git_credential_helper"',
5859
6244
  "git config --global credential.useHttpPath true",
6245
+ ...gitHttpBrokerConfigCommandLines(brokerRoutes),
5860
6246
  'wrapper_dir="${OPENGENI_GIT_CLI_WRAPPER_DIR:-$HOME/.opengeni/bin}"',
5861
6247
  'mkdir -p "$wrapper_dir"',
5862
6248
  "for opengeni_git_cli_tool in gh glab az; do",
@@ -5884,12 +6270,24 @@ function gitCredentialHelperCommandLines(
5884
6270
  " *) return 1 ;;",
5885
6271
  " esac",
5886
6272
  "}",
6273
+ "binding_hash_is_brokered() {",
6274
+ ' case "$provider|$1" in',
6275
+ ...brokeredBindingHashCases,
6276
+ " *) return 1 ;;",
6277
+ " esac",
6278
+ "}",
5887
6279
  "binding_hash_for_origin() {",
5888
6280
  ' case "$provider|$1" in',
5889
6281
  ...originWrapperHashes,
5890
6282
  " *) return 1 ;;",
5891
6283
  " esac",
5892
6284
  "}",
6285
+ "origin_is_brokered() {",
6286
+ ' case "$provider|$1" in',
6287
+ ...brokeredOriginCases,
6288
+ " *) return 1 ;;",
6289
+ " esac",
6290
+ "}",
5893
6291
  "sole_binding_hash() {",
5894
6292
  ' case "$provider" in',
5895
6293
  ...soleWrapperHashes,
@@ -5897,19 +6295,25 @@ function gitCredentialHelperCommandLines(
5897
6295
  " esac",
5898
6296
  "}",
5899
6297
  `multi_binding_providers=${shellQuote(multiWrapperProviders.join(" "))}`,
6298
+ `broker_only_providers=${shellQuote(brokerOnlyProviders.join(" "))}`,
5900
6299
  'if [ -n "$provider" ]; then',
5901
6300
  " binding_hash=",
5902
6301
  ' if [ -n "${OPENGENI_GIT_BINDING:-}" ]; then',
5903
6302
  ' binding_hash="$(hash_binding_id "$OPENGENI_GIT_BINDING")"',
6303
+ ' 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
6304
  ' 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
6305
  " elif command -v git >/dev/null 2>&1; then",
5906
6306
  ' origin="$(git config --get remote.origin.url 2>/dev/null || true)"',
6307
+ ' [ -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
6308
  ' [ -z "$origin" ] || binding_hash="$(binding_hash_for_origin "$origin" 2>/dev/null || true)"',
5908
6309
  " fi",
5909
6310
  ' [ -n "$binding_hash" ] || binding_hash="$(sole_binding_hash 2>/dev/null || true)"',
5910
6311
  ' if [ -n "$binding_hash" ]; then',
5911
6312
  ' token_file="${OPENGENI_GIT_CREDENTIALS_DIR:-$HOME/.opengeni/git-credentials}/$binding_hash-token"',
5912
6313
  " else",
6314
+ ' case " $broker_only_providers " in',
6315
+ ' *" $provider "*) printf \'%s\\n\' "$tool provider API authentication is host-brokered for this session; use the configured provider MCP tools" >&2; exit 2 ;;',
6316
+ " esac",
5913
6317
  ' case " $multi_binding_providers " in',
5914
6318
  ' *" $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
6319
  " esac",