@sayknow-cli/coding-agent 0.2.7 → 0.3.1

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 (216) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/types/async/job-manager.d.ts +3 -1
  3. package/dist/types/cli/daemon-cli.d.ts +25 -0
  4. package/dist/types/cli/notify-cli.d.ts +25 -0
  5. package/dist/types/cli/setup-cli.d.ts +20 -1
  6. package/dist/types/commands/daemon.d.ts +41 -0
  7. package/dist/types/commands/notify.d.ts +41 -0
  8. package/dist/types/config/model-profile-activation.d.ts +12 -0
  9. package/dist/types/config/model-profiles.d.ts +2 -1
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/models-config-schema.d.ts +5 -0
  12. package/dist/types/config/settings-schema.d.ts +136 -3
  13. package/dist/types/coordinator/contract.d.ts +1 -1
  14. package/dist/types/daemon/builtin.d.ts +20 -0
  15. package/dist/types/daemon/control-types.d.ts +57 -0
  16. package/dist/types/daemon/runtime.d.ts +25 -0
  17. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  18. package/dist/types/extensibility/shared-events.d.ts +1 -0
  19. package/dist/types/i18n/messages/en.d.ts +1 -0
  20. package/dist/types/lsp/types.d.ts +2 -0
  21. package/dist/types/modes/components/oauth-selector.d.ts +2 -0
  22. package/dist/types/modes/components/settings-defs.d.ts +1 -0
  23. package/dist/types/modes/controllers/selector-controller.d.ts +2 -2
  24. package/dist/types/modes/interactive-mode.d.ts +1 -1
  25. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  26. package/dist/types/modes/theme/theme.d.ts +1 -1
  27. package/dist/types/modes/types.d.ts +7 -1
  28. package/dist/types/notifications/attachment-registry.d.ts +17 -0
  29. package/dist/types/notifications/chat-adapters.d.ts +9 -0
  30. package/dist/types/notifications/config-commands.d.ts +26 -0
  31. package/dist/types/notifications/config.d.ts +69 -0
  32. package/dist/types/notifications/engine.d.ts +59 -0
  33. package/dist/types/notifications/helpers.d.ts +55 -0
  34. package/dist/types/notifications/html-format.d.ts +62 -0
  35. package/dist/types/notifications/index.d.ts +28 -0
  36. package/dist/types/notifications/managed-daemon.d.ts +48 -0
  37. package/dist/types/notifications/rate-limit-pool.d.ts +93 -0
  38. package/dist/types/notifications/telegram-cli.d.ts +19 -0
  39. package/dist/types/notifications/telegram-daemon-cli.d.ts +11 -0
  40. package/dist/types/notifications/telegram-daemon-control.d.ts +56 -0
  41. package/dist/types/notifications/telegram-daemon.d.ts +295 -0
  42. package/dist/types/notifications/telegram-reference.d.ts +111 -0
  43. package/dist/types/notifications/threaded-inbound.d.ts +77 -0
  44. package/dist/types/notifications/threaded-render.d.ts +71 -0
  45. package/dist/types/notifications/topic-registry.d.ts +67 -0
  46. package/dist/types/rlm/index.d.ts +12 -0
  47. package/dist/types/session/agent-session.d.ts +41 -2
  48. package/dist/types/session/auth-storage.d.ts +1 -1
  49. package/dist/types/setup/credential-auto-import.d.ts +63 -0
  50. package/dist/types/setup/credential-import.d.ts +3 -0
  51. package/dist/types/setup/host-plugin-setup.d.ts +39 -0
  52. package/dist/types/skc-runtime/launch-tmux.d.ts +1 -0
  53. package/dist/types/skc-runtime/ralplan-runtime.d.ts +1 -1
  54. package/dist/types/skc-runtime/state-writer.d.ts +2 -0
  55. package/dist/types/skc-runtime/tmux-common.d.ts +3 -0
  56. package/dist/types/skc-runtime/tmux-sessions.d.ts +2 -0
  57. package/dist/types/skc-runtime/ultragoal-guard.d.ts +15 -0
  58. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +14 -0
  59. package/dist/types/tools/ask-answer-registry.d.ts +13 -0
  60. package/dist/types/tools/fetch.d.ts +23 -0
  61. package/dist/types/tools/index.d.ts +19 -0
  62. package/dist/types/tools/subagent.d.ts +3 -0
  63. package/dist/types/tools/telegram-send.d.ts +32 -0
  64. package/dist/types/web/insane/bridge.d.ts +103 -0
  65. package/dist/types/web/insane/url-guard.d.ts +22 -0
  66. package/dist/types/web/search/provider.d.ts +18 -1
  67. package/dist/types/web/search/providers/insane.d.ts +53 -0
  68. package/dist/types/web/search/providers/text-citations.d.ts +23 -0
  69. package/dist/types/web/search/types.d.ts +12 -4
  70. package/package.json +10 -8
  71. package/scripts/build-binary.ts +3 -0
  72. package/scripts/verify-insane-vendor.ts +132 -0
  73. package/src/async/job-manager.ts +5 -1
  74. package/src/cli/args.ts +1 -1
  75. package/src/cli/daemon-cli.ts +122 -0
  76. package/src/cli/fast-help.ts +1 -1
  77. package/src/cli/notify-cli.ts +421 -0
  78. package/src/cli/setup-cli.ts +173 -84
  79. package/src/cli.ts +3 -3
  80. package/src/commands/daemon.ts +47 -0
  81. package/src/commands/notify.ts +61 -0
  82. package/src/commands/setup.ts +11 -1
  83. package/src/commands/team.ts +1 -1
  84. package/src/config/model-profile-activation.ts +74 -5
  85. package/src/config/model-profiles.ts +7 -4
  86. package/src/config/model-registry.ts +6 -3
  87. package/src/config/models-config-schema.ts +1 -1
  88. package/src/config/settings-schema.ts +143 -0
  89. package/src/coordinator/contract.ts +3 -0
  90. package/src/coordinator-mcp/server.ts +270 -1
  91. package/src/daemon/builtin.ts +46 -0
  92. package/src/daemon/control-types.ts +65 -0
  93. package/src/daemon/runtime.ts +51 -0
  94. package/src/defaults/skc/rules/ponytail.md +68 -0
  95. package/src/defaults/skc/skills/ralplan/SKILL.md +11 -4
  96. package/src/defaults/skc/skills/ultragoal/SKILL.md +16 -0
  97. package/src/edit/modes/replace.ts +1 -1
  98. package/src/extensibility/extensions/runner.ts +4 -0
  99. package/src/extensibility/extensions/types.ts +8 -0
  100. package/src/extensibility/shared-events.ts +1 -0
  101. package/src/goals/tools/goal-tool.ts +11 -2
  102. package/src/hashline/hash.ts +1 -1
  103. package/src/i18n/messages/de.ts +1 -0
  104. package/src/i18n/messages/en.ts +1 -0
  105. package/src/i18n/messages/es.ts +1 -0
  106. package/src/i18n/messages/fr.ts +1 -0
  107. package/src/i18n/messages/ja.ts +1 -0
  108. package/src/i18n/messages/ko.ts +1 -0
  109. package/src/i18n/messages/zh.ts +1 -0
  110. package/src/internal-urls/docs-index.generated.ts +12 -10
  111. package/src/lsp/config.ts +16 -3
  112. package/src/lsp/defaults.json +7 -0
  113. package/src/lsp/types.ts +2 -0
  114. package/src/main.ts +30 -0
  115. package/src/modes/acp/acp-event-mapper.ts +1 -0
  116. package/src/modes/components/hook-editor.ts +7 -2
  117. package/src/modes/components/oauth-selector.ts +19 -0
  118. package/src/modes/components/settings-defs.ts +2 -1
  119. package/src/modes/components/settings-selector.ts +7 -2
  120. package/src/modes/controllers/event-controller.ts +35 -0
  121. package/src/modes/controllers/selector-controller.ts +80 -17
  122. package/src/modes/interactive-mode.ts +52 -4
  123. package/src/modes/runtime-init.ts +1 -0
  124. package/src/modes/shared/agent-wire/event-contract.ts +1 -0
  125. package/src/modes/shared/agent-wire/event-envelope.ts +1 -0
  126. package/src/modes/shared/agent-wire/event-observation.ts +16 -0
  127. package/src/modes/shared/agent-wire/unattended-session.ts +22 -0
  128. package/src/modes/theme/theme.ts +4 -0
  129. package/src/modes/types.ts +7 -1
  130. package/src/modes/utils/context-usage.ts +2 -2
  131. package/src/modes/utils/ui-helpers.ts +23 -0
  132. package/src/notifications/attachment-registry.ts +23 -0
  133. package/src/notifications/chat-adapters.ts +147 -0
  134. package/src/notifications/config-commands.ts +50 -0
  135. package/src/notifications/config.ts +128 -0
  136. package/src/notifications/engine.ts +100 -0
  137. package/src/notifications/helpers.ts +135 -0
  138. package/src/notifications/html-format.ts +389 -0
  139. package/src/notifications/index.ts +842 -0
  140. package/src/notifications/managed-daemon.ts +163 -0
  141. package/src/notifications/rate-limit-pool.ts +179 -0
  142. package/src/notifications/telegram-cli.ts +194 -0
  143. package/src/notifications/telegram-daemon-cli.ts +74 -0
  144. package/src/notifications/telegram-daemon-control.ts +370 -0
  145. package/src/notifications/telegram-daemon.ts +1591 -0
  146. package/src/notifications/telegram-reference.ts +335 -0
  147. package/src/notifications/threaded-inbound.ts +136 -0
  148. package/src/notifications/threaded-render.ts +173 -0
  149. package/src/notifications/topic-registry.ts +133 -0
  150. package/src/rlm/index.ts +19 -0
  151. package/src/sdk.ts +16 -0
  152. package/src/session/agent-session.ts +195 -54
  153. package/src/session/auth-storage.ts +3 -0
  154. package/src/session/session-dump-format.ts +43 -2
  155. package/src/session/session-manager.ts +39 -5
  156. package/src/setup/credential-auto-import.ts +258 -0
  157. package/src/setup/credential-import.ts +17 -0
  158. package/src/setup/hermes/templates/operator-instructions.v1.md +10 -0
  159. package/src/setup/host-plugin-setup.ts +142 -0
  160. package/src/skc-runtime/deep-interview-recorder.ts +2 -2
  161. package/src/skc-runtime/launch-tmux.ts +27 -5
  162. package/src/skc-runtime/ledger-event-renderer.ts +1 -0
  163. package/src/skc-runtime/ralplan-runtime.ts +2 -2
  164. package/src/skc-runtime/state-runtime.ts +18 -10
  165. package/src/skc-runtime/state-writer.ts +8 -8
  166. package/src/skc-runtime/tmux-common.ts +8 -0
  167. package/src/skc-runtime/tmux-sessions.ts +8 -1
  168. package/src/skc-runtime/ultragoal-guard.ts +57 -2
  169. package/src/skc-runtime/ultragoal-runtime.ts +105 -19
  170. package/src/skc-runtime/workflow-manifest.generated.json +56 -2
  171. package/src/skc-runtime/workflow-manifest.ts +18 -3
  172. package/src/slash-commands/builtin-registry.ts +4 -1
  173. package/src/task/executor.ts +5 -1
  174. package/src/tools/ask-answer-registry.ts +25 -0
  175. package/src/tools/ask.ts +77 -6
  176. package/src/tools/fetch.ts +78 -1
  177. package/src/tools/image-gen.ts +5 -8
  178. package/src/tools/index.ts +22 -0
  179. package/src/tools/inspect-image.ts +16 -11
  180. package/src/tools/subagent-render.ts +7 -0
  181. package/src/tools/subagent.ts +38 -7
  182. package/src/tools/telegram-send.ts +137 -0
  183. package/src/web/insane/bridge.ts +350 -0
  184. package/src/web/insane/url-guard.ts +155 -0
  185. package/src/web/search/provider.ts +77 -18
  186. package/src/web/search/providers/anthropic.ts +70 -3
  187. package/src/web/search/providers/codex.ts +1 -119
  188. package/src/web/search/providers/gemini.ts +99 -0
  189. package/src/web/search/providers/insane.ts +551 -0
  190. package/src/web/search/providers/openai-compatible.ts +66 -32
  191. package/src/web/search/providers/text-citations.ts +111 -0
  192. package/src/web/search/types.ts +13 -2
  193. package/vendor/insane-search/LICENSE +21 -0
  194. package/vendor/insane-search/MANIFEST.json +24 -0
  195. package/vendor/insane-search/engine/__init__.py +23 -0
  196. package/vendor/insane-search/engine/__main__.py +128 -0
  197. package/vendor/insane-search/engine/bias_check.py +183 -0
  198. package/vendor/insane-search/engine/executor.py +254 -0
  199. package/vendor/insane-search/engine/fetch_chain.py +725 -0
  200. package/vendor/insane-search/engine/learning.py +175 -0
  201. package/vendor/insane-search/engine/phase0.py +214 -0
  202. package/vendor/insane-search/engine/safety.py +91 -0
  203. package/vendor/insane-search/engine/templates/package.json +11 -0
  204. package/vendor/insane-search/engine/templates/playwright_mobile_chrome.js +188 -0
  205. package/vendor/insane-search/engine/templates/playwright_real_chrome.js +243 -0
  206. package/vendor/insane-search/engine/tests/test_hardening.py +57 -0
  207. package/vendor/insane-search/engine/tests/test_smoke.py +152 -0
  208. package/vendor/insane-search/engine/tests/test_u1.py +200 -0
  209. package/vendor/insane-search/engine/tests/test_u4.py +131 -0
  210. package/vendor/insane-search/engine/tests/test_u5.py +163 -0
  211. package/vendor/insane-search/engine/tests/test_u7.py +124 -0
  212. package/vendor/insane-search/engine/transport.py +211 -0
  213. package/vendor/insane-search/engine/url_transforms.py +98 -0
  214. package/vendor/insane-search/engine/validators.py +331 -0
  215. package/vendor/insane-search/engine/waf_detector.py +214 -0
  216. package/vendor/insane-search/engine/waf_profiles.yaml +162 -0
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Public HTTP(S) URL guard for the insane-search read fallback.
3
+ *
4
+ * The vendored insane-search engine performs its own network requests (curl_cffi,
5
+ * a real browser) entirely outside the TypeScript fetch path, so the normal
6
+ * `loadPage()` flow cannot protect against SSRF. This guard MUST run before any
7
+ * dependency probe or engine subprocess is spawned. It is fail-closed: anything
8
+ * it cannot prove is a public, non-credentialed http/https target is rejected.
9
+ *
10
+ * It does NOT follow or re-validate redirects — the engine may follow redirects
11
+ * internally that this guard never sees. That residual risk is documented in the
12
+ * plan and mitigated by validating the input target and keeping the feature
13
+ * opt-in (default off).
14
+ */
15
+ import * as dns from "node:dns/promises";
16
+ import * as net from "node:net";
17
+
18
+ export interface PublicUrlAccepted {
19
+ ok: true;
20
+ url: URL;
21
+ addresses: string[];
22
+ }
23
+
24
+ export interface PublicUrlRejected {
25
+ ok: false;
26
+ reason: string;
27
+ }
28
+
29
+ export type PublicUrlResult = PublicUrlAccepted | PublicUrlRejected;
30
+
31
+ /** Resolver seam so tests can inject DNS results without real lookups. */
32
+ export type AddressResolver = (hostname: string) => Promise<string[]>;
33
+
34
+ const defaultResolver: AddressResolver = async hostname => {
35
+ const records = await dns.lookup(hostname, { all: true, verbatim: true });
36
+ return records.map(record => record.address);
37
+ };
38
+
39
+ const BLOCKED_HOSTNAMES = new Set(["localhost", "localhost.localdomain", "0.0.0.0", ""]);
40
+
41
+ function isBlockedHostname(hostname: string): boolean {
42
+ const normalized = hostname.toLowerCase().replace(/\.$/, "");
43
+ return (
44
+ BLOCKED_HOSTNAMES.has(normalized) ||
45
+ normalized === "localhost" ||
46
+ normalized.endsWith(".localhost") ||
47
+ normalized.endsWith(".local") ||
48
+ normalized.endsWith(".internal") ||
49
+ normalized.endsWith(".home.arpa")
50
+ );
51
+ }
52
+
53
+ function isPrivateIPv4(address: string): boolean {
54
+ const parts = address.split(".").map(part => Number.parseInt(part, 10));
55
+ if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) return true;
56
+ const [a, b] = parts;
57
+ return (
58
+ a === 0 || // unspecified / "this network"
59
+ a === 10 || // RFC1918
60
+ a === 127 || // loopback
61
+ (a === 100 && b >= 64 && b <= 127) || // CGNAT 100.64/10
62
+ (a === 169 && b === 254) || // link-local
63
+ (a === 172 && b >= 16 && b <= 31) || // RFC1918
64
+ (a === 192 && b === 0) || // 192.0.0/24 & 192.0.2/24 (documentation/reserved)
65
+ (a === 192 && b === 168) || // RFC1918
66
+ (a === 198 && (b === 18 || b === 19)) || // benchmarking 198.18/15
67
+ (a === 198 && b === 51) || // 198.51.100/24 documentation
68
+ (a === 203 && b === 0) || // 203.0.113/24 documentation
69
+ a >= 224 // multicast (224/4) + reserved (240/4) + broadcast
70
+ );
71
+ }
72
+
73
+ function normalizeIPv4MappedIPv6(address: string): string {
74
+ return address.toLowerCase().startsWith("::ffff:") ? address.slice(7) : address;
75
+ }
76
+
77
+ function isPrivateIPv6(address: string): boolean {
78
+ const normalized = address.toLowerCase();
79
+ const mapped = normalizeIPv4MappedIPv6(normalized);
80
+ if (mapped !== normalized && net.isIP(mapped) === 4) return isPrivateIPv4(mapped);
81
+ return (
82
+ normalized === "::" || // unspecified
83
+ normalized === "::1" || // loopback
84
+ normalized.startsWith("fc") || // ULA fc00::/7
85
+ normalized.startsWith("fd") || // ULA
86
+ normalized.startsWith("fe8") || // link-local fe80::/10
87
+ normalized.startsWith("fe9") ||
88
+ normalized.startsWith("fea") ||
89
+ normalized.startsWith("feb") ||
90
+ normalized.startsWith("ff") || // multicast ff00::/8
91
+ normalized.startsWith("2001:db8") || // documentation
92
+ normalized.startsWith("::ffff:") // any remaining IPv4-mapped form we could not classify
93
+ );
94
+ }
95
+
96
+ /** True for any address that is not a routable public unicast address. */
97
+ export function isPrivateOrSpecialAddress(address: string): boolean {
98
+ const normalized = normalizeIPv4MappedIPv6(address);
99
+ const family = net.isIP(normalized);
100
+ if (family === 4) return isPrivateIPv4(normalized);
101
+ if (family === 6) return isPrivateIPv6(normalized);
102
+ // Re-check the raw value in case it was an IPv4-mapped IPv6 literal.
103
+ if (net.isIP(address) === 6) return isPrivateIPv6(address);
104
+ return true; // not a recognizable IP -> treat as unsafe
105
+ }
106
+
107
+ /**
108
+ * Validate that `rawUrl` is a public http/https target safe to hand to the
109
+ * insane-search engine. Resolves DNS names and rejects any that map to a
110
+ * private/special address. Never throws; returns a discriminated result.
111
+ */
112
+ export async function validatePublicHttpUrlForInsane(
113
+ rawUrl: string,
114
+ options: { resolver?: AddressResolver } = {},
115
+ ): Promise<PublicUrlResult> {
116
+ const resolver = options.resolver ?? defaultResolver;
117
+
118
+ let url: URL;
119
+ try {
120
+ url = new URL(rawUrl);
121
+ } catch {
122
+ return { ok: false, reason: "invalid URL" };
123
+ }
124
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
125
+ return { ok: false, reason: `unsupported scheme ${url.protocol}` };
126
+ }
127
+ if (url.username || url.password) {
128
+ return { ok: false, reason: "URL credentials are not allowed" };
129
+ }
130
+ if (isBlockedHostname(url.hostname)) {
131
+ return { ok: false, reason: "localhost or internal host" };
132
+ }
133
+
134
+ const literalFamily = net.isIP(url.hostname);
135
+ if (literalFamily !== 0) {
136
+ if (isPrivateOrSpecialAddress(url.hostname)) {
137
+ return { ok: false, reason: "private, loopback, link-local, or reserved IP literal" };
138
+ }
139
+ return { ok: true, url, addresses: [url.hostname] };
140
+ }
141
+
142
+ let addresses: string[];
143
+ try {
144
+ addresses = await resolver(url.hostname);
145
+ } catch {
146
+ return { ok: false, reason: "host could not be resolved" };
147
+ }
148
+ if (addresses.length === 0) {
149
+ return { ok: false, reason: "host resolved to no addresses" };
150
+ }
151
+ if (addresses.some(isPrivateOrSpecialAddress)) {
152
+ return { ok: false, reason: "host resolves to a private or reserved address" };
153
+ }
154
+ return { ok: true, url, addresses };
155
+ }
@@ -72,6 +72,11 @@ const PROVIDER_META: Record<SearchProviderId, ProviderMeta> = {
72
72
  label: "DuckDuckGo",
73
73
  load: async () => new (await import("./providers/duckduckgo")).DuckDuckGoProvider(),
74
74
  },
