Skip to content

Commit 21ae5a4

Browse files
Merge pull request #405 from corbitsdev/cl-5687-5688-5689-5690-onboarding-fixes
Fix onboarding's credential validation and mid-session provider connect
2 parents d40ebff + 76c24c1 commit 21ae5a4

11 files changed

Lines changed: 323 additions & 130 deletions

src/config/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,20 @@ export type ProviderCatalogEntry = Omit<ProviderSettings, "name" | "contextWindo
115115
// Set when this entry is an xAI/Grok OAuth profile. It still routes through
116116
// openai-compatible; the marker only controls token refresh and persistence.
117117
xaiProfile?: string;
118+
// When true this provider is backed by a Bifrost virtual key. Inference
119+
// sources for it are built with provider "bifrost" so the adapter can
120+
// inject the x-bf-vk header (in addition to Authorization). The flag is
121+
// also used to enable /models auto-discovery scoped to the key.
122+
bifrostVirtualKey?: boolean;
123+
// Anthropic Messages API (x-api-key). Used by first-class Anthropic and by
124+
// OpenCode Go models that speak the messages protocol.
125+
anthropic?: boolean;
126+
// OpenCode Go multi-protocol provider. Per-model routing picks
127+
// openai-compatible, openai-responses, or anthropic at source-build time.
128+
opencodeGo?: boolean;
129+
// False when this credential was persisted without a passing connection
130+
// test. See ProviderSettings.verified in settings.ts.
131+
verified?: boolean;
118132
};
119133

120134
// Build the InferenceSource for a Codex OAuth profile. Routes to the
@@ -251,6 +265,9 @@ export type Config = {
251265
model: string;
252266
providerName: string;
253267
keyless?: boolean;
268+
// False when the active provider's credential was persisted without a
269+
// passing connection test. See ProviderSettings.verified in settings.ts.
270+
verified?: boolean;
254271
cwd: string;
255272
task: string;
256273
force: boolean;
@@ -616,6 +633,7 @@ export function catalogEntryAsProviderSettings(entry: ProviderCatalogEntry): Pro
616633
...(entry.bifrostVirtualKey === true ? { bifrostVirtualKey: true } : {}),
617634
...(entry.anthropic === true ? { anthropic: true } : {}),
618635
...(go ? { opencodeGo: true } : {}),
636+
...(entry.verified === false ? { verified: false } : {}),
619637
};
620638
}
621639

@@ -678,6 +696,7 @@ export function buildProviderCatalog(
678696
...(p.bifrostVirtualKey === true ? { bifrostVirtualKey: true } : {}),
679697
...(p.anthropic === true ? { anthropic: true } : {}),
680698
...(go ? { opencodeGo: true } : {}),
699+
...(p.verified === false ? { verified: false } : {}),
681700
};
682701
});
683702
}

src/config/settings.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ export type ProviderSettings = {
4343
anthropic?: boolean;
4444
// OpenCode Go multi-protocol provider; per-model adapter selection.
4545
opencodeGo?: boolean;
46+
// False when this credential was persisted without a passing connection
47+
// test (e.g. the onboarding "save anyway" bypass). Absent/true means either
48+
// the test passed or the provider is exempt from it by design. Read once at
49+
// startup to warn the operator instead of surfacing a raw auth error.
50+
//
51+
// Deliberately defaults to trusted: this field did not exist before it was
52+
// introduced, so every provider in an existing settings.json has no value
53+
// for it, and that must not retroactively flag every current user's
54+
// already-working setup as unverified. Only paths that persist a
55+
// credential without testing it write `false` explicitly.
56+
verified?: boolean;
4657
};
4758

4859
// Provider+model identity used by the models-first picker (recent / favorites).
@@ -333,6 +344,7 @@ export type ResolvedProvider = {
333344
model: string;
334345
providerName: string;
335346
keyless?: boolean;
347+
verified?: boolean;
336348
};
337349

338350
const CHAT_COMPLETIONS_SUFFIX = "/chat/completions";
@@ -394,6 +406,7 @@ const ProviderSettingsSchema = type({
394406
"bifrostVirtualKey?": "boolean",
395407
"anthropic?": "boolean",
396408
"opencodeGo?": "boolean",
409+
"verified?": "boolean",
397410
});
398411

