@bitkyc08/opencodex 2.7.39 → 2.7.40

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 (49) hide show
  1. package/README.md +4 -4
  2. package/gui/dist/assets/index-CMip1DzF.css +1 -0
  3. package/gui/dist/assets/index-cydcmbzC.js +52 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +2 -2
  6. package/src/adapters/cursor/arg-normalize.ts +23 -7
  7. package/src/adapters/cursor/live-transport.ts +26 -14
  8. package/src/adapters/cursor/native-exec-fs.ts +1 -1
  9. package/src/adapters/cursor/native-exec-network.ts +1 -1
  10. package/src/adapters/cursor/native-exec-shell.ts +1 -1
  11. package/src/adapters/cursor/protobuf-events.ts +72 -13
  12. package/src/adapters/cursor/protobuf-request.ts +82 -11
  13. package/src/adapters/cursor/request-builder.ts +35 -11
  14. package/src/adapters/cursor/tool-definitions.ts +175 -30
  15. package/src/adapters/openai-chat.ts +28 -7
  16. package/src/adapters/openai-responses.ts +150 -4
  17. package/src/bridge.ts +20 -1
  18. package/src/claude/outbound.ts +91 -6
  19. package/src/codex/auth-api.ts +12 -25
  20. package/src/codex/auth-context.ts +48 -3
  21. package/src/codex/catalog/provider-fetch.ts +56 -24
  22. package/src/codex/model-cache.ts +23 -0
  23. package/src/codex/quota.ts +120 -0
  24. package/src/codex/routing.ts +178 -9
  25. package/src/config.ts +56 -1
  26. package/src/providers/openai-sidecar.ts +8 -1
  27. package/src/providers/openai-tiers.ts +18 -0
  28. package/src/server/adapter-resolve.ts +24 -10
  29. package/src/server/auth-cors.ts +3 -0
  30. package/src/server/chat-completions.ts +4 -0
  31. package/src/server/claude-messages.ts +4 -0
  32. package/src/server/index.ts +3 -1
  33. package/src/server/live.ts +56 -0
  34. package/src/server/memory-watchdog.ts +1 -1
  35. package/src/server/responses/compact.ts +40 -10
  36. package/src/server/responses/core.ts +180 -26
  37. package/src/server/responses/terminal-guard.ts +230 -0
  38. package/src/service.ts +113 -30
  39. package/src/types.ts +52 -0
  40. package/src/usage/expected-prices.ts +12 -0
  41. package/src/web-search/anthropic-executor.ts +3 -1
  42. package/src/web-search/index.ts +7 -1
  43. package/src/web-search/loop.ts +17 -3
  44. package/README.ja.md +0 -445
  45. package/README.ko.md +0 -435
  46. package/README.ru.md +0 -486
  47. package/README.zh-CN.md +0 -411
  48. package/gui/dist/assets/index-B-cheu55.js +0 -52
  49. package/gui/dist/assets/index-oOZcqVmj.css +0 -1
package/src/service.ts CHANGED
@@ -472,26 +472,103 @@ function taskXmlSection(xml: string, tag: string): string {
472
472
  return new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, "i").exec(xml)?.[1] ?? "";
473
473
  }
474
474
 
