@bitkyc08/opencodex 2.53.0 → 2.54.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.
Files changed (34) hide show
  1. package/gui/dist/assets/{index-BlO4Yl6q.js → index-CkvITofZ.js} +2 -2
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/devin.ts +39 -7
  5. package/src/cli/capabilities.ts +4 -2
  6. package/src/cli/catalog.ts +39 -10
  7. package/src/cli/dispatch.ts +19 -64
  8. package/src/cli/doctor.ts +1 -1
  9. package/src/cli/internal-command.ts +44 -0
  10. package/src/cli/registry.ts +9 -7
  11. package/src/cli/restart-scope.ts +184 -0
  12. package/src/codex/app-server-processes.ts +20 -5
  13. package/src/codex/app-server-restart-service.ts +29 -0
  14. package/src/codex/catalog/provider-fetch.ts +13 -5
  15. package/src/codex/catalog/sync.ts +12 -3
  16. package/src/codex/desktop-app/darwin.ts +268 -0
  17. package/src/codex/desktop-app/handoff.ts +303 -0
  18. package/src/codex/desktop-app/linux.ts +388 -0
  19. package/src/codex/desktop-app/lock.ts +226 -0
  20. package/src/codex/desktop-app/types.ts +141 -0
  21. package/src/codex/desktop-app/windows.ts +239 -0
  22. package/src/codex/desktop-app-restart.ts +264 -279
  23. package/src/codex/inject.ts +83 -21
  24. package/src/codex/sync.ts +16 -22
  25. package/src/generated/compatibility-version.json +58 -22
  26. package/src/lib/codex-restart-contract.ts +31 -0
  27. package/src/providers/registry.ts +13 -12
  28. package/src/server/responses/core.ts +62 -4
  29. package/src/server/responses/encrypted-payload.ts +161 -0
  30. package/src/server/responses.ts +1 -1
  31. package/src/types/provider.ts +6 -5
  32. package/src/web-search/index.ts +14 -67
  33. package/src/web-search/passthrough-bridge.ts +256 -32
  34. package/src/web-search/sidecar-providers.ts +76 -0
@@ -158,9 +158,11 @@ import {
158
158
  import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot";
159
159
  import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
160
160
  import {
161
- createOllamaBridgeExecutor,
161
+ createPassthroughWebSearchBridgeExecutor,
162
162
  createPassthroughWebSearchBridgeStream,
163
163
  planPassthroughWebSearchBridge,
164
+ resolvePassthroughWebSearchBridgeAuth,
165
+ shouldResolveOpenAiPassthroughWebSearchBridge,
164
166
  } from "../../web-search/passthrough-bridge";
165
167
  import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images";
166
168
  import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
@@ -394,7 +396,7 @@ import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/cat
394
396
 
395
397
  import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration";
396
398
  import { mapCodexAuthContextErrorToResponse, nativeMainRefreshFailureResponse } from "./codex-auth-error";
397
- import { hasUnreadableEncryptedAgentTask, looksLikeBackendCiphertext, sanitizeEncryptedContentInPlace } from "./encrypted-payload";
399
+ import { hasUnreadableEncryptedAgentTask, looksLikeBackendCiphertext, sanitizeEncryptedContentInPlace, stripAgentMessageCiphertextInPlace } from "./encrypted-payload";
398
400
  import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel, storedPoolReplayDispatchNotifier, type ProviderFetchOptions } from "./fetch-helpers";
399
401
  import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upstream-reachability";
400
402
  import {
@@ -4024,6 +4026,49 @@ async function handleResponsesInner(
4024
4026
  return unreadableEncryptedAgentTaskResponse(recoveryFailureReason);
4025
4027
  }
4026
4028
 
4029
+ // The guard above asks whether the CURRENT worker task is readable, and it only inspects the
4030
+ // tail item. An `agent_message` that mixes readable text with backend ciphertext answers
4031
+ // "readable" to that question at every position, so it passed -- and then
4032
+ // `normalizeRoutedAgentMessages` refused to lower it, because lowering requires every part to
4033
+ // be representable. The raw Responses passthrough serialized the private item as it stood, so
4034
+ // backend ciphertext and an item type only the Codex backend declares reached a third-party
4035
+ // provider, which answered `422 unknown item type "agent_message"` (#4454).
4036
+ //
4037
+ // The opaque-blob path already knows the repair: replace the undecryptable part with an
4038
+ // omission marker, which leaves the item lowerable. It applied that repair only AFTER an
4039
+ // upstream rejection. For a destination that cannot accept the private item under any
4040
+ // circumstances, that round trip was never going to succeed and sent the ciphertext to find
4041
+ // out, so do the repair here instead. Recovery above has already had its chance to turn the
4042
+ // same bytes into real plaintext; only what it could not rescue reaches this.
4043
+ if (inboundWire === "responses" && !finalRouteCanPassThroughEncryptedTask) {
4044
+ // Only the raw Responses passthrough puts input items on the wire verbatim, so that is the
4045
+ // only wire this has to repair: translated wires rebuild the body from parsed messages, where
4046
+ // `inputContentParts` drops an encrypted part instead of forwarding it. The exemption is the
4047
+ // canonical Codex backend alone, because it is the one destination that minted these bytes and
4048
+ // can read them. `authMode: "forward"` is NOT that test -- a noncanonical forward gateway is
4049
+ // somebody else's server that happens to be configured for passthrough, and it receives the
4050
+ // ciphertext like any other third party.
4051
+ //
4052
+ // Combo children run this too. Each child carries its own `structuredClone` of the body
4053
+ // (`concreteComboRequestBody`) and its own concrete route, so a sibling's repair is invisible
4054
+ // here and a target that resolves to a routed Responses wire would otherwise send the
4055
+ // ciphertext that the parent's own dispatch no longer does.
4056
+ const wireProvider = resolveWireProtocolOverride(
4057
+ route.providerName,
4058
+ route.modelId,
4059
+ route.provider,
4060
+ inboundWire,
4061
+ );
4062
+ if (wireProvider.adapter === "openai-responses" && !isCanonicalOpenAiForwardProvider(wireProvider)) {
4063
+ const repaired = stripAgentMessageCiphertextInPlace((body as { input?: unknown } | undefined)?.input);
4064
+ if (repaired > 0) {
4065
+ console.warn(
4066
+ `[opencodex] replaced ciphertext in ${repaired} replayed agent message(s) with an omission marker; the selected provider cannot read native ChatGPT ciphertext`,
4067
+ );
4068
+ }
4069
+ }
4070
+ }
4071
+
4027
4072
  // The canonical ChatGPT backend rejects previous_response_id, so a local replay miss leaves no
4028
4073
  // safe way to recover the omitted history. Fail before auth, adapter construction, or upstream
4029
4074
  // I/O instead of stripping the id and silently forwarding a context-free delta (#702).
@@ -4726,7 +4771,8 @@ async function handleResponsesInner(
4726
4771
  const needsOpenAiVision = !visionDescribeTerminal
4727
4772
  && shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed);
4728
4773
  const needsOpenAiSearch = !routedCompaction && !adapter.runTurn
4729
- && shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough);
4774
+ && (shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough)
4775
+ || shouldResolveOpenAiPassthroughWebSearchBridge(route.provider, parsed, isPassthrough));
4730
4776
  if (needsOpenAiVision || needsOpenAiSearch) {
4731
4777
  try {
4732
4778
  const candidates = listOpenAiForwardSidecarCandidates(config);
@@ -6199,9 +6245,15 @@ async function handleResponsesInner(
6199
6245
  // conversation upstream, and hands back ordinary Responses SSE — so every rewrite below,
6200
6246
  // including the guard itself, still inspects the client-facing stream. Default OFF: without
6201
6247
  // the opt-in this is one planner call and the relay is byte-identical to before.
6248
+ const webSearchBridgeAuth = resolvePassthroughWebSearchBridgeAuth(
6249
+ route.provider.webSearchBridge?.backend,
6250
+ config,
6251
+ openAiSidecar,
6252
+ );
6202
6253
  const webSearchBridgePlan = planPassthroughWebSearchBridge(parsed, route.provider, {
6203
6254
  isPassthrough: true,
6204
6255
  stream: parsed.stream === true,
6256
+ auth: webSearchBridgeAuth,
6205
6257
  });
6206
6258
  // Capture the binding that actually served the first leg, after its permitted reselection.
6207
6259
  const webSearchBridgeBinding = requestBindings.get(request);
@@ -6235,7 +6287,13 @@ async function handleResponsesInner(
6235
6287
  }),
6236
6288
  false,
6237
6289
  ),
6238
- execute: createOllamaBridgeExecutor(webSearchBridgePlan, route.provider.apiKey ?? ""),
6290
+ execute: createPassthroughWebSearchBridgeExecutor(webSearchBridgePlan, {
6291
+ providerApiKey: route.provider.apiKey ?? "",
6292
+ auth: webSearchBridgeAuth,
6293
+ hostedTool: parsed._webSearch,
6294
+ describeImages: isModelTextOnly(route.provider, route.modelId),
6295
+ sidecar: config.webSearchSidecar,
6296
+ }),
6239
6297
  // Appending a search result can push the continuation past the ceiling the first leg
6240
6298
  // was admitted under, so the same limit is re-applied before every later send.
6241
6299
  checkOutboundBody: (continuationBody: string) => {
@@ -319,6 +319,167 @@ export function hasEncryptedContentPart(content: unknown): boolean {
319
319
  }
320
320
 
321
321
 
322
+ /** The marker `prepareOpaqueBlobRecovery` already substitutes for an undecryptable part. */
323
+ export const OMITTED_ENCRYPTED_CONTENT_TEXT = "[encrypted content omitted]";
324
+
325
+ /**
326
+ * The Fernet WIRE shape, without validating the body: version prefix, base64url alphabet, and a
327
+ * canonical encoded length. Free text is judged by this rather than by
328
+ * `looksLikeBackendCiphertext`, which is length >= 64 over a character class that a SHA-256 hex
329
+ * digest matches exactly at 64 characters -- as do a SHA-512 digest, a long key, and adjacent
330
+ * short encoded fragments. An `encrypted_content` slot carries ciphertext by definition and is
331
+ * stripped whatever it holds; a text part does not, and replacing a digest a child deliberately
332
+ * printed would destroy readable content to protect bytes that were never secret.
333
+ */
334
+ const FERNET_SHAPED = /^g[A-Za-z0-9_-]+={0,2}$/;
335
+
336
+ function looksLikeFernetToken(text: string): boolean {
337
+ return text.length >= 100 && text.length % 4 === 0 && FERNET_SHAPED.test(text);
338
+ }
339
+
340
+ function textWithRunsOmitted(payload: string, runs: readonly FernetTokenRun[]): string {
341
+ let last = 0;
342
+ let out = "";
343
+ for (const run of runs) {
344
+ out += payload.slice(last, run.index) + OMITTED_ENCRYPTED_CONTENT_TEXT;
345
+ last = run.index + run.token.length;
346
+ }
347
+ return out + payload.slice(last);
348
+ }
349
+
350
+ /**
351
+ * Replace ciphertext inside `agent_message` items with an omission marker, so
352
+ * `normalizeRoutedAgentMessages` can lower them onto public messages. Returns how many items
353
+ * were repaired. Items are replaced rather than mutated, and every other item type is left
354
+ * alone: reasoning and function-output blobs keep their own reactive recovery.
355
+ *
356
+ * `hasUnreadableEncryptedAgentTask` above answers a different question: can the CURRENT worker
357
+ * task be read at all? It inspects only the tail item and reports false the moment any plaintext
358
+ * survives the envelope. `normalizeRoutedAgentMessages` asks the opposite question -- is EVERY
359
+ * part lowerable? -- and forwards the private item verbatim when one is not. A mixed
360
+ * `input_text` + `encrypted_content` item answers "readable" to the first and "not lowerable"
361
+ * to the second, so it fell between them: the guard never fired, the adapter refused to lower it,
362
+ * and the raw Responses passthrough put a private item and backend ciphertext on the wire
363
+ * (#4454). Position was never the discriminator -- a replayed child result lands mid-history and
364
+ * the tail-only scan cannot see it -- but the tail is equally exposed when it is mixed.
365
+ *
366
+ * This is the repair `prepareOpaqueBlobRecovery` performs after an upstream rejection, applied
367
+ * before dispatch for a destination that cannot accept the private item under any circumstances.
368
+ * The round trip it replaces was never going to succeed, and it sent ciphertext to a third party
369
+ * to find that out. Nothing is decrypted, and nothing readable is lost: the parent could not read
370
+ * these bytes either.
371
+ *
372
+ * The two kinds of slot are judged differently, because they carry different guarantees. An
373
+ * `encrypted_content` slot holds ciphertext by definition, so it is stripped whatever it holds:
374
+ * demanding a well-formed token there would reopen this defect one payload later, since a
375
+ * truncated token, a standard-base64 blob carrying `+` or `/`, an unexpected version byte, or a
376
+ * run past the recovery size limits would each keep the item and forward the bytes.
377
+ *
378
+ * A text part carries no such guarantee, so it is matched strictly: embedded runs that validate
379
+ * as Fernet, or a whole slot with the Fernet wire shape. A loose character-class test would be
380
+ * worse than the defect for that half -- a SHA-256 digest is exactly 64 characters of
381
+ * `[A-Za-z0-9]` and would be replaced with a marker, silently deleting something a child
382
+ * deliberately printed.
383
+ */
384
+ export function stripAgentMessageCiphertextInPlace(input: unknown): number {
385
+ if (!Array.isArray(input)) return 0;
386
+ let repaired = 0;
387
+ for (let index = 0; index < input.length; index += 1) {
388
+ const item = input[index];
389
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
390
+ const record = item as Record<string, unknown>;
391
+ if (record.type !== "agent_message") continue;
392
+ const content = record.content;
393
+ if (typeof content === "string") {
394
+ const replaced = textWithoutCiphertext(content);
395
+ if (replaced === content) continue;
396
+ input[index] = { ...record, content: replaced };
397
+ repaired += 1;
398
+ continue;
399
+ }
400
+ if (!Array.isArray(content)) continue;
401
+ const parts = contentWithoutCiphertext(content);
402
+ if (parts === content) continue;
403
+ input[index] = { ...record, content: parts };
404
+ repaired += 1;
405
+ }
406
+ return repaired;
407
+ }
408
+
409
+ /** Free text: drop embedded token runs, and replace a slot that is nothing but a token. */
410
+ function textWithoutCiphertext(text: string): string {
411
+ const runs = fernetTokenRuns(text);
412
+ if (runs.length > 0) return textWithRunsOmitted(text, runs);
413
+ return looksLikeFernetToken(text.trim()) ? OMITTED_ENCRYPTED_CONTENT_TEXT : text;
414
+ }
415
+
416
+ function ciphertextTextOfPart(part: unknown): string | undefined {
417
+ if (!part || typeof part !== "object") return undefined;
418
+ const record = part as { type?: unknown; text?: unknown };
419
+ return (record.type === "input_text" || record.type === "text") && typeof record.text === "string"
420
+ ? record.text
421
+ : undefined;
422
+ }
423
+
424
+ /**
425
+ * Adjacent text slots that are one token between them. Each fragment can be too short to judge on
426
+ * its own, which is the text-side twin of the split `encrypted_content` run. The join must still
427
+ * be Fernet-shaped, so two ordinary encoded fragments do not become a marker by being adjacent.
428
+ */
429
+ function joinedCiphertextTextParts(content: readonly unknown[]): Set<object> {
430
+ const flagged = new Set<object>();
431
+ let run: Array<{ part: object; text: string }> = [];
432
+ const finish = (): void => {
433
+ if (run.length > 1 && looksLikeFernetToken(run.map(entry => entry.text).join(""))) {
434
+ for (const entry of run) flagged.add(entry.part);
435
+ }
436
+ run = [];
437
+ };
438
+ for (const part of content) {
439
+ const text = ciphertextTextOfPart(part);
440
+ if (text === undefined || text.trim().length === 0 || !/^[A-Za-z0-9_-]+={0,2}$/.test(text)) {
441
+ finish();
442
+ continue;
443
+ }
444
+ run.push({ part: part as object, text });
445
+ }
446
+ finish();
447
+ return flagged;
448
+ }
449
+
450
+ function contentWithoutCiphertext(content: unknown[]): unknown[] {
451
+ let changed = false;
452
+ const joined = joinedCiphertextTextParts(content);
453
+ const parts = content.map((part: unknown) => {
454
+ if (!part || typeof part !== "object") return part;
455
+ if (joined.has(part)) {
456
+ changed = true;
457
+ return { type: "input_text", text: OMITTED_ENCRYPTED_CONTENT_TEXT };
458
+ }
459
+ const record = part as { type?: unknown; text?: unknown; encrypted_content?: unknown };
460
+ if (record.type === "encrypted_content" && typeof record.encrypted_content === "string") {
461
+ changed = true;
462
+ // Keep whatever plaintext a recognizable slot carries around its token; a slot this
463
+ // cannot parse is replaced whole rather than forwarded on the chance that it is benign.
464
+ const runs = fernetTokenRuns(record.encrypted_content);
465
+ return {
466
+ type: "input_text",
467
+ text: runs.length > 0
468
+ ? textWithRunsOmitted(record.encrypted_content, runs)
469
+ : OMITTED_ENCRYPTED_CONTENT_TEXT,
470
+ };
471
+ }
472
+ const text = ciphertextTextOfPart(part);
473
+ if (text === undefined) return part;
474
+ const replaced = textWithoutCiphertext(text);
475
+ if (replaced === text) return part;
476
+ changed = true;
477
+ return { ...record, text: replaced };
478
+ });
479
+ return changed ? parts : content;
480
+ }
481
+
482
+
322
483
 
323
484
  export function sanitizeEncryptedContentInPlace(input: unknown): number {
324
485
  if (!Array.isArray(input)) return 0;
@@ -5,7 +5,7 @@ import { requestPacingOverloadResponse } from "./responses/pacing-overload";
5
5
 
6
6
  export { buildToolBridgeMaps, isV1CollabSurface, collabSurface, multiAgentGuidanceText, V2_GUIDANCE_CHAR_BUDGET, injectDeveloperMessage } from "./responses/collaboration";
7
7
  export type { MultiAgentGuidanceOptions, MultiAgentGuidanceDeps } from "./responses/collaboration";
8
- export { hasUnreadableEncryptedAgentTask, sanitizeEncryptedContentInPlace } from "./responses/encrypted-payload";
8
+ export { hasUnreadableEncryptedAgentTask, sanitizeEncryptedContentInPlace, stripAgentMessageCiphertextInPlace } from "./responses/encrypted-payload";
9
9
  export { COMPACT_RESPONSE_MAX_BYTES, bufferCompactResponse } from "./responses/compact";
10
10
  export { disableResponsesRequestTimeout, safeHostLabel, fetchWithHeaderTimeout } from "./responses/fetch-helpers";
11
11
  export { sidecarOutcomeRecorder, isShadowSourceModel, codexLogAccountId, usesCodexForwardPoolAuth, codexForwardTerminalOutcomeRecorder, decodeRequestErrorResponse, buildComboChildHeaders, linkAbortSignal } from "./responses/core";
@@ -87,11 +87,11 @@ export interface RateLimitRetryPolicy {
87
87
  }
88
88
 
89
89
  /**
90
- * Backend ids admitted by `providers.<name>.webSearchBridge.backend`. Only `"ollama"` has a
91
- * shipped executor; every other id is explicit-only and inert, the same contract the top-level
92
- * `webSearchSidecar` uses for backends whose executor has not landed. Naming one of them keeps
93
- * the bridge disarmed rather than silently falling back to a different search provider — in
94
- * particular it never auto-selects a paid Luna or Exa search.
90
+ * Backend ids admitted by `providers.<name>.webSearchBridge.backend`. Each id is explicit-only:
91
+ * an omitted backend keeps the bridge disarmed rather than silently falling back to a paid
92
+ * Luna or Exa search. `ollama` spends this provider's API key on the search endpoint.
93
+ * `openai` / `anthropic` / `xai` / `gemini` / `exa` reuse the matching sidecar executor and
94
+ * that executor's own credential; a missing credential leaves the bridge disarmed.
95
95
  */
96
96
  export const PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS = [
97
97
  "ollama",
@@ -118,6 +118,7 @@ export type ProviderWebSearchBridgeBackend = typeof PROVIDER_WEB_SEARCH_BRIDGE_B
118
118
  *
119
119
  * Never armed for `authMode: "forward"` (ChatGPT) or for a provider that executes hosted search
120
120
  * upstream; see `planPassthroughWebSearchBridge` in `src/web-search/passthrough-bridge.ts`.
121
+ * A mixed `web_search` + client tool call still fails closed. Assistant text is not a search call.
121
122
  */
122
123
  export interface ProviderWebSearchBridgeConfig {
123
124
  /** Master switch. Absent or false keeps today's relay-and-fail behavior exactly. */
@@ -7,11 +7,17 @@ import { isCodexReserveRequestEligible } from "../codex/loopback-target";
7
7
  import type { DataPlaneAdmission } from "../server/auth-cors";
8
8
  import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar";
9
9
  import { resolveSidecarAuth } from "../sidecar/auth";
10
- import { getAccountSet } from "../oauth/store";
11
10
  import { validateXaiSearchOptions, type XaiSearchOptions } from "./xai-executor";
12
11
  import type { OcxWebSearchSidecarConfig } from "../types";
13
12
  import { DEFAULT_STALL_TIMEOUT_SEC } from "../stall-timeout";
14
13
  import { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
14
+ import {
15
+ findAnthropicSidecarProvider,
16
+ findGeminiSidecarProvider,
17
+ findXaiSidecarProvider,
18
+ xaiSearchOptionsFromConfig,
19
+ type AnthropicSidecarProvider,
20
+ } from "./sidecar-providers";
15
21
 
16
22
  export { runWithWebSearch } from "./loop";
17
23
  export { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME };
@@ -19,6 +25,13 @@ export { runAnthropicWebSearch, parseAnthropicSidecarSSE } from "./anthropic-exe
19
25
  export { runXaiWebSearch, parseXaiResponsesSSE, validateXaiSearchOptions, type XaiSearchOptions } from "./xai-executor";
20
26
  export { runGeminiWebSearch, mapCcaGroundedResponse } from "./gemini-executor";
21
27
  export { runExaWebSearch, mapExaSearchResponse } from "./exa-executor";
28
+ export {
29
+ findAnthropicSidecarProvider,
30
+ findGeminiSidecarProvider,
31
+ findXaiSidecarProvider,
32
+ xaiSearchOptionsFromConfig,
33
+ type AnthropicSidecarProvider,
34
+ };
22
35
 
23
36
  const DEFAULT_SIDECAR_MODEL = "gpt-5.6-luna";
24
37
  // Default Claude model for the anthropic-backed sidecar (used when cfg.model is unset).
@@ -85,72 +98,6 @@ export function webSearchStallTimeoutSec(
85
98
  return Math.min(Number.MAX_VALUE, Math.ceil(largestUnitSec) + STALL_MARGIN_SEC);
86
99
  }
87
100
 
88
- /** A configured anthropic-adapter OAuth provider whose ACTIVE stored account is usable (not needs-reauth). */
89
- export interface AnthropicSidecarProvider {
90
- providerName: string;
91
- provider: OcxProviderConfig;
92
- }
93
-
94
- /**
95
- * First enabled anthropic-adapter OAuth provider whose ACTIVE account holds a usable credential — the
96
- * only path that can run web_search_20250305 without a ChatGPT forward provider. Presence is decided by
97
- * getAccountSet + the active account's `needsReauth` marker (audit F1: getCredential alone can pick a
98
- * terminally-invalid account); token refresh happens later at executor time.
99
- * Delegates to the shared sidecar auth module (#2188) so web-search and vision
100
- * cannot drift on what "Anthropic auth present" means.
101
- */
102
- export function findAnthropicSidecarProvider(config: OcxConfig): AnthropicSidecarProvider | undefined {
103
- const auth = resolveSidecarAuth(config);
104
- if (!auth.isAnthropicAuth || !auth.anthropicProviderName || !auth.anthropicProvider) return undefined;
105
- return { providerName: auth.anthropicProviderName, provider: auth.anthropicProvider };
106
- }
107
-
108
- /**
109
- * First enabled provider whose stored Grok OAuth account is active and not marked for
110
- * reauth — the only credential the xai web-search executor may spend. Same account-set
111
- * predicate the shared sidecar auth module applies to Anthropic.
112
- */
113
- export function findXaiSidecarProvider(config: OcxConfig): { providerName: string; provider: OcxProviderConfig } | undefined {
114
- // The stored Grok credential lives under the provider named "xai" (registry id);
115
- // OAuth account sets are keyed by provider name, so the name IS the credential key.
116
- const provider = config.providers["xai"];
117
- if (!provider || provider.disabled === true || provider.authMode !== "oauth") return undefined;
118
- const set = getAccountSet("xai");
119
- const active = set?.accounts.find(account => account.id === set.activeAccountId);
120
- if (active && active.needsReauth !== true) return { providerName: "xai", provider };
121
- return undefined;
122
- }
123
-
124
- /**
125
- * First usable Antigravity credential holder: the "google-antigravity" provider
126
- * (registry id = OAuth store key, same narrowing as findXaiSidecarProvider) whose
127
- * active stored account is healthy AND carries a discovered CCA projectId — the
128
- * executor cannot form the envelope without it.
129
- */
130
- export function findGeminiSidecarProvider(config: OcxConfig): { providerName: string; provider: OcxProviderConfig } | undefined {
131
- const provider = config.providers["google-antigravity"];
132
- if (!provider || provider.disabled === true || provider.authMode !== "oauth") return undefined;
133
- const set = getAccountSet("google-antigravity");
134
- const active = set?.accounts.find(account => account.id === set.activeAccountId);
135
- if (!active || active.needsReauth === true) return undefined;
136
- const projectId = (active.credential as { projectId?: string } | undefined)?.projectId;
137
- if (!projectId) return undefined;
138
- return { providerName: "google-antigravity", provider };
139
- }
140
-
141
- /** Lift the persisted xSearch config block into executor options (absent block = web_search only). */
142
- export function xaiSearchOptionsFromConfig(cfg: Pick<OcxWebSearchSidecarConfig, "xSearch">): XaiSearchOptions {
143
- const x = cfg.xSearch;
144
- if (!x || x.enabled !== true) return {};
145
- return {
146
- xSearch: true,
147
- ...(x.allowedXHandles ? { allowedXHandles: x.allowedXHandles } : {}),
148
- ...(x.excludedXHandles ? { excludedXHandles: x.excludedXHandles } : {}),
149
- ...(x.fromDate ? { fromDate: x.fromDate } : {}),
150
- ...(x.toDate ? { toDate: x.toDate } : {}),
151
- };
152
- }
153
-
154
101
  /** Every backend id the config union admits. New ids are explicit-only and inert until their executor ships. */
155
102
  export type WebSearchBackendId = "openai" | "anthropic" | "xai" | "gemini" | "exa";
156
103