Procházet zdrojové kódy

fix(web): validate custom provider base URLs

Turtle před 1 týdnem
rodič
revize
bc4fde06ac

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.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/architecture/2026-08-04-draft-provider-endpoint-interrogation.md
-2026-08-04-draft-provider-endpoint-interrogation.md: 462b8ba3e771018126aa6483a8cd72999361144d
-2026-08-04-draft-provider-endpoint-interrogation.zh.md: c7612513b95bc3dc9736b9bfaf70cca26a776181
+2026-08-04-draft-provider-endpoint-interrogation.md: 469ab38b0f5609808a9df03237d4d21c0080d843
+2026-08-04-draft-provider-endpoint-interrogation.zh.md: 74c6bc7ed73572d5e99ea973c9ea94d22319cbdf

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 2 - 3
.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 2 - 3
.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md


+ 2 - 2
packages/client/ui-settings-models/README.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 packages/client/ui-settings-models/README.md
-README.md: 8c852023f6c7c90a4aad67beef8c4a3081e5968e
-README.zh.md: 6b5fd016dbc17bced816a16192c80640f56b9ab2
+README.md: 283761d17f857c9142a59ee3b333081df84ecd9c
+README.zh.md: 70a5f035cb4ceb03f5d9d593d4ca69a702cbe9b1

+ 1 - 1
packages/client/ui-settings-models/README.md

@@ -39,7 +39,7 @@ The collapsed 自定义设置 fold carries the curated extras: `baseURL` for bot
 
 ### Adding and deleting providers
 
-The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each selected candidate copies its id, display name, context window, and output-token cap into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
+The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each selected candidate copies its id, display name, context window, and output-token cap into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
 
 ### First-run dialogs
 

+ 1 - 1
packages/client/ui-settings-models/README.zh.md

@@ -39,7 +39,7 @@ kind: "package-reference"
 
 ### 新增与删除提供方
 
-「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供方公布相应信息时,把 id、显示名、上下文窗口与最大输出 token 数复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。
+「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供方错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供方公布相应信息时,把 id、显示名、上下文窗口与最大输出 token 数复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。
 
 ### 首次运行弹窗
 

+ 17 - 3
packages/client/ui-settings-models/src/client/CustomProviderCard.tsx

@@ -47,6 +47,15 @@ const NS = 'llm-pi-ai'
  */
 const ROUTE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
 
+function isHttpUrl(value: string): boolean {
+  try {
+    const protocol = new URL(value).protocol
+    return protocol === 'http:' || protocol === 'https:'
+  } catch {
+    return false
+  }
+}
+
 /** Props of {@link CustomProviderCard}. */
 export interface CustomProviderCardProps {
   /** Route ids already declared, so the card refuses to shadow one. */
@@ -98,6 +107,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
 
   const routeInvalid = route.length > 0 && !ROUTE_PATTERN.test(route)
   const routeTaken = taken.includes(route)
+  const baseUrlInvalid = baseURL.length > 0 && !isHttpUrl(baseURL)
   // Rows are checked by the same per-row validator the editor cards use, so a
   // bad row is named by its position here too. Capacities have route-level
   // fallbacks; what a route cannot default is at least one model.
@@ -108,7 +118,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
   // legitimately authenticate through the provider's own ambient discovery.
   const keyValue = keyDraft.trim()
   const ready = route.length > 0 && !routeInvalid && !routeTaken
-    && baseURL.length > 0 && models.length > 0 && modelFailure === undefined
+    && baseURL.length > 0 && !baseUrlInvalid && models.length > 0 && modelFailure === undefined
     && keyFailure === undefined
   // The one blocked gate worth a line under the form. A satisfied card says
   // nothing at all rather than printing an empty paragraph.
@@ -120,7 +130,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
     // Same for the route id, and it must be tested rather than assumed: the
     // fallback arm below reads "no models yet", so an unmet route gate would
     // fall through to it and contradict the filled-in list right above.
-    || route.length === 0 || routeInvalid || routeTaken
+    || route.length === 0 || routeInvalid || routeTaken || baseUrlInvalid
     ? undefined
     : baseURL.length === 0
       ? t('customNeedsBaseUrl')
@@ -227,10 +237,12 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
           value={baseURL}
           placeholder={t('customBaseUrlPlaceholder')}
           aria-label={t('baseUrl')}
+          aria-invalid={baseUrlInvalid}
           disabled={profileDisabled}
           onChange={(event) => { setBaseURL(event.target.value) }}
         />
       </div>
+      {baseUrlInvalid ? <p className={styles['error']}>{t('customBaseUrlInvalid')}</p> : null}
       <div className={styles['field']}>
         <span className={styles['fieldLabel']}>{t('customApi')}</span>
         <select
@@ -271,7 +283,9 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
           api: protocol,
           ...keyValue.length === 0 ? {} : { apiKey: keyValue },
         }}
