From 5a422337f8fa3c1664b392360593c9fece75abe2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 11:35:23 +0800 Subject: [PATCH] fix(web): silence the stale gate hint, clear whitespace fields, narrow the paste heuristic --- ...-08-06-api-key-format-validation.i18n.yaml | 4 +-- .../2026-08-06-api-key-format-validation.md | 2 +- ...2026-08-06-api-key-format-validation.zh.md | 2 +- .../src/client/CustomProviderCard.tsx | 4 +++ .../ui-models/src/client/ProviderEditor.tsx | 7 ++++- .../client/ui-models/src/client/apiKey.ts | 14 +++++---- .../ui-models/tests/components.spec.tsx | 2 ++ .../ui-models/tests/provider-form.spec.tsx | 31 +++++++++++++++++++ 8 files changed, 55 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml index 2ffa228261..ae2d1d5934 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md -2026-08-06-api-key-format-validation.md: a0a99bfcace5422ed021d684c5d5aae48c197af7 -2026-08-06-api-key-format-validation.zh.md: b6dc836cbc7bc828f343d9d376dab6e5c3d424ee +2026-08-06-api-key-format-validation.md: 4666f6197dbed060d00c77fdd6b87842141c10f4 +2026-08-06-api-key-format-validation.zh.md: 75c98bd29cf009e69ceb450432f540e3f49d99d0 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md index a0a99bfcac..4666f6197d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md @@ -86,7 +86,7 @@ The client cannot import any of this: client packages reference only client pack A malformed key is refused at the field that holds it, and a malformed stored key fails as `INVALID_CREDENTIAL` with a message naming where to fix it and no fragment of the key. Because that code sits outside `DEFAULT_RETRYABLE_CODES`, a deterministic credential fault is no longer retried three times as a transport blip. `llm-pi-ai` discovery reports an illegal probe key as a credential fault instead of an unreachable endpoint. -The shape heuristic can refuse a real key. Upper-case-identifier-then-`=` and matched surrounding quotes are shapes no known provider issues, and the rule runs only in the browser, so a user who hits it can still set the credential through the environment. The residual cost is a confusing refusal for a key nobody has yet reported. +The shape heuristic can refuse a real key. The first draft matched any upper-case identifier followed by `=`, which review showed was broader than intended: an all-upper-case base64 key ending in padding (`ABCD==`) matched an assignment it does not resemble. Requiring a non-`=` character after the separator excludes padding, since base64 only ever pads at the end. What remains — an upper-case name, one `=`, then a value — is a shape no known provider issues, and the rule runs only in the browser, so a user who still hits it can set the credential through the environment. The residual cost is a confusing refusal for a key nobody has yet reported. Restricting to printable ASCII is stricter than the transport requires: a header value may carry `\x80`–`\xFF`. Admitting latin-1 would let `é` through to return an opaque 401 instead of a local, explained refusal, so the stricter rule is deliberate. A provider that issues latin-1 keys would need this rule widened. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md index b6dc836cbc..75c98bd29c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md @@ -86,7 +86,7 @@ Status: implemented 格式错误的 Key 在持有它的那个字段上就被拒绝;格式错误的已存储 Key 以 `INVALID_CREDENTIAL` 失败,消息指明修复位置且不含 Key 的任何片段。由于该 code 位于 `DEFAULT_RETRYABLE_CODES` 之外,一个确定性的凭据故障不再被当作瞬时传输抖动重试三次。`llm-pi-ai` 的探测把非法 Key 报为凭据故障,而非端点不可达。 -形状启发式可能拒绝一个真实的 Key。全大写标识符接 `=`、以及首尾成对引号,都是已知 provider 不会签发的形态,且该规则只在浏览器中运行,因此撞上它的用户仍可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。 +形状启发式可能拒绝一个真实的 Key。最初的写法匹配任意「全大写标识符接 `=`」,评审指出其覆盖面比预期更宽:一个以 padding 结尾的全大写 base64 Key(`ABCD==`)会命中它并不像的赋值形态。要求分隔符之后必须是非 `=` 字符即可排除 padding——base64 的 padding 只出现在末尾。剩下的形态(大写名称、一个 `=`、然后是值)是已知 provider 不会签发的,且该规则只在浏览器中运行,因此仍撞上它的用户可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。 限定为可打印 ASCII 比传输本身的要求更严:header value 是可以承载 `\x80`–`\xFF` 的。放行 latin-1 会让 `é` 通过并换回一个语焉不详的 401,而不是一次本地的、有解释的拒绝,因此从严是刻意的。若某个 provider 签发 latin-1 的 Key,这条规则需要放宽。 diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index a610f2f140..032a056144 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -93,6 +93,10 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { // because its own field already explains itself, and a satisfied card says // nothing at all rather than printing an empty paragraph. const hint = failure !== undefined || ready + // The key field prints its own failure directly beneath itself, so a card + // blocked only by the key stays silent here rather than answering with the + // next unmet gate — which is satisfied, and reads as a second, false fault. + || keyFailure !== undefined ? undefined : baseURL.length === 0 ? t('customNeedsBaseUrl') diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 79ac122c83..d72c71cc76 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -167,7 +167,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { return typeof value === 'string' && value.trim().length > 0 ? value : undefined } const setField = (key: string, next: string | undefined): void => { - setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next)) + // A value of nothing but whitespace is cleared, not stored: `stringAt` + // already reports it as absent, so the field would otherwise render empty + // while the draft still carried the spaces into `settings.yaml`, where + // both adapters would accept that non-empty string as a real value. + const value = next === undefined || next.trim().length === 0 ? undefined : next + setDraft(current => value === undefined ? deletePath(current, [key]) : setPath(current, [key], value)) } // The model list is validated by the same per-row checker for both families, diff --git a/packages/client/ui-models/src/client/apiKey.ts b/packages/client/ui-models/src/client/apiKey.ts index a9d5bb3d32..5fd1d22ee6 100644 --- a/packages/client/ui-models/src/client/apiKey.ts +++ b/packages/client/ui-models/src/client/apiKey.ts @@ -12,13 +12,15 @@ const LEGAL_API_KEY = /^[\x21-\x7E]+$/ /** - * A pasted `NAME=value` environment line. Restricted to an upper-case - * identifier so a real key cannot match: `sk-` forms break at the hyphen. - * This heuristic runs only here — a resolver applying it could lock a user - * out of a gateway whose key legitimately takes this shape, with the - * environment refusing it too and no way through. + * A pasted `NAME=value` environment line. Two narrowings keep real keys clear + * of it: the name must be upper-case, so `sk-` forms break at the hyphen, and + * the `=` must be followed by something other than another `=`, so base64 + * padding on an all-upper-case key (`ABCD==`) is not mistaken for an + * assignment. This heuristic runs only here — a resolver applying it could + * lock a user out of a gateway whose key legitimately takes this shape, with + * the environment refusing it too and no way through. */ -const ENV_LINE = /^[A-Z][A-Z0-9_]*=/ +const ENV_LINE = /^[A-Z][A-Z0-9_]*=[^=]/ /** Copy key naming why a typed key cannot be saved. */ export type ApiKeyFailureKey = 'keyBlank' | 'keyIllegalCharacters' | 'keyLooksWrapped' diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index d9034ecd44..7228d472cd 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -1092,6 +1092,8 @@ describe('apiKeyFailure', () => { ['a padded key, which the caller trims', ' sk-abc '], ['the printable-ASCII boundary characters', '!~'], ['a hyphenated key carrying an equals sign', 'sk-ABC=xyz'], + ['an all-upper-case key ending in base64 padding', 'ABCD=='], + ['an all-upper-case key ending in one padding character', 'MNOPQRST='], ])('accepts %s', (_label, draft) => { expect(apiKeyFailure(draft)).toBeUndefined() }) diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 85125919b5..5d505386e6 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -880,6 +880,22 @@ describe('hand-declared providers', () => { expect(set).not.toHaveBeenCalled() }) + it('stays silent about the other gates when only the key is refused', () => { + mountCard() + + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } }) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } }) + + // Route, endpoint, and models are all satisfied, so answering with the + // next unmet gate would print a second, false fault beside the real one. + expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy() + expect(screen.queryByText(en.customNeedsModels)).toBeNull() + expect(screen.queryByText(en.customNeedsBaseUrl)).toBeNull() + }) + it('tells a whitespace-only key what a blank field means on a create card', () => { const { mutate } = mountCard() @@ -927,6 +943,21 @@ describe('API key field', () => { expect(set).not.toHaveBeenCalled() }) + it('clears a whitespace-only base URL instead of writing the spaces', async () => { + const { mutate } = await mountSection() + openEditor('openai') + + // The field renders this as empty, so the draft must agree: storing the + // spaces would hand both adapters a non-empty string they accept as a URL. + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: ' ' } }) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(mutate).toHaveBeenCalled() }) + const ops = firstMutate(mutate).ops + expect(ops.some(op => op.op === 'set' && op.path.includes('baseURL'))).toBe(false) + expect(ops.some(op => op.op === 'unset' && op.path.includes('baseURL'))).toBe(true) + }) + it('blocks submit and names the field when the key holds only whitespace', async () => { const { mutate, set } = await mountSection() openEditor('openai')