399412
const ModelRefSchema = type({
@@ -1113,6 +1126,7 @@ export function resolveProvider(input: ResolveInput): ResolvedProvider {
11131126
apiKey: apiKey ?? "",
11141127
model,
11151128
...(keyless ? { keyless: true } : {}),
1129+
...(selected?.verified === false ? { verified: false } : {}),
11161130
};
11171131
}
11181132

src/settings.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,6 +1012,12 @@ describe("recent and favorite model helpers", () => {
10121012
expect(s.recentModels?.[0]).toEqual({ provider: "a", model: "x11" });
10131013
});
10141014

1015+
test("pushRecentModel leaves defaultProvider untouched", () => {
1016+
const s: Settings = { providers: firepass.providers, defaultProvider: "firepass" };
1017+
const next = pushRecentModel(s, { provider: "other", model: "m1" });
1018+
expect(next.defaultProvider).toBe("firepass");
1019+
});
1020+
10151021
test("toggleFavoriteModel adds and removes", () => {
10161022
let s: Settings = { providers: firepass.providers };
10171023
s = toggleFavoriteModel(s, { provider: "a", model: "m1" });
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, test, expect } from "bun:test"
2+
import { mkdtemp, rm } from "node:fs/promises"
3+
import { tmpdir } from "node:os"
4+
import { join } from "node:path"
5+
6+
import { createHarness } from "./harness.js"
7+
import { connectProviderInline } from "./provider-connect.js"
8+
import { loadSettings } from "../config/settings.js"
9+
10+
// The mid-session "connect a new provider" flow shares its persistence and
11+
// validation with first-run onboarding (see provider-setup-submit.ts) — this
12+
// pins that an empty key on a key-required preset is rejected here too,
13+
// rather than silently downgraded to a keyless credential.
14+
describe("connectProviderInline", () => {
15+
test("rejects an empty key on a key-required preset without persisting", async () => {
16+
const dir = await mkdtemp(join(tmpdir(), "provider-connect-"))
17+
const settingsPath = join(dir, "settings.json")
18+
try {
19+
const harness = await createHarness({ width: 80, height: 30 })
20+
const resultPromise = connectProviderInline({
21+
providerId: "openai",
22+
settingsPath,
23+
localSettingsPath: join(dir, "local.json"),
24+
cwd: dir,
25+
existing: null,
26+
createRenderer: async () => harness.renderer,
27+
})
28+
await harness.renderOnce()
29+
30+
// initialProviderId lands directly on the api key step; leave it blank.
31+
harness.pressKey("Enter")
32+
await harness.renderOnce()
33+
// Model step: accept the default.
34+
harness.pressKey("Enter")
35+
await harness.renderOnce()
36+
// The rejection is thrown from the async onSubmit handler.
37+
await new Promise((r) => setTimeout(r, 0))
38+
await harness.renderOnce()
39+
40+
const frame = harness.captureCharFrame()
41+
expect(frame).toContain("requires an api key")
42+
43+
harness.pressKey("Ctrl+C")
44+
const result = await resultPromise
45+
expect(result.connected).toBe(false)
46+
expect(await loadSettings(settingsPath)).toBeNull()
47+
} finally {
48+
await rm(dir, { recursive: true, force: true })
49+
}
50+
})
51+
})

src/tui-opentui/provider-connect.ts

Lines changed: 12 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,8 @@
55
* implemented there) — reused via `initialProviderId`, not reimplemented.
66
*/
77

8-
import {
9-
mergeProviderIntoSettings,
10-
saveGlobalSettings,
11-
saveLocalSettings,
12-
type Settings,
13-
} from "../config/settings.js"
14-
import { validateProviderConnection } from "../provider/validate-connection.js"
8+
import type { Settings } from "../config/settings.js"
9+
import { buildProviderSubmitHandler } from "../tui/provider-setup-submit.js"
1510
import { runProviderSetup, type ProviderSetupConfig } from "./provider-setup.js"
1611

1712
export type ConnectProviderInput = {
@@ -40,58 +35,22 @@ export async function connectProviderInline(
4035
input: ConnectProviderInput,
4136
): Promise<ConnectProviderResult> {
4237
let result: ConnectProviderResult = { connected: false }
38+
const submitProvider = buildProviderSubmitHandler(input.settingsPath, input.existing, input.cwd)
4339

4440
const submitted = await runProviderSetup({
4541
showTelemetryNotice: false,
4642
initialProviderId: input.providerId,
4743
...(input.createRenderer !== undefined ? { createRenderer: input.createRenderer } : {}),
4844
...(input.startLogin !== undefined ? { startLogin: input.startLogin } : {}),
49-
onSubmit: async (values, setPhase, { skipValidation, preset, oauth }) => {
50-
const { name, baseURL, apiKey, model } = values
51-
const providerName = name.trim()
52-
const trimmedBaseURL = baseURL.trim()
53-
const trimmedKey = apiKey.trim()
54-
55-
if (oauth !== undefined) {
56-
setPhase("saving")
57-
const base = input.existing ?? { providers: {} }
58-
await saveGlobalSettings(input.settingsPath, {
59-
...base,
60-
defaultProvider: oauth.providerName,
61-
})
62-
await saveLocalSettings(input.localSettingsPath, {
63-
provider: oauth.providerName,
64-
model: model.trim(),
65-
})
66-
result = { connected: true, providerName: oauth.providerName, model: model.trim() }
67-
return
68-
}
69-
70-
if (!skipValidation && preset?.anthropic !== true) {
71-
const check = await validateProviderConnection({
72-
baseURL: trimmedBaseURL,
73-
apiKey: trimmedKey.length > 0 ? trimmedKey : undefined,
74-
})
75-
if (!check.ok) throw new Error(check.error)
76-
}
77-
78-
setPhase("saving")
79-
const selectedModel = model.trim()
80-
const models =
81-
preset !== undefined && preset.models.includes(selectedModel)
82-
? [...preset.models]
83-
: [selectedModel]
84-
const newProvider = {
85-
baseURL: trimmedBaseURL,
86-
models,
87-
defaultModel: selectedModel,
88-
...(trimmedKey.length > 0 ? { apiKey: trimmedKey } : { keyless: true }),
89-
...(preset?.anthropic === true ? { anthropic: true } : {}),
90-
...(preset?.opencodeGo === true ? { opencodeGo: true } : {}),
91-
}
92-
const merged = mergeProviderIntoSettings(input.existing, providerName, newProvider)
93-
await saveGlobalSettings(input.settingsPath, merged)
94-
result = { connected: true, providerName, model: selectedModel }
45+
onSubmit: async (values, setPhase, opts) => {
46+
// Persistence and validation (empty-key rejection, connection test,
47+
// unverified marking) live in the one funnel every provider-setup exit
48+
// path shares — see buildProviderSubmitHandler.
49+
await submitProvider(values, setPhase, opts)
50+
result =
51+
opts.oauth !== undefined
52+
? { connected: true, providerName: opts.oauth.providerName, model: values.model.trim() }
53+
: { connected: true, providerName: values.name.trim(), model: values.model.trim() }
9554
},
9655
})
9756

src/tui-opentui/provider-setup.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,19 @@ async function mountLogin(opts: {
271271
return { done, harness }
272272
}
273273

274+
describe("runProviderSetup renderer ownership", () => {
275+
test("does not destroy a caller-supplied renderer on cancel", async () => {
276+
const { done, harness } = await mountSetup()
277+
harness.pressKey("Ctrl+C")
278+
expect(await done).toBe(false)
279+
280+
// A caller-owned renderer must still be usable for whatever mounted it
281+
// in the first place (a live session resuming its own UI after a
282+
// mid-session reconnect), not torn down out from under it.
283+
expect(harness.renderer.isDestroyed).toBe(false)
284+
})
285+
})
286+
274287
describe("runProviderSetup sign-in", () => {
275288
test("a subscription provider signs in in place and persists the selection", async () => {
276289
const seen: ProviderFormValues[] = []

src/tui-opentui/provider-setup.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,10 @@ const RAMP_TICK_MS = 120
605605
export async function runProviderSetup(
606606
config: ProviderSetupConfig,
607607
): Promise<boolean> {
608+
// A caller-supplied renderer (a headless test harness, or a live session's
609+
// renderer reused for a mid-session reconnect) is owned by that caller —
610+
// teardown here must not destroy it out from under them.
611+
const externalRenderer = config.createRenderer !== undefined
608612
const renderer = config.createRenderer
609613
? await config.createRenderer()
610614
: await createCliRenderer({
@@ -1075,10 +1079,12 @@ export async function runProviderSetup(
10751079
} catch {
10761080
// already unmounted
10771081
}
1078-
try {
1079-
renderer.destroy()
1080-
} catch {
1081-
// already destroyed
1082+
if (!externalRenderer) {
1083+
try {
1084+
renderer.destroy()
1085+
} catch {
1086+
// already destroyed
1087+
}
10821088
}
10831089
}
10841090

src/tui/onboarding.ts

Lines changed: 3 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,8 @@
11
import { runTUI } from "./runner.js";
2+
import { buildProviderSubmitHandler } from "./provider-setup-submit.js";
23
import { loadConfig, type UnconfiguredConfig } from "../config/index.js";
3-
import {
4-
globalSettingsPath,
5-
loadSettings,
6-
localSettingsPath,
7-
mergeProviderIntoSettings,
8-
saveGlobalSettings,
9-
saveLocalSettings,
10-
} from "../config/settings.js";
4+
import { globalSettingsPath, loadSettings } from "../config/settings.js";
115
import { activateHeldTelemetry, telemetryFirstRunPending } from "../telemetry/first-run.js";
12-
import { validateProviderConnection } from "../provider/validate-connection.js";
136
import { runProviderSetup } from "../tui-opentui/provider-setup.js";
147

158
export async function runOnboarding(config: UnconfiguredConfig): Promise<number> {
@@ -26,69 +19,7 @@ export async function runOnboarding(config: UnconfiguredConfig): Promise<number>
2619

2720
const submitted = await runProviderSetup({
2821
showTelemetryNotice,
29-
onSubmit: async (values, setPhase, { skipValidation, preset, oauth }) => {
30-
const { name, baseURL, apiKey, model } = values;
31-
const providerName = name.trim();
32-
const trimmedBaseURL = baseURL.trim();
33-
const trimmedKey = apiKey.trim();
34-
35-
// A signed-in subscription provider has no key to test or store: the
36-
// tokens are already in the home-level auth store, and config load
37-
// projects that store into the provider catalog. Only the selection is
38-
// persisted here — the same two files /model writes when switching.
39-
if (oauth !== undefined) {
40-
setPhase("saving");
41-
const base = existing ?? { providers: {} };
42-
await saveGlobalSettings(settingsPath, {
43-
...base,
44-
defaultProvider: oauth.providerName,
45-
});
46-
await saveLocalSettings(localSettingsPath(config.cwd), {
47-
provider: oauth.providerName,
48-
model: model.trim(),
49-
});
50-
return;
51-
}
52-
53-
// Fail fast on a bad base URL/key here rather than mid-conversation
54-
// during the first real stream request. The operator can bypass the
55-
// check (Ctrl+S) for providers that don't expose /models. Anthropic
56-
// Messages endpoints are exempt: the probe is an OpenAI-compatible GET
57-
// /models with a bearer token, which that surface always rejects.
58-
if (!skipValidation && preset?.anthropic !== true) {
59-
const check = await validateProviderConnection({
60-
baseURL: trimmedBaseURL,
61-
apiKey: trimmedKey.length > 0 ? trimmedKey : undefined,
62-
});
63-
if (!check.ok) {
64-
throw new Error(check.error);
65-
}
66-
}
67-
68-
setPhase("saving");
69-
const selectedModel = model.trim();
70-
// A picked provider seeds its whole catalog so /model has more than the
71-
// one model chosen here; the protocol flags cannot be expressed by the
72-
// four form values and come from the catalog entry.
73-
const models =
74-
preset !== undefined && preset.models.includes(selectedModel)
75-
? [...preset.models]
76-
: [selectedModel];
77-
const newProvider = {
78-
baseURL: trimmedBaseURL,
79-
models,
80-
defaultModel: selectedModel,
81-
...(trimmedKey.length > 0 ? { apiKey: trimmedKey } : { keyless: true }),
82-
...(preset?.anthropic === true ? { anthropic: true } : {}),
83-
...(preset?.opencodeGo === true ? { opencodeGo: true } : {}),
84-
};
85-
// Merge new provider with any pre-existing ones. Single write — the form
86-
// stays open (phase label) until saveGlobalSettings resolves, so the user
87-
// sees confirmation before the screen is cleared. Full-spread merge so
88-
// plugins/pluginPaths/sessionMode/shell/tools survive re-onboarding.
89-
const merged = mergeProviderIntoSettings(existing, providerName, newProvider);
90-
await saveGlobalSettings(settingsPath, merged);
91-
},
22+
onSubmit: buildProviderSubmitHandler(settingsPath, existing, config.cwd),
9223
});
9324

9425
// If the user cancelled (Ctrl+C) onSubmit was never called and settings were

0 commit comments

Comments
 (0)