-        probeBlocked={keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure}
+        probeBlocked={baseUrlInvalid
+          ? 'customBaseUrlInvalid'
+          : keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure}
         operations={operations}
         t={t}
         disabled={profileDisabled}

+ 2 - 0
packages/client/ui-settings-models/src/client/locales.ts

@@ -86,6 +86,7 @@ export const en = {
   customApi: 'API protocol',
   customApiUnset: 'Not selected',
   customNeedsBaseUrl: 'A custom provider needs a base URL.',
+  customBaseUrlInvalid: 'Enter a valid HTTP or HTTPS URL.',
   customNeedsModels: 'A custom provider needs at least one model.',
   customBaseUrlPlaceholder: 'https://gateway.example/v1',
   settingsPathUnresolvable: 'unresolvable settings path',
@@ -192,6 +193,7 @@ export const zh: { [Key in keyof typeof en]: string } = {
   customApi: 'API 协议',
   customApiUnset: '未选择',
   customNeedsBaseUrl: '自定义提供方需要填写 API 地址。',
+  customBaseUrlInvalid: '请输入有效的 HTTP 或 HTTPS 地址。',
   customNeedsModels: '自定义提供方至少需要一个模型。',
   customBaseUrlPlaceholder: 'https://gateway.example/v1',
   settingsPathUnresolvable: '无法解析设置路径',

+ 47 - 0
packages/client/ui-settings-models/tests/provider-form.client.spec.tsx

@@ -1079,6 +1079,53 @@ describe('hand-declared providers', () => {
     expect(screen.getByText(en.customRouteTaken).className).toMatch(/error/)
   })
 
+  it.each(['not-a-url', 'localhost:11434', 'ftp://gateway.acme.example/v1'])(
+    'rejects the non-HTTP base URL %j before discovery or creation', (baseURL) => {
+      const { discover, mutate } = mountCard()
+      fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
+      fireEvent.click(screen.getByRole('button', { name: en.addModel }))
+      fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
+      fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: baseURL } })
+
+      expect(screen.getByText(en.customBaseUrlInvalid)).toBeTruthy()
+      expect(screen.getByLabelText(en.baseUrl).getAttribute('aria-invalid')).toBe('true')
+      expect(buttonNamed(en.fetchModels).disabled).toBe(true)
+      expect(buttonNamed(en.fetchModels).title).toBe(en.customBaseUrlInvalid)
+      expect(buttonNamed(en.create).disabled).toBe(true)
+      expect(discover).not.toHaveBeenCalled()
+      expect(mutate).not.toHaveBeenCalled()
+    },
+  )
+
+  it.each([
+    'http://localhost:11434/v1',
+    'http://127.0.0.1:8080/v1',
+    'http://[::1]:8080/v1',
+    'https://gateway.acme.example:8443/v1',
+  ])('allows the HTTP base URL %j to be interrogated', (baseURL) => {
+    const { discover } = mountCard()
+    fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
+    fireEvent.click(screen.getByRole('button', { name: en.addModel }))
+    fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
+    fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: baseURL } })
+
+    expect(screen.queryByText(en.customBaseUrlInvalid)).toBeNull()
+    expect(buttonNamed(en.fetchModels).disabled).toBe(false)
+    expect(buttonNamed(en.create).disabled).toBe(false)
+    fireEvent.click(screen.getByText(en.fetchModels))
+    expect(firstProbe(discover)).toMatchObject({ baseURL })
+  })
+
+  it('keeps a network failure distinct from base URL syntax', async () => {
+    const discover = vi.fn(() => Promise.resolve(fail('connection refused', 'gateway/internal')))
+    mountCard({}, { discover })
+    fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'http://localhost:11434/v1' } })
+    fireEvent.click(screen.getByText(en.fetchModels))
+
+    await screen.findByText('connection refused')
+    expect(screen.queryByText(en.customBaseUrlInvalid)).toBeNull()
+  })
+
   it('derives a reference the credential seam accepts for every id it admits', () => {
     // The two rules have to stay in step; this is the relation, checked
     // directly rather than through the DOM.

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů