Skip to content

Commit 59d95c0

Browse files
committed
fix: migrate legacy Antigravity discovery settings
1 parent baea76f commit 59d95c0

7 files changed

Lines changed: 253 additions & 4 deletions

File tree

gui/src/components/provider-workspace/ProviderSettings.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,10 @@ export default function ProviderSettings({
135135
setSaving(true);
136136
setMsg(null);
137137
try {
138-
const patch: ProviderUpdatePatch = { adapter: adapter.trim(), baseUrl: nextBaseUrl, defaultModel: defaultModel.trim(), authMode, note: note.trim(), allowPrivateNetwork, liveModels };
138+
const patch: ProviderUpdatePatch = { adapter: adapter.trim(), baseUrl: nextBaseUrl, defaultModel: defaultModel.trim(), authMode, note: note.trim(), allowPrivateNetwork };
139+
// Keep omitted legacy values omitted unless the user actually changes this toggle.
140+
// Otherwise an unrelated settings save manufactures `liveModels: true` provenance.
141+
if (liveModels !== (item.liveModels !== false)) patch.liveModels = liveModels;
139142
if (supportsApiKeyTransport) patch.apiKeyTransport = apiKeyTransport;
140143
else if (item.apiKeyTransport !== undefined) patch.apiKeyTransport = "";
141144
const res = await onUpdateProvider(item.name, patch);
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { afterEach, beforeEach, expect, test } from "bun:test";
2+
import { Window } from "happy-dom";
3+
import { act } from "react";
4+
import type { Root } from "react-dom/client";
5+
import ProviderSettings from "../src/components/provider-workspace/ProviderSettings";
6+
import type { ProviderUpdatePatch } from "../src/components/provider-workspace/types";
7+
import { LanguageProvider } from "../src/i18n/provider";
8+
import type { WorkspaceItem } from "../src/provider-workspace/catalog";
9+
10+
const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const;
11+
let previousGlobals: Record<(typeof globals)[number], unknown>;
12+
let testWindow: Window;
13+
14+
beforeEach(() => {
15+
previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals;
16+
testWindow = new Window({ url: "http://localhost/#providers/workspace" });
17+
Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" });
18+
Object.defineProperties(globalThis, {
19+
document: { configurable: true, value: testWindow.document },
20+
window: { configurable: true, value: testWindow },
21+
navigator: { configurable: true, value: testWindow.navigator },
22+
localStorage: { configurable: true, value: testWindow.localStorage },
23+
});
24+
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
25+
});
26+
27+
afterEach(() => {
28+
testWindow.close();
29+
for (const key of globals) {
30+
Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] });
31+
}
32+
});
33+
34+
function provider(liveModels?: boolean): WorkspaceItem {
35+
return {
36+
name: "custom-provider",
37+
adapter: "openai-chat",
38+
baseUrl: "https://example.test/v1",
39+
authMode: "key",
40+
note: "before",
41+
...(liveModels === undefined ? {} : { liveModels }),
42+
} as WorkspaceItem;
43+
}
44+
45+
async function mountSettings(item: WorkspaceItem): Promise<{
46+
root: Root;
47+
container: HTMLElement;
48+
patches: ProviderUpdatePatch[];
49+
}> {
50+
const patches: ProviderUpdatePatch[] = [];
51+
const container = document.createElement("div");
52+
document.body.append(container);
53+
const { createRoot } = await import("react-dom/client");
54+
let root!: Root;
55+
await act(async () => {
56+
root = createRoot(container);
57+
root.render(
58+
<LanguageProvider>
59+
<ProviderSettings
60+
item={item}
61+
onUpdateProvider={async (_name, patch) => {
62+
patches.push(patch);
63+
return { ok: true };
64+
}}
65+
/>
66+
</LanguageProvider>,
67+
);
68+
});
69+
return { root, container, patches };
70+
}
71+
72+
async function save(container: HTMLElement): Promise<void> {
73+
const button = container.querySelector<HTMLButtonElement>(".pwi-settings-sticky-bar .btn-primary");
74+
expect(button).toBeTruthy();
75+
await act(async () => {
76+
button!.click();
77+
await Promise.resolve();
78+
});
79+
}
80+
81+
test("an unrelated settings save does not materialize an omitted liveModels value", async () => {
82+
const { root, container, patches } = await mountSettings(provider());
83+
const note = container.querySelector<HTMLTextAreaElement>(".pwi-settings-textarea")!;
84+
85+
await act(async () => {
86+
Object.getOwnPropertyDescriptor(testWindow.HTMLTextAreaElement.prototype, "value")!
87+
.set!.call(note, "after");
88+
note.dispatchEvent(new testWindow.Event("input", { bubbles: true }));
89+
});
90+
await save(container);
91+
92+
expect(patches).toHaveLength(1);
93+
expect(Object.hasOwn(patches[0]!, "liveModels")).toBe(false);
94+
await act(async () => { root.unmount(); });
95+
});
96+
97+
test("changing an omitted effective true to false sends an explicit liveModels choice", async () => {
98+
const { root, container, patches } = await mountSettings(provider());
99+
const toggles = container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]');
100+
101+
await act(async () => { toggles[1]!.click(); });
102+
await save(container);
103+
104+
expect(patches[0]?.liveModels).toBe(false);
105+
await act(async () => { root.unmount(); });
106+
});
107+
108+
test("changing an explicit false to true sends an explicit liveModels choice", async () => {
109+
const { root, container, patches } = await mountSettings(provider(false));
110+
const toggles = container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]');
111+
112+
await act(async () => { toggles[1]!.click(); });
113+
await save(container);
114+
115+
expect(patches[0]?.liveModels).toBe(true);
116+
await act(async () => { root.unmount(); });
117+
});

