Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions packages/engine/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,19 +59,40 @@ export function detectProxy(base: Record<string, string | undefined>): 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, string | undefined>): 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)";
}
}
2 changes: 1 addition & 1 deletion packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
41 changes: 40 additions & 1 deletion packages/engine/test/env.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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)");
});
});
20 changes: 20 additions & 0 deletions src/host/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
computeMeetingMetrics,
CreditAccountant,
detectProxy,
detectCustomEndpoint,
ExtrasBudget,
ExtrasBudgetExceededError,
ExtrasPipeline,
Expand Down Expand Up @@ -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, string | undefined>): 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). */
Expand Down Expand Up @@ -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" });
Expand Down
45 changes: 45 additions & 0 deletions test/custom-endpoint-notice.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading