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..faf6756 100644 --- a/src/host/session.ts +++ b/src/host/session.ts @@ -12,6 +12,7 @@ import { computeMeetingMetrics, CreditAccountant, detectProxy, + detectCustomEndpoint, ExtrasBudget, ExtrasBudgetExceededError, ExtrasPipeline, @@ -151,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). */ @@ -323,6 +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): 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"); + }); +});