src/config.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,9 @@ const configSchema = z.object({
677677
providers: z.record(z.string(), providerConfigSchema),
678678
defaultProvider: z.string().min(1).default("openai"),
679679
openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(),
680+
// Invalid hand edits must not discard an otherwise usable config. Treat them as
681+
// pre-migration so startup can safely re-run the one-time normalization.
682+
googleAntigravityStaticCatalogVersion: z.literal(1).optional().catch(undefined),
680683
providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(),
681684
contextCapValue: z.number().int().positive().optional(),
682685
multiAgentGuidanceEnabled: z.boolean().optional(),
@@ -1243,9 +1246,19 @@ function claudeSubagentEffortError(value: unknown): string | null {
12431246
return `schema_invalid: claudeCode.subagentEffort: must be one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`;
12441247
}
12451248

1249+
function googleAntigravityStaticCatalogVersionError(value: unknown): string | null {
1250+
const raw = rawConfigRecord(value);
1251+
if (!raw || !Object.hasOwn(raw, "googleAntigravityStaticCatalogVersion")) return null;
1252+
const version = raw.googleAntigravityStaticCatalogVersion;
1253+
if (version === undefined || version === 1) return null;
1254+
return "schema_invalid: googleAntigravityStaticCatalogVersion: must be 1 or omitted";
1255+
}
1256+
12461257
/** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */
12471258
export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
1248-
const boundaryError = blankHostnameError(value) ?? claudeSubagentEffortError(value);
1259+
const boundaryError = blankHostnameError(value)
1260+
?? claudeSubagentEffortError(value)
1261+
?? googleAntigravityStaticCatalogVersionError(value);
12491262
if (boundaryError) return { ok: false, error: boundaryError };
12501263
const result = configSchema.safeParse(value);
12511264
if (result.success) return { ok: true, config: result.data as OcxConfig };

src/oauth/index.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -571,10 +571,22 @@ const OAUTH_RECONCILE_FIELDS: (keyof OcxProviderConfig)[] = [
571571
"preserveReasoningContentModels",
572572
];
573573

574+
const GOOGLE_ANTIGRAVITY_PROVIDER = "google-antigravity";
575+
const GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION = 1 as const;
576+
574577
export function reconcileOAuthProviders(config: OcxConfig): boolean {
575578
let changed = false;
579+
const migrateAntigravityStaticCatalog =
580+
config.googleAntigravityStaticCatalogVersion !== GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION;
576581
for (const [name, prov] of Object.entries(config.providers)) {
577582
const def = OAUTH_PROVIDERS[name];
583+
// Normalize the canonical row before the OAuth-only reconciliation guard. Legacy GUI
584+
// saves can carry an omitted or non-OAuth authMode, and stamping the migration marker
585+
// while skipping such a row would make its materialized `true` look user-authored later.
586+
if (name === GOOGLE_ANTIGRAVITY_PROVIDER && migrateAntigravityStaticCatalog && prov.liveModels !== false) {
587+
prov.liveModels = false;
588+
changed = true;
589+
}
578590
if (!def || prov.authMode !== "oauth") continue;
579591
const preset = def.providerConfig;
580592
for (const field of OAUTH_RECONCILE_FIELDS) {
@@ -586,6 +598,9 @@ export function reconcileOAuthProviders(config: OcxConfig): boolean {
586598
}
587599
changed = true;
588600
}
601+
// Before this marker existed, the GUI materialized an omitted `liveModels` as `true`
602+
// on any settings save. The pre-guard normalization above resets that legacy state
603+
// once; choices made after this migration are preserved by the version boundary.
589604
if (prov.liveModels === undefined && preset.liveModels !== undefined) {
590605
prov.liveModels = preset.liveModels;
591606
changed = true;
@@ -596,6 +611,10 @@ export function reconcileOAuthProviders(config: OcxConfig): boolean {
596611
changed = true;
597612
}
598613
}
614+
if (migrateAntigravityStaticCatalog) {
615+
config.googleAntigravityStaticCatalogVersion = GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION;
616+
changed = true;
617+
}
599618
if (changed) saveConfig(config);
600619
return changed;
601620
}
@@ -657,8 +676,14 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
657676
const existing = config.providers[provider];
658677
const next: OcxProviderConfig = { ...def.providerConfig };
659678
// `liveModels` is a user-facing provider toggle. A registry default seeds new rows, but an
660-
// explicit choice must survive re-login and the post-credential latest-config upsert.
661-
if (typeof existing?.liveModels === "boolean") next.liveModels = existing.liveModels;
679+
// explicit post-migration choice must survive re-login and the latest-config upsert. A
680+
// pre-marker Antigravity `true` may have been materialized by the old GUI, so it is reset
681+
// once instead of being misclassified as user provenance.
682+
const preserveExistingLiveModels = provider !== GOOGLE_ANTIGRAVITY_PROVIDER
683+
|| config.googleAntigravityStaticCatalogVersion === GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION;
684+
if (preserveExistingLiveModels && typeof existing?.liveModels === "boolean") {
685+
next.liveModels = existing.liveModels;
686+
}
662687
if (existing && getProviderRegistryEntry(provider)?.allowKeyAuthOverride === true) {
663688
// Shared sanitizeApiKeyValue trim / no-CRLF checks from api-key pool writes.
664689
let storedApiKey = sanitizeApiKeyValue(existing.apiKey);
@@ -682,6 +707,9 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
682707
}
683708
}
684709
config.providers[provider] = next;
710+
if (provider === GOOGLE_ANTIGRAVITY_PROVIDER) {
711+
config.googleAntigravityStaticCatalogVersion = GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION;
712+
}
685713
}
686714

687715
interface RunLoginDeps {

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,8 @@ export interface OcxConfig {
517517
defaultProvider: string;
518518
/** OpenAI provider-contract migration marker (v2 = single `openai` provider with account mode). */
519519
openaiProviderTierVersion?: 1 | 2;
520+
/** One-time migration marker for Antigravity's static catalog default. */
521+
googleAntigravityStaticCatalogVersion?: 1;
520522
/** Claude Code inbound + launcher settings. */
521523
claudeCode?: OcxClaudeCodeConfig;
522524
/**

tests/config.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,37 @@ describe("opencodex config defaults", () => {
120120
expect(codexAutoStartEnabled({})).toBe(true);
121121
});
122122

123+
test("Antigravity static-catalog migration marker is schema-safe but not default-injected", () => {
124+
expect(getDefaultConfig().googleAntigravityStaticCatalogVersion).toBeUndefined();
125+
126+
writeConfig({
127+
port: 12345,
128+
defaultProvider: "custom",
129+
googleAntigravityStaticCatalogVersion: 1,
130+
providers: { custom: { adapter: "openai-chat", baseUrl: "https://example.test/v1" } },
131+
});
132+
expect(loadConfig().googleAntigravityStaticCatalogVersion).toBe(1);
133+
134+
writeConfig({
135+
port: 12345,
136+
defaultProvider: "custom",
137+
googleAntigravityStaticCatalogVersion: 99,
138+
providers: { custom: { adapter: "openai-chat", baseUrl: "https://example.test/v1" } },
139+
});
140+
const degraded = loadConfig();
141+
expect(degraded.googleAntigravityStaticCatalogVersion).toBeUndefined();
142+
expect(degraded.providers.custom.baseUrl).toBe("https://example.test/v1");
143+
expect(backupNames()).toEqual([]);
144+
145+
expect(validateConfigCandidate({
146+
...getDefaultConfig(),
147+
googleAntigravityStaticCatalogVersion: 99,
148+
})).toMatchObject({
149+
ok: false,
150+
error: expect.stringContaining("googleAntigravityStaticCatalogVersion"),
151+
});
152+
});
153+
123154
test("Codex autostart can be disabled explicitly", () => {
124155
expect(codexAutoStartEnabled({ codexAutoStart: false })).toBe(false);
125156
expect(codexAutoStartEnabled({ codexAutoStart: true })).toBe(true);

tests/oauth-provider-reconcile.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ describe("OAuth provider reconciliation", () => {
4141
modelContextWindows: { "gemini-3.5-flash-low": 1_048_576 },
4242
project: "config-project-sentinel",
4343
note: "user-owned-note",
44+
// Older Provider Settings saves materialized this effective default.
45+
liveModels: true,
4446
},
4547
},
4648
} satisfies OcxConfig;
@@ -62,6 +64,7 @@ describe("OAuth provider reconciliation", () => {
6264
expect(provider.models).not.toContain("gemini-3.6-flash-high");
6365
expect(provider.modelContextWindows?.["gemini-3.6-flash"]).toBe(1_048_576);
6466
expect(provider.liveModels).toBe(false);
67+
expect(config.googleAntigravityStaticCatalogVersion).toBe(1);
6568
expect(provider.project).toBe("config-project-sentinel");
6669
expect(provider.note).toBe("user-owned-note");
6770
expect(getCredential("google-antigravity")).toMatchObject({
@@ -73,13 +76,15 @@ describe("OAuth provider reconciliation", () => {
7376
const persisted = loadConfig();
7477
expect(persisted.providers["google-antigravity"]?.defaultModel).toBe("gemini-3.6-flash");
7578
expect(persisted.providers["google-antigravity"]?.liveModels).toBe(false);
79+
expect(persisted.googleAntigravityStaticCatalogVersion).toBe(1);
7680
expect(reconcileOAuthProviders(config)).toBe(false);
7781
});
7882

7983
test("preserves an explicit Antigravity liveModels override during reconcile and re-login", () => {
8084
const config = {
8185
port: 10100,
8286
defaultProvider: "google-antigravity",
87+
googleAntigravityStaticCatalogVersion: 1,
8388
providers: {
8489
"google-antigravity": {
8590
...structuredClone(OAUTH_PROVIDERS["google-antigravity"].providerConfig),
@@ -95,4 +100,54 @@ describe("OAuth provider reconciliation", () => {
95100
expect(config.providers["google-antigravity"].liveModels).toBe(true);
96101
expect(config.providers["google-antigravity"].models).toHaveLength(6);
97102
});
103+
104+
test("normalizes pre-marker Antigravity rows even when authMode is omitted or non-OAuth", () => {
105+
const home = mkdtempSync(join(tmpdir(), "ocx-antigravity-authmode-reconcile-"));
106+
homes.push(home);
107+
process.env.OPENCODEX_HOME = home;
108+
109+
for (const authMode of [undefined, "key"] as const) {
110+
const provider = {
111+
...structuredClone(OAUTH_PROVIDERS["google-antigravity"].providerConfig),
112+
liveModels: true,
113+
};
114+
if (authMode === undefined) delete provider.authMode;
115+
else provider.authMode = authMode;
116+
const config = {
117+
port: 10100,
118+
defaultProvider: "google-antigravity",
119+
providers: { "google-antigravity": provider },
120+
} satisfies OcxConfig;
121+
122+
expect(reconcileOAuthProviders(config)).toBe(true);
123+
expect(config.googleAntigravityStaticCatalogVersion).toBe(1);
124+
expect(config.providers["google-antigravity"].liveModels).toBe(false);
125+
}
126+
});
127+
128+
test("seeds the static catalog during pre-marker re-login, then preserves later overrides", () => {
129+
const config = {
130+
port: 10100,
131+
defaultProvider: "google-antigravity",
132+
providers: {
133+
"google-antigravity": {
134+
...structuredClone(OAUTH_PROVIDERS["google-antigravity"].providerConfig),
135+
liveModels: true,
136+
},
137+
},
138+
} satisfies OcxConfig;
139+
140+
upsertOAuthProvider(config, "google-antigravity");
141+
expect(config.googleAntigravityStaticCatalogVersion).toBe(1);
142+
expect(config.providers["google-antigravity"].liveModels).toBe(false);
143+
144+
config.providers["google-antigravity"].liveModels = true;
145+
config.providers["google-antigravity"].authMode = "key";
146+
upsertOAuthProvider(config, "google-antigravity");
147+
expect(config.providers["google-antigravity"].liveModels).toBe(true);
148+
149+
config.providers["google-antigravity"].authMode = undefined;
150+
upsertOAuthProvider(config, "google-antigravity");
151+
expect(config.providers["google-antigravity"].liveModels).toBe(true);
152+
});
98153
});

0 commit comments

Comments
 (0)