From ef3a02167a0cfd2a25c571072fcd1544bb9089cc Mon Sep 17 00:00:00 2001 From: Magolor Date: Tue, 3 Nov 2026 20:31:45 +0800 Subject: [PATCH] fix(settings): preserve saved API key mask length --- .../tests/deepseek-messages-settings.e2e.ts | 8 ++-- .../cards.expected.md | 2 +- .../default-models.expected.md | 2 +- .../models.expected.md | 2 +- apps/web/tests/models-settings.e2e.ts | 2 +- .../tests/onboarding-deepseek-config.e2e.ts | 7 ++-- docs/event-producer-consumer.md | 4 +- docs/event-producer-consumer.zh.md | 4 +- docs/subsystems/credentials.i18n.yaml | 4 +- docs/subsystems/credentials.md | 6 ++- docs/subsystems/credentials.zh.md | 6 ++- docs/user/guide/providers.i18n.yaml | 4 +- docs/user/guide/providers.md | 2 +- docs/user/guide/providers.zh.md | 2 +- .../api/settings-controller/README.i18n.yaml | 4 +- packages/api/settings-controller/README.md | 2 +- packages/api/settings-controller/README.zh.md | 2 +- .../settings-controller/src/credentials.ts | 1 + .../tests/credentials-controller.host.spec.ts | 5 ++- .../ui-settings-models/README.i18n.yaml | 8 ++-- packages/client/ui-settings-models/README.md | 4 +- .../client/ui-settings-models/README.zh.md | 4 +- .../src/client/ApiKeyInput.tsx | 5 ++- .../src/client/ProviderEditor.tsx | 1 + .../tests/components.client.spec.tsx | 41 ++++++++++++------- .../credentials-local/README.i18n.yaml | 4 +- .../credentials/credentials-local/README.md | 4 +- .../credentials-local/README.zh.md | 4 +- .../credentials-local/src/index.ts | 2 +- .../credentials-local/tests/local.spec.ts | 15 ++++--- .../credentials/credentials/README.i18n.yaml | 4 +- packages/credentials/credentials/README.md | 4 +- packages/credentials/credentials/README.zh.md | 4 +- packages/credentials/credentials/src/index.ts | 2 +- packages/credentials/credentials/src/types.ts | 2 + .../extensions/tool-cordis/src/api-catalog.ts | 4 +- .../tests/loader-composition.spec.ts | 7 +++- 37 files changed, 108 insertions(+), 80 deletions(-) diff --git a/apps/web/tests/deepseek-messages-settings.e2e.ts b/apps/web/tests/deepseek-messages-settings.e2e.ts index 2c29f0ca0e..3d88fdbf6c 100644 --- a/apps/web/tests/deepseek-messages-settings.e2e.ts +++ b/apps/web/tests/deepseek-messages-settings.e2e.ts @@ -135,7 +135,7 @@ it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webki const edit = dialog.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)', exact: true }) await edit.click() const key = dialog.getByLabel('API 密钥', { exact: true }) - expect(await key.getAttribute('placeholder')).toBe('••••••••') + expect(await key.getAttribute('placeholder')).toBe('•'.repeat('fixture-protected-old-key'.length)) expect(await key.inputValue()).toBe('') expect(await dialog.getByRole('button', { name: '显示密钥' }).isDisabled()).toBe(true) expect(await page.locator('body').innerHTML()).not.toContain('fixture-protected-old-key') @@ -144,7 +144,7 @@ it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webki expect(await key.getAttribute('placeholder')).toBe('留空使用环境认证(DEEPSEEK_API_KEY)') await dialog.getByRole('button', { name: '取消', exact: true }).click() await edit.click() - expect(await key.getAttribute('placeholder')).toBe('••••••••') + expect(await key.getAttribute('placeholder')).toBe('•'.repeat('fixture-protected-old-key'.length)) await key.fill('fixture-new-key') await dialog.getByText('保存以替换已保存的 API key。', { exact: true }).waitFor() await dialog.getByRole('button', { name: '显示密钥', exact: true }).click() @@ -157,7 +157,7 @@ it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webki await dialog.getByRole('button', { name: '保存', exact: true }).click() await expect.poll(() => key.count()).toBe(0) await edit.click() - expect(await key.getAttribute('placeholder')).toBe('••••••••') + expect(await key.getAttribute('placeholder')).toBe('•'.repeat('fixture-new-key'.length)) expect(await key.inputValue()).toBe('') expect(await key.getAttribute('type')).toBe('password') expect(await dialog.getByRole('button', { name: '显示密钥' }).isDisabled()).toBe(true) @@ -248,7 +248,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett const messages = dialog const key = messages.getByLabel('API 密钥', { exact: true }) expect(await key.inputValue()).toBe('') - expect(await key.getAttribute('placeholder')).toBe('••••••••') + expect(await key.getAttribute('placeholder')).toBe('•'.repeat('sk-messages-onboarding'.length)) await messages.getByText('使用已保存的 API key。', { exact: true }).waitFor() await messages.getByText('自定义设置', { exact: true }).click() expect(await messages.getByLabel('API 地址', { exact: true }).getAttribute('placeholder')) diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index a3878a3bdf..1304d5467b 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -17,7 +17,7 @@ - text: DeepSeek deepseek-official API 密钥 - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: •••••••• + - /placeholder: •••••••••••••••••••••• - button "显示密钥" [disabled] - paragraph: 使用已保存的 API key。 - group: diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index 003f32f36d..40f08ec904 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -17,7 +17,7 @@ - text: DeepSeek deepseek-official API 密钥 - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: •••••••• + - /placeholder: •••••••••••••••••••••••••••••••••••••••••••• - button "显示密钥" [disabled] - paragraph: 使用已保存的 API key。 - group: diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index 43cbbb0f28..509fbd7745 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -17,7 +17,7 @@ - text: DeepSeek deepseek-official API 密钥 - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: •••••••• + - /placeholder: •••••••••••••••••••••••••••••••••••••••••••• - button "显示密钥" [disabled] - paragraph: 使用已保存的 API key。 - group: diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 8ab5654d88..d467ccc230 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -221,7 +221,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-customized')) const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click() - await dialog.getByPlaceholder('••••••••', { exact: true }).waitFor({ timeout: 10_000 }) + await dialog.getByPlaceholder('•'.repeat('sk-e2e-minimax'.length), { exact: true }).waitFor({ timeout: 10_000 }) expect(await dialog.textContent()).not.toContain('API 密钥来源:') expect(await dialog.textContent()).not.toMatch(/oauth/i) await dialog.getByText('自定义设置').click() diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index b8a66b6600..70efd8dbb2 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -33,6 +33,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup let page: Page let tripwire: ReturnType const browserConsole: string[] = [] + const secret = `dsh_onboarding_${randomBytes(12).toString('hex')}_test` beforeAll(async () => { scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, welcomeNoticePending: true }) @@ -86,8 +87,6 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup const initial = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE) - // Keep the displayed prefix and suffix stable while the credential remains unique. - const secret = `dsh_onboarding_${randomBytes(12).toString('hex')}_test` await keyInput.fill(secret) await credentialStep.getByRole('button', { name: '保存并继续' }).click() await credentialStep.waitFor({ state: 'detached', timeout: 15_000 }) @@ -116,7 +115,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await expect.poll( () => configuredInput.getAttribute('placeholder'), { timeout: 10_000 }, - ).toBe('••••••••') + ).toBe('•'.repeat(secret.length)) const secondReloadWarnings = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) @@ -258,7 +257,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await expect.poll( () => settings.getByLabel('API 密钥', { exact: true }).getAttribute('placeholder'), { timeout: 10_000 }, - ).toBe('••••••••') + ).toBe('•'.repeat(secret.length)) const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE) await settings.getByRole('button', { name: '保存', exact: true }).click() diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 7e1a837be5..f309851827 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -40,8 +40,8 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:401`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:371`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:377`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | -| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:99`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform), `remotes` | -| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:87`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`mcp-client`](../packages/mcp/mcp-client), `remotes` | +| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:101`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform), `remotes` | +| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:89`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`mcp-client`](../packages/mcp/mcp-client), `remotes` | | `deepseek-account/model-sign-in-required` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:97`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account`](../packages/credentials/deepseek-account) (`emit`) | `remotes` | | `deepseek-account/session-expired` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:93`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | `account-controller`, `remotes` | | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:12`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 85f0623eaf..2924a21051 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -42,8 +42,8 @@ | `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:401`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:371`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:377`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | -| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:99`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform), `remotes` | -| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:87`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`mcp-client`](../packages/mcp/mcp-client), `remotes` | +| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:101`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform), `remotes` | +| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:89`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`mcp-client`](../packages/mcp/mcp-client), `remotes` | | `deepseek-account/model-sign-in-required` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:97`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account`](../packages/credentials/deepseek-account) (`emit`) | `remotes` | | `deepseek-account/session-expired` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:93`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | `account-controller`, `remotes` | | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:12`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) | diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index bad9827407..ee4f21fa62 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.i18n.yaml @@ -12,8 +12,8 @@ en: baaab4bca76bea6b zh: 54ccdc03ffd44f63 /user-credentials/description: - en: f7b09588c0f34952 - zh: 1b93d673c5bf6a80 + en: ee7e06c97c9e8fed + zh: fefff12b380ac76b /user-credentials/change-commits: en: 464315a953330eb2 zh: d9815591f3abc2cb diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index 2a084fb29f..2776b3fe2d 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -32,7 +32,7 @@ interface ResolvedCredential { ## Description -`describe(ref)` answers configuration surfaces without ever exposing a value: whether the reference resolves, from which layer, and whether `set` would currently succeed. The local provider ranks its managed file above the launch environment, so saving a value always creates the effective writable layer; removing it reveals the launch environment or `.env` fallback again. +`describe(ref)` answers configuration surfaces without ever exposing a value: whether the reference resolves, from which layer, and whether `set` would currently succeed. The optional `length` discloses the stored value’s UTF-16 length without key characters. The local provider ranks its managed file above the launch environment, so saving a value always creates the effective writable layer; removing it reveals the launch environment or `.env` fallback again. ```ts type-equiv /** @@ -47,6 +47,8 @@ interface CredentialInfo { source?: string /** Whether the active provider can write this reference. */ writable: boolean + /** Stored value length in UTF-16 code units, when disclosed; never the value. */ + length?: number } ``` @@ -156,7 +158,7 @@ abstract resolve(ref: CredentialRef): Promise * Describe one reference for configuration surfaces without exposing the * value. * @param ref - the reference to describe. - * @returns configured state, supplying source, and writability. + * @returns configured state, supplying source, writability, and optional stored-value length. */ abstract describe(ref: CredentialRef): Promise diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index 1e9221699a..895af599a9 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -32,7 +32,7 @@ interface ResolvedCredential { ## 描述 -`describe(ref)` 在绝不暴露值的前提下回应配置界面:引用当前是否可解析、来自哪一层、`set` 当前能否成功。本地提供方将其管理的文件排在启动环境之前,因此保存值总会成为生效且可写的那一层;移除后会重新使用启动环境或 `.env` 后备值。 +`describe(ref)` 在绝不暴露值的前提下回应配置界面:引用当前是否可解析、来自哪一层、`set` 当前能否成功。可选的 `length` 只描述已保存值的 UTF-16 长度,不包含密钥字符。本地提供方将其管理的文件排在启动环境之前,因此保存值总会成为生效且可写的那一层;移除后会重新使用启动环境或 `.env` 后备值。 ```ts type-equiv /** @@ -47,6 +47,8 @@ interface CredentialInfo { source?: string /** Whether the active provider can write this reference. */ writable: boolean + /** Stored value length in UTF-16 code units, when disclosed; never the value. */ + length?: number } ``` @@ -156,7 +158,7 @@ abstract resolve(ref: CredentialRef): Promise * Describe one reference for configuration surfaces without exposing the * value. * @param ref - the reference to describe. - * @returns configured state, supplying source, and writability. + * @returns configured state, supplying source, writability, and optional stored-value length. */ abstract describe(ref: CredentialRef): Promise diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index ddb8a4c70a..973892eac6 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -9,8 +9,8 @@ en: 44852da64a6a39ec zh: 4c280e428f0fec08 /configure-models/add-a-third-party-provider: - en: e813f9e5aaa213fc - zh: 2257b5b45b9f54b5 + en: 268a2069183197f4 + zh: 308efb099ff923f6 /configure-models/add-a-custom-model-api: en: 1f55d7d94cbfbd80 zh: 94e5a8bc7625d760 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 8509e4ce30..49fa14336b 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -20,7 +20,7 @@ Choose **Add model provider**. The card opens on **Third-party model provider**: The authentication **?** explains saved-credential priority and environment fallbacks. Leave the key blank to use a saved credential or try the listed environment variables. A green hint identifies a detected environment variable. A yellow hint means no credential variable was detected; enter an API key or configure the listed variable. An unconfirmed hint means the status read failed. Detection does not validate the credential or test the connection. Provider-managed credential files, including AWS profiles and Google application-default credentials, are outside this check. Providers without native key authentication, such as `openai-codex`, show no key field unless the profile explicitly names a credential reference. -A saved credential displays a fixed mask in the input and a green hint that the saved key will be used. The mask does not expose the key or its length. The existing eye button is disabled for saved keys, which cannot be revealed, copied, cut, or partially edited; typing or pasting replaces the whole key. Backspace or Delete stages whole-key removal. Press Backspace or Delete, then **Save**, to remove the stored override and use environment authentication. Emptying a replacement also stages deletion of the saved key. **Cancel** preserves the saved key before saving. A refused deletion leaves settings unchanged. If the key is removed but settings fail, the warning reports that deletion cannot be undone; retry **Save** to finish settings, or **Close** to leave the partial result. Clearing a shared credential affects every feature using the same reference. Without an environment credential, the provider has no authentication after clearing. Environment fallback uses **Leave blank to use env as auth** and one detection hint while the input is empty. A replacement draft shows a reminder to apply the new key. Pending removal names the environment variable used after applying, when the route declares one. Other nonempty inputs show no authentication status text. Address configuration variables appear under **Base URL** and do not count as credentials. +A saved credential displays one dot per character in the input and a green hint that the saved key will be used. The mask exposes only the key’s length, with no key characters. The existing eye button is disabled for saved keys, which cannot be revealed, copied, cut, or partially edited; typing or pasting replaces the whole key. Backspace or Delete stages whole-key removal. Press Backspace or Delete, then **Save**, to remove the stored override and use environment authentication. Emptying a replacement also stages deletion of the saved key. **Cancel** preserves the saved key before saving. A refused deletion leaves settings unchanged. If the key is removed but settings fail, the warning reports that deletion cannot be undone; retry **Save** to finish settings, or **Close** to leave the partial result. Clearing a shared credential affects every feature using the same reference. Without an environment credential, the provider has no authentication after clearing. Environment fallback uses **Leave blank to use env as auth** and one detection hint while the input is empty. A replacement draft shows a reminder to apply the new key. Pending removal names the environment variable used after applying, when the route declares one. Other nonempty inputs show no authentication status text. Address configuration variables appear under **Base URL** and do not count as credentials. A nonempty key input has an eye button. Select **Show secret** to check what you typed, and **Hide secret** to mask it again. Changing the input masks it again. Native password fields do not offer cut; select the draft and press Backspace to erase it, or reveal it before cutting. Stored credentials remain write-only. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index af8f80bcf7..b519b308fe 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -20,7 +20,7 @@ 认证 **?** 说明已保存凭据的优先级和环境后备。密钥留空时,使用已保存凭据,或尝试读取列出的环境变量。绿色提示标明已检测到环境变量。黄色提示表示未检测到凭据变量;请输入 API 密钥或配置列出的变量。状态未确认表示读取失败。检测不验证凭据,也不测试连接。AWS profile 和 Google 应用默认凭据等提供商管理的凭据文件不在检查范围内。没有原生密钥认证的提供商(例如 `openai-codex`)不显示密钥字段,除非 profile 显式指定了凭据引用。 -已保存凭据在输入框中显示固定掩码,下方显示绿色的使用已保存密钥提示。掩码不暴露密钥或长度。已保存密钥禁用原有眼睛按钮,不能显示、复制、剪切或局部编辑;键入或粘贴会整项替换。Backspace 或 Delete 会暂存整项删除。按 Backspace 或 Delete,再点**保存**,即可移除存储覆盖并使用环境认证。清空替换草稿也会暂存删除已保存密钥。保存前,**取消**会保留密钥。删除被拒绝时,设置保持不变。密钥已删除但设置失败时,警告会说明此删除无法撤销;可重试**保存**完成配置更新,或**关闭**并保留部分提交结果。清除共享凭据会影响使用同一引用的全部功能。没有环境凭据时,清除后提供商将没有认证。环境后备使用**留空使用环境认证**占位文字,输入留空时显示一条检测提示。替换草稿显示保存以替换密钥的提示。待删除提示会列出保存后使用的环境变量名(路由声明该变量时)。其他非空输入不显示认证状态文字。地址配置变量显示在**基础 URL**下方,不计作凭据。 +已保存凭据在输入框中按每个字符一个点显示掩码,下方显示绿色的使用已保存密钥提示。掩码只反映密钥长度,不暴露密钥字符。已保存密钥禁用原有眼睛按钮,不能显示、复制、剪切或局部编辑;键入或粘贴会整项替换。Backspace 或 Delete 会暂存整项删除。按 Backspace 或 Delete,再点**保存**,即可移除存储覆盖并使用环境认证。清空替换草稿也会暂存删除已保存密钥。保存前,**取消**会保留密钥。删除被拒绝时,设置保持不变。密钥已删除但设置失败时,警告会说明此删除无法撤销;可重试**保存**完成配置更新,或**关闭**并保留部分提交结果。清除共享凭据会影响使用同一引用的全部功能。没有环境凭据时,清除后提供商将没有认证。环境后备使用**留空使用环境认证**占位文字,输入留空时显示一条检测提示。替换草稿显示保存以替换密钥的提示。待删除提示会列出保存后使用的环境变量名(路由声明该变量时)。其他非空输入不显示认证状态文字。地址配置变量显示在**基础 URL**下方,不计作凭据。 密钥输入非空时,输入框内显示眼睛按钮。选择**显示密钥**检查当前输入,选择**隐藏密钥**重新遮住输入。修改输入后会重新遮住密钥。原生密码框不提供剪切;可选中草稿后按 Backspace 清空,或显示明文后再剪切。已保存凭据仍保持只写。 diff --git a/packages/api/settings-controller/README.i18n.yaml b/packages/api/settings-controller/README.i18n.yaml index 4b3f0d24d3..5ad2ca1008 100644 --- a/packages/api/settings-controller/README.i18n.yaml +++ b/packages/api/settings-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 3a654e12f423946b zh: 4e3a19aaee58f8a7 /settings-controller/use-this-package: - en: cf2f4de5bee8be55 - zh: b2630dcba4be5ae8 + en: 919b101f30707609 + zh: 5c5aec141b5d7bcf /settings-controller/configuration: en: e0297f144b649af7 zh: e70ed56edffd97f0 diff --git a/packages/api/settings-controller/README.md b/packages/api/settings-controller/README.md index b38072546d..3ce09e810e 100644 --- a/packages/api/settings-controller/README.md +++ b/packages/api/settings-controller/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) Mount this package as a Loader entry in a profile that serves browser configuration. The entry registers both namespaces independently of their providers so a missing provider produces a named configuration error at invocation. Its generated descriptors enter the strict Typert registry, while the settings and credential Definitions remain plain Cordis Services with no wire obligations of their own. -`describe(refs)` answers one map keyed by the requested names, so a settings page describing every reference its rows carry settles those rows together. It accepts at most 64 names per call, reports an invalid name or empty write value as `bad-request`, and copies each answer field by field — a provider returning more than `CredentialInfo` declares cannot widen what crosses. Valid `set(ref, value)` and `unset(ref)` calls report a provider refusal as `credential-rejected`, carrying the provider's message with only the reference in its details. Secret values cross in this direction only: no method here returns one. +`describe(refs)` answers one map keyed by the requested names, so a settings page describing every reference its rows carry settles those rows together. It accepts at most 64 names per call, reports an invalid name or empty write value as `bad-request`, and copies each answer field by field — a provider returning more than `CredentialInfo` declares cannot widen what crosses. The view may include the stored value’s `length`, without any key characters. Valid `set(ref, value)` and `unset(ref)` calls report a provider refusal as `credential-rejected`, carrying the provider's message with only the reference in its details. Secret values cross in this direction only: no method here returns one. `settings.describe()` returns deployment facts and every namespace under `redactSecrets: true`. `settings.update`, `settings.replace`, and `settings.mutate` expose the settings service's three write operations and return the namespace's new redacted view; stale writes use `settings-conflict` and other provider refusals use `settings-rejected`. diff --git a/packages/api/settings-controller/README.zh.md b/packages/api/settings-controller/README.zh.md index 3c9e876740..f6bb656b00 100644 --- a/packages/api/settings-controller/README.zh.md +++ b/packages/api/settings-controller/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" 请把本包作为 Loader entry 挂载到提供浏览器配置的 profile 中。本 entry 不依赖提供方是否存在而注册两个 namespace,因此缺少提供方会在调用时产生具名配置错误。它生成的 descriptor 进入严格 Typert 注册表,而 settings 与凭据 Definition 仍是普通 Cordis 服务,自身不承担任何 wire 义务。 -`describe(refs)` 以请求的名字为键返回一份 map,因此设置页描述其各行携带的全部引用时,这些行会一起落定。单次调用最多接受 64 个名字,无效名字或空写入值报告为 `bad-request`,并逐字段复制每个答案——提供方返回超出 `CredentialInfo` 声明的内容也无法扩大跨越 wire 的字段。有效的 `set(ref, value)` 与 `unset(ref)` 调用把提供方拒绝报告为 `credential-rejected`,携带提供方的消息,details 中只有该引用。机密值只在这个方向跨越 wire:这里没有任何方法会返回它。 +`describe(refs)` 以请求的名字为键返回一份 map,因此设置页描述其各行携带的全部引用时,这些行会一起落定。单次调用最多接受 64 个名字,无效名字或空写入值报告为 `bad-request`,并逐字段复制每个答案——提供方返回超出 `CredentialInfo` 声明的内容也无法扩大跨越 wire 的字段。视图可包含已保存值的 `length`,不含密钥字符。有效的 `set(ref, value)` 与 `unset(ref)` 调用把提供方拒绝报告为 `credential-rejected`,携带提供方的消息,details 中只有该引用。机密值只在这个方向跨越 wire:这里没有任何方法会返回它。 `settings.describe()` 返回部署信息,以及在 `redactSecrets: true` 下读取的所有 namespace。`settings.update`、`settings.replace` 与 `settings.mutate` 暴露 settings 服务的三种写入操作,并返回该 namespace 的新脱敏视图;陈旧写入使用 `settings-conflict`,其他提供方拒绝使用 `settings-rejected`。 diff --git a/packages/api/settings-controller/src/credentials.ts b/packages/api/settings-controller/src/credentials.ts index 3889669048..6e66596206 100644 --- a/packages/api/settings-controller/src/credentials.ts +++ b/packages/api/settings-controller/src/credentials.ts @@ -47,6 +47,7 @@ function projectCredentialInfo(info: CredentialInfo): CredentialInfo { configured: info.configured, ...info.source === undefined ? {} : { source: info.source }, writable: info.writable, + ...info.length === undefined ? {} : { length: info.length }, } } diff --git a/packages/api/settings-controller/tests/credentials-controller.host.spec.ts b/packages/api/settings-controller/tests/credentials-controller.host.spec.ts index 5eeb190385..4b3c335a51 100644 --- a/packages/api/settings-controller/tests/credentials-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/credentials-controller.host.spec.ts @@ -9,7 +9,7 @@ import { MemoryCredentials } from '../../../credentials/credentials/tests/memory class LeakyCredentials extends MemoryCredentials { override describe(): Promise { return Promise.resolve( - { configured: true, source: 'memory', writable: true, value: 'sk-leaked' } as CredentialInfo, + { configured: true, source: 'memory', writable: true, length: 9, value: 'sk-leaked', preview: 'sk…ed' } as CredentialInfo, ) } } @@ -101,8 +101,9 @@ describe('the credentials Remote namespace a configuration surface calls', () => it('answers only the fields the view declares, whatever a provider returns', async () => { const controller = await boot({}, LeakyCredentials) const described = await controller.describe(['DEEPSEEK_API_KEY']) - expect(described.DEEPSEEK_API_KEY).toEqual({ configured: true, source: 'memory', writable: true }) + expect(described.DEEPSEEK_API_KEY).toEqual({ configured: true, source: 'memory', writable: true, length: 9 }) expect(JSON.stringify(described)).not.toContain('sk-leaked') + expect(JSON.stringify(described)).not.toContain('sk…ed') }) it('stores and removes through the same references the batch describes', async () => { diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 5cb4d4bc1f..7812058b5b 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -18,11 +18,11 @@ en: db5aaf7f702e1f0c zh: 409a78c14fb2b6e3 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/api-keys: - en: 2075f43f7f5a25f9 - zh: d88830eb581067b7 + en: f335b529edb55754 + zh: 30e4b43b19b2da40 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider: - en: 6756a578138e88bf - zh: 371611209d82149d + en: 2c55f1b66c9452bf + zh: adea0305177c8012 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers: en: 59472e717c2f87be zh: dc2a51a5a40138bc diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index a4baf12d9e..0a62302c2b 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -41,7 +41,7 @@ Host configuration `credentialOnboarding` defaults to `true`. The Electron prelo ### API keys -API-key inputs use `autocomplete="new-password"` to ask browsers not to autofill saved login passwords. A nonempty draft has the same eye control as masked conversation text: **Show secret** reveals the current input and **Hide secret** masks it. Changing or clearing the input masks it again. Saved keys display a fixed mask as a placeholder, never a credential value or its length. They disable the existing reveal control and block copy/cut. Typing or pasting replaces the whole key; Backspace or Delete stages whole-key removal. Stored credentials are never read into the input. Native password fields do not offer cut; select and press Backspace to erase a draft, or reveal it before cutting. +API-key inputs use `autocomplete="new-password"` to ask browsers not to autofill saved login passwords. A nonempty draft has the same eye control as masked conversation text: **Show secret** reveals the current input and **Hide secret** masks it. Changing or clearing the input masks it again. Saved keys display one placeholder dot per character, using only stored-length metadata; no key characters reach the Client. A provider that omits length metadata shows the saved-key notice without inventing a mask length. They disable the existing reveal control and block copy/cut. Typing or pasting replaces the whole key; Backspace or Delete stages whole-key removal. Stored credentials are never read into the input. Native password fields do not offer cut; select and press Backspace to erase a draft, or reveal it before cutting. The primary field on an editor card is a single **API key** input — the page never asks for an environment-variable name. A typed key stores write-only through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `cordis.patch.yml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and preserves provider-native authentication (for example the Bedrock credential chain or Vertex ADC). The field's placeholder and help tooltip use the profile's `apiKeyEnv` when it has one; otherwise they use the credential variables of the route's `nativeAuth`, excluding `baseURLEnv` address variables, in lookup order, as the configurable-provider directory reports them. These display hints omit references containing `oauth` (case-insensitively), and describe saved credentials followed by the displayed environment fallbacks. This presentation filter does not change the [credential resolution order](../../../docs/subsystems/credentials.md#resolution), the adapter's complete environment list, or an explicit reference. A filtered explicit reference still has a usable key input with the plain placeholder and no tooltip. A native route that has no key authentication omits the key field and authentication help entirely; an explicit reference still enables the field. A profile without a reference on a route without `nativeAuth`, such as a hand-declared route, shows the plain key placeholder and no tooltip. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing. A successful Apply emits a local accessible status message without echoing secret material. @@ -49,7 +49,7 @@ The editor uses redacted `CredentialInfo` to choose the saved-credential placeho ### Editing a provider -Leaving the saved mask untouched preserves existing authentication. A saved credential shows a fixed input mask and a green hint that the saved key will be used. Otherwise the field offers environment authentication. Backspace or Delete on a saved mask stages removal until Save; Cancel preserves the key. Emptying a replacement also stages removal of the saved key. No separate clear button is added. Entering a new key replaces the removal draft. A refused deletion leaves settings unchanged. If deletion succeeds but settings fail, the editor reports the completed deletion and offers Save to retry settings, with Close leaving that partial result in place. A catalog route drops its user-layer derived reference to restore native authentication; aliases that differ from `_API_KEY`, inherited references, and custom routes retain their reference for environment fallback. A manually written conventional reference is treated like a page-derived reference. Hidden OAuth references have no saved API-key mask or clear control. A pending removal disables model discovery until saved. Redacted `credentials.describe` checks distinguish a detected environment reference, no detected variables, and an unconfirmed read. A missing-variable hint uses the warning color and names the first variable to configure. A detected environment reference uses green. Address configuration variables do not count as credentials. Hidden authentication references still participate in detection; a generic detected hint keeps their names hidden. Detection does not validate credentials, test a connection, or inspect provider-managed files such as AWS profiles and Google application-default credentials. A replacement draft shows a reminder to apply the new key. Pending removal names the environment variable used after applying, when the route declares one. Other nonempty inputs show no authentication status text. The page reads native variable names as well as named references, in batches of at most 64, and never reads their values into the Client. +Leaving the saved mask untouched preserves existing authentication. A saved credential shows a mask matching its length and a green hint that the saved key will be used. Otherwise the field offers environment authentication. Backspace or Delete on a saved mask stages removal until Save; Cancel preserves the key. Emptying a replacement also stages removal of the saved key. No separate clear button is added. Entering a new key replaces the removal draft. A refused deletion leaves settings unchanged. If deletion succeeds but settings fail, the editor reports the completed deletion and offers Save to retry settings, with Close leaving that partial result in place. A catalog route drops its user-layer derived reference to restore native authentication; aliases that differ from `_API_KEY`, inherited references, and custom routes retain their reference for environment fallback. A manually written conventional reference is treated like a page-derived reference. Hidden OAuth references have no saved API-key mask or clear control. A pending removal disables model discovery until saved. Redacted `credentials.describe` checks distinguish a detected environment reference, no detected variables, and an unconfirmed read. A missing-variable hint uses the warning color and names the first variable to configure. A detected environment reference uses green. Address configuration variables do not count as credentials. Hidden authentication references still participate in detection; a generic detected hint keeps their names hidden. Detection does not validate credentials, test a connection, or inspect provider-managed files such as AWS profiles and Google application-default credentials. A replacement draft shows a reminder to apply the new key. Pending removal names the environment variable used after applying, when the route declares one. Other nonempty inputs show no authentication status text. The page reads native variable names as well as named references, in batches of at most 64, and never reads their values into the Client. An endpoint without a fixed catalog default shows the provider's `baseURLEnv` configuration names below the input. A profile endpoint remains the first placeholder choice. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 5ab65ddae9..129381ff47 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -41,7 +41,7 @@ Host 配置 `credentialOnboarding` 默认为 `true`。Electron preload 标记会 ### API 密钥 -API 密钥输入框通过 `autocomplete="new-password"` 请求浏览器不要自动填入已保存的登录密码。输入非空时,显示与会话密钥遮罩一致的眼睛按钮:**显示密钥**展示当前输入,**隐藏密钥**重新遮住输入。修改或清空输入后会重新遮住密钥。已保存密钥以固定掩码占位显示,不表示凭据值或长度;禁用原有显示按钮,并阻止复制和剪切。键入或粘贴会整项替换,Backspace 或 Delete 会暂存整项删除。已保存凭据从不读入输入框。原生密码框不提供剪切;可选中后按 Backspace 清空草稿,或显示明文后再剪切。 +API 密钥输入框通过 `autocomplete="new-password"` 请求浏览器不要自动填入已保存的登录密码。输入非空时,显示与会话密钥遮罩一致的眼睛按钮:**显示密钥**展示当前输入,**隐藏密钥**重新遮住输入。修改或清空输入后会重新遮住密钥。已保存密钥的占位掩码只使用长度元数据,每个字符显示一个点,不将密钥字符读入 Client。提供方未提供长度元数据时,仅显示使用已保存密钥的提示,不猜测掩码长度;禁用原有显示按钮,并阻止复制和剪切。键入或粘贴会整项替换,Backspace 或 Delete 会暂存整项删除。已保存凭据从不读入输入框。原生密码框不提供剪切;可选中后按 Backspace 清空草稿,或显示明文后再剪切。 编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `cordis.patch.yml` 从不携带密钥值。为新的 pi-ai 提供商留空密钥会保存一个不带引用的 profile,从而保留提供商原生认证(例如 Bedrock 凭据链或 Vertex ADC)。profile 有 `apiKeyEnv` 时,字段的占位文字与帮助提示采用该引用;否则按查找顺序采用可配置提供商目录为该路由报告的 `nativeAuth` 凭据变量,并排除 `baseURLEnv` 地址变量。这些显示提示会忽略包含 `oauth` 的引用(不区分大小写),并说明优先使用已保存凭据,其后为显示的环境后备。这项显示过滤不改变[凭据解析顺序](../../../docs/subsystems/credentials.zh.md#resolution)、适配器的完整环境变量列表或显式引用。被过滤的显式引用仍有可用的密钥输入框,使用普通占位文字且不显示帮助提示。没有密钥认证的原生路由不显示密钥字段及认证帮助;显式引用仍会启用该字段。路由没有 `nativeAuth`(例如手动声明的路由)且 profile 没有引用时,显示普通的密钥占位文字,且不显示帮助提示。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。 @@ -49,7 +49,7 @@ API 密钥输入框通过 `autocomplete="new-password"` 请求浏览器不要自 ### 编辑提供商 -保持已保存掩码不变时保留现有认证。已保存凭据显示固定的输入掩码和绿色的使用已保存密钥提示。否则提示使用环境认证。在已保存掩码上按 Backspace 或 Delete,会将删除操作暂存到「保存」时执行;取消会保留密钥。清空替换草稿也会暂存删除已保存密钥。不新增单独的清除按钮。输入新密钥会替换待删除草稿。删除被拒绝时,设置保持不变。删除成功但设置失败时,编辑器说明密钥已删除,并提供「保存」重试设置;「关闭」会保留这一部分提交结果。目录路由会移除用户层中的派生引用以恢复原生认证;与 `_API_KEY` 不同的别名、继承引用和自定义路由保留原引用以使用环境后备。手写的同名常规引用与页面派生引用按相同方式处理。隐藏的 OAuth 引用不提供已保存 API 密钥掩码或清除控件。待删除期间暂不允许获取模型列表。脱敏的 `credentials.describe` 检查区分已检测到环境引用、未检测到变量和读取状态未确认。缺少变量时使用黄色提示,并列出应配置的首个变量名。已检测到环境引用时使用绿色。地址配置变量不计作凭据。隐藏的认证引用仍参与检测;使用不含变量名的通用检测提示。检测不验证凭据、不测试连接,也不检查 AWS profile 或 Google 应用默认凭据等提供商管理的文件。替换草稿显示保存以替换密钥的提示。待删除提示会列出保存后使用的环境变量名(路由声明该变量时)。其他非空输入不显示认证状态文字。页面读取原生变量名和具名引用,每批最多 64 项,不将变量值读入 Client。 +保持已保存掩码不变时保留现有认证。已保存凭据显示与密钥等长的输入掩码和绿色的使用已保存密钥提示。否则提示使用环境认证。在已保存掩码上按 Backspace 或 Delete,会将删除操作暂存到「保存」时执行;取消会保留密钥。清空替换草稿也会暂存删除已保存密钥。不新增单独的清除按钮。输入新密钥会替换待删除草稿。删除被拒绝时,设置保持不变。删除成功但设置失败时,编辑器说明密钥已删除,并提供「保存」重试设置;「关闭」会保留这一部分提交结果。目录路由会移除用户层中的派生引用以恢复原生认证;与 `_API_KEY` 不同的别名、继承引用和自定义路由保留原引用以使用环境后备。手写的同名常规引用与页面派生引用按相同方式处理。隐藏的 OAuth 引用不提供已保存 API 密钥掩码或清除控件。待删除期间暂不允许获取模型列表。脱敏的 `credentials.describe` 检查区分已检测到环境引用、未检测到变量和读取状态未确认。缺少变量时使用黄色提示,并列出应配置的首个变量名。已检测到环境引用时使用绿色。地址配置变量不计作凭据。隐藏的认证引用仍参与检测;使用不含变量名的通用检测提示。检测不验证凭据、不测试连接,也不检查 AWS profile 或 Google 应用默认凭据等提供商管理的文件。替换草稿显示保存以替换密钥的提示。待删除提示会列出保存后使用的环境变量名(路由声明该变量时)。其他非空输入不显示认证状态文字。页面读取原生变量名和具名引用,每批最多 64 项,不将变量值读入 Client。 目录未提供固定默认端点时,输入框下方显示提供方的 `baseURLEnv` 配置变量名。Profile 中的端点仍是占位符的首选。 diff --git a/packages/client/ui-settings-models/src/client/ApiKeyInput.tsx b/packages/client/ui-settings-models/src/client/ApiKeyInput.tsx index a9757972b1..100710514b 100644 --- a/packages/client/ui-settings-models/src/client/ApiKeyInput.tsx +++ b/packages/client/ui-settings-models/src/client/ApiKeyInput.tsx @@ -12,8 +12,9 @@ const EYE_OFF_PATH = `${EYE_CONTOUR}M2.5 2.5L13.5 13.5` interface ApiKeyInputProps extends Omit, 'value' | 'type' | 'className' | 'autoComplete'> { /** Unsaved input; stored credentials are never read into this field. */ readonly value: string - /** Saved-key actions; the mask never contains the stored credential. */ + /** Saved-key length and actions; the mask never contains the stored credential. */ readonly saved?: { + readonly length: number readonly clear?: () => void } /** Section copy, including the same reveal labels as SecretText. */ @@ -36,7 +37,7 @@ export function ApiKeyInput({ value, saved, t, ...props }: ApiKeyInputProps): Re type={revealed ? 'text' : 'password'} autoComplete="new-password" value={value} - placeholder={saved === undefined ? props.placeholder : '••••••••'} + placeholder={saved === undefined ? props.placeholder : '•'.repeat(saved.length)} onCopy={(event) => { if (saved !== undefined) event.preventDefault() }} onCut={(event) => { if (saved !== undefined) event.preventDefault() }} onKeyDown={(event) => { diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index 2a88a6179c..d6b3bc9cba 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -491,6 +491,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { value={keyText} {...keyDraft === undefined && savedCredential ? { saved: { + length: detected[1].length ?? 0, ...detected[1].writable ? { clear: () => { setKeyDraft({ ref: detected[0] }); setFailure(undefined) } } : {}, }, diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index ef24386913..ac17133275 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -222,7 +222,7 @@ function scriptedFace(overrides: { Promise.resolve(remoteOk( Object.fromEntries(refs.map(ref => [ref, { configured: ref === 'OPENAI_API_KEY', - ...ref === 'OPENAI_API_KEY' ? { source: 'file' } : {}, + ...ref === 'OPENAI_API_KEY' ? { source: 'file', length: 8 } : {}, writable: true, }])), ))), @@ -2206,7 +2206,7 @@ describe('apiKeyFailure', () => { it.each(['file', 'env', 'project-env', 'user-env'] as const)('shows the existing authentication with a blank input for source %s', (source) => { renderPiAiEditor('openai', piAiNamespaceWith('openai', { apiKeyEnv: 'OPENAI_API_KEY' }), { - credential: { configured: true, source, writable: true }, + credential: { configured: true, source, writable: true, ...source === 'file' ? { length: 8 } : {} }, }) const hint = en.keyFallbackDetected.replace('{hint}', 'OPENAI_API_KEY') const input = screen.getByLabelText(en.keyInput) @@ -2230,14 +2230,16 @@ it.each(['file', 'env', 'project-env', 'user-env'] as const)('shows the existing else expect(screen.getAllByText(hint)).toHaveLength(1) }) -it.each(['openai', 'anthropic', 'google', 'deepseek', 'acme-gateway'])('protects the saved %s key and reveals only its replacement', (provider) => { +it.each([ + ['openai', 1], ['anthropic', 7], ['google', 32], ['deepseek', 64], ['acme-gateway', 128], +] as const)('protects the saved %s key and reveals only its replacement', (provider, length) => { renderPiAiEditor(provider, piAiNamespaceWith(provider, { apiKeyEnv: 'TEAM_API_KEY' }), { - credential: { configured: true, source: 'file', writable: true }, + credential: { configured: true, source: 'file', writable: true, length }, }) const input = screen.getByLabelText(en.keyInput) expect(input.value).toBe('') expect(input.type).toBe('password') - expect(input.placeholder).toBe('••••••••') + expect(input.placeholder).toBe('•'.repeat(length)) expect(screen.queryByRole('button', { name: 'Clear saved key' })).toBeNull() expect(screen.getByRole('button', { name: en.keyReveal }).disabled).toBe(true) expect(fireEvent.copy(input)).toBe(false) @@ -2256,6 +2258,17 @@ it.each(['openai', 'anthropic', 'google', 'deepseek', 'acme-gateway'])('protects expect(screen.queryByLabelText(en.keyReveal)).toBeNull() }) +it('does not invent a mask length when the credential provider omits it', () => { + renderPiAiEditor('openai', piAiNamespaceWith('openai', { apiKeyEnv: 'OPENAI_API_KEY' }), { + credential: { configured: true, source: 'file', writable: true }, + }) + const input = screen.getByLabelText(en.keyInput) + expect(input.value).toBe('') + expect(input.placeholder).toBe('') + expect(screen.getByText(en.keySaved)).toBeTruthy() + expect(screen.getByRole('button', { name: en.keyReveal }).disabled).toBe(true) +}) + it.each(['Backspace', 'Delete'])('stages whole saved-key deletion with %s and can cancel it', async (key) => { const { unset } = await mountSection() const edit = () => screen.getByRole('button', { name: openaiCopy(en.editProvider) }) @@ -2280,7 +2293,7 @@ it.each(['Backspace', 'Delete'])('stages whole saved-key deletion with %s and ca it('keeps a saved read-only credential masked when deletion is unavailable', () => { renderPiAiEditor('openai', piAiNamespaceWith('openai', { apiKeyEnv: 'OPENAI_API_KEY' }), { - credential: { configured: true, source: 'file', writable: false }, + credential: { configured: true, source: 'file', writable: false, length: 8 }, }) const input = screen.getByLabelText(en.keyInput) fireEvent.keyDown(input, { key: 'Delete' }) @@ -2293,7 +2306,7 @@ it('keeps a saved read-only credential masked when deletion is unavailable', () it('keeps a hidden OAuth reference out of the saved API-key controls', () => { renderPiAiEditor('anthropic', piAiNamespaceWith('anthropic', { apiKeyEnv: 'ANTHROPIC_OAUTH_TOKEN' }), { - credential: { configured: true, source: 'file', writable: true }, + credential: { configured: true, source: 'file', writable: true, length: 8 }, }) const input = screen.getByLabelText(en.keyInput) expect(input.value).toBe('') @@ -2313,7 +2326,7 @@ it.each([ render() + credential={{ configured: true, source: 'file', writable: true, length: 8 }} />) fireEvent.keyDown(screen.getByLabelText(en.keyInput), { key: 'Delete' }) const envRef = removesReference && nativeAuth !== undefined ? nativeAuth.env[0] : ref expect(screen.getByLabelText(en.keyInput).placeholder) @@ -2333,7 +2346,7 @@ it.each([ it('keeps the clear hint generic when native authentication has no visible environment reference', () => { renderPiAiEditor('openai', piAiNamespaceWith('openai', { apiKeyEnv: 'OPENAI_API_KEY' }), { nativeAuth: { env: [], oauth: false }, - credential: { configured: true, source: 'file', writable: true }, + credential: { configured: true, source: 'file', writable: true, length: 8 }, }) fireEvent.keyDown(screen.getByLabelText(en.keyInput), { key: 'Delete' }) expect(screen.getByText(en.keyRemovePending)).toBeTruthy() @@ -2349,7 +2362,7 @@ it.each(['cancel', 'replace'])('keeps authentication unchanged after refused del namespace={piAiNamespaceWith('google', { apiKeyEnv: 'GOOGLE_API_KEY' })} settingsPath={['providers', 'google']} schema={settingsSchema} operations={operationsWith(face)} t={t} readOnly={false} onClose={closed} nativeAuth={{ env: ['GEMINI_API_KEY'], oauth: false }} - credential={{ configured: true, source: 'file', writable: true }} />) + credential={{ configured: true, source: 'file', writable: true, length: 8 }} />) fireEvent.keyDown(screen.getByLabelText(en.keyInput), { key: 'Delete' }) fireEvent.click(screen.getByRole('button', { name: en.apply })) await screen.findByText('credential store locked') @@ -2379,7 +2392,7 @@ it.each(['retry', 'close'])('reports a completed deletion when settings fail, th render() + credential={{ configured: true, source: 'file', writable: true, length: 8 }} />) fireEvent.keyDown(screen.getByLabelText(en.keyInput), { key: 'Delete' }) fireEvent.click(screen.getByRole('button', { name: en.apply })) await screen.findByText('settings locked') @@ -2402,7 +2415,7 @@ it('clears the selected reference when refreshed metadata names a different cred const props: ProviderEditorProps = { provider: 'openai', displayName: 'OpenAI', namespace: piAiNamespaceWith('openai', { apiKeyEnv: 'OLD_KEY' }), settingsPath: ['providers', 'openai'], schema: settingsSchema, operations: operationsWith(face), - t, readOnly: false, onClose: vi.fn(), credential: { configured: true, source: 'file', writable: true }, + t, readOnly: false, onClose: vi.fn(), credential: { configured: true, source: 'file', writable: true, length: 8 }, } const view = render() fireEvent.keyDown(screen.getByLabelText(en.keyInput), { key: 'Delete' }) @@ -2417,7 +2430,7 @@ it('clears the detected native key instead of an unused derived reference', asyn render() + credentialChecks={{ GEMINI_API_KEY: { configured: true, source: 'file', writable: true, length: 8 } }} />) fireEvent.keyDown(screen.getByLabelText(en.keyInput), { key: 'Delete' }) fireEvent.click(screen.getByRole('button', { name: en.apply })) await waitFor(() => { expect(unset).toHaveBeenCalledExactlyOnceWith('GEMINI_API_KEY') }) @@ -2427,7 +2440,7 @@ it('clears the detected native key instead of an unused derived reference', asyn it.each([ ['unconfigured', { apiKeyEnv: 'OPENAI_API_KEY' }, { configured: false, source: 'env', writable: true }], ['unknown source', { apiKeyEnv: 'OPENAI_API_KEY' }, { configured: true, source: 'oauth', writable: true }], - ['native authentication', {}, { configured: true, source: 'file', writable: true }], + ['native authentication', {}, { configured: true, source: 'file', writable: true, length: 8 }], ] as const)('does not guess a source for %s', (_label, profile, credential) => { renderPiAiEditor('openai', piAiNamespaceWith('openai', profile), { credential }) expect(screen.queryByText(/^API key source:/)).toBeNull() diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index b4401964e7..00075d6ddd 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-local/README.i18n.yaml @@ -24,8 +24,8 @@ en: fccad7d354c2af71 zh: 5b95b5890e21f80f /deepseek-ai-dsh-credentials-local/use-this-package/storing-and-removing-keys: - en: 5e3d45470142b9dd - zh: 1f8358f987769d3a + en: 8194c7e7125f7941 + zh: abe217a5edeb0c4f /deepseek-ai-dsh-credentials-local/use-this-package/where-keys-come-from: en: a55fac148b62e007 zh: fdb3d19ccae676ee diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index c3d15999be..080ffd3e99 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -60,11 +60,11 @@ declare const ctx: Context const ref = credentialRef('DEEPSEEK_API_KEY') await ctx.credentials.set(ref, 'sk-…') // save -await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value +await ctx.credentials.describe(ref) // { configured, source?, writable, length? } — never the value await ctx.credentials.unset(ref) // remove ``` -A key you save is usable by the next request that names it, and `describe` reports whether it is set, where it comes from, and whether you can write to it — never the value itself. Records persist in the same file, addressed by `/` and managed with the seam's record operations (`readRecord`, `describeRecord`, `listRecords`, `modifyRecord`, `deleteRecord`). +A key you save is usable by the next request that names it, and `describe` reports whether it is set, where it comes from, and whether you can write to it — never the value itself. A stored file value also reports its UTF-16 `length`; environment fallbacks omit this field. Records persist in the same file, addressed by `/` and managed with the seam's record operations (`readRecord`, `describeRecord`, `listRecords`, `modifyRecord`, `deleteRecord`). ### Where keys come from diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index 5249827cde..4d463c9d83 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -60,11 +60,11 @@ declare const ctx: Context const ref = credentialRef('DEEPSEEK_API_KEY') await ctx.credentials.set(ref, 'sk-…') // save -await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value +await ctx.credentials.describe(ref) // { configured, source?, writable, length? } — never the value await ctx.credentials.unset(ref) // remove ``` -你保存的密钥会被下一个按名引用它的请求使用;`describe` 报告它是否已设置、来自哪里、能否写入——绝不返回值本身。记录也持久化在同一文件中:插件按 `/` 寻址一条记录,并用 seam 的记录操作(`readRecord`、`describeRecord`、`listRecords`、`modifyRecord`、`deleteRecord`)管理它。 +你保存的密钥会被下一个按名引用它的请求使用;`describe` 报告它是否已设置、来自哪里、能否写入——绝不返回值本身。文件中已保存的值还会报告 UTF-16 `length`;环境后备值不包含此字段。记录也持久化在同一文件中:插件按 `/` 寻址一条记录,并用 seam 的记录操作(`readRecord`、`describeRecord`、`listRecords`、`modifyRecord`、`deleteRecord`)管理它。 ### 密钥从哪里来 diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index 48ae487c6d..7e03bca4ec 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -633,7 +633,7 @@ export class LocalCredentialProvider extends CredentialProvider { override describe(ref: CredentialRef): Promise { const stored = this.values.get(ref) - if (stored !== undefined) return Promise.resolve({ configured: true, source: 'file', writable: true }) + if (stored !== undefined) return Promise.resolve({ configured: true, source: 'file', writable: true, length: stored.length }) // Every reference is writable: a set writes the managed file, which // outranks every fallback, and an unset exposes the fallback again. if (this.inherited(ref) !== undefined) return Promise.resolve({ configured: true, source: 'env', writable: true }) diff --git a/packages/credentials/credentials-local/tests/local.spec.ts b/packages/credentials/credentials-local/tests/local.spec.ts index d1436e0731..4d61872830 100644 --- a/packages/credentials/credentials-local/tests/local.spec.ts +++ b/packages/credentials/credentials-local/tests/local.spec.ts @@ -73,7 +73,7 @@ describe('layering and reads', () => { const ctx = await boot({ path, watch: false }) expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'plain', source: 'file' }) expect(await ctx.credentials.resolve(OTHER)).toEqual({ value: 'with space', source: 'file' }) - expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true }) + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true, length: 5 }) }) it('lets the managed file win over a non-empty process environment', async () => { @@ -83,7 +83,7 @@ describe('layering and reads', () => { const ctx = await boot({ path, watch: false }) vi.stubEnv('DSH_CRED_TEST', 'from-env') expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-file', source: 'file' }) - expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true }) + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true, length: 9 }) }) it('treats an empty environment value as absent, falling through to the file', async () => { @@ -93,7 +93,7 @@ describe('layering and reads', () => { const ctx = await boot({ path, watch: false }) vi.stubEnv('DSH_CRED_TEST', '') expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' }) - expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true }) + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true, length: 6 }) }) it('fails boot loud when the document exists but cannot be read', async () => { @@ -131,7 +131,7 @@ describe('layer ladder', () => { expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' }) // A key sitting in the user's .env does not make the stored one // unwritable. - expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true }) + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true, length: 6 }) await expect(ctx.credentials.set(KEY, 'rotated')).resolves.toBeUndefined() expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'rotated', source: 'file' }) }) @@ -145,8 +145,10 @@ describe('layer ladder', () => { expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: true }) await ctx.credentials.set(KEY, 'stored') expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' }) + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true, length: 6 }) await ctx.credentials.unset(KEY) expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-launch', source: 'env' }) + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: true }) }) it('serves the user .env only when nothing is stored', async () => { @@ -230,9 +232,10 @@ describe('layer ladder', () => { { source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user-env' } }, ]) expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' }) - expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true }) + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true, length: 6 }) await expect(ctx.credentials.set(KEY, 'next')).resolves.toBeUndefined() expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'next', source: 'file' }) + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true, length: 4 }) }) }) @@ -382,7 +385,7 @@ describe('document writes', () => { const reread = await boot({ path, watch: false }) expect(await reread.credentials.resolve(KEY)).toEqual({ value: multiLine, source: 'file' }) expect(await reread.credentials.resolve(OTHER)).toEqual({ value: mixedQuotes, source: 'file' }) - expect(await reread.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true }) + expect(await reread.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true, length: multiLine.length }) }) it('unsets only the owning entry, with its own annotation, and keeps an absent unset silent', async () => { diff --git a/packages/credentials/credentials/README.i18n.yaml b/packages/credentials/credentials/README.i18n.yaml index 3f2265caf7..1e471a6cbf 100644 --- a/packages/credentials/credentials/README.i18n.yaml +++ b/packages/credentials/credentials/README.i18n.yaml @@ -24,8 +24,8 @@ en: 8f3500119608f91b zh: 147fdb5389affbc0 /deepseek-ai-dsh-credentials/use-this-package/storing-checking-and-removing-keys: - en: fa334e2d4438e0b6 - zh: 009b9252ed046886 + en: ae12e0d21a030bd1 + zh: 9072486f52cf6569 /deepseek-ai-dsh-credentials/use-this-package/storing-updating-and-removing-records: en: 300c3f1f13abdca4 zh: ec7a6486ac88889c diff --git a/packages/credentials/credentials/README.md b/packages/credentials/credentials/README.md index 85ed5a68b0..0cdff47017 100644 --- a/packages/credentials/credentials/README.md +++ b/packages/credentials/credentials/README.md @@ -53,12 +53,12 @@ declare const ctx: Context const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined -const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value +const info = await ctx.credentials.describe(ref) // { configured, source?, writable, length? } — never the value await ctx.credentials.set(ref, 'sk-…') // the stored value outranks environment fallbacks await ctx.credentials.unset(ref) // no-op when absent; an environment fallback then applies again ``` -Store a key with `set`, remove it with `unset`, check its status with `describe`, and read the current value with `resolve` when an operation needs it. `describe` reports whether the key is set, where it comes from, and whether you can write to it — it never returns the value. +Store a key with `set`, remove it with `unset`, check its status with `describe`, and read the current value with `resolve` when an operation needs it. `describe` reports whether the key is set, where it comes from, and whether you can write to it — it never returns the value. Providers may disclose the stored value’s UTF-16 length through the optional `length` field. ### Storing, updating, and removing records diff --git a/packages/credentials/credentials/README.zh.md b/packages/credentials/credentials/README.zh.md index c73f0c68bf..37bee0ff6f 100644 --- a/packages/credentials/credentials/README.zh.md +++ b/packages/credentials/credentials/README.zh.md @@ -53,12 +53,12 @@ declare const ctx: Context const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined -const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value +const info = await ctx.credentials.describe(ref) // { configured, source?, writable, length? } — never the value await ctx.credentials.set(ref, 'sk-…') // the stored value outranks environment fallbacks await ctx.credentials.unset(ref) // no-op when absent; an environment fallback then applies again ``` -用 `set` 存储密钥、用 `unset` 移除、用 `describe` 检查状态、在操作需要时用 `resolve` 读取当前值。`describe` 报告密钥是否已设置、来自哪里、能否写入——它绝不返回值。 +用 `set` 存储密钥、用 `unset` 移除、用 `describe` 检查状态、在操作需要时用 `resolve` 读取当前值。`describe` 报告密钥是否已设置、来自哪里、能否写入——它绝不返回值。提供方可通过可选的 `length` 字段报告已保存值的 UTF-16 长度。 ### 存储、更新与移除记录 diff --git a/packages/credentials/credentials/src/index.ts b/packages/credentials/credentials/src/index.ts index 2e0b56931a..156be45241 100644 --- a/packages/credentials/credentials/src/index.ts +++ b/packages/credentials/credentials/src/index.ts @@ -187,7 +187,7 @@ export abstract class CredentialProvider extends Service { * Describe one reference for configuration surfaces without exposing the * value. * @param ref - the reference to describe. - * @returns configured state, supplying source, and writability. + * @returns configured state, supplying source, writability, and optional stored-value length. */ abstract describe(ref: CredentialRef): Promise diff --git a/packages/credentials/credentials/src/types.ts b/packages/credentials/credentials/src/types.ts index 5c9ae99350..a8dae57489 100644 --- a/packages/credentials/credentials/src/types.ts +++ b/packages/credentials/credentials/src/types.ts @@ -71,6 +71,8 @@ export interface CredentialInfo { source?: string /** Whether the active provider can write this reference. */ writable: boolean + /** Stored value length in UTF-16 code units, when disclosed; never the value. */ + length?: number } declare module '@deepseek-ai/cordis' { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 74a5703129..dfe102830e 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -846,7 +846,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract describe(ref: CredentialRef): Promise', description: 'Describe one reference for configuration surfaces without exposing the value.', parameters: [{ name: 'ref', description: 'the reference to describe.' }], - returns: 'configured state, supplying source, and writability.', + returns: 'configured state, supplying source, writability, and optional stored-value length.', }, { signature: 'abstract set(ref: CredentialRef, value: string): Promise', @@ -6280,7 +6280,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CredentialInfo', - declaration: 'export interface CredentialInfo {\n configured: boolean;\n source?: string;\n writable: boolean;\n}', + declaration: 'export interface CredentialInfo {\n configured: boolean;\n source?: string;\n writable: boolean;\n length?: number;\n}', }, { name: 'CredentialKey', diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index 183c590e2b..2b47c869d1 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -323,7 +323,7 @@ describe('llm-deepseek real dynamic composition', () => { const home = root! await boot.ctx.get('credentials')!.set(KEY_REF, 'stored-by-ui') expect(await boot.ctx.get('credentials')!.describe(KEY_REF)) - .toEqual({ configured: true, source: 'file', writable: true }) + .toEqual({ configured: true, source: 'file', writable: true, length: 'stored-by-ui'.length }) await assemble(boot.ctx, { model: 'deepseek-v4-flash', messages: [] }) expect(first.headers[0]?.['x-api-key']).toBe('stored-by-ui') await boot.ctx.fiber.dispose() @@ -335,9 +335,12 @@ describe('llm-deepseek real dynamic composition', () => { // The stored key is still the provider's own file entry — not a // launch-environment value, which is what hoisting it would have made it. expect(await credentials.resolve(KEY_REF)).toEqual({ value: 'stored-by-ui', source: 'file' }) - expect(await credentials.describe(KEY_REF)).toEqual({ configured: true, source: 'file', writable: true }) + expect(await credentials.describe(KEY_REF)) + .toEqual({ configured: true, source: 'file', writable: true, length: 'stored-by-ui'.length }) // Rotation still works after the restart, and the next request uses it. await credentials.set(KEY_REF, 'rotated-after-restart') + expect(await credentials.describe(KEY_REF)) + .toEqual({ configured: true, source: 'file', writable: true, length: 'rotated-after-restart'.length }) await assemble(restarted.ctx, { model: 'deepseek-v4-flash', messages: [] }) expect(second.headers[0]?.['x-api-key']).toBe('rotated-after-restart') })