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
44 changes: 44 additions & 0 deletions src/auth/credential-surface.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test";
import { join } from "node:path";
import { buildCredentialPatterns } from "./credential-surface.js";

// CL-7929: the .lock/.tmp sidecar legs matched any directory, so a
// workspace settings file (e.g. .vscode/settings.json.lock) denied as a
// credential. They are scoped to the settings dir like the .bak legs.
describe("credential sidecar scoping (CL-7929)", () => {
test("workspace-path decoys do not deny", () => {
const patterns = buildCredentialPatterns();
const decoys = [
join("project", ".vscode", "settings.json.lock"),
join("project", "settings.json.12345.tmp"),
join("project", "settings.json.12345.1.tmp"),
join("project", ".vscode", "permissions.json.lock"),
join("project", "permissions.json.999.1.tmp"),
];
for (const decoy of decoys) {
expect(
patterns.some((pattern) => pattern.test(decoy)),
`${decoy} must not match the credential denylist`,
).toBe(false);
}
});

test("settings-dir sidecars still deny", () => {
const patterns = buildCredentialPatterns();
const home = join("/tmp", "cl7929-never-created");
const deny = [
join(home, ".corbits", "settings.json.lock"),
join(home, ".corbits", "settings.json.12345.1.tmp"),
join(home, ".corbits", "permissions.json.lock"),
join(home, ".corbits", "permissions.json.12345.1.tmp"),
join(home, ".corbits", "codex-auth.json.lock"),
join(home, ".corbits", "xai-auth.json.12345.1.tmp"),
];
for (const path of deny) {
expect(
patterns.some((pattern) => pattern.test(path)),
`${path} must match the credential denylist`,
).toBe(true);
}
});
});
4 changes: 2 additions & 2 deletions src/auth/credential-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,11 @@ export function buildCredentialPatterns(): RegExp[] {
patterns.push(new RegExp(`(^|\\/)${dir}\\/${base}~$`));
patterns.push(new RegExp(`(^|\\/)${dir}\\/${base}\\.swp$`));
patterns.push(new RegExp(`(^|\\/)${dir}\\/\\.${base}\\.swp$`));
patterns.push(new RegExp(`(^|\\/)${base}\\.lock$`));
patterns.push(new RegExp(`(^|\\/)${dir}\\/${base}\\.lock$`));
// Writers emit a pid.counter middle segment (auth/store.ts,
// mcp/auth-store.ts), so the middle segment is required; a bare
// `<base>.tmp` has no known writer and stays unmatched.
patterns.push(new RegExp(`(^|\\/)${base}\\.[^/]*\\.tmp$`));
patterns.push(new RegExp(`(^|\\/)${dir}\\/${base}\\.[^/]*\\.tmp$`));
}
return patterns;
}
29 changes: 26 additions & 3 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1154,6 +1154,21 @@ export async function loadConfig(
// (there is no logout/disconnect surface or auth-store watcher; refresh
// runs on connect, prefetch, and startup), so removal takes effect on the
// next rebuild, not live.
// Compare a settings-row baseURL against the OAuth endpoint so a
// proxy/mirror row is never mistaken for the legacy bare-row duplicate.
// Normalization failures fall back to a trailing-slash-insensitive compare
// rather than dropping a row whose URL cannot be parsed.
function sameEndpoint(raw: string | undefined, oauthBaseURL: string): boolean {
if (raw === undefined) return false;
const normalized = (value: string): string => {
try {
return normalizeOpenAICompatibleBaseURL(value);
} catch {
return value.trim().replace(/\/+$/, "");
}
};
return normalized(raw) === normalized(oauthBaseURL);
}
export function mergeOAuthCatalog(
settings: Settings | null,
resolved: ResolvedProvider,
Expand All @@ -1166,10 +1181,18 @@ export function mergeOAuthCatalog(
// connect key) reads as a second, separately-added provider next to the
// credential-backed `<kind>/<profile>` entries. Drop it once that family
// has a live profile; when nothing is connected the bare row is the only
// ChatGPT/Grok access and stays.
// ChatGPT/Grok access and stays. A bare row pointed at a different
// endpoint (proxy/mirror) is a distinct provider, not the legacy
// duplicate, so it stays alongside the credential-backed entries.
const dropBare = new Set([
...(codexEntries.length > 0 ? ["codex"] : []),
...(xaiEntries.length > 0 ? ["xai"] : []),
...(codexEntries.length > 0 &&
sameEndpoint(settings?.providers["codex"]?.baseURL, CODEX_BASE_URL)
? ["codex"]
: []),
...(xaiEntries.length > 0 &&
sameEndpoint(settings?.providers["xai"]?.baseURL, XAI_BASE_URL)
? ["xai"]
: []),
]);
return [
...buildProviderCatalog(settings, resolved).filter(
Expand Down
55 changes: 49 additions & 6 deletions src/config/oauth-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test";
import type { CodexProfile } from "../auth/codex/store.js";
import { CODEX_BASE_URL } from "../auth/codex/constants.js";
import type { XaiProfile } from "../auth/xai/store.js";
import { XAI_BASE_URL } from "../auth/xai/constants.js";
import { mergeOAuthCatalog } from "./index.js";
import type {
ProviderSettings,
Expand All @@ -11,6 +13,10 @@ import type {
// Regression tests for CL-5606: after a successful ChatGPT browser login the
// merged catalog must not list a separately-added legacy bare `codex` row
// alongside the credential-backed `codex/<profile>` entry.
//
// CL-7929: the drop only applies when the bare row points at the OAuth
// endpoint. A bare row pointed at a proxy/mirror is a distinct provider and
// stays alongside the credential-backed entries.

const resolved: ResolvedProvider = {
providerName: "openai",
Expand All @@ -23,11 +29,16 @@ function settingsWith(providers: Record<string, ProviderSettings>): Settings {
return { providers } as Settings;
}

const entry = (): ProviderSettings => ({
baseURL: "https://chatgpt.com/backend-api/codex/responses",
const codexEntry = (): ProviderSettings => ({
baseURL: CODEX_BASE_URL,
models: ["gpt-5.1-codex-max"],
});

const xaiEntry = (): ProviderSettings => ({
baseURL: XAI_BASE_URL,
models: ["grok-4-1"],
});

const codexDefault: CodexProfile = {
name: "default",
tokens: { access: "codex-access", refresh: "r", expiresAt: 1 },
Expand All @@ -42,7 +53,7 @@ const xaiWork: XaiProfile = {
describe("mergeOAuthCatalog legacy bare-row dedupe (CL-5606)", () => {
test("a legacy bare codex row is dropped once codex/default is connected", () => {
const merged = mergeOAuthCatalog(
settingsWith({ codex: entry(), "codex/default": entry() }),
settingsWith({ codex: codexEntry(), "codex/default": codexEntry() }),
resolved,
[codexDefault],
[],
Expand All @@ -52,7 +63,7 @@ describe("mergeOAuthCatalog legacy bare-row dedupe (CL-5606)", () => {

test("a legacy bare xai row is dropped once xai/work is connected", () => {
const merged = mergeOAuthCatalog(
settingsWith({ xai: entry() }),
settingsWith({ xai: xaiEntry() }),
resolved,
[],
[xaiWork],
Expand All @@ -64,7 +75,7 @@ describe("mergeOAuthCatalog legacy bare-row dedupe (CL-5606)", () => {
// Not connected: no auth-store profile and no qualified entry. The legacy
// single-instance row is the only ChatGPT access — keep it.
const merged = mergeOAuthCatalog(
settingsWith({ codex: entry() }),
settingsWith({ codex: codexEntry() }),
resolved,
[],
[],
Expand All @@ -80,12 +91,44 @@ describe("mergeOAuthCatalog legacy bare-row dedupe (CL-5606)", () => {
apiKey: "sk-test",
models: ["gpt-5"],
},
codex: entry(),
codex: codexEntry(),
}),
resolved,
[codexDefault],
[],
);
expect(merged.map((p) => p.name)).toEqual(["openai", "codex/default"]);
});

test("a bare codex row pointed at a proxy survives alongside codex/default (CL-7929)", () => {
const merged = mergeOAuthCatalog(
settingsWith({
codex: {
baseURL: "https://proxy.example.com/v1",
apiKey: "sk-proxy",
models: ["gpt-5.1-codex-max"],
},
}),
resolved,
[codexDefault],
[],
);
expect(merged.map((p) => p.name)).toEqual(["codex", "codex/default"]);
});

test("a bare xai row pointed at a mirror survives alongside xai/work (CL-7929)", () => {
const merged = mergeOAuthCatalog(
settingsWith({
xai: {
baseURL: "https://mirror.example.com/v1",
apiKey: "sk-mirror",
models: ["grok-4-1"],
},
}),
resolved,
[],
[xaiWork],
);
expect(merged.map((p) => p.name)).toEqual(["xai", "xai/work"]);
});
});
Loading