From 561afdb6e61dd5335e482c0dec079e88fff9e7dd Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 14 Jul 2026 16:52:15 +0000 Subject: [PATCH 1/2] [#174] Surface an active ANTHROPIC_BASE_URL custom endpoint to the user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #145 deliberately preserves ANTHROPIC_BASE_URL (an intentional custom endpoint) and keeps the user's credentials in that case — but, unlike proxies (surfaced via a content-free status, session.ts:322), a base-URL reroute was invisible: every caption batch, summary, and coaching prompt silently transits an arbitrary host with credentials attached, while the UI still reads "Audio never leaves this Mac". Mirror the #145 proxy notice. Per the Batch 36 amendment (the redactor `proxyHost` was module-private), extract a shared host-redactor: rename `proxyHost` → `safeHost` (used by both detectors) and add an exported `detectCustomEndpoint` that returns `safeHost(ANTHROPIC_BASE_URL)` or null. HostSession.start emits a content-free status beside the proxy notice when it is set — "translation traffic is routing to a custom Anthropic endpoint: " — carrying ONLY the safe host, never the URL path/query/credentials. #145's sanitizeChildEnv carve-out and detectProxy behavior are unchanged. Tests (env.test.ts, mirroring detectProxy — the host has no headless start() harness, so the redactor IS the tested unit, matching how #145's notice is covered): presence → host[:port]; redaction drops embedded credentials + path + query; scheme-less host:port; unparseable → "(set)"; unset/blank → null (no notice). Engine suite 269 green, app 158; typecheck (app+packages), lint, build clean. No new deps; no caption content logged. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/engine/src/env.ts | 29 ++++++++++++++++++---- packages/engine/src/index.ts | 2 +- packages/engine/test/env.test.ts | 41 +++++++++++++++++++++++++++++++- src/host/session.ts | 13 ++++++++++ 4 files changed, 79 insertions(+), 6 deletions(-) diff --git a/packages/engine/src/env.ts b/packages/engine/src/env.ts index 9573a03..32e9f59 100644 --- a/packages/engine/src/env.ts +++ b/packages/engine/src/env.ts @@ -59,19 +59,40 @@ export function detectProxy(base: Record): string | for (const name of PROXY_VARS) { for (const key of [name, name.toLowerCase()]) { const value = base[key]; - if (typeof value === "string" && value.trim() !== "") return proxyHost(value.trim()); + if (typeof value === "string" && value.trim() !== "") return safeHost(value.trim()); } } return null; } -/** Reduce a proxy URL to `host[:port]`, dropping any embedded credentials/path. */ -function proxyHost(raw: string): string { +/** + * Detect an intentional custom Anthropic endpoint (`ANTHROPIC_BASE_URL`) in the + * base environment (#174, sibling of the #145 proxy notice). Like a proxy, it is + * NOT stripped — [`sanitizeChildEnv`] deliberately preserves it and its + * credentials as a user's explicit choice — but it silently reroutes ALL Claude + * CLI transcript traffic to an arbitrary host, so the transcript must never + * transit it *silently*: the host surfaces a content-free notice when this + * returns non-null. + * + * Returns a SAFE host label (`host` or `host:port`), or null when unset/blank. + * The full URL is never returned or logged — it can embed credentials and a + * path/query — and is reduced to host[:port] via the shared [`safeHost`]. + */ +export function detectCustomEndpoint(base: Record): string | null { + const value = base.ANTHROPIC_BASE_URL; + if (typeof value === "string" && value.trim() !== "") return safeHost(value.trim()); + return null; +} + +/** Reduce a proxy / endpoint URL to `host[:port]`, dropping any embedded + * credentials and path/query. Shared by [`detectProxy`] and + * [`detectCustomEndpoint`] so both notices redact identically. */ +function safeHost(raw: string): string { try { const url = new URL(raw.includes("://") ? raw : `http://${raw}`); return url.port ? `${url.hostname}:${url.port}` : url.hostname; } catch { - // Unparseable value: acknowledge a proxy is set without echoing the raw string. + // Unparseable value: acknowledge the reroute is set without echoing the raw string. return "(set)"; } } diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 45dde6f..e32944f 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -18,7 +18,7 @@ export type { } from "./types"; export { StreamJsonParser } from "./stream-parser"; -export { sanitizeChildEnv, detectProxy } from "./env"; +export { sanitizeChildEnv, detectProxy, detectCustomEndpoint } from "./env"; export { buildClaudeArgs, ISOLATION_ARGS, DEFAULT_MODEL } from "./args"; export type { ClaudeArgsOptions } from "./args"; export { diff --git a/packages/engine/test/env.test.ts b/packages/engine/test/env.test.ts index bbaff7b..dbbb25c 100644 --- a/packages/engine/test/env.test.ts +++ b/packages/engine/test/env.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import { sanitizeChildEnv, detectProxy } from "../src/env"; +import { sanitizeChildEnv, detectProxy, detectCustomEndpoint } from "../src/env"; describe("sanitizeChildEnv", () => { it("strips ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN by default", () => { @@ -157,3 +157,42 @@ describe("detectProxy (#145)", () => { expect(detectProxy({ HTTPS_PROXY: "://:::bogus" })).toBe("(set)"); }); }); + +describe("detectCustomEndpoint (#174)", () => { + it("returns null when ANTHROPIC_BASE_URL is unset", () => { + expect(detectCustomEndpoint({ PATH: "/usr/bin" })).toBeNull(); + }); + + it("returns null for an empty/whitespace value", () => { + expect(detectCustomEndpoint({ ANTHROPIC_BASE_URL: "" })).toBeNull(); + expect(detectCustomEndpoint({ ANTHROPIC_BASE_URL: " " })).toBeNull(); + }); + + it("surfaces the host[:port] of a set custom endpoint", () => { + expect(detectCustomEndpoint({ ANTHROPIC_BASE_URL: "https://gateway.example:8443" })).toBe( + "gateway.example:8443", + ); + expect(detectCustomEndpoint({ ANTHROPIC_BASE_URL: "https://relay.corp" })).toBe("relay.corp"); + }); + + it("redacts the path/query and any embedded credentials — host[:port] only", () => { + const host = detectCustomEndpoint({ + ANTHROPIC_BASE_URL: "https://user:s3cret@gateway.example:8443/v1/messages?key=abc", + }); + expect(host).toBe("gateway.example:8443"); + expect(host).not.toContain("s3cret"); + expect(host).not.toContain("user"); + expect(host).not.toContain("v1"); + expect(host).not.toContain("abc"); + }); + + it("accepts a scheme-less host:port value", () => { + expect(detectCustomEndpoint({ ANTHROPIC_BASE_URL: "gateway.example:8443" })).toBe( + "gateway.example:8443", + ); + }); + + it("does not leak the raw value for an unparseable base URL", () => { + expect(detectCustomEndpoint({ ANTHROPIC_BASE_URL: "://:::bogus" })).toBe("(set)"); + }); +}); diff --git a/src/host/session.ts b/src/host/session.ts index dcdc25a..bbd8106 100644 --- a/src/host/session.ts +++ b/src/host/session.ts @@ -12,6 +12,7 @@ import { computeMeetingMetrics, CreditAccountant, detectProxy, + detectCustomEndpoint, ExtrasBudget, ExtrasBudgetExceededError, ExtrasPipeline, @@ -323,6 +324,18 @@ export class HostSession { if (proxyHost) { this.emit({ type: "status", detail: `translation traffic is routing through a proxy: ${proxyHost}` }); } + // Custom-endpoint discipline (#174, sibling of #145): a set + // ANTHROPIC_BASE_URL is an intentional reroute — sanitizeChildEnv preserves + // it AND its credentials — but it silently sends every translation to an + // arbitrary Anthropic-API host, so surface it ONCE per session with only the + // safe host (never the URL path/query/credentials), like the proxy notice. + const customEndpointHost = detectCustomEndpoint(process.env); + if (customEndpointHost) { + this.emit({ + type: "status", + detail: `translation traffic is routing to a custom Anthropic endpoint: ${customEndpointHost}`, + }); + } } else { if (resolved.enginePref === "cli") { this.emit({ type: "status", detail: "no Claude CLI found — using the local model" }); From 1fb966e06fdd4445c53fc2b50cc946b193cd6982 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 14 Jul 2026 16:58:34 +0000 Subject: [PATCH 2/2] [#174] Extract customEndpointNotice as a tested seam for the visible notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address RE1 review on PR #186: #174 requires proving the visible HostSession notice, not just the detector. `start()` spawns real CLI children (no headless harness), so factor the status string + its redaction into a pure, exported `customEndpointNotice(env)` — exactly what the CLI path emits (`if (endpointNotice) emit({ type: "status", detail: endpointNotice })`). New test/custom-endpoint-notice.test.ts asserts the CLI path's endpoint status: set → exactly "…custom Anthropic endpoint: " (bare host / host:port); credentials, path, and query never reach the status; unset/empty/whitespace → null (no notice). The detector redaction tests (env.test.ts) are retained; the proxy notice is untouched. App suite 162 green; typecheck (app+packages), lint, build clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/host/session.ts | 31 ++++++++++++-------- test/custom-endpoint-notice.test.ts | 45 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 12 deletions(-) create mode 100644 test/custom-endpoint-notice.test.ts diff --git a/src/host/session.ts b/src/host/session.ts index bbd8106..faf6756 100644 --- a/src/host/session.ts +++ b/src/host/session.ts @@ -152,6 +152,19 @@ export function meterEngines( } } +/** The content-free custom-endpoint notice (#174) for a base environment, or + * null when no `ANTHROPIC_BASE_URL` is set. A set base URL silently reroutes all + * CLI transcript traffic to an arbitrary host (its credentials preserved by + * #145), so the CLI path surfaces this ONCE per session — carrying ONLY the safe + * host (`detectCustomEndpoint` drops the URL path/query and any credentials), + * mirroring the #145 proxy notice. Pure over the env so the exact status string + * and its redaction are unit-testable — `HostSession.start` spawns real CLI + * children and has no headless harness. */ +export function customEndpointNotice(env: Record): string | null { + const host = detectCustomEndpoint(env); + return host === null ? null : `translation traffic is routing to a custom Anthropic endpoint: ${host}`; +} + export class HostSession { /** Routers for the two CLI lanes (null on the local-only path). A credit- or * health-driven fallback switches BOTH to the shared local engine (#142). */ @@ -324,18 +337,12 @@ export class HostSession { if (proxyHost) { this.emit({ type: "status", detail: `translation traffic is routing through a proxy: ${proxyHost}` }); } - // Custom-endpoint discipline (#174, sibling of #145): a set - // ANTHROPIC_BASE_URL is an intentional reroute — sanitizeChildEnv preserves - // it AND its credentials — but it silently sends every translation to an - // arbitrary Anthropic-API host, so surface it ONCE per session with only the - // safe host (never the URL path/query/credentials), like the proxy notice. - const customEndpointHost = detectCustomEndpoint(process.env); - if (customEndpointHost) { - this.emit({ - type: "status", - detail: `translation traffic is routing to a custom Anthropic endpoint: ${customEndpointHost}`, - }); - } + // Custom-endpoint discipline (#174, sibling of #145): surface a set + // ANTHROPIC_BASE_URL ONCE per session so its silent reroute is a conscious + // choice. The notice string + redaction live in the pure, tested + // `customEndpointNotice`; here we only emit it. + const endpointNotice = customEndpointNotice(process.env); + if (endpointNotice) this.emit({ type: "status", detail: endpointNotice }); } else { if (resolved.enginePref === "cli") { this.emit({ type: "status", detail: "no Claude CLI found — using the local model" }); diff --git a/test/custom-endpoint-notice.test.ts b/test/custom-endpoint-notice.test.ts new file mode 100644 index 0000000..265a1f8 --- /dev/null +++ b/test/custom-endpoint-notice.test.ts @@ -0,0 +1,45 @@ +// #174: prove the VISIBLE HostSession notice, not just the detector. `start()` +// spawns real CLI/llama-server children (no headless harness — see +// session-accounting.test.ts), so the status string + its redaction are factored +// into the pure, exported `customEndpointNotice`, which is exactly what the CLI +// path emits: `if (endpointNotice) emit({ type: "status", detail: endpointNotice })`. +// These assert the emitted status when ANTHROPIC_BASE_URL is set (redacted to the +// safe host) and that NO endpoint status is produced when it is unset. + +import { describe, expect, it } from "vitest"; + +import { customEndpointNotice } from "../src/host/session"; + +describe("customEndpointNotice (#174 visible endpoint status)", () => { + it("produces NO endpoint status when ANTHROPIC_BASE_URL is unset", () => { + expect(customEndpointNotice({ PATH: "/usr/bin" })).toBeNull(); + }); + + it("produces NO endpoint status for an empty/whitespace value", () => { + expect(customEndpointNotice({ ANTHROPIC_BASE_URL: "" })).toBeNull(); + expect(customEndpointNotice({ ANTHROPIC_BASE_URL: " " })).toBeNull(); + }); + + it("emits exactly the redacted endpoint status when ANTHROPIC_BASE_URL is set", () => { + expect(customEndpointNotice({ ANTHROPIC_BASE_URL: "https://gateway.example:8443" })).toBe( + "translation traffic is routing to a custom Anthropic endpoint: gateway.example:8443", + ); + // Bare host when no port is present. + expect(customEndpointNotice({ ANTHROPIC_BASE_URL: "https://relay.corp" })).toBe( + "translation traffic is routing to a custom Anthropic endpoint: relay.corp", + ); + }); + + it("carries ONLY the safe host — credentials, path, and query never reach the status", () => { + const notice = customEndpointNotice({ + ANTHROPIC_BASE_URL: "https://user:s3cret@gateway.example:8443/v1/messages?key=abc", + }); + expect(notice).toBe( + "translation traffic is routing to a custom Anthropic endpoint: gateway.example:8443", + ); + expect(notice).not.toContain("s3cret"); + expect(notice).not.toContain("user"); + expect(notice).not.toContain("v1"); + expect(notice).not.toContain("abc"); + }); +});