diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 9ec77d26cf9..235ca46bd90 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -397,6 +397,10 @@ An observed identity or digest describes those files during this observation. It Inspect and safely modify validated OpenCodex configuration. `show` and `get` mask secrets. Import validates before writing and requires `--yes`. +Display and mutation output strip credentials from proxy URLs while retaining the host and port. +`direct` and credential-free proxy values stay readable. `export` preserves credentials so the +backup can restore the configuration; store exported files as secrets. + ### Usage from a connected client `ocx usage` reads the connected hub with this client's enrolled data key. Human output identifies the hub source and client-key scope; `--json` returns the same scoped data. Range, surface, provider/model filters and custom `--since`/`--until` bounds remain available. Account breakdowns and other clients' records are not shared. An old or unavailable hub produces an explicit error instead of substituting local usage; upgrade the hub if it does not support this read. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index bc0a5653ad3..06503809376 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1175,6 +1175,7 @@ "provider-outbound-private-network.test.ts": "providers", "provider-outbound.test.ts": "providers", "provider-payload.test.ts": "gui", + "provider-quota-label-sanitize.test.ts": "providers", "provider-quota-observed-marker.test.ts": "providers", "provider-quota.test.ts": "providers", "provider-registry-parity.test.ts": "providers", diff --git a/src/cli/config-command.ts b/src/cli/config-command.ts index 9a970299fec..0c7cda8c674 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -5,6 +5,7 @@ import { VISION_REASONING_EFFORTS, isVisionReasoningEffort } from "../reasoning- import type { OcxConfig } from "../types"; import { normalizeVisionReasoningForModel } from "../vision/reasoning"; import type { ServiceApiTokenState } from "../lib/service-secrets"; +import { redactUrlForLog } from "../lib/redact"; import { CliUsageError, printData, rejectArgs, runCliAction, takeFlag } from "./runtime-api"; const USAGE = `Usage: @@ -17,12 +18,13 @@ const USAGE = `Usage: ocx config import --yes [--json]`; /** - * Keys whose VALUE is a credential and must never be printed or exported. + * Keys whose VALUE is a credential and must never be printed by display commands. + * `config export` writes the raw config so an export can restore credentials; it does + * not call `redact`. * - * `webhookUrl` is here because for Slack and Discord the URL itself is the authorization: - * anyone holding it can post to the channel. It looks like configuration rather than a secret, - * which is exactly why it needs to be named explicitly — none of the other patterns match it, - * so `ocx config show` printed it and `config export` wrote it to disk in the clear. + * URL-valued credentials must be named explicitly: `webhookUrl` matches none of the + * other patterns, and a proxy URL's userinfo is handled by the `proxy` branch in + * `redact` rather than by masking the whole value. */ const SECRET_KEYS = /^(apiKey|key|accessToken|refreshToken|idToken|token|password|clientSecret|webhookUrl)$/i; const BLOCKED_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]); @@ -97,6 +99,19 @@ async function readRemoteHubConfigNote(config: OcxConfig): Promise { const path = args.shift(); if (!path) throw new CliUsageError("config path is required", USAGE); rejectArgs(args, USAGE); - const value = redact(getPath(readConfigDiagnostics().config, path), path.split(".").at(-1)); + const value = redact(getPath(readConfigDiagnostics().config, path), pathSegments(path).at(-1)); if (wantsJson || typeof value === "object") console.log(JSON.stringify(value, null, 2)); else console.log(String(value)); return; @@ -257,7 +272,7 @@ export async function handleConfigCommand(argv: string[]): Promise { ? "config changed while applying this update; retry" : `config is ${outcome.reason}`); } - printData({ ok: true, path, value: redact(savedValue, path.split(".").at(-1)) }, wantsJson, + printData({ ok: true, path, value: redact(savedValue, pathSegments(path).at(-1)) }, wantsJson, [`${action === "unset" ? "Unset" : "Set"} ${path}.`]); return; } diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts index daf16aba298..b38e08f361b 100644 --- a/src/config/load-degrade.ts +++ b/src/config/load-degrade.ts @@ -420,10 +420,9 @@ export function degradedCredentialGroupsWarning(rawParsed: unknown): string | nu if (!pool || pool.credentialGroups === undefined) return null; const parsed = credentialGroupsSchema.safeParse(pool.credentialGroups); if (parsed.success) return null; - // Every issue message is redacted before it is joined. The custom messages embed the - // offending member through `JSON.stringify`, so a malformed credential string that - // happens to carry secret material would otherwise be printed verbatim at config load - // — a config file is exactly where a pasted token ends up in the wrong field. + // Every issue message is redacted before it is joined. The custom messages now name + // group/member positions instead of the offending strings; the redaction stays as a + // second layer for any schema default message that still embeds a value. const details = parsed.error.issues.map(issue => redactSecretString(issue.message)).join("; "); return `pool.credentialGroups is invalid (${details}) — declared quota grouping is disabled; other pool settings were preserved`; } diff --git a/src/lab/artifacts/sanitize.ts b/src/lab/artifacts/sanitize.ts index 745c42fe57b..df548208ded 100644 --- a/src/lab/artifacts/sanitize.ts +++ b/src/lab/artifacts/sanitize.ts @@ -156,8 +156,9 @@ const HOSTNAME_RE = /(?.metric.p` shape, reads as a namespace rather than a host. + * Other digit-suffixed labels are host-shaped and must not receive this weak + * marker exemption. Under a STRONG marker the marker always decides. */ -const DOTTED_NAMESPACE_RE = /^[a-z]+(?:\.[a-z]+)*\.[a-z]+[0-9]*$/i; +const DOTTED_NAMESPACE_RE = /^(?:[a-z]+(?:\.[a-z]+)+|[a-z]+(?:\.[a-z]+)*\.metric\.p[0-9]+)$/i; /** * Does this token look like a host rather than an English word? @@ -184,8 +186,10 @@ const DOTTED_NAMESPACE_RE = /^[a-z]+(?:\.[a-z]+)*\.[a-z]+[0-9]*$/i; * A stopword list would repeat the delimiter-enumeration mistake, so the * candidate is validated instead. A token qualifies when it carries host * punctuation (dot, hyphen, underscore, digit) or is a reserved name; a bare - * English word does not — EXCEPT directly after a resolver marker, where the - * argument is a name by construction and `ENOTFOUND redis` must still redact. + * English word does not — EXCEPT in a grammar position proven to hold the + * destination of a resolver or socket-state marker, where the argument is a + * name by construction and `ENOTFOUND redis` and `ECONNREFUSED redis` must + * still redact. */ const RESERVED_HOST_NAMES = new Set(["localhost", "broadcasthost"]); const PROSE_AFTER_MARKER = new Set([ @@ -198,8 +202,8 @@ function isHostCandidate(value: string, bareWordAllowed = false): boolean { if (/[.\-_0-9]/.test(value)) { return AMBIGUOUS_HOST_RE.test(value) || CONTEXTUAL_HOST_TOKEN_RE.test(value); } - // A bare word: only a resolver marker makes it a host, and only when it is - // not one of the connective words those messages actually use. + // A bare word: only a destination-bearing marker makes it a host, and only + // when it is not one of the connective words those messages actually use. return bareWordAllowed && !PROSE_AFTER_MARKER.has(lower); } @@ -499,8 +503,13 @@ function scrubString(value: string): string { s = s.replace(STRONG_HOST_CONTEXT_RE, (m, tail: string) => { // Scan the few tokens after the marker for the first host-shaped one: // Go writes `dial tcp: lookup : no such host`, so the destination - // is not always adjacent to the marker. A resolver marker also licenses a - // bare name (`ENOTFOUND redis`), which a socket-state marker does not. + // is not always adjacent to the marker. The destination sits inside + // `tail`, so the rewrite is spliced at an offset in it — replacing + // against the whole match lets a marker word that repeats the + // destination (`connect to connect failed`) take the `[host]` instead. + const head = m.slice(0, m.length - tail.length); + const redact = (at: number, token: string) => + head + tail.slice(0, at) + "[host]" + tail.slice(at + token.length); // A name paired with a port is a destination whatever else is true: // `dial tcp redis:6379` needs no other evidence. Both notations count — // adjacent `host:443` and spelled-out `gateway on port 443` — because the @@ -509,13 +518,45 @@ function scrubString(value: string): string { tail.match(/(?(); - const owner = new Map(); - for (const group of groups) { + const seenIds = new Map(); + const owner = new Map(); + for (const [groupIndex, group] of groups.entries()) { // A duplicate id is not cosmetic: both groups key to `declared:`, so the second // group's members join the first group's quota domain without anyone saying so. - if (seenIds.has(group.id)) issues.push(`duplicate group id ${JSON.stringify(group.id)}`); - seenIds.add(group.id); + const firstGroupIndex = seenIds.get(group.id); + if (firstGroupIndex !== undefined) { + issues.push(`duplicate group id at group index ${groupIndex} (first declared at group index ${firstGroupIndex})`); + } else { + seenIds.set(group.id, groupIndex); + } if (group.credentials.length === 0) { - issues.push(`group ${JSON.stringify(group.id)} lists no credentials`); + issues.push(`group at index ${groupIndex} lists no credentials`); } - for (const member of group.credentials) { + for (const [memberIndex, member] of group.credentials.entries()) { if (splitMember(member) === undefined) { issues.push( - `group ${JSON.stringify(group.id)} member ${JSON.stringify(member)} must be provider-qualified as ":"`, + `credential at member index ${memberIndex} in group index ${groupIndex} must be provider-qualified as ":"`, ); continue; } const existing = owner.get(canonicalMember(member)); - if (existing === group.id) { - issues.push(`credential ${JSON.stringify(member)} is listed twice in group ${JSON.stringify(group.id)}`); + if (existing?.groupIndex === groupIndex) { + issues.push(`credential at member index ${memberIndex} is listed twice in group index ${groupIndex}`); } else if (existing !== undefined) { issues.push( - `credential ${JSON.stringify(member)} is declared in more than one group (${existing}, ${group.id})`, + `credential at member index ${memberIndex} in group index ${groupIndex} is declared in more than one group (first declared at group index ${existing.groupIndex}, member index ${existing.memberIndex})`, ); } else { - owner.set(canonicalMember(member), group.id); + owner.set(canonicalMember(member), { groupIndex, memberIndex }); } } } diff --git a/structure/adapters/compatibility-lab.md b/structure/adapters/compatibility-lab.md index 840e00007db..93d54a69866 100644 --- a/structure/adapters/compatibility-lab.md +++ b/structure/adapters/compatibility-lab.md @@ -60,16 +60,24 @@ before anything else: `dial tcp redis:6379` needs no further signal. Otherwise, **strong** markers (`ENOTFOUND`, `EAI_AGAIN`, `ECONNREFUSED`, `ETIMEDOUT`, `EHOSTUNREACH`, `dial tcp`, `host=`/`host:`) introduce a -destination, and a resolver marker licenses even a bare name -(`getaddrinfo ENOTFOUND redis`). The destination is not assumed adjacent — Go -writes `dial tcp: lookup : no such host` — so the following few tokens are -scanned and the first host-shaped one is replaced. A plain English word is not -host-shaped, so `ETIMEDOUT request after 30 seconds` is untouched. +destination. The destination is not assumed adjacent — Go writes +`dial tcp: lookup : no such host` — so the following few tokens are +scanned and the first host-shaped one is replaced. A bare name counts only in +a position the grammar proves is the destination — directly after `ENOTFOUND`, +`EAI_AGAIN`, or `host=`/`host:` even when explanatory prose follows, the marker's sole argument +(`ECONNREFUSED redis`, `dial tcp redis`) or the argument of `lookup` +(`dial tcp: lookup redis`). Connective prose after a marker survives: +`ETIMEDOUT request after 30 seconds` and `ETIMEDOUT while waiting for +response` are both untouched. **Weak** markers (`upstream`, `connect to`) read as English at least as often as they name a host, so they redact only a candidate that is already host-shaped -and is not a plain dotted namespace. `upstream provider.metric.p95 exceeded` and -`Unable to connect to your account` both survive. +and is not a plain dotted namespace or the conventional `*.metric.p` +form. `upstream provider.metric.p95 exceeded` and +`Unable to connect to your account` both survive; `upstream db.prod1` does not. +For `connect to`, an immediately following network failure term also makes a +bare target unambiguous, so `connect to gateway failed` is redacted while +`Unable to connect to your account` survives. ### Known limits @@ -77,14 +85,14 @@ Recorded rather than implied, so a reader knows what is not covered: | Form | Behavior | |------|----------| -| Bare service name after natural-language `connect to` with no port at all (`connect to gateway failed`) | not redacted — the phrase is prose too often to trust. A port in either notation (`gateway:443`, `gateway on port 443`) does make it a host | | Bare `db.prod-1` outside any network context | not redacted — indistinguishable from a metric namespace | +| Bare word amid prose after a socket marker (`ETIMEDOUT operation timed out`) | not redacted — only the marker's sole argument or the word after `lookup` is a proven destination position | | Standalone UUID, standalone `user_…`, bare-label value (`org: engineering`) | not redacted — indistinguishable from request, trace, and correlation ids | | Phone numbers, generic high-entropy blobs | not redacted — no non-destructive pattern | | Cisco dotted MAC (`0123.4567.89ab`), ideographic-dot IDN | not redacted — unusual notations | | Escaped-quote mail local part | partially redacted; the address is broken but a fragment of the local part can remain | | Percent-encoding nested more than six deep | not decoded further | -| Fully alphabetic dotted namespace (`provider.timeout`, `provider.request.duration`) | **over-redacted to `[host]`** — indistinguishable from a real hostname. A namespace whose last label carries a digit (`provider.metric.p95`) survives | +| Fully alphabetic dotted namespace (`provider.timeout`, `provider.request.duration`) | **over-redacted to `[host]`** — indistinguishable from a real hostname. A digit-suffixed namespace survives bare (`release.v2`); after a weak marker only the conventional `*.metric.p` form does (`provider.metric.p95`) — `upstream db.prod1` and `upstream api.v2` redact | The marker behaviors and the redacted categories are asserted in both directions — positive cases for what must be removed, negative cases for the diff --git a/structure/catalog.md b/structure/catalog.md index a3f8e24df18..fb0520e7669 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -392,7 +392,9 @@ Pool mode routes across main plus added Codex credentials. Key rules: group ids, a non-empty member list, and each credential in at most one group, with members written `":"` because ids are provider-scoped in the auth store. The config write path rejects a declaration that breaks any of those and the load path drops the - list with a warning, keeping `pool.kernel` and `pool.cacheAffinity`; `classifyCredential` + list with a warning, keeping `pool.kernel` and `pool.cacheAffinity`. Validation diagnostics + identify group and member positions without reproducing operator-supplied identifiers; + `classifyCredential` reports an ambiguous claim on `declaredGroupConflict` and falls back to the documented or unknown answer rather than taking the first matching group. diff --git a/structure/config.md b/structure/config.md index 713b47b8b8d..049166a52ae 100644 --- a/structure/config.md +++ b/structure/config.md @@ -552,3 +552,8 @@ exclusive `--socks5-off`. The start owner persists only an explicitly requested change; the off flag refuses to erase a non-SOCKS proxy. Invalid-address errors never echo user-supplied credentials, and status messages redact proxy URLs. The parser regression cases live in `tests/cli/start-args.test.ts`. +The config CLI masks credential-bearing `proxy` URLs in show, get, and mutation output: +userinfo is stripped while host and port stay visible, `direct` and credential-less values +print unchanged, and a non-URL value that is not `direct` is masked whole. `config export` +keeps the raw file so exports can restore credentials. Get and mutation output select +redaction by the normalized final path segment, matching lookup and mutation semantics. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 132265964a9..d708e144e11 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -4,6 +4,9 @@ OrcaRouter key exchange uses the shared raw-byte reader before returning a durab 64 KiB response ceiling, single 30-second header/body deadline, and cancellation behavior follow the [bounded ingestion contract](transports/inventory.md#bounded-response-ingestion-and-orcarouter-login). +Anthropic model-scoped quota labels in `src/providers/quota/vendor-probes-oauth.ts` publish +only canonical Fable, Opus, or Sonnet labels after removing terminal controls; unknown upstream display names are omitted. + | Path | Responsibility | | --- | --- | | `src/providers/registry.ts` | Compatibility facade; canonical provider presets for CLI, dashboard, OAuth, key providers, and metadata live in `src/providers/registry/entries-core.ts` and `entries-extended.ts`, with model seeds in `model-seeds.ts`. | diff --git a/tests/cli/cli-config-command.test.ts b/tests/cli/cli-config-command.test.ts index ed458846808..80743245878 100644 --- a/tests/cli/cli-config-command.test.ts +++ b/tests/cli/cli-config-command.test.ts @@ -31,15 +31,22 @@ function freshConfig() { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", + proxy: "http://route_user:route_password@egress.test:3128", }, blsc: { adapter: "openai-chat", baseUrl: "https://llmapi.blsc.cn", + proxy: "direct", modelCosts: { "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, "sk-abcdef1234567890": { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 }, }, }, + nocreds: { + adapter: "openai-chat", + baseUrl: "https://nocreds.example", + proxy: "http://127.0.0.1:7890", + }, }, defaultProvider: "openai", }; @@ -48,6 +55,67 @@ function freshConfig() { } describe("ocx config display redaction", () => { + test.each([ + "providers.openai.proxy ", + "providers.openai.proxy.", + "providers..openai. proxy .", + ])("normalized proxy path %s keeps get and set output masked", (path) => { + const dir = freshConfig(); + try { + const get = runCli(["config", "get", path, "--json"], { OPENCODEX_HOME: dir }); + expect(get.status).toBe(0); + expect(JSON.parse(get.stdout)).toBe("http://egress.test:3128/"); + expect(get.stdout + get.stderr).not.toContain("route_user"); + expect(get.stdout + get.stderr).not.toContain("route_password"); + + const set = runCli([ + "config", "set", path, + "http://next_user:next_password@egress.test:8080", "--json", + ], { OPENCODEX_HOME: dir }); + expect(set.status).toBe(0); + expect(JSON.parse(set.stdout).value).toBe("http://egress.test:8080/"); + expect(set.stdout + set.stderr).not.toContain("next_user"); + expect(set.stdout + set.stderr).not.toContain("next_password"); + } finally { + removeTreeWithRetry(dir); + } + }); + + test("provider proxy credentials stay masked in show, get, and set output", () => { + const dir = freshConfig(); + const secret = "route_password"; + try { + const show = runCli(["config", "show", "--json"], { OPENCODEX_HOME: dir }); + expect(show.status).toBe(0); + expect(show.stdout).not.toContain(secret); + const shown = JSON.parse(show.stdout).providers; + // Credentialed URL: userinfo stripped, host/port kept for diagnostics. + expect(shown.openai.proxy).toBe("http://egress.test:3128/"); + expect(show.stdout).not.toContain("route_user"); + // "direct" and credential-less URLs carry no secret and stay readable. + expect(shown.blsc.proxy).toBe("direct"); + expect(shown.nocreds.proxy).toBe("http://127.0.0.1:7890"); + + const get = runCli(["config", "get", "providers.openai.proxy"], { OPENCODEX_HOME: dir }); + expect(get.status).toBe(0); + expect(get.stdout.trim()).toBe("http://egress.test:3128/"); + + const getDirect = runCli(["config", "get", "providers.blsc.proxy"], { OPENCODEX_HOME: dir }); + expect(getDirect.status).toBe(0); + expect(getDirect.stdout.trim()).toBe("direct"); + + const set = runCli([ + "config", "set", "providers.openai.proxy", + "http://next_user:next_password@egress.test:8080", "--json", + ], { OPENCODEX_HOME: dir }); + expect(set.status).toBe(0); + expect(set.stdout).not.toContain("next_password"); + expect(JSON.parse(set.stdout).value).toBe("http://egress.test:8080/"); + } finally { + removeTreeWithRetry(dir); + } + }); + test("config show --json never prints secret-shaped modelCosts keys", () => { const dir = freshConfig(); try { diff --git a/tests/config/config-load-degrade.test.ts b/tests/config/config-load-degrade.test.ts index 62dd4b24fed..58662378fc3 100644 --- a/tests/config/config-load-degrade.test.ts +++ b/tests/config/config-load-degrade.test.ts @@ -368,6 +368,41 @@ test("a malformed credentialGroups entry costs the list, not the rest of pool (# } finally { warn.mockRestore(); } }); +test("a malformed credentialGroups warning never includes operator-supplied identifiers", () => { + const pastedCredential = ["opaque", "provider", "credential", "value"].join("-"); + const privateGroupId = ["private", "billing", "group"].join("-"); + writePoolConfig([{ id: privateGroupId, credentials: [pastedCredential] }]); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + expect(loadConfig().pool?.credentialGroups).toBeUndefined(); + const output = warn.mock.calls.flat().join("\n"); + expect(output).toContain("provider-qualified"); + expect(output).toContain("group index 0"); + expect(output).not.toContain(pastedCredential); + expect(output).not.toContain(privateGroupId); + } finally { warn.mockRestore(); } +}); + +test("every credentialGroups issue shape keeps operator strings out of the warning", () => { + // The provider-qualified case above only covers one message template. Duplicate ids, + // empty groups, and members listed twice all flow through the same warning join, so + // each must be proven identifier-free too. + const groupId = ["sensitive", "team", "name"].join("-"); + const memberId = "anthropic:secret-credential-handle"; + writePoolConfig([ + { id: groupId, credentials: [memberId, memberId] }, + { id: groupId, credentials: [] }, + ]); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + expect(loadConfig().pool?.credentialGroups).toBeUndefined(); + const output = warn.mock.calls.flat().join("\n"); + expect(output).toContain("pool.credentialGroups"); + expect(output).not.toContain(groupId); + expect(output).not.toContain("secret-credential-handle"); + } finally { warn.mockRestore(); } +}); + test("an ambiguous credentialGroups declaration is rejected on write, never ordered away (#4546)", () => { const base = candidate(undefined); const withGroups = (credentialGroups: unknown) => ({ ...base, pool: { kernel: true, credentialGroups } }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 81214295a00..190019432bc 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1001,6 +1001,7 @@ "provider-outbound-private-network.test.ts": "providers", "provider-outbound.test.ts": "providers", "provider-payload.test.ts": "gui", + "provider-quota-label-sanitize.test.ts": "providers", "provider-quota-observed-marker.test.ts": "providers", "provider-quota.test.ts": "providers", "provider-registry-parity.test.ts": "providers", diff --git a/tests/lab/lab-evidence-sanitization.test.ts b/tests/lab/lab-evidence-sanitization.test.ts index 595b57e175e..672f6f1f328 100644 --- a/tests/lab/lab-evidence-sanitization.test.ts +++ b/tests/lab/lab-evidence-sanitization.test.ts @@ -214,6 +214,10 @@ describe("SEC-02 sanitizer boundary", () => { .toBe("dial tcp [host]:443: connect: refused"); expect(sanitizeDiagnostic("upstream api.us-east-1 unavailable")) .toBe("upstream [host] unavailable"); + for (const host of ["internal.service1", "db.prod1", "api.v2"]) { + expect(sanitizeDiagnostic(`upstream ${host} unavailable`)) + .toBe("upstream [host] unavailable"); + } // Without such a marker they survive — a recorded limit, asserted so it // cannot drift silently in either direction. expect(sanitizeDiagnostic("db.prod-1")).toBe("db.prod-1"); @@ -232,14 +236,15 @@ describe("SEC-02 sanitizer boundary", () => { }); test("marker confidence decides how much context is trusted", () => { - // STRONG markers — resolver and socket errors, `host=`, `connect to` — - // name a destination by construction, so even a bare word is a host. + // STRONG resolver and socket markers name a destination by construction, + // so even a bare word is a host. expect(sanitizeDiagnostic("dial tcp localhost:11434: connect: refused")) .toBe("dial tcp [host]:11434: connect: refused"); expect(sanitizeDiagnostic("getaddrinfo ENOTFOUND redis")).toBe("getaddrinfo ENOTFOUND [host]"); - // `connect to` was demoted to a weak marker: it reads as English too often - // (`Unable to connect to your account`). Without a port it no longer - // licenses a bare name — a documented limit, asserted below. + // Even a destination spelled like the marker resolves to the tail token. + expect(sanitizeDiagnostic("host host")).toBe("host [host]"); + // `connect to` reads as English too often to license a bare word on its own + // (`Unable to connect to your account`); a failure term disambiguates it. // WEAK markers appear in prose, so a dotted namespace after one survives. expect(sanitizeDiagnostic("upstream provider.metric.p95 exceeded")) .toBe("upstream provider.metric.p95 exceeded"); @@ -290,18 +295,70 @@ describe("SEC-02 sanitizer boundary", () => { .toBe("Unable to connect to [host] on port 443: timed out"); expect(sanitizeDiagnostic("connect to gateway port 8080 failed")) .toBe("connect to [host] port 8080 failed"); + // Marker repetition does not move the redaction off the destination. + expect(sanitizeDiagnostic("connect to connect:443 refused")) + .toBe("connect to [host]:443 refused"); + }); + + test("socket errors redact bare single-label destinations", () => { + expect(sanitizeDiagnostic("ECONNREFUSED redis")).toBe("ECONNREFUSED [host]"); + expect(sanitizeDiagnostic("ETIMEDOUT gateway")).toBe("ETIMEDOUT [host]"); + expect(sanitizeDiagnostic("EHOSTUNREACH backend")).toBe("EHOSTUNREACH [host]"); + expect(sanitizeDiagnostic("dial tcp redis")).toBe("dial tcp [host]"); + // `lookup` is the one mid-message position proven to carry the name: Go + // writes `dial tcp: lookup : `. + expect(sanitizeDiagnostic("dial tcp: lookup redis")).toBe("dial tcp: lookup [host]"); + expect(sanitizeDiagnostic("dial tcp: lookup redis: no such host")) + .toBe("dial tcp: lookup [host]: no such host"); + // A sentence-final period rides with the token: it must not defeat host + // classification, and it stays outside the mask. + expect(sanitizeDiagnostic("ECONNREFUSED redis.")).toBe("ECONNREFUSED [host]."); + expect(sanitizeDiagnostic("dial tcp db.prod1.")).toBe("dial tcp [host]."); + }); + + test("timeout and socket prose after a marker survives", () => { + // A bare name is licensed only where the grammar proves it is the + // destination. Connective prose after a socket marker is not a host: + // redacting it destroys the diagnostic and hides nothing. + expect(sanitizeDiagnostic("ETIMEDOUT while waiting for response")) + .toBe("ETIMEDOUT while waiting for response"); + expect(sanitizeDiagnostic("ETIMEDOUT operation timed out")) + .toBe("ETIMEDOUT operation timed out"); + expect(sanitizeDiagnostic("ECONNREFUSED connection refused")) + .toBe("ECONNREFUSED connection refused"); + }); + + test("direct resolver destinations stay masked before explanatory prose", () => { + expect(sanitizeDiagnostic("getaddrinfo ENOTFOUND redis: no such host")) + .toBe("getaddrinfo ENOTFOUND [host]: no such host"); + expect(sanitizeDiagnostic("ENOTFOUND redis failed")) + .toBe("ENOTFOUND [host] failed"); + expect(sanitizeDiagnostic("EAI_AGAIN redis temporary failure")) + .toBe("EAI_AGAIN [host] temporary failure"); + expect(sanitizeDiagnostic("host=redis unavailable")) + .toBe("host=[host] unavailable"); + expect(sanitizeDiagnostic("ENOTFOUND while waiting for response")) + .toBe("ENOTFOUND while waiting for response"); }); - test("natural-language connect-to prose is left alone", () => { + test("connect-to prose survives while real failures still redact", () => { // `connect to` reads as English far more often than as a destination, so // it no longer licenses a bare word. The cost is that `connect to gateway` // is not redacted; that limit is documented and asserted here so it cannot // change silently. + // A following failure term still supplies network context for a bare + // destination, so connective prose survives while real failures redact. expect(sanitizeDiagnostic("Unable to connect to your account. Please retry.")) .toBe("Unable to connect to your account. Please retry."); expect(sanitizeDiagnostic("failed to connect to the upstream service")) .toBe("failed to connect to the upstream service"); - expect(sanitizeDiagnostic("connect to gateway failed")).toBe("connect to gateway failed"); + expect(sanitizeDiagnostic("connect to gateway failed")).toBe("connect to [host] failed"); + expect(sanitizeDiagnostic("connecting to redis timed out")).toBe("connecting to [host] timed out"); + // A destination that repeats the marker word is still the thing redacted: + // replacing across the whole match took the marker's `connect` instead. + expect(sanitizeDiagnostic("connect to connect failed")).toBe("connect to [host] failed"); + expect(sanitizeDiagnostic("connecting to connecting timed out")) + .toBe("connecting to [host] timed out"); }); test("an internationalized domain does not preserve the address", () => { diff --git a/tests/providers/provider-quota-label-sanitize.test.ts b/tests/providers/provider-quota-label-sanitize.test.ts new file mode 100644 index 00000000000..5bb45864840 --- /dev/null +++ b/tests/providers/provider-quota-label-sanitize.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearAccountQuota } from "../../src/codex/quota"; +import { clearCodexUpstreamHealth } from "../../src/codex/routing"; +import { saveCredential } from "../../src/oauth/store"; +import { + clearProviderQuotaCache, + fetchProviderQuotaReports, + setProviderQuotaBeforePublishForTests, +} from "../../src/providers/quota"; +import type { OcxConfig } from "../../src/types"; +import { PROXY_ENV_KEYS } from "../../src/lib/proxy-env"; +const proxyKeys = PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]); +const originalProxyEnv = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); +const originalFetch = globalThis.fetch; +const previousOpencodexHome = process.env.OPENCODEX_HOME; +const previousCodexHome = process.env.CODEX_HOME; + +let opencodexHome: string; +let codexHome: string; + +beforeEach(() => { + for (const key of proxyKeys) delete process.env[key]; + opencodexHome = mkdtempSync(join(tmpdir(), "ocx-quota-")); + codexHome = mkdtempSync(join(tmpdir(), "codex-quota-")); + process.env.OPENCODEX_HOME = opencodexHome; + process.env.CODEX_HOME = codexHome; + mkdirSync(codexHome, { recursive: true }); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: "chatgpt-main-access", account_id: "chatgpt-main-account" }, + })); + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearProviderQuotaCache(); + setProviderQuotaBeforePublishForTests(null); +}); + +afterEach(() => { + for (const key of proxyKeys) { + if (originalProxyEnv[key] === undefined) delete process.env[key]; + else process.env[key] = originalProxyEnv[key]; + } + globalThis.fetch = originalFetch; + clearAccountQuota(); + clearProviderQuotaCache(); + setProviderQuotaBeforePublishForTests(null); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(opencodexHome); + removeTreeWithRetry(codexHome); +}); + +describe("fetchProviderQuotaReports", () => { + test("Anthropic report strips terminal controls from model-scoped quota labels", async () => { + await saveCredential("anthropic", { access: "claude-access-secret", refresh: "claude-refresh-secret", expires: Date.now() + 3600_000 }); + globalThis.fetch = (async () => Response.json({ + limits: [{ + kind: "weekly_scoped", + scope: { model: { display_name: "Other\u001b]52;c;UFdORUQ=\u0007 model\u009b31m" } }, + percent: 33, + }], + })) as typeof fetch; + + const result = await fetchProviderQuotaReports({ + defaultProvider: "anthropic", + providers: { anthropic: { adapter: "anthropic", authMode: "oauth", baseUrl: "https://api.anthropic.com/v1" } }, + } as OcxConfig, true); + + // An unrecognized display_name is dropped entirely: sanitized residue must not + // reach the quota line, so the window is omitted rather than relabeled. + expect(result.reports[0]?.quota.customWindows ?? []).toEqual([]); + }); + + test("Anthropic report still publishes recognized model-scoped quota labels", async () => { + await saveCredential("anthropic", { access: "claude-access-secret", refresh: "claude-refresh-secret", expires: Date.now() + 3600_000 }); + globalThis.fetch = (async () => Response.json({ + limits: [{ + kind: "weekly_scoped", + scope: { model: { display_name: "Claude Opus 4.7" } }, + percent: 41, + }], + })) as typeof fetch; + + const result = await fetchProviderQuotaReports({ + defaultProvider: "anthropic", + providers: { anthropic: { adapter: "anthropic", authMode: "oauth", baseUrl: "https://api.anthropic.com/v1" } }, + } as OcxConfig, true); + + expect(result.reports[0]?.quota.customWindows).toEqual([{ + label: "Opus", + percent: 41, + }]); + }); +});