475
+ /** Drop comments and CDATA so a commented-out decoy cannot satisfy any check. */
476
+ function taskXmlWithoutCommentsAndCdata(xml: string): string {
477
+ return xml.replace(/<!--[\s\S]*?-->/g, "").replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, "");
478
+ }
479
+
480
+ /**
481
+ * Count occurrences of an unprefixed tag, including the self-closing form. The
482
+ * element boundary matters: `<EnabledExtra>` must not count as `Enabled`.
483
+ */
484
+ function taskXmlElementCount(xml: string, tag: string): number {
485
+ return xml.match(new RegExp(`<${tag}(?:\\s[^>]*?)?\\s*\\/?>`, "gi"))?.length ?? 0;
486
+ }
487
+
488
+ /**
489
+ * True when a namespace-prefixed form of the tag appears. A prefixed element bound
490
+ * to the task namespace carries a real value, but this module parses by regex and
491
+ * cannot resolve prefixes — so it fails closed instead of reading the element as
492
+ * absent (which would silently apply the schema default).
493
+ */
494
+ function taskXmlHasPrefixedTag(xml: string, tag: string): boolean {
495
+ return new RegExp(`<[A-Za-z_][\\w.-]*:${tag}(?:[\\s/>])`, "i").test(xml);
496
+ }
497
+
498
+ /**
499
+ * Compare an element that Task Scheduler may omit when exporting a registered task.
500
+ * Absence means the documented schema default (#432); a present element must still
501
+ * match exactly, so a malformed or explicitly unsafe value never reads as healthy.
502
+ */
503
+ function taskXmlOptionalValueEquals(xml: string, tag: string, expected: string): boolean {
504
+ // Check the prefixed form first: treating `<t:Enabled>false</t:Enabled>` as an
505
+ // omission would turn an explicitly disabled task into a healthy one.
506
+ if (taskXmlHasPrefixedTag(xml, tag)) return false;
507
+ const count = taskXmlElementCount(xml, tag);
508
+ if (count === 0) return true;
509
+ if (count > 1) return false;
510
+ const value = new RegExp(`<${tag}(?:\\s[^>]*?)?>\\s*([^<]*?)\\s*<\\/${tag}>`, "i").exec(xml)?.[1];
511
+ return value?.trim().toLowerCase() === expected.toLowerCase();
512
+ }
513
+
475
514
  /** Validate the security/lifecycle-critical fields of the registered scheduler task. */
476
515
  export function windowsTaskRegistrationHealthy(
477
516
  xml: string,
478
517
  wscript = windowsWscript(),
479
518
  launcher = windowsLauncherVbsPath(),
480
519
  ): boolean {
481
- const trigger = taskXmlSection(xml, "LogonTrigger");
482
- const principal = taskXmlSection(xml, "Principal");
483
- const settings = taskXmlSection(xml, "Settings");
484
- const action = taskXmlSection(xml, "Exec");
485
- return /<Enabled>\s*true\s*<\/Enabled>/i.test(trigger)
520
+ const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
521
+ // taskXmlSection() takes the FIRST match and the schema allows arbitrary XML under
522
+ // Task/Data, so a Data block placed before the real sections could shadow them.
523
+ // We never emit Data, so its presence alone disqualifies the registration. Both
524
+ // forms are rejected because taskXmlElementCount() ignores prefixed tags.
525
+ if (taskXmlElementCount(scrubbed, "Data") > 0 || taskXmlHasPrefixedTag(scrubbed, "Data")) return false;
526
+ const triggers = taskXmlSection(scrubbed, "Triggers");
527
+ const trigger = taskXmlSection(triggers, "LogonTrigger");
528
+ const principal = taskXmlSection(scrubbed, "Principal");
529
+ const settings = taskXmlSection(scrubbed, "Settings");
530
+ const action = taskXmlSection(scrubbed, "Exec");
531
+ // A self-closing <LogonTrigger /> leaves an empty section, so look for the element
532
+ // itself — scoped to <Triggers> so a decoy elsewhere cannot satisfy it.
533
+ return taskXmlElementCount(triggers, "LogonTrigger") > 0
534
+ && taskXmlOptionalValueEquals(trigger, "Enabled", "true")
486
535
  && /<LogonType>\s*InteractiveToken\s*<\/LogonType>/i.test(principal)
487
- && /<RunLevel>\s*LeastPrivilege\s*<\/RunLevel>/i.test(principal)
488
- && /<Enabled>\s*true\s*<\/Enabled>/i.test(settings)
536
+ && taskXmlOptionalValueEquals(principal, "RunLevel", "LeastPrivilege")
537
+ && taskXmlOptionalValueEquals(settings, "Enabled", "true")
489
538
  && /<MultipleInstancesPolicy>\s*IgnoreNew\s*<\/MultipleInstancesPolicy>/i.test(settings)
490
539
  && /<ExecutionTimeLimit>\s*PT0S\s*<\/ExecutionTimeLimit>/i.test(settings)
491
540
  && action.includes(`<Command>${taskXmlString(wscript)}</Command>`)
492
541
  && action.includes(`<Arguments>${taskXmlString(`/b /nologo "${launcher}"`)}</Arguments>`);
493
542
  }
