@bitkyc08/opencodex 2.7.9-preview.20260712.1 → 2.7.9

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 (39) hide show
  1. package/README.md +3 -1
  2. package/gui/dist/assets/index-BAAFKwsh.js +40 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +2 -2
  5. package/src/adapters/cursor/transport-retry.ts +5 -3
  6. package/src/adapters/google-errors.ts +9 -19
  7. package/src/adapters/google-http.ts +29 -66
  8. package/src/adapters/kiro-errors.ts +10 -23
  9. package/src/adapters/kiro-retry.ts +26 -58
  10. package/src/adapters/upstream-http-error.ts +48 -0
  11. package/src/bridge.ts +6 -2
  12. package/src/claude/gateway-cache.ts +3 -3
  13. package/src/claude/outbound.ts +117 -40
  14. package/src/cli/claude.ts +36 -4
  15. package/src/config.ts +54 -3
  16. package/src/lib/destination-policy.ts +167 -0
  17. package/src/lib/injection-debug-log.ts +34 -0
  18. package/src/lib/upstream-retry.ts +53 -3
  19. package/src/lib/windows-secret-acl.ts +173 -0
  20. package/src/oauth/index.ts +9 -7
  21. package/src/oauth/store.ts +1 -0
  22. package/src/providers/registry.ts +10 -3
  23. package/src/providers/xai-transport.ts +89 -0
  24. package/src/router.ts +6 -1
  25. package/src/server/auth-cors.ts +4 -0
  26. package/src/server/claude-messages.ts +32 -2
  27. package/src/server/management-api.ts +159 -33
  28. package/src/server/request-decompress.ts +45 -12
  29. package/src/server/responses.ts +21 -12
  30. package/src/server/system-env.ts +110 -68
  31. package/src/service.ts +4 -0
  32. package/src/types.ts +25 -5
  33. package/src/vision/anthropic-describe.ts +185 -0
  34. package/src/vision/index.ts +219 -10
  35. package/src/web-search/anthropic-executor.ts +187 -0
  36. package/src/web-search/executor.ts +4 -2
  37. package/src/web-search/index.ts +80 -18
  38. package/src/web-search/loop.ts +14 -2
  39. package/gui/dist/assets/index-Csp2AZYr.js +0 -40