75
+ insane: {
76
+ id: "insane",
77
+ label: "Insane",
78
+ load: async () => new (await import("./providers/insane")).InsaneProvider(),
79
+ },
75
80
  "openai-compatible": {
76
81
  id: "openai-compatible",
77
82
  label: "OpenAI-compatible",
@@ -97,6 +102,7 @@ export async function getSearchProvider(id: SearchProviderId): Promise<SearchPro
97
102
 
98
103
  export const SEARCH_PROVIDER_ORDER: SearchProviderId[] = [
99
104
  "duckduckgo",
105
+ "insane",
100
106
  "tavily",
101
107
  "perplexity",
102
108
  "brave",
@@ -234,14 +240,41 @@ export function isLocalBaseUrl(baseUrl: string | undefined): boolean {
234
240
  return false;
235
241
  }
236
242
 
243
+ /**
244
+ * Whether `baseUrl` is an official OpenAI endpoint (or absent, i.e. the default
245
+ * hosted OpenAI). The dedicated `codex` provider authenticates against the
246
+ * ChatGPT backend with the user's *local* Codex OAuth, so it must only be
247
+ * selected when the active model is genuinely served by OpenAI/ChatGPT — never
248
+ * for a custom/proxy endpoint, which should reuse its own credentials through
249
+ * the `openai-compatible` adapter instead.
250
+ */
251
+ function isOpenAIOfficialBaseUrl(baseUrl: string | undefined): boolean {
252
+ if (!baseUrl?.trim()) return true;
253
+ let host: string;
254
+ try {
255
+ host = new URL(baseUrl).hostname.toLowerCase();
256
+ } catch {
257
+ return false;
258
+ }
259
+ return (
260
+ host === "api.openai.com" ||
261
+ host === "chatgpt.com" ||
262
+ host.endsWith(".openai.com") ||
263
+ host.endsWith(".chatgpt.com")
264
+ );
265
+ }
266
+
237
267
  export function inferNativeProviderFromModel(ctx: ActiveSearchModelContext | undefined): SearchProviderId | undefined {
238
268
  if (!ctx || ctx.webSearch === "off") return undefined;
239
269
  const modelId = (ctx.wireModelId ?? ctx.modelId).toLowerCase();
240
270
  if (modelId.startsWith("claude-") && isAnthropicWire(ctx.api)) return "anthropic";
241
271
  if (modelId.startsWith("gemini-") && isGoogleWire(ctx.api)) return "gemini";
242
272
  if (looksXaiFamilyModelId(ctx) && isOpenAICompatWire(ctx.api)) return "xai";
243
- if (looksOpenAIFamilyModelId(ctx) && isOpenAICompatWire(ctx.api)) {
244
- if (ctx.webSearch === "on" || !isLocalBaseUrl(ctx.baseUrl)) return "codex";
273
+ // `codex` hits the ChatGPT backend with local Codex OAuth, so only infer it
274
+ // for genuine OpenAI endpoints. Custom/proxy OpenAI-compatible models fall
275
+ // through to `activeContextNativeId` → `openai-compatible` (their own creds).
276
+ if (looksOpenAIFamilyModelId(ctx) && isOpenAICompatWire(ctx.api) && isOpenAIOfficialBaseUrl(ctx.baseUrl)) {
277
+ return "codex";
245
278
  }
246
279
  return undefined;
247
280
  }
@@ -249,8 +282,9 @@ export function inferNativeProviderFromModel(ctx: ActiveSearchModelContext | und
249
282
  function canUseDirectProviderMapping(ctx: ActiveSearchModelContext, id: SearchProviderId): boolean {
250
283
  if (ctx.webSearch === "off") return false;
251
284
  if (id !== "codex") return true;
252
- if (!isOpenAICompatWire(ctx.api)) return true;
253
- return ctx.webSearch === "on" || !isLocalBaseUrl(ctx.baseUrl);
285
+ // Same constraint as inference: the ChatGPT-backed codex provider is valid
286
+ // only for official OpenAI endpoints, not custom/proxy base URLs.
287
+ return isOpenAIOfficialBaseUrl(ctx.baseUrl);
254
288
  }
255
289
 
256
290
  export async function canUseGenericCredentials(
@@ -268,17 +302,35 @@ export async function canUseGenericCredentials(
268
302
  return Boolean(key);
269
303
  }
270
304
 
271
- export async function shouldTryGenericOpenAICompat(
272
- authStorage: AuthStorage,
273
- ctx: ActiveSearchModelContext | undefined,
274
- sessionId?: string,
275
- signal?: AbortSignal,
276
- ): Promise<boolean> {
277
- if (!ctx || ctx.webSearch === "off" || !isOpenAICompatWire(ctx.api)) return false;
278
- const autoAllowed =
279
- ctx.webSearch === "on" ||
280
- ((ctx.api === "openai-responses" || looksOpenAIFamilyModelId(ctx)) && !isLocalBaseUrl(ctx.baseUrl));
281
- return autoAllowed && (await canUseGenericCredentials(authStorage, ctx, sessionId, signal));
305
+ /**
306
+ * Native web-search provider to attempt by reusing the ACTIVE model's own
307
+ * credentials + baseUrl, dispatched by the model's wire protocol.
308
+ *
309
+ * This is the "native search over a proxy" path: when a model is served through
310
+ * a proxy/custom endpoint, its canonical search credentials (e.g. a dedicated
311
+ * `anthropic` key, or ChatGPT OAuth for `codex`) are usually absent, but the
312
+ * credential that authenticates the model itself — stored under the active
313
+ * provider id and aimed at `ctx.baseUrl` — can drive native web search just as
314
+ * well. Each provider's `search()` falls back to those active credentials when
315
+ * its canonical ones are missing.
316
+ *
317
+ * Returned ids are matched purely from the wire `api` (+ model-id family where a
318
+ * native tool only makes sense for that family); the providers themselves fail
319
+ * closed (and the chain falls through to DuckDuckGo) if the endpoint does not
320
+ * actually support web search.
321
+ */
322
+ export function activeContextNativeId(ctx: ActiveSearchModelContext | undefined): SearchProviderId | undefined {
323
+ if (!ctx || ctx.webSearch === "off") return undefined;
324
+ const modelId = (ctx.wireModelId ?? ctx.modelId).toLowerCase();
325
+ // Dispatch must match exactly what each provider can service by reusing the
326
+ // active credential: the OpenAI-compatible adapter only speaks the two plain
327
+ // OpenAI wires (not azure), and the Gemini active path only speaks the public
328
+ // Generative Language wire (not vertex/cloud-code). Returning an id the
329
+ // provider would reject just wastes a guaranteed-fail attempt before DuckDuckGo.
330
+ if (isAnthropicWire(ctx.api) && modelId.startsWith("claude-")) return "anthropic";
331
+ if (ctx.api === "openai-responses" || ctx.api === "openai-completions") return "openai-compatible";
332
+ if (ctx.api === "google-generative-ai" && modelId.startsWith("gemini-")) return "gemini";
333
+ return undefined;
282
334
  }
283
335
 
284
336
  export async function resolveProviderChain(options: ResolveProviderChainOptions): Promise<SearchProvider[]> {
@@ -304,9 +356,16 @@ export async function resolveProviderChain(options: ResolveProviderChainOptions)
304
356
  await appendAvailable(chain, directId, authStorage);
305
357
  const inferred = inferNativeProviderFromModel(activeModelContext);
306
358
  if (inferred) await appendAvailable(chain, inferred, authStorage);
307
- const hasNativeXai = chain.includes("xai");
308
- if (!hasNativeXai && (await shouldTryGenericOpenAICompat(authStorage, activeModelContext, sessionId, signal)))
309
- appendDeduped(chain, "openai-compatible");
359
+ // Native-over-proxy: when no canonical native provider was selected above,
360
+ // fall back to the model's own credentials (resolved under the active
361
+ // provider id against its baseUrl) to drive native web search. Gated on
362
+ // those credentials actually resolving; otherwise the chain ends at the
363
+ // keyless DuckDuckGo terminal fallback.
364
+ if (chain.length === 0) {
365
+ const activeNativeId = activeContextNativeId(activeModelContext);
366
+ if (activeNativeId && (await canUseGenericCredentials(authStorage, activeModelContext, sessionId, signal)))
367
+ chain.push(activeNativeId);
368
+ }
310
369
  }
311
370
 
312
371
  // Configured fallbacks are user-facing only: the internal `openai-compatible`
@@ -25,6 +25,7 @@ import type {
25
25
  import { SearchProviderError } from "../../../web/search/types";
26
26
  import type { SearchParams } from "./base";
27
27
  import { SearchProvider } from "./base";
28
+ import { extractTextSources } from "./text-citations";
28
29
  import { classifyProviderHttpError, withHardTimeout } from "./utils";
29
30
 
30
31
  const DEFAULT_MODEL = "claude-haiku-4-5";
@@ -87,9 +88,10 @@ async function callSearch(
87
88
  maxTokens?: number,
88
89
  temperature?: number,
89
90
  signal?: AbortSignal,
91
+ extraHeaders?: Record<string, string>,
90
92
  ): Promise<AnthropicApiResponse> {
91
93
  const url = buildAnthropicUrl(auth);
92
- const headers = buildAnthropicSearchHeaders(auth);
94
+ const headers = { ...(extraHeaders ?? {}), ...buildAnthropicSearchHeaders(auth) };
93
95
 
94
96
  const systemBlocks = buildSystemBlocks(auth, model, systemPrompt);
95
97
 
@@ -191,7 +193,7 @@ function parseResponse(response: AnthropicApiResponse): SearchResponse {
191
193
  if (block.input?.query) {
192
194
  searchQueries.push(block.input.query);
193
195
  }
194
- } else if (block.type === "web_search_tool_result" && block.content) {
196
+ } else if (block.type === "web_search_tool_result" && Array.isArray(block.content)) {
195
197
  // Search results
196
198
  for (const result of block.content) {
197
199
  if (result.type === "web_search_result") {
@@ -235,6 +237,34 @@ function parseResponse(response: AnthropicApiResponse): SearchResponse {
235
237
  };
236
238
  }
237
239
 
240
+ /**
241
+ * Whether the response carries proof that a web search actually ran: a
242
+ * `web_search_tool_result` block, a `web_search` server tool call, or a
243
+ * non-zero `server_tool_use.web_search_requests` usage counter.
244
+ */
245
+ function anthropicSearchPerformed(response: AnthropicApiResponse): boolean {
246
+ if (response.usage?.server_tool_use?.web_search_requests) return true;
247
+ for (const block of response.content ?? []) {
248
+ if (block.type === "web_search_tool_result") {
249
+ // `content` is an array of results on success but an error OBJECT
250
+ // (`web_search_tool_result_error`) on failure; only count a result
251
+ // array with at least one real result as proof of search.
252
+ if (Array.isArray(block.content) && block.content.some(result => result.type === "web_search_result")) {
253
+ return true;
254
+ }
255
+ continue;
256
+ }
257
+ if (
258
+ block.type === "server_tool_use" &&
259
+ block.name &&
260
+ stripClaudeToolPrefix(block.name) === WEB_SEARCH_TOOL_NAME
261
+ ) {
262
+ return true;
263
+ }
264
+ }
265
+ return false;
266
+ }
267
+
238
268
  /**
239
269
  * Executes a web search using Anthropic's Anthropic model with built-in web search tool.
240
270
  * @param params - Search parameters including query and optional settings
@@ -248,6 +278,10 @@ export async function searchAnthropic(
248
278
  const searchApiKey = $env.ANTHROPIC_SEARCH_API_KEY;
249
279
  const searchBaseUrl = $env.ANTHROPIC_SEARCH_BASE_URL;
250
280
  let auth: AnthropicAuthConfig | undefined;
281
+ // When reusing the active model's own credentials (native search over a
282
+ // proxy), prefer its wire model id and carry its request headers through.
283
+ let modelOverride: string | undefined;
284
+ let extraHeaders: Record<string, string> | undefined;
251
285
 
252
286
  if (searchApiKey) {
253
287
  auth = buildAnthropicAuthConfig(searchApiKey, searchBaseUrl);
@@ -256,6 +290,23 @@ export async function searchAnthropic(
256
290
  signal: params.signal,
257
291
  });
258
292
  if (apiKey) auth = buildAnthropicAuthConfig(apiKey);
293
+
294
+ // Fall back to the active model's own credentials + baseUrl when no
295
+ // canonical Anthropic key exists but the active model speaks the
296
+ // Anthropic wire (e.g. Claude served through a proxy).
297
+ const ctx = params.activeModelContext;
298
+ if (!auth && ctx && ctx.api === "anthropic-messages") {
299
+ const ctxKey = await params.authStorage.getApiKey(ctx.provider, params.sessionId, {
300
+ baseUrl: ctx.baseUrl,
301
+ modelId: ctx.modelId,
302
+ signal: params.signal,
303
+ });
304
+ if (ctxKey) {
305
+ auth = buildAnthropicAuthConfig(ctxKey, ctx.baseUrl);
306
+ modelOverride = ctx.wireModelId ?? ctx.modelId;
307
+ extraHeaders = ctx.headers;
308
+ }
309
+ }
259
310
  }
260
311
 
261
312
  if (!auth) {
@@ -264,7 +315,7 @@ export async function searchAnthropic(
264
315
  );
265
316
  }
266
317
 
267
- const model = getModel();
318
+ const model = modelOverride ?? getModel();
268
319
  const systemPrompt = "authStorage" in params ? params.systemPrompt : params.system_prompt;
269
320
  const maxTokens = "authStorage" in params ? params.maxOutputTokens : params.max_tokens;
270
321
  const response = await callSearch(
@@ -275,9 +326,25 @@ export async function searchAnthropic(
275
326
  maxTokens,
276
327
  params.temperature,
277
328
  params.signal,
329
+ extraHeaders,
278
330
  );
279
331
 
280
332
  const result = parseResponse(response);
333
+ const searched = anthropicSearchPerformed(response);
334
+
335
+ // When a search ran but the model wrote its citations inline instead of as
336
+ // structured `web_search_result_location` blocks, recover sources from the
337
+ // answer text so a genuinely grounded result is not discarded.
338
+ if (result.sources.length === 0 && searched && result.answer) {
339
+ const inline = extractTextSources(result.answer);
340
+ if (inline.length > 0) result.sources = inline;
341
+ }
342
+
343
+ // Fail closed so the chain falls through to DuckDuckGo when Claude answered
344
+ // from stable knowledge without running a web search.
345
+ if (result.sources.length === 0 && !(result.citations && result.citations.length > 0) && !searched) {
346
+ throw new SearchProviderError("anthropic", "Anthropic web search returned no grounded sources", 424);
347
+ }
281
348
 
282
349
  const numResults = "authStorage" in params ? (params.numSearchResults ?? params.limit) : params.num_results;
283
350
  if (numResults && result.sources.length > numResults) {
@@ -15,6 +15,7 @@ import type { SearchResponse, SearchSource } from "../../../web/search/types";
15
15
  import { SearchProviderError } from "../../../web/search/types";
16
16
  import type { SearchParams } from "./base";
17
17
  import { SearchProvider } from "./base";
18
+ import { addSource, extractTextSources } from "./text-citations";
18
19
  import { classifyProviderHttpError, withHardTimeout } from "./utils";
19
20
 
20
21
  const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
@@ -118,125 +119,6 @@ function isImagePlaceholderAnswer(text: string): boolean {
118
119
  return text.trim().toLowerCase() === "(see attached image)";
119
120
  }
120
121
 
121
- function addSource(sources: SearchSource[], source: SearchSource): void {
122
- if (!sources.some(existing => existing.url === source.url)) {
123
- sources.push(source);
124
- }
125
- }
126
-
127
- function countCharacter(text: string, target: string): number {
128
- let count = 0;
129
- for (const char of text) {
130
- if (char === target) {
131
- count += 1;
132
- }
133
- }
134
- return count;
135
- }
136
-
137
- /**
138
- * Strips prose punctuation and unmatched closing delimiters from extracted URLs.
139
- * OpenAI code backend often returns links in markdown or sentence text without structured annotations.
140
- */
141
- function normalizeExtractedUrl(candidate: string): string | null {
142
- let url = candidate.trim();
143
-
144
- while (url.length > 0) {
145
- const lastCharacter = url.at(-1);
146
- if (!lastCharacter) break;
147
- if (/[.,!?;:'"]/u.test(lastCharacter)) {
148
- url = url.slice(0, -1);
149
- continue;
150
- }
151
- if (lastCharacter === ")" && countCharacter(url, ")") > countCharacter(url, "(")) {
152
- url = url.slice(0, -1);
153
- continue;
154
- }
155
- if (lastCharacter === "]" && countCharacter(url, "]") > countCharacter(url, "[")) {
156
- url = url.slice(0, -1);
157
- continue;
158
- }
159
- if (lastCharacter === "}" && countCharacter(url, "}") > countCharacter(url, "{")) {
160
- url = url.slice(0, -1);
161
- continue;
162
- }
163
- break;
164
- }
165
-
166
- if (!/^https?:\/\//.test(url)) {
167
- return null;
168
- }
169
-
170
- try {
171
- return new URL(url).toString();
172
- } catch {
173
- return null;
174
- }
175
- }
176
-
177
- function findMarkdownLinkUrlEnd(text: string, openParenIndex: number): number | null {
178
- let depth = 0;
179
-
180
- for (let index = openParenIndex; index < text.length; index += 1) {
181
- const character = text[index];
182
- if (!character || character === "\n") {
183
- return null;
184
- }
185
- if (character === "(") {
186
- depth += 1;
187
- continue;
188
- }
189
- if (character !== ")") {
190
- continue;
191
- }
192
- depth -= 1;
193
- if (depth === 0) {
194
- return index;
195
- }
196
- if (depth < 0) {
197
- return null;
198
- }
199
- }
200
-
201
- return null;
202
- }
203
-
204
- /**
205
- * Extracts citation sources from markdown links and bare URLs in the answer text.
206
- * Used as a fallback when the OpenAI code backend response omits `url_citation` annotations.
207
- */
208
- function extractTextSources(text: string): SearchSource[] {
209
- const sources: SearchSource[] = [];
210
-
211
- for (let index = 0; index < text.length; index += 1) {
212
- if (text[index] !== "[") {
213
- continue;
214
- }
215
- const titleEnd = text.indexOf("]", index + 1);
216
- if (titleEnd === -1 || text[titleEnd + 1] !== "(") {
217
- continue;
218
- }
219
- const urlEnd = findMarkdownLinkUrlEnd(text, titleEnd + 1);
220
- if (urlEnd === null) {
221
- continue;
222
- }
223
- const title = text.slice(index + 1, titleEnd).trim();
224
- const url = normalizeExtractedUrl(text.slice(titleEnd + 2, urlEnd));
225
- if (url) {
226
- addSource(sources, { title: title || url, url });
227
- }
228
- index = urlEnd;
229
- }
230
-
231
- for (const match of text.matchAll(/https?:\/\/\S+/g)) {
232
- const url = normalizeExtractedUrl(match[0] ?? "");
233
- if (!url) continue;
234
- addSource(sources, { title: url, url });
235
- }
236
-
237
- return sources;
238
- }
239
-
240
122
  /**
241
123
  * Extracts account ID from a OpenAI code backend access token.
242
124
  * @param accessToken - JWT access token
@@ -425,6 +425,98 @@ export async function searchGemini(params: GeminiSearchParams): Promise<SearchRe
425
425
  };
426
426
  }
427
427
 
428
+ /**
429
+ * Native Gemini web search over the public Generative Language REST API
430
+ * (`{baseUrl}/v1beta/models/{model}:generateContent`), reusing the ACTIVE
431
+ * model's own API key + baseUrl. This is the "native search over a proxy" path
432
+ * for `google-generative-ai` wire models whose canonical gemini-cli/antigravity
433
+ * OAuth is absent. Distinct from {@link searchGemini}, which speaks the Cloud
434
+ * Code Assist API with OAuth.
435
+ */
436
+ async function searchGeminiViaGenerativeLanguage(params: SearchParams): Promise<SearchResponse> {
437
+ const ctx = params.activeModelContext;
438
+ if (!ctx) throw new SearchProviderError("gemini", "Gemini web search requires active model context", 400);
439
+ const apiKey = await params.authStorage.getApiKey(ctx.provider, params.sessionId, {
440
+ baseUrl: ctx.baseUrl,
441
+ modelId: ctx.modelId,
442
+ signal: params.signal,
443
+ });
444
+ if (!apiKey) throw new SearchProviderError("gemini", `No credentials for ${ctx.provider}`, 401);
445
+
446
+ const model = ctx.wireModelId ?? ctx.modelId;
447
+ const base = (ctx.baseUrl ?? "https://generativelanguage.googleapis.com").replace(/\/+$/, "");
448
+ // Respect an already-versioned active baseUrl (e.g. a proxy exposing `…/v1beta`)
449
+ // instead of double-appending the version segment.
450
+ const versionedBase = /\/v1(beta|alpha)?$/.test(base) ? base : `${base}/v1beta`;
451
+ const url = `${versionedBase}/models/${encodeURIComponent(model)}:generateContent`;
452
+ const systemPrompt = params.systemPrompt?.toWellFormed();
453
+ const body: Record<string, unknown> = {
454
+ contents: [{ role: "user", parts: [{ text: params.query }] }],
455
+ tools: buildGeminiRequestTools({ google_search: params.googleSearch }),
456
+ ...(systemPrompt ? { systemInstruction: { parts: [{ text: systemPrompt }] } } : {}),
457
+ };
458
+ if (params.maxOutputTokens !== undefined || params.temperature !== undefined) {
459
+ const generationConfig: Record<string, number> = {};
460
+ if (params.maxOutputTokens !== undefined) generationConfig.maxOutputTokens = params.maxOutputTokens;
461
+ if (params.temperature !== undefined) generationConfig.temperature = params.temperature;
462
+ body.generationConfig = generationConfig;
463
+ }
464
+
465
+ const response = await fetch(url, {
466
+ method: "POST",
467
+ headers: { ...(ctx.headers ?? {}), "x-goog-api-key": apiKey, "Content-Type": "application/json" },
468
+ body: JSON.stringify(body),
469
+ signal: withHardTimeout(params.signal),
470
+ });
471
+ const text = await response.text();
472
+ if (!response.ok) {
473
+ const classified = classifyProviderHttpError("gemini", response.status, text);
474
+ if (classified) throw classified;
475
+ throw new SearchProviderError("gemini", `Gemini API error (${response.status}): ${text}`, response.status);
476
+ }
477
+
478
+ const json = text ? JSON.parse(text) : {};
479
+ const candidate = json.candidates?.[0];
480
+ const grounding: GeminiGroundingMetadata | undefined = candidate?.groundingMetadata;
481
+ const answer = (candidate?.content?.parts ?? []).map((part: { text?: string }) => part.text ?? "").join("");
482
+
483
+ const sources: SearchSource[] = [];
484
+ const citations: SearchCitation[] = [];
485
+ const searchQueries: string[] = [];
486
+ const seenUrls = new Set<string>();
487
+ const chunks = grounding?.groundingChunks ?? [];
488
+ for (const grChunk of chunks) {
489
+ const uri = grChunk.web?.uri;
490
+ if (uri && !seenUrls.has(uri)) {
491
+ seenUrls.add(uri);
492
+ sources.push({ title: grChunk.web?.title ?? uri, url: uri });
493
+ }
494
+ }
495
+ for (const support of grounding?.groundingSupports ?? []) {
496
+ const citedText = support.segment?.text;
497
+ for (const idx of support.groundingChunkIndices ?? []) {
498
+ const uri = chunks[idx]?.web?.uri;
499
+ if (uri) citations.push({ url: uri, title: chunks[idx]?.web?.title ?? uri, citedText });
500
+ }
501
+ }
502
+ for (const q of grounding?.webSearchQueries ?? []) {
503
+ if (!searchQueries.includes(q)) searchQueries.push(q);
504
+ }
505
+
506
+ if (sources.length === 0) {
507
+ throw new SearchProviderError("gemini", "Gemini native search returned no grounding sources", 424);
508
+ }
509
+ const limit = params.numSearchResults ?? params.limit;
510
+ return {
511
+ provider: "gemini",
512
+ answer: answer || undefined,
513
+ sources: limit && sources.length > limit ? sources.slice(0, limit) : sources,
514
+ citations: citations.length > 0 ? citations : undefined,
515
+ searchQueries: searchQueries.length > 0 ? searchQueries : undefined,
516
+ model: json.modelVersion ?? model,
517
+ };
518
+ }
519
+
428
520
  /** Search provider for Google Gemini web search. */
429
521
  export class GeminiProvider extends SearchProvider {
430
522
  readonly id = "gemini";
@@ -438,6 +530,13 @@ export class GeminiProvider extends SearchProvider {
438
530
  }
439
531
 
440
532
  search(params: SearchParams): Promise<SearchResponse> {
533
+ // Native-over-proxy: when canonical gemini-cli/antigravity OAuth is
534
+ // absent but the active model speaks the Generative Language wire, reuse
535
+ // its own API key + baseUrl instead of failing closed.
536
+ const ctx = params.activeModelContext;
537
+ if (!hasGeminiOAuth(params.authStorage) && ctx?.api === "google-generative-ai") {
538
+ return searchGeminiViaGenerativeLanguage(params);
539
+ }
441
540
  return searchGemini({
442
541
  query: params.query,
443
542
  system_prompt: params.systemPrompt,