494
543
 
544
+ export interface WindowsSchedulerXmlState {
545
+ installed: boolean;
546
+ enabled: boolean;
547
+ registrationHealthy: boolean;
548
+ }
549
+
550
+ /**
551
+ * Single source of truth for reading a registered task's XML. Both the status
552
+ * diagnostic and its tests go through here, so a partial fix cannot leave one
553
+ * caller on an older, stricter reading of the same document (#432).
554
+ */
555
+ export function readWindowsSchedulerXmlState(
556
+ xml: string,
557
+ wscript?: string,
558
+ launcher?: string,
559
+ ): WindowsSchedulerXmlState {
560
+ const installed = xml.length > 0;
561
+ if (!installed) return { installed: false, enabled: false, registrationHealthy: false };
562
+ const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
563
+ const hasData = taskXmlElementCount(scrubbed, "Data") > 0 || taskXmlHasPrefixedTag(scrubbed, "Data");
564
+ const settings = hasData ? "" : taskXmlSection(scrubbed, "Settings");
565
+ return {
566
+ installed: true,
567
+ enabled: !hasData && taskXmlOptionalValueEquals(settings, "Enabled", "true"),
568
+ registrationHealthy: windowsTaskRegistrationHealthy(xml, wscript, launcher),
569
+ };
570
+ }
571
+
495
572
  // ── macOS (launchd) ──
