From bbaea3c3e337a50cee568fad13c781b6c2fe566c Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Thu, 22 Oct 2026 18:21:05 -0400 Subject: [PATCH] fix(host): say when a provider rejected a saved key, not just Saved in Keychain --- ...S1RP81MQCE0-a-hosted-channel-s-location.md | 6 ++-- ...MC0KH3Q6BN-a-hosted-channel-must-be-rea.md | 6 ++-- ...ZKM0B3AWRVX-a-move-to-a-cloud-workspace.md | 6 ++-- ...3EYAFM3YC4-worker-requests-worker-never.md | 6 ++-- ...XJSWZK588G-every-project-op-that-change.md | 6 ++-- apps/app/src/settings.tsx | 6 ++-- apps/host/src/gateway.ts | 2 +- apps/host/src/keys.ts | 14 ++++++++ apps/host/src/protocol.ts | 7 +++- apps/host/src/settings.ts | 33 ++++++++++++++++--- 10 files changed, 67 insertions(+), 25 deletions(-) diff --git a/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md b/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md index 2f292d0b..8b8de64f 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md @@ -4,9 +4,9 @@ anchor: function handle created: 2026-10-15T04:08:49Z norm: '1' sig: e597b5fbc8e664ce -body_hash: cca617df6db2e076 -raw_hash: 93e262486d850a9c -vouched: 2026-10-22T21:13:07Z +body_hash: e0d9256e67814623 +raw_hash: a6afeafd1ea5bd24 +vouched: 2026-10-22T22:21:15Z lines: 266-519 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md b/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md index 2d5835ad..049ba4db 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md @@ -4,9 +4,9 @@ anchor: function handle created: 2026-10-15T06:00:20Z norm: '1' sig: e597b5fbc8e664ce -body_hash: cca617df6db2e076 -raw_hash: 93e262486d850a9c -vouched: 2026-10-22T21:13:07Z +body_hash: e0d9256e67814623 +raw_hash: a6afeafd1ea5bd24 +vouched: 2026-10-22T22:21:16Z lines: 281-561 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md b/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md index a6d52dcf..7199390f 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md @@ -4,9 +4,9 @@ anchor: function handle created: 2026-10-19T21:09:57Z norm: '1' sig: e597b5fbc8e664ce -body_hash: cca617df6db2e076 -raw_hash: 93e262486d850a9c -vouched: 2026-10-22T21:13:07Z +body_hash: e0d9256e67814623 +raw_hash: a6afeafd1ea5bd24 +vouched: 2026-10-22T22:21:16Z lines: 384-700 supersedes: 01M5APK9YCQH467YNMKAC8BT5B --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md b/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md index 747a13e8..0d2795bf 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md @@ -4,9 +4,9 @@ anchor: '@file' created: 2026-10-20T17:04:56Z norm: '1' sig: 1acdc0f6a0895ac5 -body_hash: 94af0091ce8ea05b -raw_hash: 43d4d53a6b1fecd9 -vouched: 2026-10-22T21:42:37Z +body_hash: d40dd86c1ca9274b +raw_hash: b0a5d34f855ac98b +vouched: 2026-10-22T22:21:16Z lines: 1-1153 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5HYM54QCSF4ESXJSWZK588G-every-project-op-that-change.md b/.lane/memory/apps/host/src/gateway.ts/01M5HYM54QCSF4ESXJSWZK588G-every-project-op-that-change.md index d4f13cd9..5757e5ef 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5HYM54QCSF4ESXJSWZK588G-every-project-op-that-change.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5HYM54QCSF4ESXJSWZK588G-every-project-op-that-change.md @@ -4,9 +4,9 @@ anchor: '@file' created: 2026-10-22T14:00:47Z norm: '1' sig: 1acdc0f6a0895ac5 -body_hash: 94af0091ce8ea05b -raw_hash: 43d4d53a6b1fecd9 -vouched: 2026-10-22T21:42:37Z +body_hash: d40dd86c1ca9274b +raw_hash: b0a5d34f855ac98b +vouched: 2026-10-22T22:21:16Z lines: 1-1309 --- diff --git a/apps/app/src/settings.tsx b/apps/app/src/settings.tsx index 864b06ab..a50a37e4 100644 --- a/apps/app/src/settings.tsx +++ b/apps/app/src/settings.tsx @@ -121,9 +121,9 @@ function Provider({ provider, disabled }: { provider: ProviderSettings; disabled {editing && ( diff --git a/apps/host/src/gateway.ts b/apps/host/src/gateway.ts index 3a920493..b6e1f260 100644 --- a/apps/host/src/gateway.ts +++ b/apps/host/src/gateway.ts @@ -685,7 +685,7 @@ async function handle( await setKey(request.provider, request.value); return settingsChanged(); case "key-check": - return checkKey(request.provider); + return checkKey(request.provider, settingsChanged); case "key-remove": await removeKey(request.provider); return settingsChanged(); diff --git a/apps/host/src/keys.ts b/apps/host/src/keys.ts index 4469f3d0..6d3c6cd2 100644 --- a/apps/host/src/keys.ts +++ b/apps/host/src/keys.ts @@ -85,11 +85,25 @@ const known = new Map(); /** When Ace Helper last saved or removed each name; a read started before then is stale. */ const changed = new Map(); +/** Names whose current value its provider rejected at the last check; a new value clears it. */ +const rejected = new Set(); + function change(name: string): void { known.delete(name); + rejected.delete(name); changed.set(name, performance.now()); } +/** Records a provider's verdict on `name`'s current value; true when that changes what Settings shows. */ +export function judge(name: string, accepted: boolean): boolean { + if (accepted !== rejected.has(name)) return false; + if (accepted) rejected.delete(name); + else rejected.add(name); + return true; +} + +export const isRejected = (name: string) => rejected.has(name); + export async function store(name: string, value: string): Promise { await Bun.secrets.set({ service: config.keychain, name, value }); change(name); diff --git a/apps/host/src/protocol.ts b/apps/host/src/protocol.ts index a1909037..bcfac83e 100644 --- a/apps/host/src/protocol.ts +++ b/apps/host/src/protocol.ts @@ -289,7 +289,12 @@ export type CredentialStatus = { waiting?: string; }; -export type ProviderSettings = CredentialStatus & { id: ProviderId; name: string }; +export type ProviderSettings = CredentialStatus & { + id: ProviderId; + name: string; + /** The provider rejected the key at its last check, and the key hasn't changed since. */ + rejected?: true; +}; export type Settings = { providers: ProviderSettings[]; diff --git a/apps/host/src/settings.ts b/apps/host/src/settings.ts index dc8775e7..2157a303 100644 --- a/apps/host/src/settings.ts +++ b/apps/host/src/settings.ts @@ -4,7 +4,17 @@ import { available } from "@ace/channel/models"; import { config } from "./config"; import * as directory from "./directory"; import { rejection } from "./http"; -import { forget, key, KEYCHAIN_WAITING, latest, overriding, settled, store } from "./keys"; +import { + forget, + isRejected, + judge, + key, + KEYCHAIN_WAITING, + latest, + overriding, + settled, + store, +} from "./keys"; import { models, parseModel } from "./manage"; import { canonical } from "./move"; import { @@ -41,7 +51,11 @@ export async function settings(fresh?: boolean): Promise { const [statuses, usable] = await Promise.all([ Promise.all( [...providers].map(async ([id, { name, key }]) => - Object.assign(await latest(key, fresh), { id, name }) + Object.assign( + await latest(key, fresh), + { id, name }, + isRejected(key) ? { rejected: true as const } : {}, + ) ), ), available(catalog.models).catch((error: Error) => error), @@ -56,6 +70,8 @@ export async function settings(fresh?: boolean): Promise { }; } +class Rejected extends Error {} + async function check(id: ProviderId, value: string): Promise { const selected = provider(id); const headers: Record = id === "anthropic" @@ -74,7 +90,7 @@ async function check(id: ProviderId, value: string): Promise { // Provider errors can echo credentials. Only status codes leave the host. await response.body?.cancel(); if (response.ok) return; - if (response.status === 401) throw new Error(`${selected.name} rejected this API key.`); + if (response.status === 401) throw new Rejected(`${selected.name} rejected this API key.`); if (response.status === 403) { throw new Error( `${selected.name} denied access. Check this key's permissions for listing models.`, @@ -105,11 +121,18 @@ export async function setKey(id: ProviderId, value: string): Promise { } } -export async function checkKey(id: ProviderId): Promise { +/** `changed` runs when the verdict changes what Settings shows for the key. */ +export async function checkKey(id: ProviderId, changed: () => void): Promise { const selected = provider(id); const value = await key(selected.key); if (!value) throw new Error(`Add a ${selected.name} API key first.`); - await check(id, value); + try { + await check(id, value); + } catch (error) { + if (error instanceof Rejected && judge(selected.key, false)) changed(); + throw error; + } + if (judge(selected.key, true)) changed(); } export async function removeKey(id: ProviderId): Promise {