From 3bc2db5f2bef10fcb7f0e397a29edd180de3502b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 11:52:04 -0700 Subject: [PATCH 1/3] Stop the model picker from overwriting the persisted default provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trying a model in the picker recorded it in recentModels but also carried defaultProvider into the same settings write, so browsing a model became an implicit "set as default" — every model tried in a session silently became the new default for future launches. --- src/settings.test.ts | 6 ++++++ src/tui/runner.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/settings.test.ts b/src/settings.test.ts index ab0a6f2d0..a7f299bea 100644 --- a/src/settings.test.ts +++ b/src/settings.test.ts @@ -1012,6 +1012,12 @@ describe("recent and favorite model helpers", () => { expect(s.recentModels?.[0]).toEqual({ provider: "a", model: "x11" }); }); + test("pushRecentModel leaves defaultProvider untouched", () => { + const s: Settings = { providers: firepass.providers, defaultProvider: "firepass" }; + const next = pushRecentModel(s, { provider: "other", model: "m1" }); + expect(next.defaultProvider).toBe("firepass"); + }); + test("toggleFavoriteModel adds and removes", () => { let s: Settings = { providers: firepass.providers }; s = toggleFavoriteModel(s, { provider: "a", model: "m1" }); diff --git a/src/tui/runner.ts b/src/tui/runner.ts index b99819a0b..d1c0c3841 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -2062,7 +2062,7 @@ export async function runTUI(initialConfig: Config): Promise { const onDisk = (await loadGlobalSettingsWriteBase(trueGlobalSettingsPath)) ?? { providers: {}, }; - const next = pushRecentModel({ ...onDisk, defaultProvider: provider }, ref); + const next = pushRecentModel(onDisk, ref); await saveGlobalSettings(trueGlobalSettingsPath, next); config = { ...config, settings: next }; host.refreshModels(listRecentModels(next), listFavoriteModels(next)); From a924909be181dc94ab5a0788040e1d834844e7a3 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 11:52:29 -0700 Subject: [PATCH 2/3] Validate a provider credential before onboarding reports it configured An empty API key on a key-required preset was written as keyless: true, and resolveProvider skips the missing-key check entirely once keyless is set, so a config that should have failed validation loaded as configured and only broke on the first real send. The submit funnel now rejects an empty key on any preset that isn't genuinely keyless-capable (the custom/manual endpoint), instead of downgrading it. The "save anyway" escape hatch had the same failure shape one step further along: a credential that failed its connection test still got persisted, indistinguishable from a verified one. It's now marked verified: false, and the running session surfaces a one-time startup notice pointing back to setup instead of letting the first send fail with a raw adapter error. Both bugs existed twice over: first-run onboarding and the mid-session "connect a new provider" flow each reimplemented the same submit logic, so a fix to one alone would have left the other silently broken. The submit logic moves into its own module and both callers now share it. --- src/config/index.ts | 19 +++++ src/config/settings.ts | 8 ++ src/tui-opentui/provider-connect.test.ts | 51 ++++++++++++ src/tui-opentui/provider-connect.ts | 65 +++------------- src/tui/onboarding.ts | 75 +----------------- src/tui/provider-setup-submit.test.ts | 79 +++++++++++++++++++ src/tui/provider-setup-submit.ts | 99 ++++++++++++++++++++++++ src/tui/runner.ts | 7 ++ 8 files changed, 278 insertions(+), 125 deletions(-) create mode 100644 src/tui-opentui/provider-connect.test.ts create mode 100644 src/tui/provider-setup-submit.test.ts create mode 100644 src/tui/provider-setup-submit.ts diff --git a/src/config/index.ts b/src/config/index.ts index ee4cd78d7..0b4a6d923 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -115,6 +115,20 @@ export type ProviderCatalogEntry = Omit { + test("rejects an empty key on a key-required preset without persisting", async () => { + const dir = await mkdtemp(join(tmpdir(), "provider-connect-")) + const settingsPath = join(dir, "settings.json") + try { + const harness = await createHarness({ width: 80, height: 30 }) + const resultPromise = connectProviderInline({ + providerId: "openai", + settingsPath, + localSettingsPath: join(dir, "local.json"), + cwd: dir, + existing: null, + createRenderer: async () => harness.renderer, + }) + await harness.renderOnce() + + // initialProviderId lands directly on the api key step; leave it blank. + harness.pressKey("Enter") + await harness.renderOnce() + // Model step: accept the default. + harness.pressKey("Enter") + await harness.renderOnce() + // The rejection is thrown from the async onSubmit handler. + await new Promise((r) => setTimeout(r, 0)) + await harness.renderOnce() + + const frame = harness.captureCharFrame() + expect(frame).toContain("requires an api key") + + harness.pressKey("Ctrl+C") + const result = await resultPromise + expect(result.connected).toBe(false) + expect(await loadSettings(settingsPath)).toBeNull() + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/tui-opentui/provider-connect.ts b/src/tui-opentui/provider-connect.ts index e1e20e497..37a9f32ae 100644 --- a/src/tui-opentui/provider-connect.ts +++ b/src/tui-opentui/provider-connect.ts @@ -5,13 +5,8 @@ * implemented there) — reused via `initialProviderId`, not reimplemented. */ -import { - mergeProviderIntoSettings, - saveGlobalSettings, - saveLocalSettings, - type Settings, -} from "../config/settings.js" -import { validateProviderConnection } from "../provider/validate-connection.js" +import type { Settings } from "../config/settings.js" +import { buildProviderSubmitHandler } from "../tui/provider-setup-submit.js" import { runProviderSetup, type ProviderSetupConfig } from "./provider-setup.js" export type ConnectProviderInput = { @@ -40,58 +35,22 @@ export async function connectProviderInline( input: ConnectProviderInput, ): Promise { let result: ConnectProviderResult = { connected: false } + const submitProvider = buildProviderSubmitHandler(input.settingsPath, input.existing, input.cwd) const submitted = await runProviderSetup({ showTelemetryNotice: false, initialProviderId: input.providerId, ...(input.createRenderer !== undefined ? { createRenderer: input.createRenderer } : {}), ...(input.startLogin !== undefined ? { startLogin: input.startLogin } : {}), - onSubmit: async (values, setPhase, { skipValidation, preset, oauth }) => { - const { name, baseURL, apiKey, model } = values - const providerName = name.trim() - const trimmedBaseURL = baseURL.trim() - const trimmedKey = apiKey.trim() - - if (oauth !== undefined) { - setPhase("saving") - const base = input.existing ?? { providers: {} } - await saveGlobalSettings(input.settingsPath, { - ...base, - defaultProvider: oauth.providerName, - }) - await saveLocalSettings(input.localSettingsPath, { - provider: oauth.providerName, - model: model.trim(), - }) - result = { connected: true, providerName: oauth.providerName, model: model.trim() } - return - } - - if (!skipValidation && preset?.anthropic !== true) { - const check = await validateProviderConnection({ - baseURL: trimmedBaseURL, - apiKey: trimmedKey.length > 0 ? trimmedKey : undefined, - }) - if (!check.ok) throw new Error(check.error) - } - - setPhase("saving") - const selectedModel = model.trim() - const models = - preset !== undefined && preset.models.includes(selectedModel) - ? [...preset.models] - : [selectedModel] - const newProvider = { - baseURL: trimmedBaseURL, - models, - defaultModel: selectedModel, - ...(trimmedKey.length > 0 ? { apiKey: trimmedKey } : { keyless: true }), - ...(preset?.anthropic === true ? { anthropic: true } : {}), - ...(preset?.opencodeGo === true ? { opencodeGo: true } : {}), - } - const merged = mergeProviderIntoSettings(input.existing, providerName, newProvider) - await saveGlobalSettings(input.settingsPath, merged) - result = { connected: true, providerName, model: selectedModel } + onSubmit: async (values, setPhase, opts) => { + // Persistence and validation (empty-key rejection, connection test, + // unverified marking) live in the one funnel every provider-setup exit + // path shares — see buildProviderSubmitHandler. + await submitProvider(values, setPhase, opts) + result = + opts.oauth !== undefined + ? { connected: true, providerName: opts.oauth.providerName, model: values.model.trim() } + : { connected: true, providerName: values.name.trim(), model: values.model.trim() } }, }) diff --git a/src/tui/onboarding.ts b/src/tui/onboarding.ts index 14bd31691..7536a5687 100644 --- a/src/tui/onboarding.ts +++ b/src/tui/onboarding.ts @@ -1,15 +1,8 @@ import { runTUI } from "./runner.js"; +import { buildProviderSubmitHandler } from "./provider-setup-submit.js"; import { loadConfig, type UnconfiguredConfig } from "../config/index.js"; -import { - globalSettingsPath, - loadSettings, - localSettingsPath, - mergeProviderIntoSettings, - saveGlobalSettings, - saveLocalSettings, -} from "../config/settings.js"; +import { globalSettingsPath, loadSettings } from "../config/settings.js"; import { activateHeldTelemetry, telemetryFirstRunPending } from "../telemetry/first-run.js"; -import { validateProviderConnection } from "../provider/validate-connection.js"; import { runProviderSetup } from "../tui-opentui/provider-setup.js"; export async function runOnboarding(config: UnconfiguredConfig): Promise { @@ -26,69 +19,7 @@ export async function runOnboarding(config: UnconfiguredConfig): Promise const submitted = await runProviderSetup({ showTelemetryNotice, - onSubmit: async (values, setPhase, { skipValidation, preset, oauth }) => { - const { name, baseURL, apiKey, model } = values; - const providerName = name.trim(); - const trimmedBaseURL = baseURL.trim(); - const trimmedKey = apiKey.trim(); - - // A signed-in subscription provider has no key to test or store: the - // tokens are already in the home-level auth store, and config load - // projects that store into the provider catalog. Only the selection is - // persisted here — the same two files /model writes when switching. - if (oauth !== undefined) { - setPhase("saving"); - const base = existing ?? { providers: {} }; - await saveGlobalSettings(settingsPath, { - ...base, - defaultProvider: oauth.providerName, - }); - await saveLocalSettings(localSettingsPath(config.cwd), { - provider: oauth.providerName, - model: model.trim(), - }); - return; - } - - // Fail fast on a bad base URL/key here rather than mid-conversation - // during the first real stream request. The operator can bypass the - // check (Ctrl+S) for providers that don't expose /models. Anthropic - // Messages endpoints are exempt: the probe is an OpenAI-compatible GET - // /models with a bearer token, which that surface always rejects. - if (!skipValidation && preset?.anthropic !== true) { - const check = await validateProviderConnection({ - baseURL: trimmedBaseURL, - apiKey: trimmedKey.length > 0 ? trimmedKey : undefined, - }); - if (!check.ok) { - throw new Error(check.error); - } - } - - setPhase("saving"); - const selectedModel = model.trim(); - // A picked provider seeds its whole catalog so /model has more than the - // one model chosen here; the protocol flags cannot be expressed by the - // four form values and come from the catalog entry. - const models = - preset !== undefined && preset.models.includes(selectedModel) - ? [...preset.models] - : [selectedModel]; - const newProvider = { - baseURL: trimmedBaseURL, - models, - defaultModel: selectedModel, - ...(trimmedKey.length > 0 ? { apiKey: trimmedKey } : { keyless: true }), - ...(preset?.anthropic === true ? { anthropic: true } : {}), - ...(preset?.opencodeGo === true ? { opencodeGo: true } : {}), - }; - // Merge new provider with any pre-existing ones. Single write — the form - // stays open (phase label) until saveGlobalSettings resolves, so the user - // sees confirmation before the screen is cleared. Full-spread merge so - // plugins/pluginPaths/sessionMode/shell/tools survive re-onboarding. - const merged = mergeProviderIntoSettings(existing, providerName, newProvider); - await saveGlobalSettings(settingsPath, merged); - }, + onSubmit: buildProviderSubmitHandler(settingsPath, existing, config.cwd), }); // If the user cancelled (Ctrl+C) onSubmit was never called and settings were diff --git a/src/tui/provider-setup-submit.test.ts b/src/tui/provider-setup-submit.test.ts new file mode 100644 index 000000000..9759b76fb --- /dev/null +++ b/src/tui/provider-setup-submit.test.ts @@ -0,0 +1,79 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { buildProviderSubmitHandler } from "./provider-setup-submit.js"; +import { loadSettings } from "../config/settings.js"; +import type { ProviderFormValues, SubmitPhase } from "../tui-opentui/provider-setup.js"; + +const noopSetPhase = (_phase: SubmitPhase): void => {}; + +async function withTempSettingsPath( + run: (path: string) => Promise, +): Promise { + const dir = await mkdtemp(join(tmpdir(), "provider-setup-submit-")); + const path = join(dir, "settings.json"); + try { + await run(path); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +describe("buildProviderSubmitHandler", () => { + test("rejects an empty key on a key-required preset without persisting", async () => { + await withTempSettingsPath(async (path) => { + const submit = buildProviderSubmitHandler(path, null, "/tmp/cwd"); + const values: ProviderFormValues = { + name: "openai", + baseURL: "https://api.openai.com/v1", + apiKey: "", + model: "gpt-5", + }; + const preset = { id: "openai", models: ["gpt-5"], anthropic: false, opencodeGo: false }; + + await expect( + submit(values, noopSetPhase, { skipValidation: false, preset }), + ).rejects.toThrow(/api key/i); + + expect(await loadSettings(path)).toBeNull(); + }); + }); + + test("allows an empty key on the manual/custom path (no preset)", async () => { + await withTempSettingsPath(async (path) => { + const submit = buildProviderSubmitHandler(path, null, "/tmp/cwd"); + const values: ProviderFormValues = { + name: "local", + baseURL: "http://localhost:11434/v1", + apiKey: "", + model: "llama3", + }; + + // skipValidation avoids the live connection probe in this unit test. + await submit(values, noopSetPhase, { skipValidation: true }); + + const settings = await loadSettings(path); + expect(settings?.providers.local?.keyless).toBe(true); + }); + }); + + test("marks a save-anyway submit as unverified", async () => { + await withTempSettingsPath(async (path) => { + const submit = buildProviderSubmitHandler(path, null, "/tmp/cwd"); + const values: ProviderFormValues = { + name: "openai", + baseURL: "https://api.openai.com/v1", + apiKey: "sk-test-fake", + model: "gpt-5", + }; + const preset = { id: "openai", models: ["gpt-5"], anthropic: false, opencodeGo: false }; + + await submit(values, noopSetPhase, { skipValidation: true, preset }); + + const settings = await loadSettings(path); + expect(settings?.providers.openai?.verified).toBe(false); + }); + }); +}); diff --git a/src/tui/provider-setup-submit.ts b/src/tui/provider-setup-submit.ts new file mode 100644 index 000000000..c37c4168e --- /dev/null +++ b/src/tui/provider-setup-submit.ts @@ -0,0 +1,99 @@ +import { + localSettingsPath, + mergeProviderIntoSettings, + saveGlobalSettings, + saveLocalSettings, + type Settings, +} from "../config/settings.js"; +import { validateProviderConnection } from "../provider/validate-connection.js"; +import type { ProviderSetupSubmit } from "../tui-opentui/provider-setup.js"; + +/** + * The single write path every provider-setup exit takes, shared by first-run + * onboarding and mid-session "connect a new provider" so a credential is + * validated (or explicitly marked unverified) the same way regardless of + * where the form was opened from. + */ +export function buildProviderSubmitHandler( + settingsPath: string, + existing: Settings | null, + cwd: string, +): ProviderSetupSubmit { + return async (values, setPhase, { skipValidation, preset, oauth }) => { + const { name, baseURL, apiKey, model } = values; + const providerName = name.trim(); + const trimmedBaseURL = baseURL.trim(); + const trimmedKey = apiKey.trim(); + + // A signed-in subscription provider has no key to test or store: the + // tokens are already in the home-level auth store, and config load + // projects that store into the provider catalog. Only the selection is + // persisted here — the same two files /model writes when switching. + if (oauth !== undefined) { + setPhase("saving"); + const base = existing ?? { providers: {} }; + await saveGlobalSettings(settingsPath, { + ...base, + defaultProvider: oauth.providerName, + }); + await saveLocalSettings(localSettingsPath(cwd), { + provider: oauth.providerName, + model: model.trim(), + }); + return; + } + + // A known preset (anything but the custom/manual endpoint) always speaks + // to a real provider that requires a key; only the manual path is + // genuinely keyless-capable (e.g. a local OpenAI-compatible runtime). + // Reject an empty key here rather than silently downgrading it to + // `keyless: true` and letting resolveProvider skip the missing-key check + // entirely. + if (preset !== undefined && trimmedKey.length === 0) { + throw new Error(`${providerName || preset.id} requires an API key.`); + } + + // Fail fast on a bad base URL/key here rather than mid-conversation + // during the first real stream request. The operator can bypass the + // check (Ctrl+S) for providers that don't expose /models. Anthropic + // Messages endpoints are exempt: the probe is an OpenAI-compatible GET + // /models with a bearer token, which that surface always rejects. + if (!skipValidation && preset?.anthropic !== true) { + const check = await validateProviderConnection({ + baseURL: trimmedBaseURL, + apiKey: trimmedKey.length > 0 ? trimmedKey : undefined, + }); + if (!check.ok) { + throw new Error(check.error); + } + } + + setPhase("saving"); + const selectedModel = model.trim(); + // A picked provider seeds its whole catalog so /model has more than the + // one model chosen here; the protocol flags cannot be expressed by the + // four form values and come from the catalog entry. + const models = + preset !== undefined && preset.models.includes(selectedModel) + ? [...preset.models] + : [selectedModel]; + const newProvider = { + baseURL: trimmedBaseURL, + models, + defaultModel: selectedModel, + ...(trimmedKey.length > 0 ? { apiKey: trimmedKey } : { keyless: true }), + ...(preset?.anthropic === true ? { anthropic: true } : {}), + ...(preset?.opencodeGo === true ? { opencodeGo: true } : {}), + // "Save anyway" (Ctrl+S) persists a credential the connection test + // never passed. Mark it so the running session can warn on first use + // instead of surfacing a bare adapter error. + ...(skipValidation ? { verified: false } : {}), + }; + // Merge new provider with any pre-existing ones. Single write — the form + // stays open (phase label) until saveGlobalSettings resolves, so the user + // sees confirmation before the screen is cleared. Full-spread merge so + // plugins/pluginPaths/sessionMode/shell/tools survive re-onboarding. + const merged = mergeProviderIntoSettings(existing, providerName, newProvider); + await saveGlobalSettings(settingsPath, merged); + }; +} diff --git a/src/tui/runner.ts b/src/tui/runner.ts index d1c0c3841..749f99b83 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -439,6 +439,13 @@ export async function runTUI(initialConfig: Config): Promise { const startupPluginNotices: string[] = []; const discoveryNotice = formatPluginWarningsSummary(pluginLoadDiag.warnings); if (discoveryNotice !== undefined) startupPluginNotices.push(discoveryNotice); + // Saved through onboarding's "save anyway" bypass without a passing + // connection test — warn now instead of a bare adapter error on first send. + if (config.verified === false) { + startupPluginNotices.push( + `Provider "${config.providerName}" was saved without a passing connection test. If the first message fails with an auth error, run onboarding again to reconnect it.`, + ); + } // Mutable list so trusting a project/path plugin can replace a metadata-only stub // with a fully loaded module without restarting the process. let livePluginModules = pluginModules; From 76c24c119fbf82402728a90d1211ace34cac7eb7 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 12:06:24 -0700 Subject: [PATCH 3/3] Validate a provider credential before onboarding reports it configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty API key on a key-required preset was written as keyless: true, and resolveProvider skips the missing-key check entirely once keyless is set, so a config that should have failed validation loaded as configured and only broke on the first real send. The submit funnel now rejects an empty key on any preset that isn't genuinely keyless-capable (the custom/manual endpoint), instead of downgrading it. The "save anyway" escape hatch had the same failure shape one step further along: a credential that failed its connection test still got persisted, indistinguishable from a verified one. It's now marked verified: false, and the running session surfaces a plain-language startup notice instead of letting the first send fail with a raw adapter error. Both bugs existed twice over: first-run onboarding and the mid-session "connect a new provider" flow each reimplemented the same submit logic, so a fix to one alone would have left the other silently broken. The submit logic moves into its own module and both callers now share it. verified defaults to trusted (absent, not false) so this doesn't retroactively flag every existing user's already-working setup; only a path that persists a credential without testing it sets it false. The OAuth submit path is exempt from the same marking on purpose — a completed OAuth login is a stronger signal than a pasted key (the provider's own server just issued it), though it doesn't confirm the resulting token carries API scope, which is tracked as a separate follow-up rather than faked here. --- src/config/settings.ts | 6 ++++++ src/tui-opentui/provider-setup.test.ts | 13 +++++++++++++ src/tui-opentui/provider-setup.ts | 14 ++++++++++---- src/tui/provider-setup-submit.ts | 9 +++++++++ src/tui/runner.ts | 2 +- 5 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/config/settings.ts b/src/config/settings.ts index e918592bd..8f0df7a35 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -47,6 +47,12 @@ export type ProviderSettings = { // test (e.g. the onboarding "save anyway" bypass). Absent/true means either // the test passed or the provider is exempt from it by design. Read once at // startup to warn the operator instead of surfacing a raw auth error. + // + // Deliberately defaults to trusted: this field did not exist before it was + // introduced, so every provider in an existing settings.json has no value + // for it, and that must not retroactively flag every current user's + // already-working setup as unverified. Only paths that persist a + // credential without testing it write `false` explicitly. verified?: boolean; }; diff --git a/src/tui-opentui/provider-setup.test.ts b/src/tui-opentui/provider-setup.test.ts index 0b7413da1..5ee18401c 100644 --- a/src/tui-opentui/provider-setup.test.ts +++ b/src/tui-opentui/provider-setup.test.ts @@ -271,6 +271,19 @@ async function mountLogin(opts: { return { done, harness } } +describe("runProviderSetup renderer ownership", () => { + test("does not destroy a caller-supplied renderer on cancel", async () => { + const { done, harness } = await mountSetup() + harness.pressKey("Ctrl+C") + expect(await done).toBe(false) + + // A caller-owned renderer must still be usable for whatever mounted it + // in the first place (a live session resuming its own UI after a + // mid-session reconnect), not torn down out from under it. + expect(harness.renderer.isDestroyed).toBe(false) + }) +}) + describe("runProviderSetup sign-in", () => { test("a subscription provider signs in in place and persists the selection", async () => { const seen: ProviderFormValues[] = [] diff --git a/src/tui-opentui/provider-setup.ts b/src/tui-opentui/provider-setup.ts index 4ecbfd27a..74008c49a 100644 --- a/src/tui-opentui/provider-setup.ts +++ b/src/tui-opentui/provider-setup.ts @@ -605,6 +605,10 @@ const RAMP_TICK_MS = 120 export async function runProviderSetup( config: ProviderSetupConfig, ): Promise { + // A caller-supplied renderer (a headless test harness, or a live session's + // renderer reused for a mid-session reconnect) is owned by that caller — + // teardown here must not destroy it out from under them. + const externalRenderer = config.createRenderer !== undefined const renderer = config.createRenderer ? await config.createRenderer() : await createCliRenderer({ @@ -1075,10 +1079,12 @@ export async function runProviderSetup( } catch { // already unmounted } - try { - renderer.destroy() - } catch { - // already destroyed + if (!externalRenderer) { + try { + renderer.destroy() + } catch { + // already destroyed + } } } diff --git a/src/tui/provider-setup-submit.ts b/src/tui/provider-setup-submit.ts index c37c4168e..6f0ad6991 100644 --- a/src/tui/provider-setup-submit.ts +++ b/src/tui/provider-setup-submit.ts @@ -29,6 +29,15 @@ export function buildProviderSubmitHandler( // tokens are already in the home-level auth store, and config load // projects that store into the provider catalog. Only the selection is // persisted here — the same two files /model writes when switching. + // + // Unlike a pasted key, this credential was just issued by the real + // provider's own OAuth server completing a PKCE round-trip, so the + // "unverified" concept the API-key path uses doesn't apply the same way + // — there is no separate probe step to skip. What a completed login + // does not confirm is that the resulting token actually carries API + // scope (vs. e.g. a chat-only subscription), which can still surface as + // a first-send auth error; tracked separately rather than faked here + // with a flag this path has no real signal for. if (oauth !== undefined) { setPhase("saving"); const base = existing ?? { providers: {} }; diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 749f99b83..1a4d4ec6c 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -443,7 +443,7 @@ export async function runTUI(initialConfig: Config): Promise { // connection test — warn now instead of a bare adapter error on first send. if (config.verified === false) { startupPluginNotices.push( - `Provider "${config.providerName}" was saved without a passing connection test. If the first message fails with an auth error, run onboarding again to reconnect it.`, + `We couldn't confirm your "${config.providerName}" key works. If your first message fails with an auth error, double-check the key.`, ); } // Mutable list so trusting a project/path plugin can replace a metadata-only stub