496
573
  function installLaunchd(): void {
497
574
  const dir = join(homedir(), "Library", "LaunchAgents");
@@ -878,9 +955,15 @@ export function serviceStartableFromTray(service: ServiceDiagnostic): boolean {
878
955
  }
879
956
 
880
957
  export interface WindowsServiceDiagnosticInputs {
881
- schedulerInstalled: boolean;
882
- schedulerEnabled: boolean;
883
- schedulerAssetsHealthy: boolean;
958
+ /**
959
+ * Raw `schtasks /query /xml` output; empty when no task is registered. Passed as
960
+ * XML rather than pre-computed booleans so every caller reads the document through
961
+ * readWindowsSchedulerXmlState() — a second, stricter reading elsewhere would
962
+ * silently reintroduce the stale-status false positive (#432).
963
+ */
964
+ schedulerXml: string;
965
+ /** Whether the on-disk service assets exist. A filesystem concern, not an XML one. */
966
+ schedulerAssetsPresent: boolean;
884
967
  nativeStatus: "started" | "stopped" | "nonexistent" | "unknown";
885
968
  recordedBackend: ServiceBackend | null;
886
969
  staleBakedPaths: boolean;
@@ -889,37 +972,41 @@ export interface WindowsServiceDiagnosticInputs {
889
972
  }
890
973
 
891
974
  export function deriveWindowsServiceDiagnostic(inputs: WindowsServiceDiagnosticInputs): ServiceDiagnostic {
975
+ const schedulerState = readWindowsSchedulerXmlState(inputs.schedulerXml);
976
+ const schedulerInstalled = schedulerState.installed;
977
+ const schedulerEnabled = schedulerState.enabled;
978
+ const schedulerAssetsHealthy = inputs.schedulerAssetsPresent && schedulerState.registrationHealthy;
892
979
  const nativeInstalled = inputs.nativeStatus !== "nonexistent";
893
- const conflict = inputs.schedulerInstalled && nativeInstalled;
894
- const backendStateMismatch = inputs.schedulerInstalled
980
+ const conflict = schedulerInstalled && nativeInstalled;
981
+ const backendStateMismatch = schedulerInstalled
895
982
  ? inputs.recordedBackend !== "scheduler"
896
983
  : nativeInstalled && inputs.recordedBackend !== "native";
897
984
  const stale = inputs.staleBakedPaths
898
- || (inputs.schedulerInstalled && !inputs.schedulerAssetsHealthy)
985
+ || (schedulerInstalled && !schedulerAssetsHealthy)
899
986
  || backendStateMismatch
900
987
  || (inputs.nativeStatus === "nonexistent" && inputs.nativeRepairAssetsOnly);
901
- const backend = inputs.schedulerInstalled ? "scheduler" : nativeInstalled ? "native" : null;
902
- const enabled = inputs.schedulerInstalled ? inputs.schedulerEnabled : inputs.nativeStatus === "started";
903
- const running = nativeInstalled ? inputs.nativeStatus === "started" : inputs.schedulerInstalled && inputs.schedulerEnabled;
988
+ const backend = schedulerInstalled ? "scheduler" : nativeInstalled ? "native" : null;
989
+ const enabled = schedulerInstalled ? schedulerEnabled : inputs.nativeStatus === "started";
990
+ const running = nativeInstalled ? inputs.nativeStatus === "started" : schedulerInstalled && schedulerEnabled;
904
991
  const viable = !conflict && !stale
905
- && (inputs.schedulerInstalled ? inputs.schedulerEnabled && inputs.schedulerAssetsHealthy : inputs.nativeStatus === "started");
992
+ && (schedulerInstalled ? schedulerEnabled && schedulerAssetsHealthy : inputs.nativeStatus === "started");
906
993
  const startable = !conflict && !stale
907
- && (inputs.schedulerInstalled
908
- ? inputs.schedulerEnabled && inputs.schedulerAssetsHealthy
994
+ && (schedulerInstalled
995
+ ? schedulerEnabled && schedulerAssetsHealthy
909
996
  : inputs.nativeStatus === "started" || inputs.nativeStatus === "stopped");
910
997
  const detail = conflict
911
998
  ? "CONFLICT: Task Scheduler and native WinSW are both present — run 'ocx service uninstall' then reinstall one"
912
999
  : stale
913
1000
  ? "stale or missing service assets — run 'ocx service install' to repair"
914
- : inputs.schedulerInstalled
915
- ? inputs.schedulerEnabled ? "Task Scheduler enabled" : "Task Scheduler disabled"
1001
+ : schedulerInstalled
1002
+ ? schedulerEnabled ? "Task Scheduler enabled" : "Task Scheduler disabled"
916
1003
  : nativeInstalled
917
1004
  ? `native (WinSW ${WINSW_VERSION}): ${inputs.nativeStatus}`
918
1005
  : "not installed";
919
1006
  const summary = backend ? `installed, ${detail} (${inputs.diagnostics})` : `not installed (${inputs.diagnostics})`;
920
1007
  return {
921
1008
  supported: true,
922
- installed: inputs.schedulerInstalled || nativeInstalled,
1009
+ installed: schedulerInstalled || nativeInstalled,
923
1010
  enabled,
924
1011
  running,
925
1012
  viable,
@@ -951,20 +1038,16 @@ export function diagnoseService(): ServiceDiagnostic {
951
1038
  }
952
1039
  if (process.platform === "win32") {
953
1040
  const schedulerXml = statusWindowsXml();
954
- const schedulerInstalled = schedulerXml.length > 0;
955
- const schedulerSettings = taskXmlSection(schedulerXml, "Settings");
956
- const schedulerEnabled = schedulerInstalled && /<Enabled>\s*true\s*<\/Enabled>/i.test(schedulerSettings);
957
- const schedulerAssets = [windowsServiceScriptPath(), windowsLauncherVbsPath(), windowsTaskXmlPath()].every(existsSync)
958
- && windowsTaskRegistrationHealthy(schedulerXml);
1041
+ const schedulerAssetsPresent = [windowsServiceScriptPath(), windowsLauncherVbsPath(), windowsTaskXmlPath()]
1042
+ .every(existsSync);
959
1043
  const nativeStatus = statusWinswRaw();
960
1044
  const installState = readServiceInstallState();
961
1045
  const recordedBackend: ServiceBackend | null = !installState
962
1046
  ? null
963
1047
  : installState.backend === "native" ? "native" : "scheduler";
964
1048
  return deriveWindowsServiceDiagnostic({
965
- schedulerInstalled,
966
- schedulerEnabled,
967
- schedulerAssetsHealthy: schedulerAssets,
1049
+ schedulerXml,
1050
+ schedulerAssetsPresent,
968
1051
  nativeStatus,
969
1052
  recordedBackend,
970
1053
  staleBakedPaths: bakedServicePathsDiagnostic() !== null,
package/src/types.ts CHANGED
@@ -246,6 +246,8 @@ export type AdapterEvent =
246
246
  | { type: "tool_call_start"; id: string; name: string }
247
247
  | { type: "tool_call_delta"; arguments: string }
248
248
  | { type: "tool_call_end" }
249
+ /** Internal boundary between a guarded first pass and its one-shot continuation. */
250
+ | { type: "assistant_boundary" }
249
251
  // Native web-search activity surfaced by the web-search sidecar so Codex renders a "Searched the
250
252
  // web" cell. Emitted as a lifecycle PAIR at real wall-clock moments by src/web-search/loop.ts
251
253
  // (routed adapters never emit these): `begin` right before the sidecar runs so Codex shows the
@@ -718,6 +720,17 @@ export interface ResponsesItemIdRepairConfig {
718
720
 
719
721
  export interface OcxProviderConfig {
720
722
  adapter: string;
723
+ /**
724
+ * Per-model wire override, keyed by the upstream native model id (after namespace
725
+ * and combo resolution). A single gateway can front models that speak different
726
+ * wires — Grok needs the Responses API for hosted web_search while a sibling model
727
+ * is fine on chat completions (#404).
728
+ *
729
+ * Only OpenAI-shaped wires may be selected; see MODEL_ADAPTER_OVERRIDE_ALLOWED.
730
+ * Absent or empty means the provider-wide `adapter` applies to everything, exactly
731
+ * as before.
732
+ */
733
+ modelAdapters?: Record<string, string>;
721
734
  baseUrl: string;
722
735
  /**
723
736
  * Optional relative resource path for key-auth openai-responses requests. Must start with `/`
@@ -930,6 +943,45 @@ export type CodexAccountMode = "direct" | "pool";
930
943
 
931
944
  export const OPENAI_PROVIDER_TIER_VERSION = 2 as const;
932
945
 
946
+ /**
947
+ * Wires that a per-model `modelAdapters` override may select.
948
+ *
949
+ * Deliberately narrow: provider-specific adapters (cursor, kiro, google, ...) carry
950
+ * their own credential and base-URL semantics, so exposing them here would widen the
951
+ * auth boundary rather than pick a wire. Widening this set needs a per-adapter
952
+ * credential threat model first (#404).
953
+ */
954
+ export const MODEL_ADAPTER_OVERRIDE_ALLOWED: ReadonlySet<string> = new Set([
955
+ "openai-chat",
956
+ "openai-responses",
957
+ ]);
958
+
959
+ /**
960
+ * Providers whose listed model ids must be driven over the Anthropic wire even when
961
+ * the provider's configured adapter says otherwise — the upstream only speaks
962
+ * Anthropic for these models.
963
+ */
964
+ const ANTHROPIC_WIRE_MODELS: Record<string, ReadonlySet<string>> = {
965
+ "opencode-go": new Set(["minimax-m2.5", "minimax-m2.7", "minimax-m3"]),
966
+ };
967
+
968
+ /**
969
+ * True when the upstream speaks exactly one wire for this model, so a configured
970
+ * override must not apply.
971
+ *
972
+ * Deliberately independent of the provider's current adapter: the wire resolver runs
973
+ * more than once per request, and a check phrased as "pin differs from the current
974
+ * adapter" would pass on the first pass and then let the override win on the second.
975
+ */
976
+ export function isWirePinnedModel(providerName: string, modelId: string): boolean {
977
+ return ANTHROPIC_WIRE_MODELS[providerName]?.has(modelId) ?? false;
978
+ }
979
+
980
+ /** The wire a pinned model must use, or undefined when the model is not pinned. */
981
+ export function pinnedWireAdapter(providerName: string, modelId: string): string | undefined {
982
+ return isWirePinnedModel(providerName, modelId) ? "anthropic" : undefined;
983
+ }
984
+
933
985
  export interface CodexAccount {
934
986
  id: string;
935
987
  email: string;
@@ -41,6 +41,11 @@ const QWEN38_ROUTEWAY_TEMPORARY: Cost4 = { input: 1.5, output: 5, cacheRead: 0.1
41
41
  // Anthropic official list prices (USD / 1M tokens). Cache write uses the published 5-minute rate.
42
42
  const CLAUDE_SONNET_46: Cost4 = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 };
43
43
  const CLAUDE_OPUS_46: Cost4 = { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 };
44
+ // Opus 5 is priced from the maintainer's confirmation that it matches the previous
45
+ // Opus, not from a published Opus 5 page. Hence `verified-derived`, and a source
46
+ // string that states the provenance instead of pointing at ANTHROPIC_PRICING.
47
+ const CLAUDE_OPUS_5_DERIVED_SOURCE =
48
+ "user-confirmed: claude-opus-5 matches Claude Opus 4.6; no separate Anthropic Opus 5 price page verified";
44
49
  const ANTHROPIC_PRICING = "https://platform.claude.com/docs/en/about-claude/pricing (official; 5m cache-write tier)";
45
50
 
46
51
  const GEMINI_PRICING = "https://ai.google.dev/gemini-api/docs/pricing (2026-07-22); cacheWrite=0: storage is billed per-hour, not per-token";
@@ -54,6 +59,13 @@ const KIMI_PRICING = "https://platform.kimi.ai/docs/pricing (official table; cac
54
59
  const QWEN38_ROUTEWAY_PRICING = "https://routeway.ai/models/qwen3.8-max-preview (temporary reseller proxy; NOT Alibaba Token Plan billing; cacheWrite unpublished -> 0)";
55
60
 
56
61
  export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [
62
+ // claude-opus-5 is exposed by three providers but absent from the jawcode bundle, so
63
+ // cost resolution returned null and the Logs `~$` column rendered an em dash. The
64
+ // model-level vendor fallback only searches jawcode metadata, never overlays, so one
65
+ // anthropic row would not cover cursor/kiro — each exposing provider needs its own.
66
+ { provider: "anthropic", modelId: "claude-opus-5", cost4: CLAUDE_OPUS_46, source: CLAUDE_OPUS_5_DERIVED_SOURCE, verifiedAt: "2026-07-25", status: "verified-derived" },
67
+ { provider: "cursor", modelId: "claude-opus-5", cost4: CLAUDE_OPUS_46, source: CLAUDE_OPUS_5_DERIVED_SOURCE, verifiedAt: "2026-07-25", status: "verified-derived" },
68
+ { provider: "kiro", modelId: "claude-opus-5", cost4: CLAUDE_OPUS_46, source: CLAUDE_OPUS_5_DERIVED_SOURCE, verifiedAt: "2026-07-25", status: "verified-derived" },
57
69
  // MiniMax M2.1 highspeed — published PAYG price (verified).
58
70
  { provider: "minimax", modelId: "MiniMax-M2.1-highspeed", cost4: MINIMAX_M21_HIGHSPEED, source: MINIMAX_PRICING, verifiedAt: "2026-07-20", status: "verified" },
59
71
  { provider: "minimax-cn", modelId: "MiniMax-M2.1-highspeed", cost4: MINIMAX_M21_HIGHSPEED, source: MINIMAX_PRICING, verifiedAt: "2026-07-20", status: "verified" },
@@ -3,6 +3,7 @@ import { getValidAccessToken } from "../oauth";
3
3
  import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM_INSTRUCTION } from "../oauth/anthropic";
4
4
  import { CLAUDE_CODE_HEADERS, claudeCodeSessionId } from "../adapters/client-fingerprint";
5
5
  import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort";
6
+ import { redactSecretString } from "../lib/redact";
6
7
  import { sidecarEnter } from "../lib/sidecar-tracker";
7
8
  import { fetchWithResetRetry } from "../lib/upstream-retry";
8
9
  import type { WebSearchSource } from "./parse";
@@ -168,7 +169,8 @@ export async function runAnthropicWebSearch(
168
169
  if (!res.ok) {
169
170
  const t = await res.text().catch(() => "");
170
171
  console.warn(`[web-search] anthropic sidecar HTTP ${res.status} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`);
171
- return { text: "", sources: [], error: `sidecar HTTP ${res.status}: ${t.slice(0, 200)}` };
172
+ // Redact before surfacing: the body can echo auth headers/tokens (#398 review).
173
+ return { text: "", sources: [], error: `sidecar HTTP ${res.status}: ${redactSecretString(t.slice(0, 200))}` };
172
174
  }
173
175
  const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
174
176
  try {
@@ -16,7 +16,13 @@ const DEFAULT_ANTHROPIC_SIDECAR_MODEL = "claude-sonnet-5";
16
16
  // "tools cannot be used with reasoning.effort 'minimal'") — keeps the sidecar fast/cheap.
17
17
  const DEFAULT_SIDECAR_REASONING = "low";
18
18
  const DEFAULT_MAX_SEARCHES = 3;
19
- const DEFAULT_TIMEOUT_MS = 200_000;
19
+ // Per-search sidecar deadline. Lowered from 200_000 to 60_000 (#398): a hung
20
+ // hosted web_search used to run the full 200s, so the client cancelled first
21
+ // (turn 499) or the forced-answer routed iteration failed (502). Hosted-search
22
+ // p90 is ~43s, so 60s bounds hangs while leaving tail margin. `cfg.timeoutMs`
23
+ // still overrides. Distinct from DEFAULT_ROUTED_MODEL_STALL_TIMEOUT_MS below,
24
+ // which is the routed-model body-inactivity budget (unchanged).
25
+ const DEFAULT_TIMEOUT_MS = 60_000;
20
26
  const DEFAULT_ROUTED_MODEL_STALL_TIMEOUT_MS = 200_000;
21
27
  const MAX_ROUTED_MODEL_STALL_TIMEOUT_MS = 2_147_483_647;
22
28
  const STALL_MARGIN_SEC = 30;
@@ -5,6 +5,7 @@ import { bridgeToResponsesSSE } from "../bridge";
5
5
  import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor";
6
6
  import { runAnthropicWebSearch } from "./anthropic-executor";
7
7
  import { clearableDeadline } from "../lib/abort";
8
+ import { redactSecretString } from "../lib/redact";
8
9
  import { readBoundedResponseBody } from "../lib/bounded-body";
9
10
  import { fetchWithResetRetry } from "../lib/upstream-retry";
10
11
  import { formatWebSearchResults } from "./format-result";
@@ -424,9 +425,22 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
424
425
  }
425
426
  // F5: the anthropic sidecar authenticates with its own stored OAuth — it never touches the
426
427
  // ChatGPT forward headers and must NOT record a Codex/OpenAI pool outcome.
427
- outcome = backend === "anthropic" && anthropicSidecar
428
- ? await runAnthropicWebSearch(query, anthropicSidecar.providerName, anthropicSidecar.provider, settings, signal)
429
- : await runWebSearch(query, hostedTool, forwardProvider!, selectedForwardHeaders, settings, signal, recordSidecarOutcome);
428
+ // #398: the executors are "never throws", but enforce the contract defensively so a future
429
+ // throw degrades to a failed tool result instead of aborting the whole turn. A genuine
430
+ // parent abort MUST stay 499 the executors catch abort and RETURN {error}, so check
431
+ // signal.aborted both after the await and in the catch (a fulfilled {error} on an aborted
432
+ // signal would otherwise look like an ordinary degradable failure).
433
+ try {
434
+ outcome = backend === "anthropic" && anthropicSidecar
435
+ ? await runAnthropicWebSearch(query, anthropicSidecar.providerName, anthropicSidecar.provider, settings, signal)
436
+ : await runWebSearch(query, hostedTool, forwardProvider!, selectedForwardHeaders, settings, signal, recordSidecarOutcome);
437
+ if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
438
+ } catch (e) {
439
+ if (e instanceof LoopError) throw e;
440
+ if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
441
+ // Unexpected executor throw: degrade this query to a failed tool result (redacted).
442
+ outcome = { text: "", sources: [], error: `sidecar failed: ${redactSecretString(e instanceof Error ? e.message : String(e))}` };
443
+ }
430
444
  searchesExecuted++;
431
445
  executedSearchCount++;
432
446
  if (outcome.error) failedQueries.add(normalizeQuery(query));