@@ -54,7 +54,7 @@ export function anthropicErrorResponse(status: number, message: string, type?: s
54
54
  * subtract the full cache detail (devlog 070 — subtracting reads only inflated the
55
55
  * non-cached input Claude Code displays by the write share).
56
56
  */
57
- export function anthropicUsage(usage: unknown): Rec {
57
+ export function anthropicUsage(usage: unknown, webSearchRequests = 0): Rec {
58
58
  const u = isRec(usage) ? usage : {};
59
59
  const details = isRec(u.input_tokens_details) ? u.input_tokens_details : {};
60
60
  const cached = typeof details.cached_tokens === "number" ? details.cached_tokens : 0;
@@ -66,6 +66,8 @@ export function anthropicUsage(usage: unknown): Rec {
66
66
  output_tokens: output,
67
67
  cache_read_input_tokens: cached,
68
68
  cache_creation_input_tokens: cacheWrite,
69
+ // Only successful searches are billed/counted (Anthropic contract; Claude Code cost accounting).
70
+ ...(webSearchRequests > 0 ? { server_tool_use: { web_search_requests: webSearchRequests } } : {}),
69
71
  };
70
72
  }
71
73
 
@@ -73,6 +75,37 @@ function sseFrame(name: string, data: Rec): string {
73
75
  return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
74
76
  }
75
77
 
78
+ /**
79
+ * Map a Responses `web_search_call` item to its Anthropic pair: the server_tool_use
80
+ * input (query/queries) and the web_search_tool_result content (hits, or the error
81
+ * object when the search failed). Shared by the SSE and JSON translation paths.
82
+ */
83
+ function webSearchPairFromItem(item: Rec): { id: string; input: Rec; resultContent: unknown; completed: boolean } {
84
+ const action = isRec(item.action) ? item.action : {};
85
+ const queries = Array.isArray(action.queries)
86
+ ? action.queries.filter((q): q is string => typeof q === "string" && q.length > 0)
87
+ : [];
88
+ const query = typeof action.query === "string" ? action.query : "";
89
+ const input: Rec = queries.length > 1 ? { queries } : { query: queries[0] ?? query };
90
+ const completed = item.status !== "failed";
91
+ let resultContent: unknown;
92
+ if (completed) {
93
+ const hits: Rec[] = [];
94
+ if (Array.isArray(item.sources)) {
95
+ for (const s of item.sources) {
96
+ if (isRec(s) && typeof s.url === "string" && s.url.length > 0) {
97
+ hits.push({ type: "web_search_result", title: typeof s.title === "string" ? s.title : "", url: s.url });
98
+ }
99
+ }
100
+ }
101
+ resultContent = hits;
102
+ } else {
103
+ resultContent = { type: "web_search_tool_result_error", error_code: "unavailable" };
104
+ }
105
+ const id = typeof item.id === "string" && item.id.length > 0 ? item.id : `srvtoolu_${uuid()}`;
106
+ return { id, input, resultContent, completed };
107
+ }
108
+
76
109
  function messageSnapshot(model: string): Rec {
77
110
  return {
78
111
  id: `msg_${uuid()}`,
@@ -105,13 +138,16 @@ export function responsesSseToAnthropicSse(
105
138
  let buffer = "";
106
139
  let started = false;
107
140
  let terminated = false;
141
+ let cancelled = false;
108
142
  let blockIndex = 0;
109
143
  let open: OpenBlock | null = null;
110
144
  let sawToolUse = false;
145
+ let webSearchRequests = 0;
111
146
  let pingTimer: ReturnType<typeof setInterval> | undefined;
147
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
112
148
 
113
149
  return new ReadableStream<Uint8Array>({
114
- async start(controller) {
150
+ start(controller) {
115
151
  const emit = (name: string, data: Rec) => controller.enqueue(encoder.encode(sseFrame(name, data)));
116
152
  const ensureStarted = () => {
117
153
  if (started) return;
@@ -162,7 +198,7 @@ export function responsesSseToAnthropicSse(
162
198
  emit("message_delta", {
163
199
  type: "message_delta",
164
200
  delta: { stop_reason: stopReason, stop_sequence: null },
165
- usage: anthropicUsage(usage),
201
+ usage: anthropicUsage(usage, webSearchRequests),
166
202
  });
167
203
  emit("message_stop", { type: "message_stop" });
168
204
  };
@@ -232,7 +268,35 @@ export function responsesSseToAnthropicSse(
232
268
  }
233
269
  case "response.output_item.done": {
234
270
  const item = isRec(data.item) ? data.item : null;
235
- if (!open || !item) break;
271
+ if (!item) break;
272
+ // Server-side web search (native passthrough or sidecar bridge): translate the
273
+ // finished call into the Anthropic pair Claude Code natively parses —
274
+ // server_tool_use (query via input_json_delta) + web_search_tool_result.
275
+ // Never marks sawToolUse (stop_reason stays end_turn unless a real tool ran).
276
+ if (item.type === "web_search_call") {
277
+ ensureStarted();
278
+ closeOpenBlock();
279
+ const pair = webSearchPairFromItem(item);
280
+ const toolIndex = blockIndex++;
281
+ emit("content_block_start", {
282
+ type: "content_block_start", index: toolIndex,
283
+ content_block: { type: "server_tool_use", id: pair.id, name: "web_search" },
284
+ });
285
+ emit("content_block_delta", {
286
+ type: "content_block_delta", index: toolIndex,
287
+ delta: { type: "input_json_delta", partial_json: JSON.stringify(pair.input) },
288
+ });
289
+ emit("content_block_stop", { type: "content_block_stop", index: toolIndex });
290
+ const resultIndex = blockIndex++;
291
+ emit("content_block_start", {
292
+ type: "content_block_start", index: resultIndex,
293
+ content_block: { type: "web_search_tool_result", tool_use_id: pair.id, content: pair.resultContent },
294
+ });
295
+ emit("content_block_stop", { type: "content_block_stop", index: resultIndex });
296
+ if (pair.completed) webSearchRequests++;
297
+ break;
298
+ }
299
+ if (!open) break;
236
300
  // Close the matching open block (message/reasoning items close implicitly on
237
301
  // the next block; function_call items must close here so tool input parses).
238
302
  if (open.kind === "tool_use" && item.type === "function_call") closeOpenBlock();
@@ -267,46 +331,49 @@ export function responsesSseToAnthropicSse(
267
331
  }
268
332
  };
269
333
 
270
- const reader = upstream.getReader();
271
- try {
272
- for (;;) {
273
- const { done, value } = await reader.read();
274
- if (done) break;
275
- buffer += decoder.decode(value, { stream: true });
276
- let sep: number;
277
- while ((sep = buffer.indexOf("\n\n")) !== -1) {
278
- const rawFrame = buffer.slice(0, sep);
279
- buffer = buffer.slice(sep + 2);
280
- let eventName = "";
281
- let dataLine = "";
282
- for (const line of rawFrame.split("\n")) {
283
- if (line.startsWith("event: ")) eventName = line.slice(7).trim();
284
- else if (line.startsWith("data: ")) dataLine += line.slice(6);
334
+ reader = upstream.getReader();
335
+ void (async () => {
336
+ try {
337
+ for (;;) {
338
+ const { done, value } = await reader.read();
339
+ if (done) break;
340
+ buffer += decoder.decode(value, { stream: true });
341
+ let sep: number;
342
+ while ((sep = buffer.indexOf("\n\n")) !== -1) {
343
+ const rawFrame = buffer.slice(0, sep);
344
+ buffer = buffer.slice(sep + 2);
345
+ let eventName = "";
346
+ let dataLine = "";
347
+ for (const line of rawFrame.split("\n")) {
348
+ if (line.startsWith("event: ")) eventName = line.slice(7).trim();
349
+ else if (line.startsWith("data: ")) dataLine += line.slice(6);
350
+ }
351
+ if (!eventName || !dataLine) continue;
352
+ let data: unknown;
353
+ try { data = JSON.parse(dataLine); } catch { continue; }
354
+ if (!isRec(data)) continue;
355
+ if (terminated) continue;
356
+ handleFrame(eventName, data);
285
357
  }
286
- if (!eventName || !dataLine) continue;
287
- let data: unknown;
288
- try { data = JSON.parse(dataLine); } catch { continue; }
289
- if (!isRec(data)) continue;
290
- if (terminated) continue;
291
- handleFrame(eventName, data);
292
358
  }
359
+ // EOF without a terminal frame is a TRUNCATION, not success (devlog 100:
360
+ // gateways that close such streams politely hand Claude Code an empty/partial
361
+ // turn with no retryable error — CLIProxyAPI#2189 failure pattern). Fail closed
362
+ // with a mid-stream Anthropic error event so the client can retry.
363
+ if (!cancelled) fail(502, "upstream stream ended before a terminal frame (truncated response)");
364
+ } catch (err) {
365
+ fail(500, err instanceof Error ? err.message : String(err));
366
+ } finally {
367
+ if (pingTimer !== undefined) clearInterval(pingTimer);
368
+ reader.releaseLock();
369
+ if (!cancelled) controller.close();
293
370
  }
294
- // EOF without a terminal frame is a TRUNCATION, not success (devlog 100:
295
- // gateways that close such streams politely hand Claude Code an empty/partial
296
- // turn with no retryable error — CLIProxyAPI#2189 failure pattern). Fail closed
297
- // with a mid-stream Anthropic error event so the client can retry.
298
- fail(502, "upstream stream ended before a terminal frame (truncated response)");
299
- } catch (err) {
300
- fail(500, err instanceof Error ? err.message : String(err));
301
- } finally {
302
- if (pingTimer !== undefined) clearInterval(pingTimer);
303
- reader.releaseLock();
304
- controller.close();
305
- }
371
+ })();
306
372
  },
307
373
  cancel(reason) {
374
+ cancelled = true;
308
375
  if (pingTimer !== undefined) clearInterval(pingTimer);
309
- return upstream.cancel(reason);
376
+ return reader?.cancel(reason);
310
377
  },
311
378
  });
312
379
  }
@@ -317,6 +384,7 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string): R
317
384
  const output = Array.isArray(body.output) ? body.output : [];
318
385
  const content: Rec[] = [];
319
386
  let sawToolUse = false;
387
+ let webSearchRequests = 0;
320
388
 
321
389
  for (const raw of output) {
322
390
  if (!isRec(raw)) continue;
@@ -361,6 +429,14 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string): R
361
429
  });
362
430
  break;
363
431
  }
432
+ case "web_search_call": {
433
+ // Server-side search: emit the Anthropic pair. Does NOT set sawToolUse.
434
+ const pair = webSearchPairFromItem(raw);
435
+ content.push({ type: "server_tool_use", id: pair.id, name: "web_search", input: pair.input });
436
+ content.push({ type: "web_search_tool_result", tool_use_id: pair.id, content: pair.resultContent });
437
+ if (pair.completed) webSearchRequests++;
438
+ break;
439
+ }
364
440
  default:
365
441
  break;
366
442
  }
@@ -379,7 +455,7 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string): R
379
455
  model,
380
456
  stop_reason: stopReason,
381
457
  stop_sequence: null,
382
- usage: anthropicUsage(body.usage),
458
+ usage: anthropicUsage(body.usage, webSearchRequests),
383
459
  };
384
460
  }
385
461
 
@@ -402,7 +478,8 @@ export async function collectAnthropicMessage(stream: ReadableStream<Uint8Array>
402
478
 
403
479
  const closeBlock = () => {
404
480
  if (!openBlock) return;
405
- if (openBlock.type === "tool_use") {
481
+ // server_tool_use streams its query via input_json_delta exactly like tool_use (audit F3).
482
+ if (openBlock.type === "tool_use" || openBlock.type === "server_tool_use") {
406
483
  try { openBlock.input = toolJson.length > 0 ? JSON.parse(toolJson) : {}; } catch { openBlock.input = {}; }
407
484
  }
408
485
  content.push(openBlock);
package/src/cli/claude.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Mirrors `ccr code` UX (devlog/260711_claude_inbound/020, 003 E1/E2/E5/G1):
5
5
  * ensures the proxy is running, injects the Anthropic env slots, then execs the
6
- * `claude` CLI with stdio inherited. User-exported env always wins.
6
+ * `claude` CLI with stdio inherited. User-exported env wins except when a stale
7
+ * loopback opencodex base URL points at a different proxy port.
7
8
  */
8
9
  import { spawn } from "node:child_process";
9
10
  import { loadConfig } from "../config";
@@ -20,7 +21,8 @@ export interface ClaudeLaunchEnv {
20
21
  /**
21
22
  * Pure env assembly (unit-tested): never sets ANTHROPIC_API_KEY (setting both
22
23
  * token vars triggers Claude Code's auth-conflict warning, 003 E1), and never
23
- * overrides variables the user already exported.
24
+ * overrides variables the user already exported, apart from stale loopback
25
+ * ANTHROPIC_BASE_URL values owned by a previous opencodex launch.
24
26
  */
25
27
  export function buildClaudeEnv(config: OcxConfig, port: number, base: ClaudeLaunchEnv, contextWindows: Record<string, number> = {}): ClaudeLaunchEnv {
26
28
  const env: ClaudeLaunchEnv = { ...base };
@@ -30,6 +32,20 @@ export function buildClaudeEnv(config: OcxConfig, port: number, base: ClaudeLaun
30
32
  env[name] = value;
31
33
  };
32
34
  setDefault("ANTHROPIC_BASE_URL", `http://127.0.0.1:${port}`);
35
+ const existingBaseUrl = env.ANTHROPIC_BASE_URL;
36
+ if (existingBaseUrl) {
37
+ try {
38
+ const parsed = new URL(existingBaseUrl);
39
+ const isLoopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
40
+ if (isLoopback && parsed.port !== "" && Number(parsed.port) !== port) {
41
+ const replacement = `http://127.0.0.1:${port}`;
42
+ console.error(`⚠ Replacing stale opencodex ANTHROPIC_BASE_URL ${existingBaseUrl} with ${replacement}.`);
43
+ env.ANTHROPIC_BASE_URL = replacement;
44
+ }
45
+ } catch {
46
+ // Preserve user-provided values that are not parseable URLs.
47
+ }
48
+ }
33
49
  // Subscription-preserving default (teamclaude --no-mitm / Vercel gateway pattern):
34
50
  // setting ANTHROPIC_AUTH_TOKEN/API_KEY disables claude.ai connectors and overrides
35
51
  // the user's Claude login. Only inject a token when the proxy actually requires an
@@ -137,9 +153,25 @@ export async function cmdClaude(args: string[]): Promise<number> {
137
153
  const env = buildClaudeEnv(config, port, process.env, contextWindows);
138
154
  // Pre-write the CLI's gateway-model cache (devlog 030): without a token the CLI
139
155
  // never refreshes it, so the picker would keep showing yesterday's aliases.
140
- await refreshGatewayModelCacheFromProxy(port);
156
+ try {
157
+ const cachePath = await refreshGatewayModelCacheFromProxy(port);
158
+ if (cachePath === null) {
159
+ console.error("⚠ Gateway model cache could not be refreshed; the model picker may be stale.");
160
+ }
161
+ } catch (error) {
162
+ const message = error instanceof Error ? error.message : String(error);
163
+ console.error(`⚠ Gateway model cache could not be refreshed: ${message}`);
164
+ }
141
165
  // Sync roster agents (devlog 070): subagentModels + self -> ~/.claude/agents/ocx-*.md.
142
- injectClaudeAgentDefs(config, contextWindows);
166
+ try {
167
+ const written = injectClaudeAgentDefs(config, contextWindows);
168
+ if (written === null) {
169
+ console.error("⚠ Claude agent definitions could not be synced; check ~/.claude/agents permissions.");
170
+ }
171
+ } catch (error) {
172
+ const message = error instanceof Error ? error.message : String(error);
173
+ console.error(`⚠ Claude agent definitions could not be synced: ${message}`);
174
+ }
143
175
  return await new Promise<number>(resolve => {
144
176
  const child = spawn("claude", args, { stdio: "inherit", env: env as NodeJS.ProcessEnv });
145
177
  child.on("error", (err: NodeJS.ErrnoException) => {
package/src/config.ts CHANGED
@@ -3,9 +3,41 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSy
3
3
  import { homedir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
  import * as z from "zod/v4";
6
+ import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
7
+ import { providerDestinationConfigError } from "./lib/destination-policy";
6
8
  import type { OcxConfig } from "./types";
7
9
 
8
10
  let _atomicSeq = 0;
11
+
12
+ interface AtomicRenameIO {
13
+ platform: NodeJS.Platform;
14
+ rename: (source: string, destination: string) => void;
15
+ sleep: (milliseconds: number) => void;
16
+ }
17
+
18
+ export function renameAtomicFile(
19
+ source: string,
20
+ destination: string,
21
+ io: AtomicRenameIO = {
22
+ platform: process.platform,
23
+ rename: renameSync,
24
+ sleep: Bun.sleepSync,
25
+ },
26
+ ): void {
27
+ for (let attempt = 0; ; attempt += 1) {
28
+ try {
29
+ io.rename(source, destination);
30
+ return;
31
+ } catch (error) {
32
+ const code = (error as NodeJS.ErrnoException).code;
33
+ const transientWindowsError = io.platform === "win32"
34
+ && (code === "EBUSY" || code === "EPERM" || code === "EACCES");
35
+ if (!transientWindowsError || attempt >= 2) throw error;
36
+ io.sleep(25 * (attempt + 1));
37
+ }
38
+ }
39
+ }
40
+
9
41
  /**
10
42
  * Write a file atomically (temp + rename) so concurrent writers — e.g. `ocx stop` and the
11
43
  * proxy's own shutdown handler both restoring Codex — can never leave a half-written file.
@@ -13,7 +45,7 @@ let _atomicSeq = 0;
13
45
  export function atomicWriteFile(path: string, content: string): void {
14
46
  const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
15
47
  writeFileSync(tmp, content, { encoding: "utf-8", mode: 0o600 });
16
- renameSync(tmp, path);
48
+ renameAtomicFile(tmp, path);
17
49
  }
18
50
 
19
51
  /**
@@ -54,6 +86,7 @@ const warnedConfigFallbacks = new Set<string>();
54
86
  const providerConfigSchema = z.object({
55
87
  adapter: z.string().min(1),
56
88
  baseUrl: z.string().min(1),
89
+ allowPrivateNetwork: z.boolean().optional(),
57
90
  }).passthrough();
58
91
 
59
92
  const RESERVED_PROVIDER_NAMES = new Set(["__proto__", "prototype", "constructor"]);
@@ -128,6 +161,15 @@ const configSchema = z.object({
128
161
  path: ["providers", name, "baseUrl"],
129
162
  message: baseUrlError,
130
163
  });
164
+ } else {
165
+ const destinationError = providerDestinationConfigError(name, provider);
166
+ if (destinationError) {
167
+ ctx.addIssue({
168
+ code: "custom",
169
+ path: ["providers", name, "baseUrl"],
170
+ message: destinationError,
171
+ });
172
+ }
131
173
  }
132
174
  const headersError = providerHeadersConfigError((provider as { headers?: unknown }).headers);
133
175
  if (headersError) {
@@ -178,15 +220,20 @@ export function hardenConfigDir(): void {
178
220
  const dir = getConfigDir();
179
221
  if (existsSync(dir)) {
180
222
  try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
223
+ if (process.platform === "win32") {
224
+ hardenSecretDir(dir, { required: false });
225
+ }
181
226
  }
182
227
  }
183
228
 
184
229
  export function hardenExistingSecret(path: string): void {
185
230
  if (existsSync(path)) {
186
231
  try { chmodSync(path, 0o600); } catch { /* best-effort */ }
232
+ if (process.platform === "win32") {
233
+ hardenSecretPath(path, { required: false });
234
+ }
187
235
  }
188
236
  }
189
-
190
237
  export function loadConfig(): OcxConfig {
191
238
  const dir = getConfigDir();
192
239
  const configPath = getConfigPath();
@@ -305,7 +352,11 @@ export function saveConfig(config: OcxConfig): void {
305
352
  } else {
306
353
  try { chmodSync(dir, 0o700); } catch { /* best-effort on existing dir */ }
307
354
  }
308
- atomicWriteFile(getConfigPath(), JSON.stringify(config, null, 2) + "\n");
355
+ if (process.platform === "win32") {
356
+ hardenSecretDir(dir, { required: true });
357
+ }
358
+ const configPath = getConfigPath();
359
+ atomicWriteFile(configPath, JSON.stringify(config, null, 2) + "\n");
309
360
  }
310
361
 
311
362
  export function websocketsEnabled(config: Pick<OcxConfig, "websockets">): boolean {
@@ -0,0 +1,167 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { isIP } from "node:net";
3
+ import { getProviderRegistryEntry } from "../providers/registry";
4
+ import type { OcxProviderConfig } from "../types";
5
+
6
+ const BLOCKED_METADATA_HOSTS = new Set([
7
+ "instance-data.ec2.internal",
8
+ "metadata.azure.internal",
9
+ "metadata.google.internal",
10
+ ]);
11
+
12
+ const BLOCKED_METADATA_IPV4 = new Set([
13
+ "100.100.100.200",
14
+ "169.254.169.254",
15
+ "169.254.170.2",
16
+ ]);
17
+
18
+ const BLOCKED_METADATA_IPV6 = new Set([
19
+ "fd00:ec2::254",
20
+ ]);
21
+
22
+ type DestinationKind =
23
+ | "public"
24
+ | "hostname"
25
+ | "localhost"
26
+ | "loopback"
27
+ | "private"
28
+ | "link-local"
29
+ | "unspecified"
30
+ | "metadata";
31
+
32
+ interface DestinationAssessment {
33
+ kind: DestinationKind;
34
+ detail: string;
35
+ }
36
+
37
+ function normalizeHostname(hostname: string): string {
38
+ const trimmed = hostname.trim().toLowerCase().replace(/\.+$/, "");
39
+ return trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
40
+ }
41
+
42
+ function parseIpv4(hostname: string): number[] | null {
43
+ const parts = hostname.split(".");
44
+ if (parts.length !== 4) return null;
45
+ const octets = parts.map(part => Number(part));
46
+ return octets.every(octet => Number.isInteger(octet) && octet >= 0 && octet <= 255) ? octets : null;
47
+ }
48
+
49
+ function classifyIpv4(hostname: string): DestinationAssessment {
50
+ if (BLOCKED_METADATA_IPV4.has(hostname)) return { kind: "metadata", detail: "blocked metadata endpoint" };
51
+ const octets = parseIpv4(hostname);
52
+ if (!octets) return { kind: "public", detail: "public IP" };
53
+ const [a, b, c] = octets;
54
+ if (a === 127) return { kind: "loopback", detail: "loopback address" };
55
+ if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127)) {
56
+ return { kind: "private", detail: "private-network address" };
57
+ }
58
+ if (a === 169 && b === 254) return { kind: "link-local", detail: "link-local address" };
59
+ if (a === 0) return { kind: "unspecified", detail: "unspecified address" };
60
+ // Reserved / non-public ranges (review finding, PR #96): protocol-assignment,
61
+ // documentation, benchmark, multicast, and reserved-future space never name a
62
+ // legitimate provider endpoint.
63
+ if (a === 192 && b === 0 && (c === 0 || c === 2)) return { kind: "private", detail: "reserved address" };
64
+ if (a === 198 && (b === 18 || b === 19)) return { kind: "private", detail: "benchmark address" };
65
+ if (a === 198 && b === 51 && c === 100) return { kind: "private", detail: "documentation address" };
66
+ if (a === 203 && b === 0 && c === 113) return { kind: "private", detail: "documentation address" };
67
+ if (a >= 224) return { kind: "private", detail: "multicast/reserved address" };
68
+ return { kind: "public", detail: "public IP" };
69
+ }
70
+
71
+ function firstIpv6Hextet(hostname: string): number | null {
72
+ const head = hostname.split(":")[0];
73
+ if (!head) return 0;
74
+ const parsed = Number.parseInt(head, 16);
75
+ return Number.isNaN(parsed) ? null : parsed;
76
+ }
77
+
78
+ function classifyIpv6(hostname: string): DestinationAssessment {
79
+ if (BLOCKED_METADATA_IPV6.has(hostname)) return { kind: "metadata", detail: "blocked metadata endpoint" };
80
+ const mappedIpv4 = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i)?.[1];
81
+ if (mappedIpv4) return classifyIpv4(mappedIpv4);
82
+ if (hostname === "::1") return { kind: "loopback", detail: "loopback address" };
83
+ if (hostname === "::") return { kind: "unspecified", detail: "unspecified address" };
84
+ const hextet = firstIpv6Hextet(hostname);
85
+ if (hextet === null) return { kind: "public", detail: "public IP" };
86
+ if (hextet >= 0xfc00 && hextet <= 0xfdff) return { kind: "private", detail: "private-network address" };
87
+ if (hextet >= 0xfe80 && hextet <= 0xfebf) return { kind: "link-local", detail: "link-local address" };
88
+ return { kind: "public", detail: "public IP" };
89
+ }
90
+
91
+ function assessDestination(baseUrl: string): DestinationAssessment | null {
92
+ try {
93
+ const parsed = new URL(baseUrl.trim());
94
+ const hostname = normalizeHostname(parsed.hostname);
95
+ if (!hostname) return null;
96
+ if (BLOCKED_METADATA_HOSTS.has(hostname)) return { kind: "metadata", detail: "blocked metadata endpoint" };
97
+ if (hostname === "localhost" || hostname.endsWith(".localhost")) {
98
+ return { kind: "localhost", detail: "localhost destination" };
99
+ }
100
+ const ipKind = isIP(hostname);
101
+ if (ipKind === 4) return classifyIpv4(hostname);
102
+ if (ipKind === 6) return classifyIpv6(hostname);
103
+ return { kind: "hostname", detail: "hostname destination" };
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ function registryAllowsPrivateNetwork(name: string): boolean {
110
+ return getProviderRegistryEntry(name)?.allowPrivateNetworkByDefault === true;
111
+ }
112
+
113
+ export function providerDestinationConfigError(name: string, provider: Pick<OcxProviderConfig, "baseUrl" | "allowPrivateNetwork">): string | null {
114
+ const assessment = assessDestination(provider.baseUrl);
115
+ if (!assessment) return null;
116
+ if (assessment.kind === "public" || assessment.kind === "hostname") return null;
117
+ if (assessment.kind === "metadata") return "baseUrl targets a blocked metadata endpoint";
118
+ if (registryAllowsPrivateNetwork(name)) return null;
119
+ if (provider.allowPrivateNetwork === true) return null;
120
+ return `baseUrl points to a ${assessment.detail}; set allowPrivateNetwork:true only for intentionally local/self-hosted providers`;
121
+ }
122
+
123
+ export function assertProviderDestinationAllowed(name: string, provider: Pick<OcxProviderConfig, "baseUrl" | "allowPrivateNetwork">): void {
124
+ const error = providerDestinationConfigError(name, provider);
125
+ if (error) throw new Error(`provider ${name} ${error}`);
126
+ }
127
+
128
+ /**
129
+ * Async companion to {@link providerDestinationConfigError} for hostname destinations:
130
+ * resolves A/AAAA records and classifies every address, so a hostname that points at
131
+ * loopback/private/metadata space is caught at provider write time (review finding,
132
+ * PR #96 — the sync path must stay literal-only because the router hot path and
133
+ * config load are synchronous). DNS failures return null: config-time validation is
134
+ * advisory and must not hard-fail offline startups. DNS rebinding after validation is
135
+ * a recorded residual for this loopback proxy (devlog 260712_pr_batch_landing 000).
136
+ */
137
+ export async function providerDestinationResolvedError(
138
+ name: string,
139
+ provider: Pick<OcxProviderConfig, "baseUrl" | "allowPrivateNetwork">,
140
+ ): Promise<string | null> {
141
+ const syncError = providerDestinationConfigError(name, provider);
142
+ if (syncError) return syncError;
143
+ let hostname: string;
144
+ try {
145
+ hostname = normalizeHostname(new URL(provider.baseUrl.trim()).hostname);
146
+ } catch {
147
+ return null;
148
+ }
149
+ if (!hostname || isIP(hostname) !== 0 || hostname === "localhost" || hostname.endsWith(".localhost")) {
150
+ return null; // literals and localhost are fully handled by the sync path
151
+ }
152
+ if (registryAllowsPrivateNetwork(name) || provider.allowPrivateNetwork === true) return null;
153
+ let addresses: { address: string }[];
154
+ try {
155
+ addresses = await lookup(hostname, { all: true, verbatim: true });
156
+ } catch {
157
+ return null; // unresolvable now ≠ malicious; the provider simply won't connect
158
+ }
159
+ for (const { address } of addresses) {
160
+ const ipKind = isIP(address);
161
+ const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null;
162
+ if (!assessment || assessment.kind === "public") continue;
163
+ if (assessment.kind === "metadata") return `baseUrl hostname ${hostname} resolves to a blocked metadata endpoint (${address})`;
164
+ return `baseUrl hostname ${hostname} resolves to a ${assessment.detail} (${address}); set allowPrivateNetwork:true only for intentionally local/self-hosted providers`;
165
+ }
166
+ return null;
167
+ }
@@ -0,0 +1,34 @@
1
+ /** In-memory ring buffer of multi-agent guidance-injection / effort-cap log lines.
2
+ *
3
+ * Injection debug lines were previously console-only, so the GUI had an "Injection log"
4
+ * toggle with nothing to display. This buffer mirrors the provider debug buffer so the
5
+ * management API and GUI can tail injection lines the same way. Callers keep their own
6
+ * `isInjectionDebugEnabled()` guard; this module only stores what it is given. */
7
+
8
+ import type { DebugLogEntry } from "./debug-log-buffer";
9
+
10
+ const MAX_LINES = 2_000;
11
+ const buffer: DebugLogEntry[] = [];
12
+ let nextSeq = 1;
13
+
14
+ /** Append a line to the injection buffer and echo it to the server console. */
15
+ export function injectionDebugLog(line: string): void {
16
+ const entry: DebugLogEntry = { seq: nextSeq++, at: Date.now(), line };
17
+ buffer.push(entry);
18
+ if (buffer.length > MAX_LINES) buffer.splice(0, buffer.length - MAX_LINES);
19
+ console.log(line);
20
+ }
21
+
22
+ export function getInjectionDebugLogEntries(options?: { after?: number; limit?: number }): DebugLogEntry[] {
23
+ const after = options?.after ?? 0;
24
+ const limit = options?.limit ?? 500;
25
+ const filtered = after > 0 ? buffer.filter(entry => entry.seq > after) : buffer;
26
+ if (filtered.length <= limit) return filtered;
27
+ return filtered.slice(-limit);
28
+ }
29
+
30
+ /** Test isolation. */
31
+ export function resetInjectionDebugLogBufferForTests(): void {
32
+ buffer.length = 0;
33
+ nextSeq = 1;
34
+ }